@mintlify/cli 4.0.1714 → 4.0.1716

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { claimDeployment } from '../src/agent-claim.js';
4
+ import { loadAgentCredentials, saveAgentCredentials } from '../src/agent-credentials.js';
5
+
6
+ vi.mock('../src/agent-credentials.js', () => ({
7
+ loadAgentCredentials: vi.fn(),
8
+ saveAgentCredentials: vi.fn(),
9
+ }));
10
+ const issuer = 'https://auth.example.com';
11
+ const apiUrl = 'https://api.example.com';
12
+ const options = { apiUrl, directory: '/docs', code: 'ABCDEFGH' };
13
+ const original = {
14
+ registrationId: 'reg',
15
+ issuer,
16
+ assertion: 'old-assertion',
17
+ claimToken: 'secret-claim',
18
+ accessToken: 'anonymous-token',
19
+ accessTokenExpiresAt: Date.now() + 300_000,
20
+ };
21
+ const discovery = {
22
+ issuer,
23
+ token_endpoint: `${issuer}/oauth2/token`,
24
+ agent_auth: {
25
+ identity_endpoint: `${issuer}/agent/identity`,
26
+ claim_endpoint: `${issuer}/agent/identity/claim`,
27
+ identity_types_supported: ['anonymous'],
28
+ },
29
+ };
30
+ const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
31
+ const fetchMock = vi.fn();
32
+ beforeEach(() => {
33
+ vi.resetAllMocks();
34
+ vi.stubGlobal('fetch', fetchMock);
35
+ vi.mocked(loadAgentCredentials).mockResolvedValue({ ...original });
36
+ vi.mocked(saveAgentCredentials).mockResolvedValue(undefined);
37
+ fetchMock.mockImplementation(async (url: string) => {
38
+ if (url.endsWith('oauth-protected-resource'))
39
+ return response({ authorization_servers: [issuer] });
40
+ if (url.endsWith('oauth-authorization-server')) return response(discovery);
41
+ if (url.endsWith('/complete'))
42
+ return response({ identity: { assertion: 'claimed-assertion' } });
43
+ if (url.endsWith('/oauth2/token'))
44
+ return response({ access_token: 'claimed-token', expires_in: 300, token_type: 'Bearer' });
45
+ if (url.endsWith('/signup')) return response({ orgId: 'org', claimed: true });
46
+ if (url.endsWith('/claim'))
47
+ return response({ attempt: { verification_uri: `${issuer}/verify?token=attempt` } });
48
+ throw new Error('Unexpected endpoint');
49
+ });
50
+ });
51
+ afterEach(() => vi.unstubAllGlobals());
52
+
53
+ describe('claim command', () => {
54
+ it('completes the existing claim, saves the new identity, and signs up with a fresh token', async () => {
55
+ expect(await claimDeployment(options, vi.fn())).toContain('successfully');
56
+ expect(fetchMock).toHaveBeenCalledWith(
57
+ `${issuer}/agent/identity/claim/complete`,
58
+ expect.objectContaining({
59
+ body: JSON.stringify({ claim_token: 'secret-claim', user_code: options.code }),
60
+ })
61
+ );
62
+ expect(fetchMock).not.toHaveBeenCalledWith(`${issuer}/agent/identity/claim`, expect.anything());
63
+ expect(fetchMock).toHaveBeenCalledWith(
64
+ `${apiUrl}/api/agent/signup`,
65
+ expect.objectContaining({ headers: { Authorization: 'Bearer claimed-token' } })
66
+ );
67
+ expect(vi.mocked(saveAgentCredentials).mock.calls[0]?.[2]).toMatchObject({
68
+ assertion: 'claimed-assertion',
69
+ claimVerified: true,
70
+ accessToken: undefined,
71
+ });
72
+ expect(vi.mocked(saveAgentCredentials).mock.invocationCallOrder[0]).toBeLessThan(
73
+ fetchMock.mock.invocationCallOrder[3]!
74
+ );
75
+ });
76
+ it('resumes after signup fails without consuming the code again', async () => {
77
+ const implementation = fetchMock.getMockImplementation()!;
78
+ fetchMock.mockImplementation((url, init) =>
79
+ url.endsWith('/signup') ? response({ code: 'unavailable' }, 503) : implementation(url, init)
80
+ );
81
+ await expect(claimDeployment(options, vi.fn())).rejects.toThrow('503');
82
+ const saved = vi.mocked(saveAgentCredentials).mock.calls[0]![2];
83
+ vi.mocked(loadAgentCredentials).mockResolvedValue(saved);
84
+ fetchMock.mockClear().mockImplementation(implementation);
85
+ expect(await claimDeployment(options, vi.fn())).toContain('successfully');
86
+ expect(fetchMock.mock.calls.some(([url]) => url.endsWith('/complete'))).toBe(false);
87
+ });
88
+ it('does not save or sign up after an invalid code', async () => {
89
+ const implementation = fetchMock.getMockImplementation()!;
90
+ fetchMock.mockImplementation((url, init) =>
91
+ url.endsWith('/complete')
92
+ ? response({ code: 'invalid_user_code' }, 400)
93
+ : implementation(url, init)
94
+ );
95
+ await expect(claimDeployment(options, vi.fn())).rejects.toThrow('invalid_user_code');
96
+ expect(saveAgentCredentials).not.toHaveBeenCalled();
97
+ expect(fetchMock.mock.calls.some(([url]) => url.endsWith('/signup'))).toBe(false);
98
+ });
99
+ it('warns on storage failure and continues signup without exposing secrets', async () => {
100
+ vi.mocked(saveAgentCredentials).mockRejectedValue(new Error('keyring unavailable'));
101
+ const warn = vi.fn();
102
+ const output = await claimDeployment(options, warn);
103
+ expect(output).toContain('successfully');
104
+ expect(warn).toHaveBeenCalled();
105
+ expect(JSON.stringify([output, warn.mock.calls])).not.toContain('secret-claim');
106
+ expect(JSON.stringify([output, warn.mock.calls])).not.toContain('claimed-assertion');
107
+ });
108
+ it('creates only a claim link for --email', async () => {
109
+ const output = await claimDeployment(
110
+ { apiUrl, directory: '/docs', email: 'person@example.com' },
111
+ vi.fn()
112
+ );
113
+ expect(output).toContain('mint claim --code');
114
+ expect(
115
+ fetchMock.mock.calls.some(([url]) => url.endsWith('/signup') || url.endsWith('/complete'))
116
+ ).toBe(false);
117
+ });
118
+ it('fails before network calls when credentials are missing', async () => {
119
+ vi.mocked(loadAgentCredentials).mockResolvedValue(null);
120
+ await expect(claimDeployment(options, vi.fn())).rejects.toThrow('No saved agent registration');
121
+ expect(fetchMock).not.toHaveBeenCalled();
122
+ });
123
+ it.each([{ code: undefined }, { code: ' ', email: undefined }, { email: 'person@example.com' }])(
124
+ 'rejects missing or conflicting arguments: %o',
125
+ async (override) => {
126
+ await expect(claimDeployment({ ...options, ...override }, vi.fn())).rejects.toThrow(
127
+ 'Provide either'
128
+ );
129
+ expect(fetchMock).not.toHaveBeenCalled();
130
+ }
131
+ );
132
+ it('rejects a different issuer without sending the claim token', async () => {
133
+ vi.mocked(loadAgentCredentials).mockResolvedValue({
134
+ ...original,
135
+ issuer: 'https://other.example.com',
136
+ });
137
+ await expect(claimDeployment(options, vi.fn())).rejects.toThrow('another issuer');
138
+ expect(fetchMock.mock.calls).toHaveLength(2);
139
+ });
140
+ it('does not report success if signup returns an anonymous identity', async () => {
141
+ const implementation = fetchMock.getMockImplementation()!;
142
+ fetchMock.mockImplementation((url, init) =>
143
+ url.endsWith('/signup')
144
+ ? response({ orgId: 'org', claimed: false })
145
+ : implementation(url, init)
146
+ );
147
+ await expect(claimDeployment(options, vi.fn())).rejects.toThrow('unexpected response');
148
+ });
149
+ });
@@ -5,7 +5,8 @@ import { DASHBOARD_URL } from '../src/constants.js';
5
5
  import { deploymentInstructions, DEPLOY_HELP } from '../src/deploy-instructions.js';
