diff --git a/src/auth.ts b/src/auth.ts index c14b6201..6ed259e4 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -8,9 +8,11 @@ import * as z from "zod/mini"; import { CREDENTIALS_PATH } from "./config"; import { DEFAULT_PRISMIC_HOST, env } from "./env"; +import { openBrowser } from "./lib/browser"; +import { CommandError } from "./lib/command"; import { exists, readJsonFile, writeFileRecursive } from "./lib/file"; import { stringify } from "./lib/json"; -import { refreshToken as baseRefreshToken } from "./lib/prismic/client"; +import { type Credentials, refreshToken as baseRefreshToken } from "./lib/prismic/client"; import { appendTrailingSlash } from "./lib/url"; import { forgetTrackedUser } from "./tracking"; @@ -22,19 +24,58 @@ const CredentialsSchema = z.looseObject({ token: z.optional(z.string().check(z.minLength(1))), host: z.optional(z.string().check(z.minLength(1))), }); -type Credentials = z.infer; - -export async function getCredentials(): Promise<{ token: string | undefined; host: string }> { - const credentials = await readCredentials(); +type StoredCredentials = z.infer; + +// When a request finds the token missing or invalid, it can ask for a new +// token with `getToken({ login: true })`. That starts a login that prints its +// URL. A command that wants the browser login calls `logIn` itself. +export async function getCredentials(options: { allowLogin?: boolean } = {}): Promise { + const { allowLogin = true } = options; + const stored = await readCredentials(); + let token = env.PRISMIC_TOKEN || stored?.token; + const canLogIn = allowLogin && !env.PRISMIC_TOKEN && !env.CI; return { - token: env.PRISMIC_TOKEN || credentials?.token, - host: env.PRISMIC_HOST || credentials?.host || DEFAULT_PRISMIC_HOST, + host: env.PRISMIC_HOST || stored?.host || DEFAULT_PRISMIC_HOST, + getToken: async ({ login } = {}) => { + if (!login) return token; + if (!canLogIn) return undefined; + token = await logInOnce(token); + return token; + }, }; } +export async function logIn(options: { openBrowser?: boolean } = {}): Promise { + const { email, token } = await createLoginSession({ + onReady: (url) => { + if (options.openBrowser) { + console.info("Opening browser to complete login..."); + console.info(`If the browser doesn't open, visit: ${url}`); + openBrowser(url); + } else { + console.info(`Open this URL to log in: ${url}`); + } + }, + }); + console.info(`Logged in to Prismic as ${email}`); + return token; +} + +let pendingLogin: Promise | undefined; + +// Parallel requests that find an invalid token share one login. +function logInOnce(invalidToken: string | undefined): Promise { + if (!pendingLogin) { + console.info(invalidToken ? "Your login expired." : "You are not logged in."); + pendingLogin = logIn().finally(() => (pendingLogin = undefined)); + } + return pendingLogin; +} + export async function refreshToken(): Promise { if (env.PRISMIC_TOKEN) return; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials({ allowLogin: false }); + const token = await getToken(); if (!token) return; const newToken = await baseRefreshToken(token, { host }); await saveCredentials({ token: newToken, host }); @@ -54,17 +95,17 @@ export async function logout(): Promise { } } -async function readCredentials(): Promise { +async function readCredentials(): Promise { return readJsonFile(CREDENTIALS_PATH, { schema: CredentialsSchema }).catch(() => undefined); } -async function saveCredentials(credentials: Credentials): Promise { +async function saveCredentials(credentials: StoredCredentials): Promise { await writeFileRecursive(CREDENTIALS_PATH, stringify(credentials)); } -export async function createLoginSession(options: { +async function createLoginSession(options: { onReady: (url: URL) => void; -}): Promise<{ email: string }> { +}): Promise<{ email: string; token: string }> { const { host } = await getCredentials(); const corsOrigin = `https://${host}`; @@ -116,7 +157,7 @@ export async function createLoginSession(options: { clearTimeout(timeoutId); server.close(); - resolve({ email }); + resolve({ email, token }); } catch { respond(400, { error: "Invalid request" }); } @@ -131,7 +172,7 @@ export async function createLoginSession(options: { const timeoutId = setTimeout(() => { server.close(); - reject(new Error("Login timed out. Please try again.")); + reject(new CommandError("Login timed out. Please try again.")); }, LOGIN_TIMEOUT_MS); const onListening = async (): Promise => { diff --git a/src/commands/dev.ts b/src/commands/dev.ts index e9c54c14..fe8f00ff 100644 --- a/src/commands/dev.ts +++ b/src/commands/dev.ts @@ -13,7 +13,13 @@ import { openBrowser } from "../lib/browser"; import { createCommand, type CommandConfig, CommandError, exclusiveOptions } from "../lib/command"; import { readJsonFile, watchFiles, writeFileRecursive } from "../lib/file"; import { stringify } from "../lib/json"; -import { createRelease, type CustomTypesConfig, deleteRelease } from "../lib/prismic/client"; +import { + createRelease, + type Credentials, + type CustomTypesConfig, + deleteRelease, + type RepositoryConfig, +} from "../lib/prismic/client"; import { canonicalizeCustomType, canonicalizeSlice, @@ -66,15 +72,18 @@ export default createCommand(config, async ({ values }) => { new: newSession, "no-browser": noBrowser, } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); const sessionPath = await getSessionPath(); - const previous = await resolvePreviousSession(adapter, sessionPath, { - token, - host, - continueSession, - newSession, - }); + const previous = await resolvePreviousSession( + adapter, + sessionPath, + { host, getToken }, + { + continueSession, + newSession, + }, + ); let session: Session; if (previous) { @@ -82,9 +91,9 @@ export default createCommand(config, async ({ values }) => { console.info(`Continuing your session for ${repo}...`); } else { console.info(`Preparing your session for ${repo}...`); - session = await createSession({ repo, token, host }); + session = await createSession({ repo, host, getToken }); } - const release = { repo, token, host, releaseId: session.releaseId }; + const release = { repo, host, getToken, releaseId: session.releaseId }; const sync = (push = true) => syncModels(adapter, release, session, sessionPath, { push, @@ -173,14 +182,13 @@ async function saveSession(sessionPath: URL, session: Session): Promise { await writeFileRecursive(sessionPath, stringify(session)); } -async function createSession({ repo, token, host }: CustomTypesConfig): Promise { - const release = await createRelease( - { label: "prismic dev", hidden: true }, - { repo, token, host }, - ).catch((error) => { - throw explainRequestError(error); - }); - return { repo, releaseId: release.id, pid: process.pid, synced: {} }; +async function createSession(config: RepositoryConfig): Promise { + const release = await createRelease({ label: "prismic dev", hidden: true }, config).catch( + (error) => { + throw explainRequestError(error); + }, + ); + return { repo: config.repo, releaseId: release.id, pid: process.pid, synced: {} }; } async function endSession({ releaseId, ...config }: Release, sessionPath: URL): Promise { @@ -191,14 +199,10 @@ async function endSession({ releaseId, ...config }: Release, sessionPath: URL): async function resolvePreviousSession( adapter: Adapter, sessionPath: URL, - options: { - token: string | undefined; - host: string; - continueSession?: boolean; - newSession?: boolean; - }, + { host, getToken }: Credentials, + options: { continueSession?: boolean; newSession?: boolean }, ): Promise { - const { token, host, continueSession, newSession } = options; + const { continueSession, newSession } = options; const previous = await readSession(sessionPath); if (previous && isRunning(previous.pid)) { throw new CommandError( @@ -218,7 +222,7 @@ async function resolvePreviousSession( if (!newSession) { const unpulled = await hasUnpulledChanges( adapter, - { repo: previous.repo, token, host, releaseId: previous.releaseId }, + { repo: previous.repo, host, getToken, releaseId: previous.releaseId }, previous.synced, ); if (unpulled) { @@ -227,7 +231,7 @@ async function resolvePreviousSession( ); } } - await deleteRelease(previous.releaseId, { repo: previous.repo, token, host }).catch(() => {}); + await deleteRelease(previous.releaseId, { repo: previous.repo, host, getToken }).catch(() => {}); } async function hasUnpulledChanges( diff --git a/src/commands/env-list.ts b/src/commands/env-list.ts index 2696dba2..95e93eaf 100644 --- a/src/commands/env-list.ts +++ b/src/commands/env-list.ts @@ -19,8 +19,8 @@ export default createCommand(config, async ({ values }) => { const projectRepo = await getRepositoryName(); const { json, repo = projectRepo } = values; - const { token, host } = await getCredentials(); - const environments = await getUserEnvironments({ repo, token, host }); + const { host, getToken } = await getCredentials(); + const environments = await getUserEnvironments({ repo, host, getToken }); let activeEnvironment: string | undefined; if (repo === projectRepo) { diff --git a/src/commands/env-set.ts b/src/commands/env-set.ts index 720142f1..77f2d561 100644 --- a/src/commands/env-set.ts +++ b/src/commands/env-set.ts @@ -16,8 +16,8 @@ export default createCommand(config, async ({ positionals }) => { const [environment] = positionals; const repo = await getRepositoryName(); - const { token, host } = await getCredentials(); - const environments = await getUserEnvironments({ repo, token, host }); + const { host, getToken } = await getCredentials(); + const environments = await getUserEnvironments({ repo, host, getToken }); const validEnvironment = environments.some((env) => env.domain === environment); if (!validEnvironment) throw new InvalidEnvironmentError(environment, environments, repo); diff --git a/src/commands/init.ts b/src/commands/init.ts index e965a345..e71a984f 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,9 +1,8 @@ import { rm } from "node:fs/promises"; import { type Adapter, FRAMEWORKS, getAdapter, NoSupportedFrameworkError } from "../adapters"; -import { createLoginSession, getCredentials } from "../auth"; +import { getCredentials, logIn } from "../auth"; import { DEFAULT_PRISMIC_HOST, env } from "../env"; -import { openBrowser } from "../lib/browser"; import { CommandError, createCommand, exclusiveOptions, type CommandConfig } from "../lib/command"; import { getInstallCommand, @@ -20,13 +19,14 @@ import { enableTypeBuilder, getPreviews, getRepository, - getUserProfile, + hasValidToken, type Repository, + type RepositoryConfig, setSimulatorUrl, } from "../lib/prismic/client"; import { diffModels, getLegacySlices, getRemoteModels } from "../lib/prismic/models"; import { completeOnboardingSteps } from "../lib/prismic/onboarding"; -import { ForbiddenRequestError, UnauthorizedRequestError } from "../lib/request"; +import { ForbiddenRequestError } from "../lib/request"; import { sentryCaptureError } from "../lib/sentry"; import { dedent } from "../lib/string"; import { @@ -116,37 +116,16 @@ export default createCommand(config, async ({ values }) => { } } - const { host, token: initialToken } = await getCredentials(); - let token = initialToken; - try { - await getUserProfile({ token, host }); - } catch (error) { - if (error instanceof UnauthorizedRequestError || error instanceof ForbiddenRequestError) { - if (env.PRISMIC_TOKEN) throw error; - console.info("Not logged in. Starting login..."); - const { email } = await createLoginSession({ - onReady: (url) => { - if (noBrowser) { - console.info(`Open this URL to log in: ${url}`); - } else { - console.info("Opening browser to complete login..."); - console.info(`If the browser doesn't open, visit: ${url}`); - openBrowser(url); - } - }, - }); - console.info(`Logged in as ${email}`); - const loggedIn = await getCredentials(); - token = loggedIn.token; - } else { - throw error; - } + // Log in with the browser before changing the project, if needed. + if (!env.PRISMIC_TOKEN && !(await hasValidToken(await getCredentials({ allowLogin: false })))) { + await logIn({ openBrowser: !noBrowser }); } + const { host, getToken } = await getCredentials(); let repo = (explicitRepo ?? legacySliceMachineConfig?.repositoryName)?.toLowerCase(); let connectedRepository: Repository | undefined; if (repo) { - connectedRepository = await getRepository({ repo, token, host }).catch((error) => { + connectedRepository = await getRepository({ repo, host, getToken }).catch((error) => { if (!(error instanceof ForbiddenRequestError)) throw error; throw new CommandError( `Repository "${repo}" not found in your account. Check the name or request access to the repository.`, @@ -181,7 +160,7 @@ export default createCommand(config, async ({ values }) => { (!isTypeBuilderEnabled || connectedRepository.framework !== adapter.id) ) { try { - await enableTypeBuilder(adapter.id, { repo, token, host }); + await enableTypeBuilder(adapter.id, { repo, host, getToken }); if (!isTypeBuilderEnabled) console.info(`Turned on the Type Builder for repository "${repo}".`); } catch (error) { @@ -198,13 +177,10 @@ export default createCommand(config, async ({ values }) => { } if (!repo) { - repo = await createRepo({ - name: values["repo-name"], - lang, - framework: adapter.id, - token, - host, - }); + repo = await createRepo( + { name: values["repo-name"], lang, framework: adapter.id }, + { host, getToken }, + ); console.info(`Created repository: ${repo}`); } @@ -275,7 +251,7 @@ export default createCommand(config, async ({ values }) => { // Sync models from remote and generate types const [remote, local] = await Promise.all([ - getRemoteModels({ repo, token, host }), + getRemoteModels({ repo, host, getToken }), adapter.getModels(), ]); const diff = diffModels(remote, local); @@ -316,11 +292,7 @@ export default createCommand(config, async ({ values }) => { } if (isExistingProjectHandoff && connectedRepository?.starter) { - await completeStarterHandoff(adapter, connectedRepository.starter, { - repo, - token, - host, - }); + await completeStarterHandoff(adapter, connectedRepository.starter, { repo, host, getToken }); if (!hasStarterModelChanges) { console.info("\n---"); @@ -350,7 +322,7 @@ async function isStarterPackage(starter: NonNullable): Pr async function completeStarterHandoff( adapter: Adapter, starter: NonNullable, - config: { repo: string; token: string | undefined; host: string }, + config: RepositoryConfig, ): Promise { try { const { results: previews } = await getPreviews(config); diff --git a/src/commands/locale-add.ts b/src/commands/locale-add.ts index 5fb3c6d6..21585969 100644 --- a/src/commands/locale-add.ts +++ b/src/commands/locale-add.ts @@ -31,9 +31,9 @@ export default createCommand(config, async ({ positionals, values }) => { const [code] = positionals; const { env, repo = env ?? (await getActiveRepositoryName()), master = false, name } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); - await upsertLocale({ id: code, isMaster: master, customName: name }, { repo, token, host }); + await upsertLocale({ id: code, isMaster: master, customName: name }, { repo, host, getToken }); console.info(`Locale added: ${code}`); }); diff --git a/src/commands/locale-list.ts b/src/commands/locale-list.ts index b801bb5e..35187150 100644 --- a/src/commands/locale-list.ts +++ b/src/commands/locale-list.ts @@ -28,9 +28,9 @@ const config = { export default createCommand(config, async ({ values }) => { const { env, repo = env ?? (await getActiveRepositoryName()), json } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); - const { results: locales } = await getLocales({ repo, token, host }); + const { results: locales } = await getLocales({ repo, host, getToken }); if (json) { console.info(stringify(locales)); diff --git a/src/commands/locale-remove.ts b/src/commands/locale-remove.ts index 605400ef..d7916f45 100644 --- a/src/commands/locale-remove.ts +++ b/src/commands/locale-remove.ts @@ -29,9 +29,9 @@ export default createCommand(config, async ({ positionals, values }) => { const [code] = positionals; const { env, repo = env ?? (await getActiveRepositoryName()) } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); - await deleteLocale(code, { repo, token, host }); + await deleteLocale(code, { repo, host, getToken }); console.info(`Locale removed: ${code}`); }); diff --git a/src/commands/locale-set-master.ts b/src/commands/locale-set-master.ts index f47da0d2..1e8c514f 100644 --- a/src/commands/locale-set-master.ts +++ b/src/commands/locale-set-master.ts @@ -29,9 +29,9 @@ export default createCommand(config, async ({ positionals, values }) => { const [code] = positionals; const { env, repo = env ?? (await getActiveRepositoryName()) } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); - const { results: locales } = await getLocales({ repo, token, host }); + const { results: locales } = await getLocales({ repo, host, getToken }); const locale = locales.find((l) => l.id === code); @@ -47,7 +47,7 @@ export default createCommand(config, async ({ positionals, values }) => { await upsertLocale( { id: locale.id, isMaster: true, customName: locale.customName ?? undefined }, - { repo, token, host }, + { repo, host, getToken }, ); console.info(`Master locale set: ${code}`); diff --git a/src/commands/login.ts b/src/commands/login.ts index a14083a5..76f8e9cb 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -1,5 +1,4 @@ -import { createLoginSession } from "../auth"; -import { openBrowser } from "../lib/browser"; +import { logIn } from "../auth"; import { createCommand, type CommandConfig } from "../lib/command"; const config = { @@ -11,19 +10,5 @@ const config = { } satisfies CommandConfig; export default createCommand(config, async ({ values }) => { - const { "no-browser": noBrowser } = values; - - const { email } = await createLoginSession({ - onReady: (url) => { - if (noBrowser) { - console.info(`Open this URL to log in: ${url}`); - } else { - console.info("Opening browser to complete login..."); - console.info(`If the browser doesn't open, visit: ${url}`); - openBrowser(url); - } - }, - }); - - console.info(`Logged in to Prismic as ${email}`); + await logIn({ openBrowser: !values["no-browser"] }); }); diff --git a/src/commands/preview-add.ts b/src/commands/preview-add.ts index 534fa8c0..1711330e 100644 --- a/src/commands/preview-add.ts +++ b/src/commands/preview-add.ts @@ -42,9 +42,9 @@ export default createCommand(config, async ({ positionals, values }) => { const websiteURL = `${parsed.protocol}//${parsed.host}`; const resolverPath = parsed.pathname === "/" ? undefined : parsed.pathname; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); - await addPreview({ name: displayName, websiteURL, resolverPath }, { repo, token, host }); + await addPreview({ name: displayName, websiteURL, resolverPath }, { repo, host, getToken }); console.info(`Preview added: ${previewUrl}`); console.info("Run `prismic preview set-simulator ` to set the slice simulator URL."); diff --git a/src/commands/preview-list.ts b/src/commands/preview-list.ts index f17ecf73..20d27cd0 100644 --- a/src/commands/preview-list.ts +++ b/src/commands/preview-list.ts @@ -28,11 +28,11 @@ const config = { export default createCommand(config, async ({ values }) => { const { env, repo = env ?? (await getActiveRepositoryName()), json } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); const [{ results: previews }, { simulator_url: simulatorUrl }] = await Promise.all([ - getPreviews({ repo, token, host }), - getSimulatorUrl({ repo, token, host }), + getPreviews({ repo, host, getToken }), + getSimulatorUrl({ repo, host, getToken }), ]); if (json) { diff --git a/src/commands/preview-remove.ts b/src/commands/preview-remove.ts index 4a948916..ff577709 100644 --- a/src/commands/preview-remove.ts +++ b/src/commands/preview-remove.ts @@ -29,16 +29,16 @@ export default createCommand(config, async ({ positionals, values }) => { const [previewUrl] = positionals; const { env, repo = env ?? (await getActiveRepositoryName()) } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); - const { results: previews } = await getPreviews({ repo, token, host }); + const { results: previews } = await getPreviews({ repo, host, getToken }); const preview = previews.find((p) => p.url === previewUrl); if (!preview) { throw new CommandError(`Preview not found: ${previewUrl}`); } - await deletePreview(preview.id, { repo, token, host }); + await deletePreview(preview.id, { repo, host, getToken }); console.info(`Preview removed: ${previewUrl}`); }); diff --git a/src/commands/preview-set-simulator.ts b/src/commands/preview-set-simulator.ts index d02a12b8..343c5005 100644 --- a/src/commands/preview-set-simulator.ts +++ b/src/commands/preview-set-simulator.ts @@ -47,9 +47,9 @@ export default createCommand(config, async ({ positionals, values }) => { } const simulatorUrl = parsed.toString(); - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); - await setSimulatorUrl(simulatorUrl, { repo, token, host }); + await setSimulatorUrl(simulatorUrl, { repo, host, getToken }); console.info(`Simulator URL set: ${simulatorUrl}`); }); diff --git a/src/commands/pull.ts b/src/commands/pull.ts index ea2bc4a0..a9e7284d 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -42,7 +42,7 @@ export default createCommand(config, async ({ values }) => { repo = env ?? (await adapter.getEnvironment()) ?? (await getRepositoryName()), } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); const projectRoot = await findProjectRoot(); console.info(`Pulling from repository: ${repo}`); @@ -85,7 +85,7 @@ export default createCommand(config, async ({ values }) => { const [localCustomTypes, localSlices, remote] = await Promise.all([ adapter.getCustomTypes(), adapter.getSlices(), - getRemoteModels({ repo, token, host }), + getRemoteModels({ repo, host, getToken }), ]); const diff = diffModels( remote, @@ -123,8 +123,8 @@ export default createCommand(config, async ({ values }) => { await completeOnboardingSteps(["connectPrismic"], { repo: await getRepositoryName(), - token, host, + getToken, }).catch(() => {}); const totalTypes = diff.customTypes.insert.length + diff.customTypes.update.length; diff --git a/src/commands/push.ts b/src/commands/push.ts index a9a7fc16..31ca526b 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -6,7 +6,11 @@ import { getAdapter } from "../adapters"; import { getCredentials } from "../auth"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { getDirtyPaths, getGitRoot } from "../lib/git"; -import { deleteSliceScreenshots, getDocumentTotalByCustomType } from "../lib/prismic/client"; +import { + deleteSliceScreenshots, + getDocumentTotalByCustomType, + type RepositoryConfig, +} from "../lib/prismic/client"; import { diffModels, getRemoteModels, writeRemoteModels } from "../lib/prismic/models"; import { completeOnboardingSteps, type OnboardingStep } from "../lib/prismic/onboarding"; import { ForbiddenRequestError } from "../lib/request"; @@ -52,7 +56,7 @@ export default createCommand(config, async ({ values }) => { repo = env ?? (await adapter.getEnvironment()) ?? (await getRepositoryName()), } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); const projectRoot = await findProjectRoot(); console.info(`Pushing to repository: ${repo}`); @@ -91,7 +95,7 @@ export default createCommand(config, async ({ values }) => { const [local, remote] = await Promise.all([ adapter.getModels(), - getRemoteModels({ repo, token, host }), + getRemoteModels({ repo, host, getToken }), ]); const diff = diffModels(local, remote); @@ -117,12 +121,12 @@ export default createCommand(config, async ({ values }) => { } try { - await writeRemoteModels(diff, { repo, token, host }); + await writeRemoteModels(diff, { repo, host, getToken }); } catch (error) { - throw await explainExistingDocuments(error, diff.customTypes.delete, { repo, token, host }); + throw await explainExistingDocuments(error, diff.customTypes.delete, { repo, host, getToken }); } for (const { id } of diff.slices.delete) { - await deleteSliceScreenshots(id, { repo, token, host }).catch((error) => { + await deleteSliceScreenshots(id, { repo, host, getToken }).catch((error) => { const message = error instanceof Error ? error.message : String(error); console.warn( `Failed to delete screenshots for slice "${id}"${message ? `: ${message}` : "."}`, @@ -140,8 +144,8 @@ export default createCommand(config, async ({ values }) => { if (onboardingSteps.length > 0) { await completeOnboardingSteps(onboardingSteps, { repo: await getRepositoryName(), - token, host, + getToken, }).catch(() => {}); } @@ -161,7 +165,7 @@ const ExistingDocumentsErrorSchema = z.object({ hasExistingDocuments: z.literal( async function explainExistingDocuments( error: unknown, deletedCustomTypes: DynamicCustomTypeModel[], - config: { repo: string; token: string | undefined; host: string }, + config: RepositoryConfig, ): Promise { if (!(error instanceof ForbiddenRequestError)) return error; if (!z.safeParse(ExistingDocumentsErrorSchema, error.body).success) return error; diff --git a/src/commands/repo-create.ts b/src/commands/repo-create.ts index aacdc5cf..4ccb92de 100644 --- a/src/commands/repo-create.ts +++ b/src/commands/repo-create.ts @@ -2,7 +2,12 @@ import { FRAMEWORKS, getAdapter } from "../adapters"; import { getCredentials } from "../auth"; import { detectAgent } from "../lib/ai"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; -import { checkIsDomainAvailable, createRepository, upsertLocale } from "../lib/prismic/client"; +import { + checkIsDomainAvailable, + createRepository, + type Credentials, + upsertLocale, +} from "../lib/prismic/client"; import { completeOnboardingSteps } from "../lib/prismic/onboarding"; const MAX_DOMAIN_TRIES = 5; @@ -51,52 +56,45 @@ export default createCommand(config, async ({ values }) => { ); } - const { token, host } = await getCredentials(); - const domain = await createRepo({ name, lang, framework, token, host }); + const { host, getToken } = await getCredentials(); + const domain = await createRepo({ name, lang, framework }, { host, getToken }); console.info(`Repository created: ${domain}`); console.info(`URL: https://${domain}.${host}/`); }); -export async function createRepo(config: { - name?: string; - lang?: string; - framework: string; - token: string | undefined; - host: string; -}): Promise { - const { name, lang = "en-us", framework, token, host } = config; +export async function createRepo( + config: { name?: string; lang?: string; framework: string }, + { host, getToken }: Credentials, +): Promise { + const { name, lang = "en-us", framework } = config; - const domain = await findAvailableDomain({ token, host }); + const domain = await findAvailableDomain({ host, getToken }); if (!domain) { throw new CommandError("Failed to create a repository. Please try again."); } const agent = detectAgent(); - await createRepository({ domain, name: name ?? domain, framework, agent, token, host }); + await createRepository({ domain, name: name ?? domain, framework, agent, host, getToken }); // A new repository has no locale, so set the master locale to make it usable. - await upsertLocale({ id: lang, isMaster: true }, { repo: domain, token, host }); + await upsertLocale({ id: lang, isMaster: true }, { repo: domain, host, getToken }); await completeOnboardingSteps(["createPrismicProject"], { repo: domain, - token, host, + getToken, }).catch(() => {}); return domain; } -async function findAvailableDomain(config: { - token: string | undefined; - host: string; -}): Promise { - const { token, host } = config; +async function findAvailableDomain({ host, getToken }: Credentials): Promise { let domain; for (let i = 0; i < MAX_DOMAIN_TRIES; i++) { const candidate = crypto.randomUUID().replace(/-/g, "").slice(0, 8); - const available = await checkIsDomainAvailable({ domain: candidate, token, host }); + const available = await checkIsDomainAvailable({ domain: candidate, host, getToken }); if (available) { domain = candidate; break; diff --git a/src/commands/repo-list.ts b/src/commands/repo-list.ts index cd9daa3b..a3818e1a 100644 --- a/src/commands/repo-list.ts +++ b/src/commands/repo-list.ts @@ -15,9 +15,9 @@ const config = { export default createCommand(config, async ({ values }) => { const { json } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); - const { repositories: repos } = await getUserRepositories({ token, host }); + const { repositories: repos } = await getUserRepositories({ host, getToken }); if (json) { console.info( diff --git a/src/commands/repo-set-api-access.ts b/src/commands/repo-set-api-access.ts index 04f2de16..52cb33c9 100644 --- a/src/commands/repo-set-api-access.ts +++ b/src/commands/repo-set-api-access.ts @@ -33,9 +33,9 @@ export default createCommand(config, async ({ positionals, values }) => { ); } - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); - await setRepositoryApiAccess(level as RepositoryApiAccessLevel, { repo, token, host }); + await setRepositoryApiAccess(level as RepositoryApiAccessLevel, { repo, host, getToken }); console.info(`Repository access set to: ${level}`); }); diff --git a/src/commands/repo-set-name.ts b/src/commands/repo-set-name.ts index 844c78f3..1d52cd0c 100644 --- a/src/commands/repo-set-name.ts +++ b/src/commands/repo-set-name.ts @@ -23,9 +23,9 @@ export default createCommand(config, async ({ positionals, values }) => { const [displayName] = positionals; const { repo = await getRepositoryName() } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); - const { repository } = await setRepositoryName(displayName, { repo, token, host }); + const { repository } = await setRepositoryName(displayName, { repo, host, getToken }); console.info(`Repository name set to: ${repository.name}`); }); diff --git a/src/commands/repo-view.ts b/src/commands/repo-view.ts index 8deb129d..83e2d6cc 100644 --- a/src/commands/repo-view.ts +++ b/src/commands/repo-view.ts @@ -24,7 +24,7 @@ const config = { export default createCommand(config, async ({ values }) => { const { repo = await getRepositoryName(), web, json } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); const url = `https://${repo}.${host}/`; if (web) { @@ -34,8 +34,8 @@ export default createCommand(config, async ({ values }) => { } const [repository, syncState] = await Promise.all([ - getRepository({ repo, token, host }), - getRepositoryApiAccess({ repo, token, host }).catch((error) => { + getRepository({ repo, host, getToken }), + getRepositoryApiAccess({ repo, host, getToken }).catch((error) => { if (!isInvalidAuthContextError(error)) throw error; }), ]); diff --git a/src/commands/slice-add-variation.ts b/src/commands/slice-add-variation.ts index 3d6e06c5..ca9e4a9a 100644 --- a/src/commands/slice-add-variation.ts +++ b/src/commands/slice-add-variation.ts @@ -40,7 +40,7 @@ export default createCommand(config, async ({ positionals, values }) => { let imageUrl = ""; if (screenshot) { const repo = await getRepositoryName(); - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); const url = /^https?:\/\//i.test(screenshot) ? new URL(screenshot) : pathToFileURL(screenshot); const blob = await readURLFile(url); @@ -48,8 +48,8 @@ export default createCommand(config, async ({ positionals, values }) => { sliceId: slice.id, variationId: id, repo, - token, host, + getToken, }); imageUrl = screenshotUrl.toString(); } diff --git a/src/commands/slice-edit-variation.ts b/src/commands/slice-edit-variation.ts index 8ebaeeef..552a2a70 100644 --- a/src/commands/slice-edit-variation.ts +++ b/src/commands/slice-edit-variation.ts @@ -36,7 +36,7 @@ export default createCommand(config, async ({ positionals, values }) => { if (screenshot) { const repo = await getRepositoryName(); - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); const url = /^https?:\/\//i.test(screenshot) ? new URL(screenshot) : pathToFileURL(screenshot); const blob = await readURLFile(url); @@ -44,8 +44,8 @@ export default createCommand(config, async ({ positionals, values }) => { sliceId: slice.id, variationId: variation.id, repo, - token, host, + getToken, }); variation.imageUrl = screenshotUrl.toString(); } diff --git a/src/commands/status.ts b/src/commands/status.ts index 5638eedc..39237cc6 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -40,7 +40,7 @@ export default createCommand(config, async ({ values }) => { const activeEnvironment = await adapter.getEnvironment(); const { env, repo = env ?? activeEnvironment ?? repositoryName } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); const projectRoot = await findProjectRoot(); const [gitRoot, customTypeLibraries, sliceLibraries, local] = await Promise.all([ @@ -52,10 +52,10 @@ export default createCommand(config, async ({ values }) => { let authenticatedAs: string | undefined; let diff: ModelsDiff | undefined; - if (token) { + if (await getToken()) { const [session, remote] = await Promise.all([ - validateToken(token, { host }), - getRemoteModels({ repo, token, host }), + validateToken({ host, getToken }), + getRemoteModels({ repo, host, getToken }), ]); authenticatedAs = session.type === "USER" diff --git a/src/commands/sync.ts b/src/commands/sync.ts index e5e4bb83..97e4a1c6 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -46,7 +46,7 @@ export default createCommand(config, async ({ values }) => { repo = envFlag ?? (await adapter.getEnvironment()) ?? (await getRepositoryName()), } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); trackCommandStart("sync", { watch: true }); process.on("SIGINT", () => { @@ -64,7 +64,7 @@ export default createCommand(config, async ({ values }) => { while (true) { try { - const remote = await getRemoteModels({ repo, token, host }); + const remote = await getRemoteModels({ repo, host, getToken }); const sinceLastPoll = lastRemote && diffModels(remote, lastRemote); if ( @@ -92,8 +92,8 @@ export default createCommand(config, async ({ values }) => { if (isInitial) { await completeOnboardingSteps(["connectPrismic"], { repo: await getRepositoryName(), - token, host, + getToken, }).catch(() => {}); console.info("Initial sync complete."); } else if (changed.length > 0) { diff --git a/src/commands/token-create.ts b/src/commands/token-create.ts index df82d6f9..6120b9b4 100644 --- a/src/commands/token-create.ts +++ b/src/commands/token-create.ts @@ -55,22 +55,22 @@ export default createCommand(config, async ({ values }) => { throw new CommandError("--allow-releases is only valid for access tokens (not with --write)"); } - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); let createdToken: string; let scope: string | undefined; if (write) { - const writeToken = await createWriteToken(name, { repo, token, host }); + const writeToken = await createWriteToken(name, { repo, host, getToken }); createdToken = writeToken.token; } else { scope = allowReleases ? "master+releases" : "master"; // Find or create the OAuth app. - const apps = await getOAuthApps({ repo, token, host }); + const apps = await getOAuthApps({ repo, host, getToken }); let app = apps.find((a) => a.name === name); - if (!app) app = await createOAuthApp(name, { repo, token, host }); + if (!app) app = await createOAuthApp(name, { repo, host, getToken }); - const accessToken = await createOAuthAuthorization(app.id, scope, { repo, token, host }); + const accessToken = await createOAuthAuthorization(app.id, scope, { repo, host, getToken }); createdToken = accessToken.token; } diff --git a/src/commands/token-delete.ts b/src/commands/token-delete.ts index df5d8be3..e92a3838 100644 --- a/src/commands/token-delete.ts +++ b/src/commands/token-delete.ts @@ -34,18 +34,18 @@ export default createCommand(config, async ({ positionals, values }) => { const [tokenValue] = positionals; const { env, repo = env ?? (await getActiveRepositoryName()) } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); const [apps, writeTokensInfo] = await Promise.all([ - getOAuthApps({ repo, token, host }), - getWriteTokens({ repo, token, host }), + getOAuthApps({ repo, host, getToken }), + getWriteTokens({ repo, host, getToken }), ]); // Search access tokens const accessTokenAuths = apps.flatMap((app) => app.wroom_auths); const accessToken = accessTokenAuths.find((auth) => auth.token === tokenValue); if (accessToken) { - await deleteOAuthAuthorization(accessToken.id, { repo, token, host }); + await deleteOAuthAuthorization(accessToken.id, { repo, host, getToken }); console.info("Token deleted"); return; } @@ -53,7 +53,7 @@ export default createCommand(config, async ({ positionals, values }) => { // Search write tokens const writeToken = writeTokensInfo.tokens.find((t) => t.token === tokenValue); if (writeToken) { - await deleteWriteToken(writeToken.token, { repo, token, host }); + await deleteWriteToken(writeToken.token, { repo, host, getToken }); console.info("Token deleted"); return; } diff --git a/src/commands/token-list.ts b/src/commands/token-list.ts index 9c1e4f0b..2f3a43ab 100644 --- a/src/commands/token-list.ts +++ b/src/commands/token-list.ts @@ -28,11 +28,11 @@ const config = { export default createCommand(config, async ({ values }) => { const { env, repo = env ?? (await getActiveRepositoryName()), json } = values; - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); const [apps, writeTokensInfo] = await Promise.all([ - getOAuthApps({ repo, token, host }), - getWriteTokens({ repo, token, host }), + getOAuthApps({ repo, host, getToken }), + getWriteTokens({ repo, host, getToken }), ]); const accessTokens = apps.flatMap((app) => diff --git a/src/commands/webhook-create.ts b/src/commands/webhook-create.ts index 50c4ebe1..6d787def 100644 --- a/src/commands/webhook-create.ts +++ b/src/commands/webhook-create.ts @@ -74,7 +74,7 @@ export default createCommand(config, async ({ positionals, values }) => { } } - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials(); const defaultValue = trigger.length > 0 ? false : true; @@ -90,7 +90,7 @@ export default createCommand(config, async ({ positionals, values }) => { tagsCreated: trigger.includes("tagsCreated") || defaultValue, tagsDeleted: trigger.includes("tagsDeleted") || defaultValue, }, - { repo, token, host }, + { repo, host, getToken }, ); console.info(`Webhook created: ${webhookUrl}`); diff --git a/src/commands/webhook-disable.ts b/src/commands/webhook-disable.ts index 26dd7294..6ac106e3 100644 --- a/src/commands/webhook-disable.ts +++ b/src/commands/webhook-disable.ts @@ -29,8 +29,8 @@ export default createCommand(config, async ({ positionals, values }) => { const [webhookUrl] = positionals; const { env, repo = env ?? (await getActiveRepositoryName()) } = values; - const { token, host } = await getCredentials(); - const webhooks = await getWebhooks({ repo, token, host }); + const { host, getToken } = await getCredentials(); + const webhooks = await getWebhooks({ repo, host, getToken }); const webhook = webhooks.find((w) => w.config.url === webhookUrl); if (!webhook) { @@ -47,7 +47,7 @@ export default createCommand(config, async ({ positionals, values }) => { const updatedConfig = structuredClone(webhook.config); updatedConfig.active = false; - await updateWebhook(id, updatedConfig, { repo, token, host }); + await updateWebhook(id, updatedConfig, { repo, host, getToken }); console.info(`Webhook disabled: ${webhookUrl}`); }); diff --git a/src/commands/webhook-enable.ts b/src/commands/webhook-enable.ts index 82e3fbb5..56833270 100644 --- a/src/commands/webhook-enable.ts +++ b/src/commands/webhook-enable.ts @@ -29,8 +29,8 @@ export default createCommand(config, async ({ positionals, values }) => { const [webhookUrl] = positionals; const { env, repo = env ?? (await getActiveRepositoryName()) } = values; - const { token, host } = await getCredentials(); - const webhooks = await getWebhooks({ repo, token, host }); + const { host, getToken } = await getCredentials(); + const webhooks = await getWebhooks({ repo, host, getToken }); const webhook = webhooks.find((w) => w.config.url === webhookUrl); if (!webhook) { @@ -47,7 +47,7 @@ export default createCommand(config, async ({ positionals, values }) => { const updatedConfig = structuredClone(webhook.config); updatedConfig.active = true; - await updateWebhook(id, updatedConfig, { repo, token, host }); + await updateWebhook(id, updatedConfig, { repo, host, getToken }); console.info(`Webhook enabled: ${webhookUrl}`); }); diff --git a/src/commands/webhook-list.ts b/src/commands/webhook-list.ts index fc086f7b..735d69e4 100644 --- a/src/commands/webhook-list.ts +++ b/src/commands/webhook-list.ts @@ -28,8 +28,8 @@ const config = { export default createCommand(config, async ({ values }) => { const { env, repo = env ?? (await getActiveRepositoryName()), json } = values; - const { token, host } = await getCredentials(); - const webhooks = await getWebhooks({ repo, token, host }); + const { host, getToken } = await getCredentials(); + const webhooks = await getWebhooks({ repo, host, getToken }); if (json) { console.info(stringify(webhooks.map((webhook) => webhook.config))); diff --git a/src/commands/webhook-remove.ts b/src/commands/webhook-remove.ts index fb66359a..475b782d 100644 --- a/src/commands/webhook-remove.ts +++ b/src/commands/webhook-remove.ts @@ -29,8 +29,8 @@ export default createCommand(config, async ({ positionals, values }) => { const [webhookUrl] = positionals; const { env, repo = env ?? (await getActiveRepositoryName()) } = values; - const { token, host } = await getCredentials(); - const webhooks = await getWebhooks({ repo, token, host }); + const { host, getToken } = await getCredentials(); + const webhooks = await getWebhooks({ repo, host, getToken }); const webhook = webhooks.find((w) => w.config.url === webhookUrl); if (!webhook) { @@ -39,7 +39,7 @@ export default createCommand(config, async ({ positionals, values }) => { const id = webhook.config._id; - await deleteWebhook(id, { repo, token, host }); + await deleteWebhook(id, { repo, host, getToken }); console.info(`Webhook removed: ${webhookUrl}`); }); diff --git a/src/commands/webhook-set-triggers.ts b/src/commands/webhook-set-triggers.ts index e5855f99..d106ae3a 100644 --- a/src/commands/webhook-set-triggers.ts +++ b/src/commands/webhook-set-triggers.ts @@ -55,8 +55,8 @@ export default createCommand(config, async ({ positionals, values }) => { } } - const { token, host } = await getCredentials(); - const webhooks = await getWebhooks({ repo, token, host }); + const { host, getToken } = await getCredentials(); + const webhooks = await getWebhooks({ repo, host, getToken }); const webhook = webhooks.find((w) => w.config.url === webhookUrl); if (!webhook) { @@ -76,7 +76,7 @@ export default createCommand(config, async ({ positionals, values }) => { tagsCreated: trigger.includes("tagsCreated"), tagsDeleted: trigger.includes("tagsDeleted"), }, - { repo, token, host }, + { repo, host, getToken }, ); console.info(`Webhook triggers updated: ${trigger.join(", ")}`); diff --git a/src/commands/webhook-view.ts b/src/commands/webhook-view.ts index 2d39e51c..54c7d59a 100644 --- a/src/commands/webhook-view.ts +++ b/src/commands/webhook-view.ts @@ -29,8 +29,8 @@ export default createCommand(config, async ({ positionals, values }) => { const [webhookUrl] = positionals; const { env, repo = env ?? (await getActiveRepositoryName()) } = values; - const { token, host } = await getCredentials(); - const webhooks = await getWebhooks({ repo, token, host }); + const { host, getToken } = await getCredentials(); + const webhooks = await getWebhooks({ repo, host, getToken }); const webhook = webhooks.find((webhook) => webhook.config.url === webhookUrl); if (!webhook) { diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index f5f6ce1b..fc8b816a 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -8,8 +8,8 @@ const config = { } satisfies CommandConfig; export default createCommand(config, async () => { - const { token, host } = await getCredentials(); - const session = await validateToken(token, { host }); + const { host, getToken } = await getCredentials({ allowLogin: false }); + const session = await validateToken({ host, getToken }); if (session.type === "Machine2Machine") { console.info(`${session.appName} (Write API token for ${session.domain})`); diff --git a/src/env.ts b/src/env.ts index bfc218a1..992acc37 100644 --- a/src/env.ts +++ b/src/env.ts @@ -7,6 +7,7 @@ export const DEFAULT_PRISMIC_HOST = "prismic.io"; const Env = z.object({ PROD: z.stringbool(), + CI: z.optional(z.string()), PRISMIC_SENTRY_DSN: z._default(z.httpUrl(), DEFAULT_PRISMIC_SENTRY_DSN), PRISMIC_SENTRY_ENVIRONMENT: z.optional(z.string()), PRISMIC_SENTRY_ENABLED: z.optional(z.stringbool()), diff --git a/src/error.ts b/src/error.ts index d1a0b052..4e407176 100644 --- a/src/error.ts +++ b/src/error.ts @@ -11,8 +11,8 @@ import { dedent } from "./lib/string"; export async function getErrorMessage(error: unknown): Promise { if (error instanceof UnauthorizedRequestError || error instanceof ForbiddenRequestError) { - const { token } = await getCredentials(); - if (!token) { + const { getToken } = await getCredentials({ allowLogin: false }); + if (!(await getToken())) { return "Not logged in. Run `prismic login` first."; } if (env.PRISMIC_TOKEN) { diff --git a/src/index.ts b/src/index.ts index eaf925fc..82a68df8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -148,7 +148,8 @@ async function main(): Promise { } if (!help) { - const { token, host } = await getCredentials(); + const { host, getToken } = await getCredentials({ allowLogin: false }); + const token = await getToken(); const telemetryEnabled = env.PRISMIC_TELEMETRY_ENABLED ?? (await isTelemetryEnabled()); const sentryEnabled = env.PRISMIC_SENTRY_ENABLED ?? (telemetryEnabled && env.PROD); @@ -174,7 +175,7 @@ async function main(): Promise { if (knownUserId) { sentrySetUser({ id: knownUserId }); } else if (!exp || exp > now) { - getUserProfile({ token, host }) + getUserProfile({ host, getToken }) .then((profile) => { trackUser(profile); sentrySetUser({ id: profile.shortId }); diff --git a/src/lib/prismic/client.ts b/src/lib/prismic/client.ts index 2674f3d4..6c6512e7 100644 --- a/src/lib/prismic/client.ts +++ b/src/lib/prismic/client.ts @@ -3,15 +3,22 @@ import { createHash } from "node:crypto"; import type { DynamicCustomTypeModel, SharedSliceModel } from "@prismicio/types-internal"; import * as z from "zod/mini"; -import { request } from "../request"; +import { + ForbiddenRequestError, + request, + type RequestError, + UnauthorizedRequestError, +} from "../request"; import { appendTrailingSlash } from "../url"; -type Config = { - token: string | undefined; +export type Credentials = { host: string; + // With `login`, gets a new token by logging in, or nothing when a login + // is not possible. + getToken: (options?: { login?: boolean }) => Promise; }; -type RepositoryConfig = Config & { repo: string }; +export type RepositoryConfig = Credentials & { repo: string }; export async function refreshToken(token: string, config: { host: string }): Promise { const url = new URL("refreshtoken", getAuthServiceUrl(config.host)); @@ -24,13 +31,23 @@ const SessionSchema = z.union([ z.object({ type: z.literal("Machine2Machine"), domain: z.string(), appName: z.string() }), ]); -export async function validateToken( - token: string | undefined, - config: { host: string }, -): Promise> { +export async function validateToken(config: Credentials): Promise> { const url = new URL("validate", getAuthServiceUrl(config.host)); + const token = await config.getToken(); if (token) url.searchParams.set("token", token); - return request(url, { schema: SessionSchema }); + return request(url, { + schema: SessionSchema, + onAuthError: (error) => retryAfterLogin(error, config, validateToken), + }); +} + +export async function hasValidToken(config: Credentials): Promise { + const token = await config.getToken(); + if (!token) return false; + return validateToken({ host: config.host, getToken: tokenWithoutLogin(token) }).then( + () => true, + (error) => !(error instanceof UnauthorizedRequestError), + ); } function getAuthServiceUrl(host: string): URL { @@ -53,23 +70,25 @@ const UserRepositorySchema = z.object({ role: z.optional(z.union([z.string(), z.record(z.string(), z.string())])), }); -export async function getUserProfile(config: Config): Promise { +export async function getUserProfile(config: Credentials): Promise { const url = new URL("user/profile", getUserServiceUrl(config.host)); return request(url, { - headers: { Authorization: `Bearer ${config.token}` }, + headers: { Authorization: `Bearer ${await config.getToken()}` }, schema: ProfileSchema, unknownErrorMessage: "Failed to load your profile", + onAuthError: (error) => retryAfterLogin(error, config, getUserProfile), }); } export async function getUserRepositories( - config: Config, + config: Credentials, ): Promise<{ repositories: z.infer[] }> { const url = new URL("user/repositories", getUserServiceUrl(config.host)); return request(url, { - headers: { Authorization: `Bearer ${config.token}` }, + headers: { Authorization: `Bearer ${await config.getToken()}` }, schema: z.object({ repositories: z.array(UserRepositorySchema) }), unknownErrorMessage: "Failed to load your repositories", + onAuthError: (error) => retryAfterLogin(error, config, getUserRepositories), }); } @@ -93,13 +112,14 @@ const RepositorySchema = z.object({ export type Repository = z.infer; -export function getRepository(config: RepositoryConfig): Promise { +export async function getRepository(config: RepositoryConfig): Promise { const url = new URL("", getRepositoryServiceUrl(config.host)); url.searchParams.set("repository", config.repo); return request(url, { - headers: { Authorization: `Bearer ${config.token}`, repository: config.repo }, + headers: { Authorization: `Bearer ${await config.getToken()}`, repository: config.repo }, schema: RepositorySchema, notFoundMessage: `Repository not found: ${config.repo}`, + onAuthError: (error) => retryAfterLogin(error, config, getRepository), }); } @@ -112,8 +132,10 @@ export async function enableTypeBuilder( await request(url, { method: "PATCH", json: { framework }, - headers: { Authorization: `Bearer ${config.token}`, repository: config.repo }, + headers: { Authorization: `Bearer ${await config.getToken()}`, repository: config.repo }, notFoundMessage: `Repository not found: ${config.repo}`, + onAuthError: (error) => + retryAfterLogin(error, config, (config) => enableTypeBuilder(framework, config)), }); } @@ -126,8 +148,9 @@ export async function getOnboardingState(config: RepositoryConfig): Promise retryAfterLogin(error, config, getOnboardingState), }); } @@ -142,8 +165,10 @@ export async function toggleOnboardingStep( url.searchParams.set("repository", config.repo); return request(url, { method: "PATCH", - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: OnboardingStateSchema, + onAuthError: (error) => + retryAfterLogin(error, config, (config) => toggleOnboardingStep(stepId, config)), }); } @@ -153,21 +178,23 @@ function getRepositoryServiceUrl(host: string): URL { export type CustomTypesConfig = RepositoryConfig & { releaseId?: string }; -export function getCustomTypes(config: CustomTypesConfig): Promise { +export async function getCustomTypes(config: CustomTypesConfig): Promise { const url = new URL("customtypes", getCustomTypesServiceUrl(config.host)); if (config.releaseId) url.searchParams.set("release", config.releaseId); return request(url, { - headers: { Authorization: `Bearer ${config.token}`, repository: config.repo }, + headers: { Authorization: `Bearer ${await config.getToken()}`, repository: config.repo }, notFoundMessage: `Repository not found: ${config.repo}`, + onAuthError: (error) => retryAfterLogin(error, config, getCustomTypes), }); } -export function getSlices(config: CustomTypesConfig): Promise { +export async function getSlices(config: CustomTypesConfig): Promise { const url = new URL("slices", getCustomTypesServiceUrl(config.host)); if (config.releaseId) url.searchParams.set("release", config.releaseId); return request(url, { - headers: { Authorization: `Bearer ${config.token}`, repository: config.repo }, + headers: { Authorization: `Bearer ${await config.getToken()}`, repository: config.repo }, notFoundMessage: `Repository not found: ${config.repo}`, + onAuthError: (error) => retryAfterLogin(error, config, getSlices), }); } @@ -186,9 +213,11 @@ export async function bulkUpdateModels( await request(url, { method: "POST", json: { changes }, - headers: { Authorization: `Bearer ${config.token}`, repository: config.repo }, + headers: { Authorization: `Bearer ${await config.getToken()}`, repository: config.repo }, notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to update models", + onAuthError: (error) => + retryAfterLogin(error, config, (config) => bulkUpdateModels(changes, config)), }); } @@ -220,7 +249,9 @@ export async function deleteSliceScreenshots( await request(url, { method: "POST", json: { sliceId }, - headers: { Authorization: `Bearer ${config.token}`, repository: config.repo }, + headers: { Authorization: `Bearer ${await config.getToken()}`, repository: config.repo }, + onAuthError: (error) => + retryAfterLogin(error, config, (config) => deleteSliceScreenshots(sliceId, config)), }); } @@ -259,14 +290,15 @@ export async function uploadSliceScreenshot( return url; } -function getScreenshotUploadForm( +async function getScreenshotUploadForm( config: RepositoryConfig, ): Promise> { const url = new URL("presigned-url", getScreenshotServiceUrl(config.host)); url.searchParams.set("repository", config.repo); return request(url, { - headers: { Authorization: `Bearer ${config.token}`, repository: config.repo }, + headers: { Authorization: `Bearer ${await config.getToken()}`, repository: config.repo }, schema: ScreenshotPresignedUrlResponseSchema, + onAuthError: (error) => retryAfterLogin(error, config, getScreenshotUploadForm), }); } @@ -298,10 +330,11 @@ export async function getLocales(config: RepositoryConfig): Promise<{ results: L const url = new URL("repository/locales", getLocaleServiceUrl(config.host)); url.searchParams.set("repository", config.repo); return request(url, { - headers: { Authorization: `Bearer ${config.token}` }, + headers: { Authorization: `Bearer ${await config.getToken()}` }, schema: z.object({ results: z.array(LocaleSchema) }), notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to load locales", + onAuthError: (error) => retryAfterLogin(error, config, getLocales), }); } @@ -318,10 +351,12 @@ export async function upsertLocale( isMaster: locale.isMaster ?? false, ...(locale.customName ? { customName: locale.customName } : {}), }, - headers: { Authorization: `Bearer ${config.token}` }, + headers: { Authorization: `Bearer ${await config.getToken()}` }, schema: LocaleSchema, notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to save locale", + onAuthError: (error) => + retryAfterLogin(error, config, (config) => upsertLocale(locale, config)), }); } @@ -333,9 +368,10 @@ export async function deleteLocale(code: string, config: RepositoryConfig): Prom url.searchParams.set("repository", config.repo); await request(url, { method: "DELETE", - headers: { Authorization: `Bearer ${config.token}` }, + headers: { Authorization: `Bearer ${await config.getToken()}` }, notFoundMessage: `Locale not found: ${code}`, unknownErrorMessage: "Failed to remove locale", + onAuthError: (error) => retryAfterLogin(error, config, (config) => deleteLocale(code, config)), }); } @@ -357,10 +393,11 @@ export async function getPreviews(config: RepositoryConfig): Promise<{ results: getRepositoryDomainUrl(config.repo, config.host), ); return request(url, { - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: z.object({ results: z.array(PreviewSchema) }), notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to load previews", + onAuthError: (error) => retryAfterLogin(error, config, getPreviews), }); } @@ -380,9 +417,11 @@ export async function addPreview( websiteURL: previewConfig.websiteURL, resolverPath: previewConfig.resolverPath, }, - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to add preview", + onAuthError: (error) => + retryAfterLogin(error, config, (config) => addPreview(previewConfig, config)), }); } @@ -394,9 +433,10 @@ export async function deletePreview(id: string, config: RepositoryConfig): Promi await request(url, { method: "POST", json: {}, - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, notFoundMessage: `Preview not found: ${id}`, unknownErrorMessage: "Failed to remove preview", + onAuthError: (error) => retryAfterLogin(error, config, (config) => deletePreview(id, config)), }); } @@ -413,9 +453,10 @@ export async function getEnvironments( ): Promise<{ results: Environment[] }> { const url = new URL("core/environments", getRepositoryDomainUrl(config.repo, config.host)); return request(url, { - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: z.object({ results: z.array(EnvironmentSchema) }), notFoundMessage: `Repository not found: ${config.repo}`, + onAuthError: (error) => retryAfterLogin(error, config, getEnvironments), }); } @@ -424,10 +465,11 @@ export async function getSimulatorUrl( ): Promise<{ simulator_url?: string }> { const url = new URL("core/repository", getRepositoryDomainUrl(config.repo, config.host)); return request(url, { - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: z.object({ simulator_url: z.optional(z.string()) }), notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to load simulator URL", + onAuthError: (error) => retryAfterLogin(error, config, getSimulatorUrl), }); } @@ -439,9 +481,11 @@ export async function setSimulatorUrl( await request(url, { method: "PATCH", json: { simulator_url: simulatorUrl }, - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to set simulator URL", + onAuthError: (error) => + retryAfterLogin(error, config, (config) => setSimulatorUrl(simulatorUrl, config)), }); } @@ -453,9 +497,13 @@ export async function getDocumentTotalByCustomType( return request(url, { method: "POST", json: { customTypes: [customTypeId], limit: 0 }, - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: z.object({ total: z.number() }), notFoundMessage: `Repository not found: ${config.repo}`, + onAuthError: (error) => + retryAfterLogin(error, config, (config) => + getDocumentTotalByCustomType(customTypeId, config), + ), }); } @@ -467,10 +515,12 @@ export async function createRelease( return request(url, { method: "POST", json: release, - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: z.looseObject({ id: z.string() }), notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to create a release", + onAuthError: (error) => + retryAfterLogin(error, config, (config) => createRelease(release, config)), }); } @@ -481,9 +531,10 @@ export async function deleteRelease(id: string, config: RepositoryConfig): Promi ); await request(url, { method: "DELETE", - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, notFoundMessage: `Release not found: ${id}`, unknownErrorMessage: "Failed to delete the release", + onAuthError: (error) => retryAfterLogin(error, config, (config) => deleteRelease(id, config)), }); } @@ -510,13 +561,14 @@ const WebhookSchema = z.object({ }); type Webhook = z.infer; -export function getWebhooks(config: RepositoryConfig): Promise { +export async function getWebhooks(config: RepositoryConfig): Promise { const url = new URL("app/settings/webhooks", getRepositoryDomainUrl(config.repo, config.host)); return request(url, { - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: z.array(WebhookSchema), notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to load webhooks", + onAuthError: (error) => retryAfterLogin(error, config, getWebhooks), }); } @@ -532,9 +584,11 @@ export async function createWebhook( await request(url, { method: "POST", body, - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to create webhook", + onAuthError: (error) => + retryAfterLogin(error, config, (config) => createWebhook(webhookConfig, config)), }); } @@ -551,9 +605,11 @@ export async function updateWebhook( await request(url, { method: "POST", body, - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, notFoundMessage: `Webhook not found: ${id}`, unknownErrorMessage: "Failed to update webhook", + onAuthError: (error) => + retryAfterLogin(error, config, (config) => updateWebhook(id, webhookConfig, config)), }); } @@ -564,9 +620,10 @@ export async function deleteWebhook(id: string, config: RepositoryConfig): Promi ); await request(url, { method: "POST", - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, notFoundMessage: `Webhook not found: ${id}`, unknownErrorMessage: "Failed to delete webhook", + onAuthError: (error) => retryAfterLogin(error, config, (config) => deleteWebhook(id, config)), }); } @@ -614,16 +671,17 @@ const WriteTokensInfoSchema = z.object({ }); type WriteTokensInfo = z.infer; -export function getOAuthApps(config: RepositoryConfig): Promise { +export async function getOAuthApps(config: RepositoryConfig): Promise { const url = new URL( "settings/security/contentapi", getRepositoryDomainUrl(config.repo, config.host), ); return request(url, { - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: z.array(OAuthAppSchema), notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to load OAuth apps", + onAuthError: (error) => retryAfterLogin(error, config, getOAuthApps), }); } @@ -635,10 +693,12 @@ export async function createOAuthApp(name: string, config: RepositoryConfig): Pr return request(url, { method: "POST", json: { app_name: name }, - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: OAuthAppSchema, notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to create OAuth app", + onAuthError: (error) => + retryAfterLogin(error, config, (config) => createOAuthApp(name, config)), }); } @@ -654,10 +714,12 @@ export async function createOAuthAuthorization( return request(url, { method: "POST", json: { app: appId, scope }, - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: AccessTokenSchema, notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to create token", + onAuthError: (error) => + retryAfterLogin(error, config, (config) => createOAuthAuthorization(appId, scope, config)), }); } @@ -671,22 +733,25 @@ export async function deleteOAuthAuthorization( ); await request(url, { method: "DELETE", - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, notFoundMessage: `Token not found: ${authId}`, unknownErrorMessage: "Failed to delete token", + onAuthError: (error) => + retryAfterLogin(error, config, (config) => deleteOAuthAuthorization(authId, config)), }); } -export function getWriteTokens(config: RepositoryConfig): Promise { +export async function getWriteTokens(config: RepositoryConfig): Promise { const url = new URL( "settings/security/customtypesapi", getRepositoryDomainUrl(config.repo, config.host), ); return request(url, { - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: WriteTokensInfoSchema, notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to load write tokens", + onAuthError: (error) => retryAfterLogin(error, config, getWriteTokens), }); } @@ -698,10 +763,12 @@ export async function createWriteToken( return request(url, { method: "POST", json: { app_name: name }, - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: WriteTokenSchema, notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to create write token", + onAuthError: (error) => + retryAfterLogin(error, config, (config) => createWriteToken(name, config)), }); } @@ -715,9 +782,11 @@ export async function deleteWriteToken( ); await request(url, { method: "DELETE", - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, notFoundMessage: "Token not found", unknownErrorMessage: "Failed to delete write token", + onAuthError: (error) => + retryAfterLogin(error, config, (config) => deleteWriteToken(tokenValue, config)), }); } @@ -726,10 +795,11 @@ export async function getRepositoryApiAccess( ): Promise<{ repository: { api_access: string } }> { const url = new URL("syncState", getRepositoryDomainUrl(config.repo, config.host)); return request(url, { - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: z.object({ repository: z.object({ api_access: z.string() }) }), notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to load repository access", + onAuthError: (error) => retryAfterLogin(error, config, getRepositoryApiAccess), }); } @@ -746,9 +816,11 @@ export async function setRepositoryApiAccess( await request(url, { method: "POST", json: { api_access: level }, - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to set repository access", + onAuthError: (error) => + retryAfterLogin(error, config, (config) => setRepositoryApiAccess(level, config)), }); } @@ -762,10 +834,12 @@ export async function setRepositoryName( return request(url, { method: "POST", body: formData, - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: z.object({ repository: z.object({ name: z.string() }) }), notFoundMessage: `Repository not found: ${config.repo}`, unknownErrorMessage: "Failed to set repository name", + onAuthError: (error) => + retryAfterLogin(error, config, (config) => setRepositoryName(name, config)), }); } @@ -774,20 +848,21 @@ function getRepositoryDomainUrl(repo: string, host: string): URL { } export async function checkIsDomainAvailable( - config: Config & { domain: string }, + config: Credentials & { domain: string }, ): Promise { const url = new URL( `app/dashboard/repositories/${encodeURIComponent(config.domain)}/exists`, getDashboardUrl(config.host), ); return request(url, { - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, schema: z.boolean(), + onAuthError: (error) => retryAfterLogin(error, config, checkIsDomainAvailable), }); } export async function createRepository( - config: Config & { + config: Credentials & { domain: string; name: string; framework: string; @@ -801,8 +876,9 @@ export async function createRepository( await request(url, { method: "POST", json: { domain, name, framework, plan: "personal" }, - credentials: { "prismic-auth": config.token }, + credentials: { "prismic-auth": await config.getToken() }, unknownErrorMessage: "Failed to create repository", + onAuthError: (error) => retryAfterLogin(error, config, createRepository), }); } @@ -866,3 +942,20 @@ export async function getDocsPageContent(path: string, config: DocsConfig): Prom function getDocsUrl(host = DEFAULT_DOCS_HOST): URL { return new URL(`https://${host}/docs/`); } + +async function retryAfterLogin( + error: RequestError, + config: C, + retry: (config: C) => Promise, +): Promise { + // Most services answer an invalid token with a 403. A 403 with a valid + // token means no access, and a new login would not help. + if (error instanceof ForbiddenRequestError && (await hasValidToken(config))) throw error; + const token = await config.getToken({ login: true }); + if (!token) throw error; + return retry({ ...config, getToken: tokenWithoutLogin(token) }); +} + +function tokenWithoutLogin(token: string): Credentials["getToken"] { + return async ({ login } = {}) => (login ? undefined : token); +} diff --git a/src/lib/prismic/environments.ts b/src/lib/prismic/environments.ts index cdd7d009..51324776 100644 --- a/src/lib/prismic/environments.ts +++ b/src/lib/prismic/environments.ts @@ -1,15 +1,10 @@ import { dedent } from "../string"; -import { type Environment, getEnvironments, validateToken } from "./client"; +import { type Environment, getEnvironments, type RepositoryConfig, validateToken } from "./client"; -export async function getUserEnvironments(config: { - repo: string; - token: string | undefined; - host: string; -}): Promise { - const { repo, token, host } = config; +export async function getUserEnvironments(config: RepositoryConfig): Promise { const [session, { results: environments }] = await Promise.all([ - validateToken(token, { host }), - getEnvironments({ repo, token, host }), + validateToken(config), + getEnvironments(config), ]); if (session.type === "Machine2Machine") { return environments.filter((environment) => environment.domain === session.domain); diff --git a/src/lib/prismic/onboarding.ts b/src/lib/prismic/onboarding.ts index 2dd30bf7..d9ba2773 100644 --- a/src/lib/prismic/onboarding.ts +++ b/src/lib/prismic/onboarding.ts @@ -1,10 +1,4 @@ -import { getOnboardingState, toggleOnboardingStep } from "./client"; - -type OnboardingConfig = { - repo: string; - token: string | undefined; - host: string; -}; +import { getOnboardingState, type RepositoryConfig, toggleOnboardingStep } from "./client"; export type OnboardingStep = | "createPrismicProject" @@ -15,14 +9,13 @@ export type OnboardingStep = export async function completeOnboardingSteps( stepIds: OnboardingStep[], - config: OnboardingConfig, + config: RepositoryConfig, ): Promise { - const { repo, token, host } = config; - const { completedSteps } = await getOnboardingState({ repo, token, host }); + const { completedSteps } = await getOnboardingState(config); const missing = stepIds.filter((id) => !completedSteps.includes(id)); // API does not accept multiple steps; toggle each missing step sequentially. for (const stepId of missing) { - await toggleOnboardingStep(stepId, { repo, token, host }); + await toggleOnboardingStep(stepId, config); } } diff --git a/src/lib/request.ts b/src/lib/request.ts index da38136e..be8c782f 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -2,15 +2,17 @@ import * as z from "zod/mini"; const USER_AGENT = "prismic-cli"; -type CustomRequestInit = Omit & { +type CustomRequestInit = Omit & { credentials?: Record; notFoundMessage?: string; unknownErrorMessage?: string; + // Handles a 401 or 403 error. Resolve to recover the request, or throw. + onAuthError?: (error: RequestError) => Promise; }; type RequestBody = { body?: BodyInit | null; json?: never } | { body?: never; json?: unknown }; -export type RequestOptions = CustomRequestInit & +export type RequestOptions = CustomRequestInit & RequestBody & { schema?: z.ZodMiniType; }; @@ -19,7 +21,15 @@ export async function request( input: RequestInfo | URL, init: RequestOptions = {}, ): Promise { - const { credentials, json, notFoundMessage, unknownErrorMessage, schema, ...requestInit } = init; + const { + credentials, + json, + notFoundMessage, + unknownErrorMessage, + onAuthError, + schema, + ...requestInit + } = init; const headers = new Headers(init.headers); if (!headers.has("Accept")) { @@ -57,17 +67,29 @@ export async function request( return schema ? z.parse(schema, value) : (value as T); } + const error = createRequestError(response, value, { notFoundMessage, unknownErrorMessage }); + const isAuthError = + error instanceof UnauthorizedRequestError || error instanceof ForbiddenRequestError; + if (isAuthError && onAuthError) return onAuthError(error); + throw error; +} + +function createRequestError( + response: Response, + value: unknown, + messages: { notFoundMessage?: string; unknownErrorMessage?: string }, +): RequestError { switch (response.status) { case 400: - throw new BadRequestError(response, value); + return new BadRequestError(response, value); case 401: - throw new UnauthorizedRequestError(response, value); + return new UnauthorizedRequestError(response, value); case 403: - throw new ForbiddenRequestError(response, value); + return new ForbiddenRequestError(response, value); case 404: - throw new NotFoundRequestError(response, value, notFoundMessage); + return new NotFoundRequestError(response, value, messages.notFoundMessage); default: - throw new UnknownRequestError(response, value, unknownErrorMessage); + return new UnknownRequestError(response, value, messages.unknownErrorMessage); } } diff --git a/test/dev.test.ts b/test/dev.test.ts index 5223b2b7..a595a7a8 100644 --- a/test/dev.test.ts +++ b/test/dev.test.ts @@ -24,9 +24,17 @@ it("supports --help", async ({ expect, prismic }) => { expect(stdout).toContain("prismic dev [options]"); }); -it("fails when not logged in", async ({ expect, prismic, logout }) => { +it("starts a login when not logged in", async ({ expect, prismic, logout }) => { await logout(); - const { stderr, exitCode } = await prismic("dev"); + const proc = prismic("dev", [], { nodeOptions: { env: { CI: "" } } }); + const output = captureOutput(proc); + await expect.poll(output, { timeout: 15_000 }).toMatch(/port=(\d+)/); + proc.kill(); +}); + +it("fails when not logged in on CI", async ({ expect, prismic, logout }) => { + await logout(); + const { stderr, exitCode } = await prismic("dev", [], { nodeOptions: { env: { CI: "true" } } }); expect(exitCode).toBe(1); expect(stderr).toContain("Not logged in"); }); diff --git a/test/locale-list.test.ts b/test/locale-list.test.ts index dccda9c0..96cabab5 100644 --- a/test/locale-list.test.ts +++ b/test/locale-list.test.ts @@ -29,3 +29,17 @@ describe("with an isolated repository", () => { expect(parsed).toEqual(expect.arrayContaining([expect.objectContaining({ id: "fr-fr" })])); }); }); + +it("fails without a new login when the repository is not accessible", async ({ + expect, + prismic, +}) => { + const { stdout, stderr, exitCode } = await prismic( + "locale", + ["list", "--repo", "nonexistent-repo-xyz-12345"], + { nodeOptions: { env: { CI: "" } } }, + ); + expect(exitCode).toBe(1); + expect(stderr).toContain("You do not have access to this repository"); + expect(stdout).not.toContain("login"); +}); diff --git a/test/repo-list.test.ts b/test/repo-list.test.ts index a3357723..7111fe3d 100644 --- a/test/repo-list.test.ts +++ b/test/repo-list.test.ts @@ -1,4 +1,8 @@ -import { it } from "./it"; +import { writeFile } from "node:fs/promises"; + +import type { ExpectStatic } from "vitest"; + +import { captureOutput, it } from "./it"; it("supports --help", async ({ expect, prismic }) => { const { stdout, stderr, exitCode } = await prismic("repo", ["list", "--help"]); @@ -40,3 +44,49 @@ it("lists the one repository a Write API token can write to", async ({ }, ]); }); + +it("logs in and continues when not logged in", async ({ expect, prismic, logout, token, repo }) => { + await logout(); + const proc = prismic("repo", ["list"], { nodeOptions: { env: { CI: "" } } }); + const output = captureOutput(proc); + await completeLogin(expect, output, token); + const { exitCode } = await proc; + expect(exitCode, output()).toBe(0); + expect(output()).toContain("You are not logged in."); + expect(output()).toContain("Open this URL to log in:"); + expect(output()).toContain(repo); +}); + +it("logs in again and continues when the login expired", async ({ + expect, + prismic, + home, + host, + token, + repo, +}) => { + await writeFile( + new URL(".config/prismic/credentials.json", home), + JSON.stringify({ token: "expired-token", host }), + ); + const proc = prismic("repo", ["list"], { nodeOptions: { env: { CI: "" } } }); + const output = captureOutput(proc); + await completeLogin(expect, output, token); + const { exitCode } = await proc; + expect(exitCode, output()).toBe(0); + expect(output()).toContain("Your login expired."); + expect(output()).toContain(repo); +}); + +async function completeLogin(expect: ExpectStatic, output: () => string, token: string) { + await expect.poll(output, { timeout: 15_000 }).toMatch(/port=(\d+)/); + const port = output().match(/port=(\d+)/)![1]; + await fetch(`http://localhost:${port}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + cookies: [`prismic-auth=${token}; path=/`], + email: "test@example.com", + }), + }); +}