Skip to content
Merged
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
120 changes: 120 additions & 0 deletions src/clients/wroom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,126 @@ export async function deleteWebhook(
});
}

const AccessTokenSchema = z.object({
id: z.string(),
scope: z.string(),
token: z.string(),
created_at: z.object({ $date: z.number() }),
});
type AccessToken = z.infer<typeof AccessTokenSchema>;

const OAuthAppSchema = z.object({
id: z.string(),
name: z.string(),
wroom_auths: z.array(AccessTokenSchema),
});
type OAuthApp = z.infer<typeof OAuthAppSchema>;

const WriteTokenSchema = z.object({
app_name: z.string(),
token: z.string(),
timestamp: z.number(),
});
type WriteToken = z.infer<typeof WriteTokenSchema>;

const WriteTokensInfoSchema = z.object({
max_tokens: z.number(),
tokens: z.array(WriteTokenSchema),
});
type WriteTokensInfo = z.infer<typeof WriteTokensInfoSchema>;

export async function getOAuthApps(config: {
repo: string;
token: string | undefined;
host: string;
}): Promise<OAuthApp[]> {
const url = new URL("settings/security/contentapi", getWroomUrl(config.repo, config.host));
return await request(url, {
credentials: { "prismic-auth": config.token },
schema: z.array(OAuthAppSchema),
});
}

export async function createOAuthApp(
name: string,
config: { repo: string; token: string | undefined; host: string },
): Promise<OAuthApp> {
const url = new URL("settings/security/oauthapp", getWroomUrl(config.repo, config.host));
return await request(url, {
method: "POST",
body: { app_name: name },
credentials: { "prismic-auth": config.token },
schema: OAuthAppSchema,
});
}

export async function createOAuthAuthorization(
appId: string,
scope: string,
config: { repo: string; token: string | undefined; host: string },
): Promise<AccessToken> {
const url = new URL("settings/security/authorizations", getWroomUrl(config.repo, config.host));
return await request(url, {
method: "POST",
body: { app: appId, scope },
credentials: { "prismic-auth": config.token },
schema: AccessTokenSchema,
});
}

export async function deleteOAuthAuthorization(
authId: string,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
const url = new URL(
`settings/security/authorizations/${encodeURIComponent(authId)}`,
getWroomUrl(config.repo, config.host),
);
await request(url, {
method: "DELETE",
credentials: { "prismic-auth": config.token },
});
}

export async function getWriteTokens(config: {
repo: string;
token: string | undefined;
host: string;
}): Promise<WriteTokensInfo> {
const url = new URL("settings/security/customtypesapi", getWroomUrl(config.repo, config.host));
return await request(url, {
credentials: { "prismic-auth": config.token },
schema: WriteTokensInfoSchema,
});
}

export async function createWriteToken(
name: string,
config: { repo: string; token: string | undefined; host: string },
): Promise<WriteToken> {
const url = new URL("settings/security/token", getWroomUrl(config.repo, config.host));
return await request(url, {
method: "POST",
body: { app_name: name },
credentials: { "prismic-auth": config.token },
schema: WriteTokenSchema,
});
}

export async function deleteWriteToken(
tokenValue: string,
config: { repo: string; token: string | undefined; host: string },
): Promise<void> {
const url = new URL(
`settings/security/token/${encodeURIComponent(tokenValue)}`,
getWroomUrl(config.repo, config.host),
);
await request(url, {
method: "DELETE",
credentials: { "prismic-auth": config.token },
});
}

function getWroomUrl(repo: string, host: string): URL {
return new URL(`https://${repo}.${host}/`);
}
55 changes: 55 additions & 0 deletions src/commands/token-create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { getHost, getToken } from "../auth";
import {
createOAuthAuthorization,
createOAuthApp,
createWriteToken,
getOAuthApps,
} from "../clients/wroom";
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
import { getRepositoryName } from "../project";

const CLI_APP_NAME = "Prismic CLI";

const config = {
name: "prismic token create",
description: `
Create a new API token for a Prismic repository.

By default, this command reads the repository from prismic.config.json at the
project root.
`,
options: {
write: { type: "boolean", description: "Create a write token" },
"allow-releases": {
type: "boolean",
description: "Allow access to releases (read tokens only)",
},
repo: { type: "string", short: "r", description: "Repository domain" },
},
} satisfies CommandConfig;

export default createCommand(config, async ({ values }) => {
const { repo = await getRepositoryName(), write, "allow-releases": allowReleases } = values;

if (write && allowReleases) {
throw new CommandError("--allow-releases is only valid for access tokens (not with --write)");
}

const token = await getToken();
const host = await getHost();

if (write) {
const writeToken = await createWriteToken(CLI_APP_NAME, { repo, token, host });
console.info(`Token created: ${writeToken.token}`);
} else {
const scope = allowReleases ? "master+releases" : "master";

// Find or create the CLI OAuth app.
const apps = await getOAuthApps({ repo, token, host });
let app = apps.find((a) => a.name === CLI_APP_NAME);
if (!app) app = await createOAuthApp(CLI_APP_NAME, { repo, token, host });

const accessToken = await createOAuthAuthorization(app.id, scope, { repo, token, host });
console.info(`Token created: ${accessToken.token}`);
}
});
61 changes: 61 additions & 0 deletions src/commands/token-delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { getHost, getToken } from "../auth";
import {
deleteOAuthAuthorization,
deleteWriteToken,
getOAuthApps,
getWriteTokens,
} from "../clients/wroom";
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
import { getRepositoryName } from "../project";

