From d3eaa9aa4fdba2772ad419acbcc34c792bb5b77c Mon Sep 17 00:00:00 2001 From: Adam Ginzberg Date: Thu, 18 Dec 2025 15:35:54 -0800 Subject: [PATCH] Add support for multiple teams --- packages/shared/types.ts | 2 + packages/subframe-cli/src/access-token.ts | 46 +++++++++++++++------- packages/subframe-cli/src/config.ts | 29 +++++++++++--- packages/subframe-cli/src/init-project.ts | 2 +- packages/subframe-cli/src/init.ts | 13 +++--- packages/subframe-cli/src/sync-settings.ts | 13 ++++++ packages/subframe-cli/src/sync.ts | 10 ++++- 7 files changed, 87 insertions(+), 28 deletions(-) diff --git a/packages/shared/types.ts b/packages/shared/types.ts index 610ac3a6..db6e87ac 100644 --- a/packages/shared/types.ts +++ b/packages/shared/types.ts @@ -67,6 +67,7 @@ export interface InitProjectResponse { cssType: CodeGenCSSType oldImportAlias?: string projectInfo: { + teamId: number truncatedProjectId: TruncatedProjectId name: string } @@ -96,6 +97,7 @@ export interface SyncProjectResponse { otherFiles: CodeGenFileValid[] missingComponents: string[] projectInfo: { + teamId: number truncatedProjectId: TruncatedProjectId name: string } diff --git a/packages/subframe-cli/src/access-token.ts b/packages/subframe-cli/src/access-token.ts index 74b56d9b..e3886947 100644 --- a/packages/subframe-cli/src/access-token.ts +++ b/packages/subframe-cli/src/access-token.ts @@ -3,14 +3,19 @@ import prompt from "prompts" import { CLI_AUTH_ROUTE } from "shared/routes" import { apiVerifyToken } from "./api-endpoints" import { isDev } from "./common" -import { readAuthConfig, writeAuthConfig } from "./config" +import { getToken, storeToken } from "./config" import { CLILogger } from "./logger/logger-cli" import { link } from "./output/format" import { abortOnState } from "./prompt-helpers" const BASE_URL = isDev ? "http://localhost:6501" : "https://app.subframe.com" -export async function verifyTokenWithOra(cliLogger: CLILogger, token: string): Promise { +interface TokenWithTeam { + token: string + teamId: number +} + +export async function verifyTokenWithOra(cliLogger: CLILogger, token: string): Promise { try { const { userId, teamId } = await oraPromise(apiVerifyToken(token), { prefixText: "", @@ -26,41 +31,52 @@ export async function verifyTokenWithOra(cliLogger: CLILogger, token: string): P groupId: String(teamId), }, }) - return true + return { token, teamId } } catch (error) { await cliLogger.trackWarningAndFlush("[CLI]: verifyToken failed", { error: error.toString() }) - return false + return null } } -export async function promptForNewAccessToken(cliLogger: CLILogger): Promise { +export async function promptForNewAccessToken(cliLogger: CLILogger): Promise { console.log("> To get new credentials, please visit the following URL in your web browser:") console.log(`> ${link(`${BASE_URL}${CLI_AUTH_ROUTE}`)}`) console.log() console.log("> You will need to login then enter the provided credentials below.") - const { token } = await prompt({ + + let tokenWithTeam: TokenWithTeam | null = null + + await prompt({ type: "text", name: "token", message: "Access token", validate: async (token: string) => { - const isValid = await verifyTokenWithOra(cliLogger, token) - return isValid ? true : `Invalid token` + tokenWithTeam = await verifyTokenWithOra(cliLogger, token) + return tokenWithTeam ? true : `Invalid token` }, onState: abortOnState, }) - await writeAuthConfig({ token }) + if (!tokenWithTeam) { + throw new Error("Unexpected error: failed to verify token") + } + + await storeToken(cliLogger, tokenWithTeam) - return token + return tokenWithTeam } -export async function getAccessToken(cliLogger: CLILogger): Promise { - const authConfig = await readAuthConfig(cliLogger) - if (authConfig && (await verifyTokenWithOra(cliLogger, authConfig.token))) { - return authConfig.token +export async function getAccessToken(cliLogger: CLILogger, { teamId }: { teamId?: number }): Promise { + if (!teamId) { + return promptForNewAccessToken(cliLogger) + } + + const token = await getToken(cliLogger, { teamId }) + if (token && (await verifyTokenWithOra(cliLogger, token))) { + return { token, teamId } } - if (!authConfig) { + if (!token) { console.log("> No existing credentials found.") } else { console.log("> Credentials are no longer valid.") diff --git a/packages/subframe-cli/src/config.ts b/packages/subframe-cli/src/config.ts index 448fe781..85fb1513 100644 --- a/packages/subframe-cli/src/config.ts +++ b/packages/subframe-cli/src/config.ts @@ -1,21 +1,25 @@ import { mkdir, readFile, writeFile } from "node:fs/promises" import { join } from "node:path" import XDGAppPaths from "xdg-app-paths" +import { isDev } from "./common" import { CLILogger } from "./logger/logger-cli" import { exists } from "./utils/fs" const SUBFRAME_DIRECTORY = XDGAppPaths("com.subframe.cli").dataDirs()[0] -const SUBFRAME_AUTH_CONFIG_PATH = join(SUBFRAME_DIRECTORY, "auth.json") +const AUTH_CONFIG_FILENAME = isDev ? "auth.dev.json" : "auth.json" +const SUBFRAME_AUTH_CONFIG_PATH = join(SUBFRAME_DIRECTORY, AUTH_CONFIG_FILENAME) interface AuthConfig { - token: string + tokens: { + [teamId: number]: string + } } function isAuthConfig(config: any): config is AuthConfig { - return typeof config === "object" && config !== null && typeof config.token === "string" + return typeof config === "object" && config !== null && typeof config.tokens === "object" } -export async function readAuthConfig(cliLogger: CLILogger): Promise { +async function readAuthConfig(cliLogger: CLILogger): Promise { try { if (!(await exists(SUBFRAME_AUTH_CONFIG_PATH))) { return null @@ -33,7 +37,22 @@ export async function readAuthConfig(cliLogger: CLILogger): Promise { +async function writeAuthConfig(authConfig: AuthConfig): Promise { await mkdir(SUBFRAME_DIRECTORY, { recursive: true }) await writeFile(SUBFRAME_AUTH_CONFIG_PATH, JSON.stringify(authConfig, null, 2)) } + +export async function getToken(cliLogger: CLILogger, { teamId }: { teamId: number }): Promise { + const config = await readAuthConfig(cliLogger) + return config?.tokens[teamId] ?? null +} + +export async function storeToken( + cliLogger: CLILogger, + { teamId, token }: { teamId: number; token: string }, +): Promise { + const config = await readAuthConfig(cliLogger) + const tokens = config?.tokens ?? {} + tokens[teamId] = token + await writeAuthConfig({ tokens }) +} diff --git a/packages/subframe-cli/src/init-project.ts b/packages/subframe-cli/src/init-project.ts index 60457657..fd459e16 100644 --- a/packages/subframe-cli/src/init-project.ts +++ b/packages/subframe-cli/src/init-project.ts @@ -33,7 +33,7 @@ export async function initProject({ } catch (error) { if (error.message === FAILED_TO_FETCH_PROJECT_ERROR) { console.log("> Unable to fetch project. Try authenticating again.") - const newAccessToken = await promptForNewAccessToken(cliLogger) + const { token: newAccessToken } = await promptForNewAccessToken(cliLogger) return initProject({ cliLogger, accessToken: newAccessToken, truncatedProjectId, cssType }) } diff --git a/packages/subframe-cli/src/init.ts b/packages/subframe-cli/src/init.ts index 9481355c..84f9976a 100644 --- a/packages/subframe-cli/src/init.ts +++ b/packages/subframe-cli/src/init.ts @@ -29,7 +29,7 @@ import { TruncatedProjectId } from "shared/types" import { getAccessToken, verifyTokenWithOra } from "./access-token" import { apiUpdateImportAlias } from "./api-endpoints" import { localSyncSettings } from "./common" -import { writeAuthConfig } from "./config" +import { storeToken } from "./config" import { SUBFRAME_INIT_MESSAGE } from "./constants" import { initProject } from "./init-project" import { initSync } from "./init-sync" @@ -79,14 +79,14 @@ initCommand.action(async (opts) => { let accessToken = opts.authToken if (accessToken) { - const isValid = await verifyTokenWithOra(cliLogger, accessToken) - if (!isValid) { + const tokenWithTeam = await verifyTokenWithOra(cliLogger, accessToken) + if (!tokenWithTeam) { throw new Error("Failed to authenticate with provided token") } - - await writeAuthConfig({ token: accessToken }) + await storeToken(cliLogger, tokenWithTeam) } else { - accessToken = await getAccessToken(cliLogger) + const tokenWithTeam = await getAccessToken(cliLogger, { teamId: localSyncSettings?.teamId }) + accessToken = tokenWithTeam.token } console.time(SUBFRAME_INIT_MESSAGE) @@ -108,6 +108,7 @@ initCommand.action(async (opts) => { directory: opts.dir ?? localSyncSettings?.directory, importAlias: localSyncSettings?.importAlias, projectId: truncatedProjectId, + teamId: projectInfo.teamId, cssType: styleInfo.cssType, }, opts, diff --git a/packages/subframe-cli/src/sync-settings.ts b/packages/subframe-cli/src/sync-settings.ts index 0ff89dfc..8de92ab2 100644 --- a/packages/subframe-cli/src/sync-settings.ts +++ b/packages/subframe-cli/src/sync-settings.ts @@ -13,6 +13,7 @@ export interface SyncSettingsConfig { directory: string importAlias: string projectId?: TruncatedProjectId + teamId?: number cssType?: "tailwind" | "tailwind-v4" } @@ -66,6 +67,7 @@ export async function setupSyncSettings( directory: options.directory, importAlias: options.importAlias, projectId: options.projectId, + teamId: options.teamId, cssType: options.cssType, } @@ -130,6 +132,17 @@ export async function setupSyncSettings( directory: config.directory!, importAlias: config.importAlias!, projectId: options.projectId, + teamId: options.teamId, cssType: options.cssType, } } + +/** + * Writes sync settings to sync.json file + */ +export async function updateSyncSettings(cwd: string, settings: SyncSettingsConfig): Promise { + const subframeDirPath = join(cwd, SUBFRAME_DIR) + const syncSettingsPath = join(subframeDirPath, SYNC_SETTINGS_FILENAME) + + await writeFile(syncSettingsPath, JSON.stringify(settings, null, 2)) +} diff --git a/packages/subframe-cli/src/sync.ts b/packages/subframe-cli/src/sync.ts index 1e4fabf3..46026f09 100644 --- a/packages/subframe-cli/src/sync.ts +++ b/packages/subframe-cli/src/sync.ts @@ -16,6 +16,7 @@ import { MALFORMED_INIT_MESSAGE, SUBFRAME_SYNC_MESSAGE, WRONG_PROJECT_MESSAGE } import { installDependencies } from "./install-dependencies" import { makeCLILogger } from "./logger/logger-cli" import { syncComponents } from "./sync-components" +import { updateSyncSettings } from "./sync-settings" export const syncCommand = new Command() .name("sync") @@ -40,7 +41,14 @@ export const syncCommand = new Command() process.exit(1) } - const accessToken = await getAccessToken(cliLogger) + const tokenWithTeam = await getAccessToken(cliLogger, { teamId: localSyncSettings?.teamId }) + + const accessToken = tokenWithTeam.token + + // Update sync.json with teamId if it's missing (legacy project migration) + if (!localSyncSettings.teamId) { + await updateSyncSettings(cwd, { ...localSyncSettings, teamId: tokenWithTeam.teamId }) + } // strip /* which is used for tsconfig.json const importAlias = localSyncSettings.importAlias.endsWith("/*")