Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface InitProjectResponse {
cssType: CodeGenCSSType
oldImportAlias?: string
projectInfo: {
teamId: number
truncatedProjectId: TruncatedProjectId
name: string
}
Expand Down Expand Up @@ -96,6 +97,7 @@ export interface SyncProjectResponse {
otherFiles: CodeGenFileValid[]
missingComponents: string[]
projectInfo: {
teamId: number
truncatedProjectId: TruncatedProjectId
name: string
}
Expand Down
46 changes: 31 additions & 15 deletions packages/subframe-cli/src/access-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
interface TokenWithTeam {
token: string
teamId: number
}

export async function verifyTokenWithOra(cliLogger: CLILogger, token: string): Promise<TokenWithTeam | null> {
try {
const { userId, teamId } = await oraPromise(apiVerifyToken(token), {
prefixText: "",
Expand All @@ -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<string> {
export async function promptForNewAccessToken(cliLogger: CLILogger): Promise<TokenWithTeam> {
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<string> {
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<TokenWithTeam> {
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.")
Expand Down
29 changes: 24 additions & 5 deletions packages/subframe-cli/src/config.ts
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Contributor Author

@adam-subframe adam-subframe Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated quality of life improvement for developing to not hit token errors when switching between dev and prod

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<AuthConfig | null> {
async function readAuthConfig(cliLogger: CLILogger): Promise<AuthConfig | null> {
try {
if (!(await exists(SUBFRAME_AUTH_CONFIG_PATH))) {
return null
Expand All @@ -33,7 +37,22 @@ export async function readAuthConfig(cliLogger: CLILogger): Promise<AuthConfig |
}
}

export async function writeAuthConfig(authConfig: AuthConfig): Promise<void> {
async function writeAuthConfig(authConfig: AuthConfig): Promise<void> {
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<string | null> {
const config = await readAuthConfig(cliLogger)
return config?.tokens[teamId] ?? null
}

export async function storeToken(
cliLogger: CLILogger,
{ teamId, token }: { teamId: number; token: string },
): Promise<void> {
const config = await readAuthConfig(cliLogger)
const tokens = config?.tokens ?? {}
tokens[teamId] = token
await writeAuthConfig({ tokens })
}
2 changes: 1 addition & 1 deletion packages/subframe-cli/src/init-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}

Expand Down
13 changes: 7 additions & 6 deletions packages/subframe-cli/src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions packages/subframe-cli/src/sync-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface SyncSettingsConfig {
directory: string
importAlias: string
projectId?: TruncatedProjectId
teamId?: number
cssType?: "tailwind" | "tailwind-v4"
}

Expand Down Expand Up @@ -66,6 +67,7 @@ export async function setupSyncSettings(
directory: options.directory,
importAlias: options.importAlias,
projectId: options.projectId,
teamId: options.teamId,
cssType: options.cssType,
}

Expand Down Expand Up @@ -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<void> {
const subframeDirPath = join(cwd, SUBFRAME_DIR)
const syncSettingsPath = join(subframeDirPath, SYNC_SETTINGS_FILENAME)

await writeFile(syncSettingsPath, JSON.stringify(settings, null, 2))
}
10 changes: 9 additions & 1 deletion packages/subframe-cli/src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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("/*")
Expand Down