const config = {
name: "prismic token delete",
description: `
Delete a token from a Prismic repository.

By default, this command reads the repository from prismic.config.json at the
project root.
`,
positionals: {
token: { description: "Token value" },
},
options: {
repo: { type: "string", short: "r", description: "Repository domain" },
},
} satisfies CommandConfig;

export default createCommand(config, async ({ positionals, values }) => {
const [tokenValue] = positionals;
const { repo = await getRepositoryName() } = values;

if (!tokenValue) {
throw new CommandError("Missing required argument: <token>");
}

const token = await getToken();
const host = await getHost();

const [apps, writeTokensInfo] = await Promise.all([
getOAuthApps({ repo, token, host }),
getWriteTokens({ repo, token, host }),
]);

// 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 });
console.info("Token deleted");
return;
}

// Search write tokens
const writeToken = writeTokensInfo.tokens.find((t) => t.token === tokenValue);
if (writeToken) {
await deleteWriteToken(writeToken.token, { repo, token, host });
console.info("Token deleted");
return;
}

throw new CommandError(`Token not found: ${tokenValue}`);
});
67 changes: 67 additions & 0 deletions src/commands/token-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { getHost, getToken } from "../auth";
import { getOAuthApps, getWriteTokens } from "../clients/wroom";
import { createCommand, type CommandConfig } from "../lib/command";
import { stringify } from "../lib/json";
import { getRepositoryName } from "../project";

const config = {
name: "prismic token list",
description: `
List all API tokens for a Prismic repository.

By default, this command reads the repository from prismic.config.json at the
project root.
`,
options: {
json: { type: "boolean", description: "Output as JSON" },
repo: { type: "string", short: "r", description: "Repository domain" },
},
} satisfies CommandConfig;

export default createCommand(config, async ({ values }) => {
const { repo = await getRepositoryName(), json } = values;

const token = await getToken();
const host = await getHost();

const [apps, writeTokensInfo] = await Promise.all([
getOAuthApps({ repo, token, host }),
getWriteTokens({ repo, token, host }),
]);

const accessTokens = apps.flatMap((app) =>
app.wroom_auths.map((auth) => ({
name: app.name,
scope: auth.scope,
token: auth.token,
createdAt: new Date(auth.created_at.$date).toISOString().split("T")[0],
})),
);
const writeTokens = writeTokensInfo.tokens;

if (json) {
console.info(stringify({ accessTokens, writeTokens }));
return;
Copy link

Choose a reason for hiding this comment

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

JSON output has inconsistent key naming and formats

Low Severity

The --json output mixes two different schemas within the same response object. accessTokens entries are reformatted into camelCase keys with a formatted date string (name, createdAt), while writeTokens entries are passed through raw from the API with snake_case keys and a raw Unix timestamp (app_name, timestamp). Consumers of the JSON output need to handle two different naming conventions and date formats in a single response.

Additional Locations (1)
Fix in Cursor Fix in Web

}

if (accessTokens.length > 0) {
console.info("ACCESS TOKENS");
for (const accessToken of accessTokens) {
console.info(` ${accessToken.name} ${accessToken.scope} ${accessToken.token} ${accessToken.createdAt}`);
}
} else {
console.info("ACCESS TOKENS (none)");
}

console.info("");

if (writeTokens.length > 0) {
console.info("WRITE TOKENS");
for (const writeToken of writeTokens) {
const date = new Date(writeToken.timestamp * 1000).toISOString().split("T")[0];
console.info(` ${writeToken.app_name} ${writeToken.token} ${date}`);
}
} else {
console.info("WRITE TOKENS (none)");
}
});
23 changes: 23 additions & 0 deletions src/commands/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createCommandRouter } from "../lib/command";
import tokenCreate from "./token-create";
import tokenDelete from "./token-delete";
import tokenList from "./token-list";

export default createCommandRouter({
name: "prismic token",
description: "Manage API tokens for a Prismic repository.",
commands: {
list: {
handler: tokenList,
description: "List all tokens",
},
create: {
handler: tokenCreate,
description: "Create a new token",
},
delete: {
handler: tokenDelete,
description: "Delete a token",
},
},
});
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import login from "./commands/login";
import logout from "./commands/logout";
import preview from "./commands/preview";
import sync from "./commands/sync";
import token from "./commands/token";
import webhook from "./commands/webhook";
import whoami from "./commands/whoami";
import { InvalidPrismicConfig, MissingPrismicConfig } from "./config";
Expand Down Expand Up @@ -56,6 +57,10 @@ const router = createCommandRouter({
handler: preview,
description: "Manage preview configurations",
},
token: {
handler: token,
description: "Manage API tokens",
},
webhook: {
handler: webhook,
description: "Manage webhooks",
Expand Down
Loading