Skip to content
Draft
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
69 changes: 55 additions & 14 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<typeof CredentialsSchema>;

export async function getCredentials(): Promise<{ token: string | undefined; host: string }> {
const credentials = await readCredentials();
type StoredCredentials = z.infer<typeof CredentialsSchema>;

// 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<Credentials> {
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<string> {
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<string> | undefined;

// Parallel requests that find an invalid token share one login.
function logInOnce(invalidToken: string | undefined): Promise<string> {
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<string | undefined> {
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 });
Expand All @@ -54,17 +95,17 @@ export async function logout(): Promise<boolean> {
}
}

async function readCredentials(): Promise<Credentials | undefined> {
async function readCredentials(): Promise<StoredCredentials | undefined> {
return readJsonFile(CREDENTIALS_PATH, { schema: CredentialsSchema }).catch(() => undefined);
}

async function saveCredentials(credentials: Credentials): Promise<void> {
async function saveCredentials(credentials: StoredCredentials): Promise<void> {
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}`;

Expand Down Expand Up @@ -116,7 +157,7 @@ export async function createLoginSession(options: {

clearTimeout(timeoutId);
server.close();
resolve({ email });
resolve({ email, token });
} catch {
respond(400, { error: "Invalid request" });
}
Expand All @@ -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<void> => {
Expand Down
58 changes: 31 additions & 27 deletions src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -66,25 +72,28 @@ 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) {
session = { ...previous, pid: process.pid };
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,
Expand Down Expand Up @@ -173,14 +182,13 @@ async function saveSession(sessionPath: URL, session: Session): Promise<void> {
await writeFileRecursive(sessionPath, stringify(session));
}

async function createSession({ repo, token, host }: CustomTypesConfig): Promise<Session> {
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<Session> {
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<void> {
Expand All @@ -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<Session | undefined> {
const { token, host, continueSession, newSession } = options;
const { continueSession, newSession } = options;
const previous = await readSession(sessionPath);
if (previous && isRunning(previous.pid)) {
throw new CommandError(
Expand All @@ -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) {
Expand All @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions src/commands/env-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions src/commands/env-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
62 changes: 17 additions & 45 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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.`,
Expand Down Expand Up @@ -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) {
Expand All @@ -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}`);
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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---");
Expand Down Expand Up @@ -350,7 +322,7 @@ async function isStarterPackage(starter: NonNullable<Repository["starter"]>): Pr
async function completeStarterHandoff(
adapter: Adapter,
starter: NonNullable<Repository["starter"]>,
config: { repo: string; token: string | undefined; host: string },
config: RepositoryConfig,
): Promise<void> {
try {
const { results: previews } = await getPreviews(config);
Expand Down
4 changes: 2 additions & 2 deletions src/commands/locale-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
});
Loading
Loading