@mintlify/cli 4.0.1465 → 4.0.1467

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.
Files changed (40) hide show
  1. package/__test__/mintTestFilePreview.test.ts +35 -0
  2. package/__test__/mintTestOutput.test.ts +194 -0
  3. package/__test__/runHistory.test.ts +164 -0
  4. package/bin/agent-harness/agentPreflight.js +24 -0
  5. package/bin/agent-harness/buildTaskPrompt.js +17 -7
  6. package/bin/agent-harness/countReport.js +10 -0
  7. package/bin/agent-harness/executeCodeBlocks.js +53 -10
  8. package/bin/agent-harness/generateTestCode.js +58 -43
  9. package/bin/agent-harness/index.js +14 -5
  10. package/bin/agent-harness/manifest.js +113 -0
  11. package/bin/agent-harness/runHistory.js +252 -0
  12. package/bin/agent-harness/setupFolders.js +20 -6
  13. package/bin/agent-harness/tasks/checkTestability.js +7 -5
  14. package/bin/agent-harness/types.js +33 -2
  15. package/bin/constants.js +3 -3
  16. package/bin/mintTest.js +31 -9
  17. package/bin/mintTestFilePreview.js +39 -0
  18. package/bin/mintTestText.js +31 -0
  19. package/bin/mintTestUi.js +295 -105
  20. package/bin/status.js +26 -0
  21. package/bin/tsconfig.build.tsbuildinfo +1 -1
  22. package/package.json +7 -7
  23. package/src/agent-harness/MINT_TEST_SYSTEM_DESIGN.md +15 -9
  24. package/src/agent-harness/agentPreflight.ts +13 -0
  25. package/src/agent-harness/buildTaskPrompt.ts +18 -7
  26. package/src/agent-harness/countReport.ts +17 -0
  27. package/src/agent-harness/executeCodeBlocks.ts +67 -1
  28. package/src/agent-harness/generateTestCode.ts +81 -51
  29. package/src/agent-harness/index.ts +20 -5
  30. package/src/agent-harness/manifest.ts +116 -0
  31. package/src/agent-harness/runHistory.ts +250 -0
  32. package/src/agent-harness/setupFolders.ts +19 -5
  33. package/src/agent-harness/tasks/checkTestability.ts +15 -6
  34. package/src/agent-harness/types.ts +81 -5
  35. package/src/constants.ts +6 -2
  36. package/src/mintTest.tsx +34 -8
  37. package/src/mintTestFilePreview.ts +32 -0
  38. package/src/mintTestText.ts +35 -0
  39. package/src/mintTestUi.tsx +586 -224
  40. package/src/status.tsx +24 -0