6
6
  import { renderWelcome } from '../src/welcome.js';
7
7
 
8
- vi.mock('../src/agent-auth.js', () => ({
8
+ vi.mock('../src/agent-auth.js', async (importOriginal) => ({
9
+ ...(await importOriginal<typeof import('../src/agent-auth.js')>()),
9
10
  discoverAgentAuth: async () => ({
10
11
  issuer: 'https://auth.example.com',
11
12
  agent_auth: { claim_endpoint: 'https://auth.example.com/agent/identity/claim' },
@@ -81,6 +82,8 @@ describe('deploy help and claim handoff', () => {
81
82
  'https://app.example.com/claim'
82
83
  );
83
84
  expect(text).toContain('Do not force-push');
85
+ expect(text).toContain('mint claim --code <code>');
86
+ expect(text).not.toContain('/auth.md');
84
87
  expect(text).toContain('docs.json');
85
88
  expect(text).toContain('https://app.example.com/claim');
86
89
  expect(text).not.toContain('assertion-secret');
@@ -91,6 +94,8 @@ describe('deploy help and claim handoff', () => {
91
94
  expect(renderWelcome({ packageName: 'mint', signedIn: false })).toContain('deploy');
92
95
  expect(DEPLOY_HELP).toContain('Commit and push the documentation yourself');
93
96
  expect(DEPLOY_HELP).toContain('--new');
97
+ expect(DEPLOY_HELP).toContain('mint claim --code <code>');
98
+ expect(renderWelcome({ packageName: 'mint', signedIn: false })).toContain('claim --email');
94
99
  expect(DEPLOY_HELP).toContain('ask the user for their email');
95
100
  });
96
101
  });
package/bin/agent-auth.js CHANGED
@@ -19,9 +19,10 @@ const discoverySchema = z.object({
19
19
  identity_types_supported: z.array(z.string()),
20
20
  }),
21
21
  });
22
+ export const agentIdentityAssertionSchema = z.object({ assertion: z.string().min(1) });
22
23
  const registrationSchema = z.object({
23
24
  id: z.string().min(1),
24
- identity: z.object({ assertion: z.string().min(1) }),
25
+ identity: agentIdentityAssertionSchema,
25
26
  claim: z.object({ token: z.string().min(1) }),
26
27
  });
27
28
  const tokenSchema = z.object({
@@ -8,7 +8,8 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
8
8
  });
9
9
  };
10
10
  import { z } from 'zod';
11
- import { discoverAgentAuth } from './agent-auth.js';
11
+ import { agentIdentityAssertionSchema, discoverAgentAuth, exchangeAgentToken, } from './agent-auth.js';
12
+ import { loadAgentCredentials, saveAgentCredentials, } from './agent-credentials.js';
12
13
  import { agentRequest, agentResponse } from './agent-http.js';
13
14
  import { DASHBOARD_URL } from './constants.js';
14
15
  const claimAttemptSchema = z.object({ attempt: z.object({ verification_uri: z.url() }) });
@@ -37,3 +38,56 @@ export function createAgentClaimLink(apiUrl, credentials, email) {
37
38
  return claimUrl.toString();
38
39
  });
39
40
  }
41
+ const completedClaimSchema = z.object({
42
+ identity: agentIdentityAssertionSchema,
43
+ });
44
+ const signupSchema = z.object({ orgId: z.string().min(1), claimed: z.literal(true) });
45
+ export function claimDeployment(options_1, warn_1) {
46
+ return __awaiter(this, arguments, void 0, function* (options, warn, progress = () => { }) {
47
+ var _a, _b;
48
+ const { apiUrl, directory } = options;
49
+ const code = (_a = options.code) === null || _a === void 0 ? void 0 : _a.trim();
50
+ const email = (_b = options.email) === null || _b === void 0 ? void 0 : _b.trim();
51
+ if ((!code && !email) || (code && email)) {
52
+ throw new Error('Provide either --email <email> for a claim link or --code <code> to finish the claim.');
53
+ }
54
+ let credentials = yield loadAgentCredentials(apiUrl, directory);
55
+ if (!credentials)
56
+ throw new Error('No saved agent registration. Run from the directory used for mint deploy, or pass --directory.');
57
+ if (email && !credentials.claimVerified) {
58
+ progress('Creating the claim link...');
59
+ const link = yield createAgentClaimLink(apiUrl, credentials, email);
60
+ return `Claim link for the user: ${link}\nAsk the user to open it, sign in, and return the displayed code. Then run mint claim --code <code> from the same directory (or repeat --directory).`;
61
+ }
62
+ const discovery = yield discoverAgentAuth(apiUrl);
63
+ if (new URL(discovery.issuer).origin !== new URL(credentials.issuer).origin) {
64
+ throw new Error('Saved agent credentials belong to another issuer.');
65
+ }
66
+ const save = (value) => __awaiter(this, void 0, void 0, function* () {
67
+ try {
68
+ yield saveAgentCredentials(apiUrl, directory, value);
69
+ }
70
+ catch (_a) {
71
+ warn('Could not save claimed credentials to the keyring. Continuing for this run; a retry may not recover the completed claim.');
72
+ }
73
+ });
74
+ if (!credentials.claimVerified) {
75
+ progress('Completing the WorkOS claim...');
76
+ const completed = yield agentResponse(yield agentRequest(`${discovery.agent_auth.claim_endpoint.replace(/\/$/, '')}/complete`, {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({ claim_token: credentials.claimToken, user_code: code }),
80
+ }), completedClaimSchema);
81
+ credentials = Object.assign(Object.assign({}, credentials), { assertion: completed.identity.assertion, claimVerified: true, accessToken: undefined, accessTokenExpiresAt: undefined });
82
+ yield save(credentials);
83
+ }
84
+ progress('Finishing the site claim...');
85
+ const authenticated = yield exchangeAgentToken(discovery, credentials);
86
+ yield agentResponse(yield agentRequest(`${apiUrl}/api/agent/signup`, {
87
+ method: 'POST',
88
+ headers: { Authorization: `Bearer ${authenticated.accessToken}` },
89
+ }), signupSchema);
90
+ yield save(authenticated);
91
+ return 'Site claimed successfully. The user can now manage it in the Mintlify dashboard.';
92
+ });
93
+ }
@@ -17,6 +17,7 @@ export const agentCredentialsSchema = z.object({
17
17
  issuer: z.url(),
18
18
  assertion: z.string().min(1),
19
19
  claimToken: z.string().min(1),
20
+ claimVerified: z.boolean().optional(),
20
21
  accessToken: z.string().min(1).optional(),
21
22
  accessTokenExpiresAt: z.number().optional(),
22
23
  });
package/bin/cli.js CHANGED
@@ -17,7 +17,7 @@ import yargs from 'yargs';
17
17
  import { hideBin } from 'yargs/helpers';
18
18
  import { accessibilityCheck } from './accessibilityCheck.js';
19
19
  import { addDomain } from './addDomain.js';
20
- import { createAgentClaimLink } from './agent-claim.js';
20
+ import { claimDeployment, createAgentClaimLink } from './agent-claim.js';
21
21
  import { analyticsBuilder } from './analytics/index.js';
22
22
  import { brokenLinksHandler } from './brokenLinks.js';
23
23
  import { comingSoon } from './comingSoon.js';
@@ -96,6 +96,31 @@ export const cli = ({ packageName = 'mint' }) => {
96
96
  .middleware(checkNodeVersion)
97
97
  .middleware(suppressConsoleWarnings)
98
98
  .middleware(telemetryMiddleware)
99
+ .command('claim', 'Claim a site created with mint deploy', (yargs) => yargs
100
+ .option('email', { type: 'string', description: 'User email for a claim link' })
101
+ .option('code', {
102
+ type: 'string',
103
+ description: 'Code returned by the user from the claim page',
104
+ })
105
+ .option('directory', {
106
+ type: 'string',
107
+ default: '.',
108
+ description: 'Original documentation directory used for mint deploy',
109
+ })
110
+ .conflicts('code', 'email')
111
+ .example('$0 claim --email you@example.com', 'Get a claim link for the current site')
112
+ .example('$0 claim --code ABCDEFGH', 'Finish the claim with the code from the user')
113
+ .epilog('Run from the same directory as mint deploy, or pass --directory. Ask the user to open the claim link and return the code. The CLI reads saved credentials internally; do not read or print keychain secrets. Retry the same command if Mintlify signup fails.'), (argv) => __awaiter(void 0, void 0, void 0, function* () {
114
+ try {
115
+ const message = yield claimDeployment({ apiUrl: API_URL, directory: argv.directory, code: argv.code, email: argv.email }, (message) => process.stderr.write(`Warning: ${message}\n`), (message) => process.stderr.write(`${message}\n`));
116
+ process.stdout.write(`${message}\n`);
117
+ yield terminate(0);
118
+ }
119
+ catch (error) {
120
+ process.stderr.write(`${error instanceof Error ? error.message : 'Claim failed.'}\n`);
121
+ yield terminate(1);
122
+ }
123
+ }))
99
124
  .command('deploy', 'Provision an anonymous docs site and get Git push instructions', (yargs) => yargs
100
125
  .option('subdomain', {
101
126
  type: 'string',
@@ -1,10 +1,10 @@
1
- import { API_URL } from './constants.js';
2
1
  export const DEPLOY_HELP = [
3
2
  'No login is required. This command provisions a temporary site and returns repository access. Commit and push the documentation yourself; the CLI does not upload files.',
4
3
  'Run from your documentation directory, or pass --directory. Put docs.json, pages, and assets at the hosted repository root.',
5
4
  'Run again from the same documentation directory for the current anonymous site and a fresh write token. Use --new --subdomain <name> to create another site.',
6
5
  'Claimed sites are managed in the dashboard. Confirmed expired sites start a new anonymous registration.',
7
6
  'Pass --email to obtain a claim link for the user. If it is absent, ask the user for their email and rerun with --email from the same directory. Never invent an email address.',
7
+ 'To claim an existing site without provisioning, run mint claim --email <email>. Show the link to the user and ask them to return the displayed code. Then run mint claim --code <code>. Use the original documentation directory for both commands, or pass --directory.',
8
8
  'The output contains a short-lived write token. Do not paste it into public logs or save it in a Git remote.',
9
9
  ].join('\n\n');
10
10
  export function deploymentInstructions(result, claimUrl) {
@@ -29,7 +29,7 @@ export function deploymentInstructions(result, claimUrl) {
29
29
  ? [
30
30
  `Claim link for the user: ${claimUrl}`,
31
31
  'Show this link to the user. Ask them to open it, sign in, and return the displayed code. Do not sign in on their behalf.',
32
- `Follow the claim-completion instructions at ${API_URL}/api/agent/auth.md. This document includes the WorkOS steps and the authenticated Mintlify signup request.`,
32
+ 'After the user returns the code, run mint claim --code <code> from the same documentation directory (or repeat --directory). The CLI completes the claim using its saved credentials.',
33
33
  ]
34
34
  : [
35
35
  'Ask the user for the email address they want to use to claim this site. Never invent an email address.',