@@ -0,0 +1,116 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import type { z } from 'zod';
4
+
5
+ import { manifestSchema, type GeneratedTestManifest } from './types.js';
6
+
7
+ export const MANIFEST_FILENAME = 'mint-test.json';
8
+ export const AGENT_REPLY_FILENAME = 'mint-test-agent.md';
9
+ export const ERROR_FILENAME = 'mint-test-error.txt';
10
+
11
+ export const SAFE_EXECUTABLES = new Set([
12
+ 'bun',
13
+ 'bunx',
14
+ 'bundle',
15
+ 'cargo',
16
+ 'composer',
17
+ 'deno',
18
+ 'dotnet',
19
+ 'go',
20
+ 'gradle',
21
+ 'java',
22
+ 'javac',
23
+ 'mvn',
24
+ 'node',
25
+ 'npm',
26
+ 'npx',
27
+ 'php',
28
+ 'pip',
29
+ 'pip3',
30
+ 'pnpm',
31
+ 'python',
32
+ 'python3',
33
+ 'pytest',
34
+ 'ruby',
35
+ 'rustc',
36
+ 'uv',
37
+ 'vitest',
38
+ 'yarn',
39
+ ]);
40
+
41
+ function isMissingFile(error: unknown): boolean {
42
+ return (
43
+ typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'ENOENT'
44
+ );
45
+ }
46
+
47
+ /** Turn a schema failure into one readable line per problem, e.g. `testCommands.0.id: expected string`. */
48
+ export function formatManifestIssues(error: z.ZodError): string {
49
+ return error.issues
50
+ .map((issue) => {
51
+ const location = issue.path.map(String).join('.');
52
+ return `${location || MANIFEST_FILENAME}: ${issue.message}`;
53
+ })
54
+ .join('\n');
55
+ }
56
+
57
+ /**
58
+ * Read and schema-check the manifest an agent wrote. Every failure mode has a message a
59
+ * documentation author can act on, because it is what `mint test` shows for the page.
60
+ */
61
+ export async function readManifest(directory: string): Promise<GeneratedTestManifest> {
62
+ const manifestPath = path.join(directory, MANIFEST_FILENAME);
63
+ let raw: string;
64
+ try {
65
+ raw = await fs.readFile(manifestPath, 'utf8');
66
+ } catch (error) {
67
+ if (isMissingFile(error)) {
68
+ throw new Error(`The agent finished without writing ${MANIFEST_FILENAME}`);
69
+ }
70
+ throw error;
71
+ }
72
+
73
+ let parsed: unknown;
74
+ try {
75
+ parsed = JSON.parse(raw);
76
+ } catch (error) {
77
+ const reason = error instanceof Error ? error.message : String(error);
78
+ throw new Error(`${MANIFEST_FILENAME} is not valid JSON: ${reason}`);
79
+ }
80
+
81
+ const result = manifestSchema.safeParse(parsed);
82
+ if (!result.success) {
83
+ const [first = '', ...rest] = formatManifestIssues(result.error).split('\n');
84
+ throw new Error(
85
+ [`${MANIFEST_FILENAME} does not match the expected shape: ${first}`, ...rest].join('\n')
86
+ );
87
+ }
88
+ return result.data;
89
+ }
90
+
91
+ /** Keep the agent's final message next to its output so a failure can be traced later. */
92
+ export async function appendAgentReply(
93
+ directory: string,
94
+ attempt: number,
95
+ reply: string
96
+ ): Promise<void> {
97
+ const body = reply.trim() || '(no summary)';
98
+ await fs
99
+ .appendFile(path.join(directory, AGENT_REPLY_FILENAME), `## Attempt ${attempt}\n\n${body}\n\n`)
100
+ .catch(() => {});
101
+ }
102
+
103
+ /** Persist every attempt's failure reason. Returns the file path, or undefined if it could not be written. */
104
+ export async function writeErrorDetails(
105
+ directory: string,
106
+ errors: string[]
107
+ ): Promise<string | undefined> {
108
+ if (errors.length === 0) return undefined;
109
+ const errorPath = path.join(directory, ERROR_FILENAME);
110
+ try {
111
+ await fs.writeFile(errorPath, `${errors.join('\n\n')}\n`);
112
+ return errorPath;
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
@@ -0,0 +1,250 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { z } from 'zod';
6
+
7
+ import { countReport } from './countReport.js';
8
+ import type { CodeTestReport, TestRunSummary, TestRunsIndex } from './types.js';
9
+
10
+ const RUN_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
11
+ const INDEX_LOCK_FILENAME = 'testRuns.lock';
12
+ const LOCK_RETRY_MS = 20;
13
+ const STALE_LOCK_MS = 30_000;
14
+ const LOCK_TIMEOUT_MS = STALE_LOCK_MS + 10_000;
15
+ const commandResultSchema = z
16
+ .object({
17
+ id: z.string(),
18
+ phase: z.enum(['setup', 'test']),
19
+ status: z.enum(['passed', 'failed', 'cancelled']),
20
+ executable: z.string(),
21
+ args: z.array(z.string()),
22
+ cwd: z.string(),
23
+ durationMs: z.number().nonnegative(),
24
+ exitCode: z.number().int().nullable(),
25
+ stdout: z.string(),
26
+ stderr: z.string(),
27
+ error: z.string().optional(),
28
+ })
29
+ .strict();
30
+ const taskResultSchema = z
31
+ .object({
32
+ id: z.string(),
33
+ agent: z.enum(['claude', 'codex']),
34
+ file: z.string(),
35
+ directory: z.string(),
36
+ generatedFiles: z.array(z.string()),
37
+ testable: z.boolean(),
38
+ attempts: z.number().int().nonnegative(),
39
+ tokens: z.number().nonnegative(),
40
+ status: z.enum(['passed', 'failed', 'agent_error', 'cancelled']),
41
+ error: z.string().optional(),
42
+ commands: z.array(commandResultSchema),
43
+ })
44
+ .strict();
45
+ const codeTestReportSchema = z
46
+ .object({
47
+ version: z.literal(1),
48
+ runId: z.string().regex(RUN_ID_PATTERN),
49
+ status: z.enum(['passed', 'completed', 'failed', 'cancelled']),
50
+ docsRoot: z.string(),
51
+ outputDirectory: z.string(),
52
+ reportPath: z.string(),
53
+ agents: z.array(z.enum(['claude', 'codex'])).min(1),
54
+ model: z.string().min(1),
55
+ selectedFiles: z.array(z.string()),
56
+ startedAt: z.string(),
57
+ completedAt: z.string(),
58
+ durationMs: z.number().nonnegative(),
59
+ tasks: z.array(taskResultSchema),
60
+ historyError: z.string().optional(),
61
+ })
62
+ .strict();
63
+ const countsSchema = z
64
+ .object({
65
+ passed: z.number().int().nonnegative(),
66
+ failed: z.number().int().nonnegative(),
67
+ cancelled: z.number().int().nonnegative(),
68
+ agentErrors: z.number().int().nonnegative(),
69
+ })
70
+ .strict();
71
+ const testRunSummarySchema = z
72
+ .object({
73
+ runId: z.string().regex(RUN_ID_PATTERN),
74
+ status: z.enum(['passed', 'completed', 'failed', 'cancelled']),
75
+ agents: z.array(z.enum(['claude', 'codex'])).min(1),
76
+ model: z.string().min(1),
77
+ selectedFiles: z.array(z.string()),
78
+ startedAt: z.string(),
79
+ completedAt: z.string(),
80
+ durationMs: z.number().nonnegative(),
81
+ reportPath: z.string(),
82
+ outputDirectory: z.string(),
83
+ counts: countsSchema,
84
+ })
85
+ .strict();
86
+ const testRunsIndexSchema = z
87
+ .object({ version: z.literal(1), runs: z.array(testRunSummarySchema) })
88
+ .strict();
89
+
90
+ function historyDirectory(docsRoot: string): string {
91
+ return path.join(docsRoot, '.mintlify', 'test');
92
+ }
93
+
94
+ function runsDirectory(docsRoot: string): string {
95
+ return path.join(historyDirectory(docsRoot), 'runs');
96
+ }
97
+
98
+ export function testRunReportPath(docsRoot: string, runId: string): string {
99
+ if (!RUN_ID_PATTERN.test(runId)) throw new Error(`Invalid mint test run ID: ${runId}`);
100
+ return path.join(runsDirectory(docsRoot), `${runId}.json`);
101
+ }
102
+
103
+ export function testRunsIndexPath(docsRoot: string): string {
104
+ return path.join(historyDirectory(docsRoot), 'testRuns.json');
105
+ }
106
+
107
+ function summarizeReport(report: CodeTestReport): TestRunSummary {
108
+ return {
109
+ runId: report.runId,
110
+ status: report.status,
111
+ agents: report.agents,
112
+ model: report.model,
113
+ selectedFiles: report.selectedFiles,
114
+ startedAt: report.startedAt,
115
+ completedAt: report.completedAt,
116
+ durationMs: report.durationMs,
117
+ reportPath: report.reportPath,
118
+ outputDirectory: report.outputDirectory,
119
+ counts: countReport(report.tasks),
120
+ };
121
+ }
122
+
123
+ function errorCode(error: unknown): string | undefined {
124
+ return typeof error === 'object' && error !== null && 'code' in error
125
+ ? String(error.code)
126
+ : undefined;
127
+ }
128
+
129
+ async function writeJsonAtomically(filePath: string, value: unknown): Promise<void> {
130
+ const temporaryPath = path.join(
131
+ path.dirname(filePath),
132
+ `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`
133
+ );
134
+ try {
135
+ await fs.writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' });
136
+ await fs.rename(temporaryPath, filePath);
137
+ } finally {
138
+ await fs.unlink(temporaryPath).catch(() => undefined);
139
+ }
140
+ }
141
+
142
+ async function acquireIndexLock(docsRoot: string): Promise<() => Promise<void>> {
143
+ const lockPath = path.join(historyDirectory(docsRoot), INDEX_LOCK_FILENAME);
144
+ const token = `${process.pid}:${randomUUID()}`;
145
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
146
+
147
+ while (Date.now() < deadline) {
148
+ try {
149
+ const lock = await fs.open(lockPath, 'wx');
150
+ try {
151
+ await lock.writeFile(token);
152
+ } finally {
153
+ await lock.close();
154
+ }
155
+ return async () => {
156
+ const owner = await fs.readFile(lockPath, 'utf8').catch(() => undefined);
157
+ if (owner === token) await fs.unlink(lockPath).catch(() => undefined);
158
+ };
159
+ } catch (error) {
160
+ if (errorCode(error) !== 'EEXIST') throw error;
161
+ }
162
+
163
+ const stats = await fs.stat(lockPath).catch(() => undefined);
164
+ if (stats && Date.now() - stats.mtimeMs > STALE_LOCK_MS) {
165
+ await fs.unlink(lockPath).catch(() => undefined);
166
+ continue;
167
+ }
168
+ await delay(LOCK_RETRY_MS);
169
+ }
170
+ throw new Error('Timed out updating mint test run history');
171
+ }
172
+
173
+ async function readReport(filePath: string): Promise<CodeTestReport | null> {
174
+ try {
175
+ return codeTestReportSchema.parse(JSON.parse(await fs.readFile(filePath, 'utf8')));
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ async function rebuildIndex(docsRoot: string): Promise<TestRunsIndex> {
182
+ const entries = await fs
183
+ .readdir(runsDirectory(docsRoot), { withFileTypes: true })
184
+ .catch(() => []);
185
+ const reports = await Promise.all(
186
+ entries
187
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
188
+ .map((entry) => readReport(path.join(runsDirectory(docsRoot), entry.name)))
189
+ );
190
+ return {
191
+ version: 1,
192
+ runs: reports
193
+ .filter((report): report is CodeTestReport => report !== null)
194
+ .map(summarizeReport)
195
+ .sort((left, right) => right.completedAt.localeCompare(left.completedAt)),
196
+ };
197
+ }
198
+
199
+ export async function loadTestRuns(docsRoot: string): Promise<TestRunsIndex> {
200
+ try {
201
+ return testRunsIndexSchema.parse(
202
+ JSON.parse(await fs.readFile(testRunsIndexPath(docsRoot), 'utf8'))
203
+ );
204
+ } catch {
205
+ return rebuildIndex(docsRoot);
206
+ }
207
+ }
208
+
209
+ export async function loadLatestTestRun(docsRoot: string): Promise<CodeTestReport | null> {
210
+ const index = await loadTestRuns(docsRoot);
211
+ for (const run of index.runs) {
212
+ const report = await readReport(testRunReportPath(docsRoot, run.runId));
213
+ if (report) return report;
214
+ }
215
+ return null;
216
+ }
217
+
218
+ export async function writeTestRunHistory(docsRoot: string, report: CodeTestReport): Promise<void> {
219
+ await fs.mkdir(runsDirectory(docsRoot), { recursive: true });
220
+ await writeJsonAtomically(report.reportPath, report);
221
+ const releaseLock = await acquireIndexLock(docsRoot);
222
+ try {
223
+ await writeJsonAtomically(testRunsIndexPath(docsRoot), await rebuildIndex(docsRoot));
224
+ } finally {
225
+ await releaseLock();
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Saves a finished run to history without letting a history failure fail the run.
231
+ * On failure the returned report points at `fallbackReportPath` (the report that was
232
+ * already written) and carries the problem in `historyError`.
233
+ */
234
+ export async function recordTestRun(
235
+ docsRoot: string,
236
+ report: CodeTestReport,
237
+ fallbackReportPath: string
238
+ ): Promise<CodeTestReport> {
239
+ try {
240
+ await writeTestRunHistory(docsRoot, report);
241
+ return report;
242
+ } catch (error) {
243
+ const message = error instanceof Error ? error.message : String(error);
244
+ return {
245
+ ...report,
246
+ reportPath: fallbackReportPath,
247
+ historyError: `run history could not be saved: ${message}`,
248
+ };
249
+ }
250
+ }
@@ -9,17 +9,32 @@ const PACKAGE_JSON = `${JSON.stringify(
9
9
  name: 'mint-test-task',
10
10
  private: true,
11
11
  type: 'module',
12
- scripts: { test: 'node --test tests/' },
12
+ // No positional path: Node 21+ treats --test arguments as globs, so a bare
13
+ // directory such as `tests/` fails with MODULE_NOT_FOUND. The default patterns
14
+ // pick up tests/*.test.js on every supported Node version.
15
+ scripts: { test: 'node --test' },
13
16
  },
14
17
  null,
15
18
  2
16
19
  )}\n`;
17
20
 
21
+ function isExistingFile(error: unknown): boolean {
22
+ return (
23
+ typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'EEXIST'
24
+ );
25
+ }
26
+
18
27
  async function scaffoldTaskDirectory(directory: string): Promise<void> {
19
28
  await fs.mkdir(path.join(directory, 'tests'), { recursive: true });
20
- await fs
21
- .writeFile(path.join(directory, 'package.json'), PACKAGE_JSON, { flag: 'wx' })
22
- .catch(() => {});
29
+ try {
30
+ await fs.writeFile(path.join(directory, 'package.json'), PACKAGE_JSON, { flag: 'wx' });
31
+ } catch (error) {
32
+ if (!isExistingFile(error)) throw error;
33
+ }
34
+ }
35
+
36
+ export async function scaffoldTaskDirectories(tasks: GeneratedTask[]): Promise<void> {
37
+ for (const task of tasks) await scaffoldTaskDirectory(task.directory);
23
38
  }
24
39
 
25
40
  export async function setupFolders({
@@ -68,6 +83,5 @@ export async function setupFolders({
68
83
  });
69
84
  }
70
85
  }
71
- for (const task of tasks) await scaffoldTaskDirectory(task.directory);
72
86
  return { runId, runDirectory, reportPath, tasks };
73
87
  }
@@ -50,17 +50,16 @@ async function runTestabilityCheck({
50
50
  file,
51
51
  content,
52
52
  agent,
53
- outputPath,
53
+ workingDirectory,
54
54
  signal,
55
55
  }: {
56
56
  file: string;
57
57
  content: string;
58
58
  agent: HarnessAgent;
59
- outputPath: string;
59
+ workingDirectory: string;
60
60
  signal: AbortSignal;
61
61
  }): Promise<{ tokens: number; check: TestCheck }> {
62
62
  const prompt = buildCheckPrompt(file, content);
63
- const workingDirectory = path.dirname(outputPath);
64
63
  let tokens = 0;
65
64
  let raw: unknown;
66
65
  if (agent === 'claude') {
@@ -81,19 +80,24 @@ async function runTestabilityCheck({
81
80
  const check = testCheckSchema.parse(raw);
82
81
  const errors = validateCheck(check);
83
82
  if (errors.length > 0) throw new Error(errors.join('\n'));
84
- await fs.writeFile(outputPath, `${JSON.stringify(check, null, 2)}\n`);
85
83
  return { tokens, check };
86
84
  }
87
85
 
88
86
  export const checkFileTestability = async (
89
87
  task: GeneratedTask,
90
- { onTaskUpdate, signal }: Pick<RunCodeTestsOptions, 'onTaskUpdate' | 'signal'>
88
+ {
89
+ onTaskUpdate,
90
+ signal,
91
+ runDirectory,
92
+ }: Pick<RunCodeTestsOptions, 'onTaskUpdate' | 'signal'> & { runDirectory: string }
91
93
  ): Promise<GeneratedTask> => {
92
94
  const emit = (phase: TaskUpdate['phase'], tokens: number) =>
93
95
  onTaskUpdate({
94
96
  id: task.id,
95
97
  agent: task.agent,
96
98
  file: task.file,
99
+ directory: task.directory,
100
+ generatedFiles: [],
97
101
  phase,
98
102
  tokens,
99
103
  });
@@ -112,7 +116,7 @@ export const checkFileTestability = async (
112
116
  file: task.file,
113
117
  content,
114
118
  agent: task.agent,
115
- outputPath: path.join(task.directory, CHECK_FILENAME),
119
+ workingDirectory: runDirectory,
116
120
  signal,
117
121
  });
118
122
  tokens += reply.tokens;
@@ -135,6 +139,11 @@ export const checkFileTestability = async (
135
139
  emit('not_testable', tokens);
136
140
  return { ...task, attempts, tokens, testable: false };
137
141
  }
142
+ await fs.mkdir(task.directory, { recursive: true });
143
+ await fs.writeFile(
144
+ path.join(task.directory, CHECK_FILENAME),
145
+ `${JSON.stringify(check, null, 2)}\n`
146
+ );
138
147
  emit('checked', tokens);
139
148
  return { ...task, attempts, tokens, testable: true };
140
149
  };
@@ -30,6 +30,8 @@ export interface TaskResult {
30
30
  agent: HarnessAgent;
31
31
  file: string;
32
32
  directory: string;
33
+ generatedFiles: string[];
34
+ testable: boolean;
33
35
  attempts: number;
34
36
  tokens: number;
35
37
  status: TaskStatus;
@@ -45,11 +47,38 @@ export interface CodeTestReport {
45
47
  outputDirectory: string;
46
48
  reportPath: string;
47
49
  agents: HarnessAgent[];
50
+ model: string;
48
51
  selectedFiles: string[];
49
52
  startedAt: string;
50
53
  completedAt: string;
51
54
  durationMs: number;
52
55
  tasks: TaskResult[];
56
+ /** Set when the run finished but could not be saved to `.mintlify/test` history. */
57
+ historyError?: string;
58
+ }
59
+
60
+ export interface TestRunSummary {
61
+ runId: string;
62
+ status: CodeTestReport['status'];
63
+ agents: HarnessAgent[];
64
+ model: string;
65
+ selectedFiles: string[];
66
+ startedAt: string;
67
+ completedAt: string;
68
+ durationMs: number;
69
+ reportPath: string;
70
+ outputDirectory: string;
71
+ counts: {
72
+ passed: number;
73
+ failed: number;
74
+ cancelled: number;
75
+ agentErrors: number;
76
+ };
77
+ }
78
+
79
+ export interface TestRunsIndex {
80
+ version: 1;
81
+ runs: TestRunSummary[];
53
82
  }
54
83
 
55
84
  export type TaskPhase =
@@ -68,8 +97,12 @@ export interface TaskUpdate {
68
97
  id: string;
69
98
  agent: HarnessAgent;
70
99
  file: string;
100
+ directory: string;
101
+ generatedFiles: string[];
71
102
  phase: TaskPhase;
72
103
  tokens: number;
104
+ /** Why the task ended in `agent_error` or `failed`; the first line is shown in the UI. */
105
+ error?: string;
73
106
  }
74
107
 
75
108
  export interface RunSave {
@@ -98,7 +131,7 @@ const identifier = z.string().trim().min(1).max(120);
98
131
 
99
132
  const commandSchema = z
100
133
  .object({
101
- id: identifier,
134
+ id: identifier.optional(),
102
135
  executable: z.string().trim().min(1).max(512),
103
136
  args: z.array(z.string().max(10_000)).max(100),
104
137
  cwd: z.string().max(1_024).optional(),
@@ -111,7 +144,7 @@ const commandSchema = z
111
144
  })
112
145
  .strict();
113
146
 
114
- export const manifestSchema = z
147
+ const manifestFileSchema = z
115
148
  .object({
116
149
  version: z.literal(1),
117
150
  generatedFiles: z.array(z.string().min(1).max(1_024)).max(1_000),
@@ -120,7 +153,51 @@ export const manifestSchema = z
120
153
  })
121
154
  .strict();
122
155
 
123
- export type GeneratedTestManifest = z.infer<typeof manifestSchema>;
156
+ type ManifestFile = z.infer<typeof manifestFileSchema>;
157
+ type ManifestFileCommand = ManifestFile['testCommands'][number];
158
+
159
+ export type ManifestCommand = Omit<ManifestFileCommand, 'id'> & { id: string };
160
+
161
+ export interface GeneratedTestManifest {
162
+ version: 1;
163
+ generatedFiles: string[];
164
+ setupCommands: ManifestCommand[];
165
+ testCommands: ManifestCommand[];
166
+ }
167
+
168
+ /**
169
+ * Agents often copy the manifest example verbatim and omit command ids. The id only needs to be
170
+ * unique within the manifest, so assign `setup-N` / `test-N` instead of rejecting the whole page.
171
+ */
172
+ export function assignCommandIds(manifest: ManifestFile): GeneratedTestManifest {
173
+ const taken = new Set(
174
+ [...manifest.setupCommands, ...manifest.testCommands]
175
+ .map((command) => command.id)
176
+ .filter((id): id is string => id !== undefined)
177
+ );
178
+ const withIds = (commands: ManifestFileCommand[], phase: 'setup' | 'test'): ManifestCommand[] => {
179
+ let next = 1;
180
+ return commands.map((command) => {
181
+ if (command.id !== undefined) return { ...command, id: command.id };
182
+ let id = `${phase}-${next}`;
183
+ while (taken.has(id)) {
184
+ next += 1;
185
+ id = `${phase}-${next}`;
186
+ }
187
+ taken.add(id);
188
+ next += 1;
189
+ return { ...command, id };
190
+ });
191
+ };
192
+ return {
193
+ version: manifest.version,
194
+ generatedFiles: manifest.generatedFiles,
195
+ setupCommands: withIds(manifest.setupCommands, 'setup'),
196
+ testCommands: withIds(manifest.testCommands, 'test'),
197
+ };
198
+ }
199
+
200
+ export const manifestSchema = manifestFileSchema.transform(assignCommandIds);
124
201
 
125
202
  export const testCheckSchema = z
126
203
  .object({
@@ -136,8 +213,6 @@ export const testCheckSchema = z
136
213
 
137
214
  export type TestCheck = z.infer<typeof testCheckSchema>;
138
215
 
139
- export type ManifestCommand = GeneratedTestManifest['testCommands'][number];
140
-
141
216
  export interface GeneratedTask {
142
217
  id: string;
143
218
  agent: HarnessAgent;
@@ -148,5 +223,6 @@ export interface GeneratedTask {
148
223
  tokens: number;
149
224
  testable?: boolean;
150
225
  manifest?: GeneratedTestManifest;
226
+ generatedFiles?: string[];
151
227
  error?: string;
152
228
  }
package/src/constants.ts CHANGED
@@ -29,7 +29,11 @@ const PROD_TOKEN_ENDPOINT =
29
29
  'https://api.stytch.com/v1/public/project-live-731b7a04-9ac3-4923-90b8-0806d4aa29d4/oauth2/token';
30
30
  const PROD_STYTCH_CLIENT_ID = 'connected-app-live-d813eedd-dbb0-434b-a1f9-2ce69e5efc49';
31
31
 
32
- export const TOKEN_ENDPOINT = IS_LOCAL_BUILD ? DEV_TOKEN_ENDPOINT : PROD_TOKEN_ENDPOINT;
33
- export const STYTCH_CLIENT_ID = IS_LOCAL_BUILD ? DEV_STYTCH_CLIENT_ID : PROD_STYTCH_CLIENT_ID;
32
+ export const TOKEN_ENDPOINT =
33
+ process.env.MINTLIFY_TOKEN_ENDPOINT ??
34
+ (IS_LOCAL_BUILD ? DEV_TOKEN_ENDPOINT : PROD_TOKEN_ENDPOINT);
35
+ export const STYTCH_CLIENT_ID =
36
+ process.env.MINTLIFY_STYTCH_CLIENT_ID ??
37
+ (IS_LOCAL_BUILD ? DEV_STYTCH_CLIENT_ID : PROD_STYTCH_CLIENT_ID);
34
38
 
35
39
  export const CUSTOM_DOMAIN_CNAME_TARGET = 'cname.mintlify.builders';