idcmd 0.0.4 → 0.0.6
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.
- package/README.md +23 -4
- package/package.json +2 -2
- package/src/build.ts +4 -3
- package/src/cli/commands/build.ts +7 -0
- package/src/cli/commands/client.ts +317 -0
- package/src/cli/commands/dev.ts +89 -24
- package/src/cli/commands/init.ts +93 -2
- package/src/cli/main.ts +12 -0
- package/src/cli/runtime-assets.ts +92 -0
- package/src/client/index.ts +7 -1
- package/src/render/layout-loader.ts +6 -3
- package/src/render/layout.tsx +10 -2
- package/src/render/page-renderer.ts +12 -2
- package/src/render/right-rail-loader.ts +49 -0
- package/src/render/right-rail.tsx +10 -6
- package/src/search/page.tsx +4 -2
- package/src/search/search-page-loader.ts +51 -0
- package/src/search/server-page.ts +52 -18
- package/templates/default/.github/workflows/ci.yml +24 -0
- package/templates/default/README.md +23 -0
- package/templates/default/package.json +2 -1
- package/templates/default/scripts/check-internal.ts +56 -0
- package/templates/default/scripts/check.ts +318 -0
- package/templates/default/scripts/smoke.ts +193 -0
- package/templates/default/site/client/layout.tsx +237 -2
- package/templates/default/site/client/right-rail.tsx +246 -1
- package/templates/default/site/{public/_idcmd/llm-menu.js → client/runtime/llm-menu.ts} +27 -18
- package/templates/default/site/{public/_idcmd/nav-prefetch.js → client/runtime/nav-prefetch.ts} +3 -3
- package/templates/default/site/{public/_idcmd/right-rail-scrollspy.js → client/runtime/right-rail-scrollspy.ts} +73 -32
- package/templates/default/site/client/search-page.tsx +87 -1
- package/templates/default/tsconfig.json +1 -1
- /package/templates/default/site/{public/_idcmd/live-reload.js → client/runtime/live-reload.ts} +0 -0
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Everything you edit lives in `site/`.
|
|
4
4
|
|
|
5
|
+
This starter is intentionally opinionated for AI-friendly markdown sites.
|
|
6
|
+
|
|
5
7
|
## Quickstart
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -9,14 +11,35 @@ bun install
|
|
|
9
11
|
bun run dev
|
|
10
12
|
```
|
|
11
13
|
|
|
14
|
+
## CI Smoke
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bun run check
|
|
18
|
+
bun run smoke
|
|
19
|
+
```
|
|
20
|
+
|
|
12
21
|
## Layout
|
|
13
22
|
|
|
14
23
|
- `site/content/` markdown pages (`index.md` -> `/`, `about.md` -> `/about/`)
|
|
24
|
+
- `site/client/` local UI implementation (`layout.tsx`, `right-rail.tsx`, `search-page.tsx`)
|
|
25
|
+
- `site/client/runtime/` local browser runtime TS (`*_idcmd` scripts compile from here)
|
|
15
26
|
- `site/styles/tailwind.css` Tailwind entrypoint (compiled to `site/public/styles.css`)
|
|
16
27
|
- `site/public/` static assets
|
|
17
28
|
- `site/server/routes/` file-based server routes (dev/server-host only)
|
|
18
29
|
- `site/site.jsonc` site configuration
|
|
19
30
|
|
|
31
|
+
## Sync Local Client Files
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
idcmd client add all
|
|
35
|
+
idcmd client update all --dry-run
|
|
36
|
+
idcmd client update layout --yes
|
|
37
|
+
idcmd client update runtime --yes
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
These commands copy the latest baseline implementations from `idcmd` into `site/client/`.
|
|
41
|
+
Runtime files in `site/client/runtime/` are compiled automatically by `idcmd dev` and `idcmd build`.
|
|
42
|
+
|
|
20
43
|
## Deploy (Vercel static)
|
|
21
44
|
|
|
22
45
|
```bash
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
"build": "idcmd build",
|
|
8
8
|
"preview": "idcmd preview",
|
|
9
9
|
"deploy": "idcmd deploy",
|
|
10
|
-
"check": "
|
|
10
|
+
"check": "bun run scripts/check.ts",
|
|
11
|
+
"smoke": "bun run scripts/smoke.ts",
|
|
11
12
|
"test": "bun test",
|
|
12
13
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
13
14
|
"fix": "ultracite fix"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
interface InternalCheck {
|
|
2
|
+
description: string;
|
|
3
|
+
run: () => Promise<boolean>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const fileExists = (path: string): Promise<boolean> => Bun.file(path).exists();
|
|
7
|
+
|
|
8
|
+
const checks: InternalCheck[] = [
|
|
9
|
+
{
|
|
10
|
+
description: "package.json must exist at the project root",
|
|
11
|
+
run: () => fileExists("package.json"),
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
description: "site config must exist (site/site.jsonc or site.jsonc)",
|
|
15
|
+
run: async () =>
|
|
16
|
+
(await fileExists("site/site.jsonc")) || (await fileExists("site.jsonc")),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
description:
|
|
20
|
+
"tailwind input must exist (site/styles/tailwind.css or content/styles.css)",
|
|
21
|
+
run: async () =>
|
|
22
|
+
(await fileExists("site/styles/tailwind.css")) ||
|
|
23
|
+
(await fileExists("content/styles.css")),
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const runInternalChecks = async (): Promise<string[]> => {
|
|
28
|
+
const failures: string[] = [];
|
|
29
|
+
|
|
30
|
+
for (const check of checks) {
|
|
31
|
+
// eslint-disable-next-line no-await-in-loop
|
|
32
|
+
const ok = await check.run();
|
|
33
|
+
if (!ok) {
|
|
34
|
+
failures.push(check.description);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return failures;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const main = async (): Promise<number> => {
|
|
42
|
+
const failures = await runInternalChecks();
|
|
43
|
+
if (failures.length === 0) {
|
|
44
|
+
console.log("Internal checks passed.");
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.error("Internal checks failed:");
|
|
49
|
+
for (const failure of failures) {
|
|
50
|
+
console.error(`- ${failure}`);
|
|
51
|
+
}
|
|
52
|
+
return 1;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const code = await main();
|
|
56
|
+
process.exit(code);
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
type StepStatus = "pass" | "fail";
|
|
2
|
+
|
|
3
|
+
export interface CheckStep {
|
|
4
|
+
command: string[];
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface StepResult {
|
|
10
|
+
durationMs: number;
|
|
11
|
+
output: string;
|
|
12
|
+
status: StepStatus;
|
|
13
|
+
step: CheckStep;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface TruncatedOutput {
|
|
17
|
+
omittedLines: number;
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface StepCounts {
|
|
22
|
+
failed: number;
|
|
23
|
+
passed: number;
|
|
24
|
+
status: StepStatus;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const LINT_CONFIG_ERROR =
|
|
28
|
+
"No linter configuration found. Run `bun x ultracite init` once in this project.";
|
|
29
|
+
const NO_TESTS_MESSAGE = "No tests found; skipping test step.";
|
|
30
|
+
const MAX_OUTPUT_LINES = 120;
|
|
31
|
+
const ROOT_TEST_FILE_PATTERNS = [
|
|
32
|
+
"*.test.ts",
|
|
33
|
+
"*.test.tsx",
|
|
34
|
+
"*.test.js",
|
|
35
|
+
"*.test.jsx",
|
|
36
|
+
"*_test_*.ts",
|
|
37
|
+
"*_test_*.tsx",
|
|
38
|
+
"*_test_*.js",
|
|
39
|
+
"*_test_*.jsx",
|
|
40
|
+
"*.spec.ts",
|
|
41
|
+
"*.spec.tsx",
|
|
42
|
+
"*.spec.js",
|
|
43
|
+
"*.spec.jsx",
|
|
44
|
+
"*_spec_*.ts",
|
|
45
|
+
"*_spec_*.tsx",
|
|
46
|
+
"*_spec_*.js",
|
|
47
|
+
"*_spec_*.jsx",
|
|
48
|
+
];
|
|
49
|
+
const NESTED_TEST_FILE_PATTERNS = [
|
|
50
|
+
"**/*.test.ts",
|
|
51
|
+
"**/*.test.tsx",
|
|
52
|
+
"**/*.test.js",
|
|
53
|
+
"**/*.test.jsx",
|
|
54
|
+
"**/*_test_*.ts",
|
|
55
|
+
"**/*_test_*.tsx",
|
|
56
|
+
"**/*_test_*.js",
|
|
57
|
+
"**/*_test_*.jsx",
|
|
58
|
+
"**/*.spec.ts",
|
|
59
|
+
"**/*.spec.tsx",
|
|
60
|
+
"**/*.spec.js",
|
|
61
|
+
"**/*.spec.jsx",
|
|
62
|
+
"**/*_spec_*.ts",
|
|
63
|
+
"**/*_spec_*.tsx",
|
|
64
|
+
"**/*_spec_*.js",
|
|
65
|
+
"**/*_spec_*.jsx",
|
|
66
|
+
];
|
|
67
|
+
const TEST_SOURCE_DIRS = ["site", "scripts", "src", "tests"];
|
|
68
|
+
|
|
69
|
+
const buildScopedTestFilePatterns = (): string[] => {
|
|
70
|
+
const patterns = [...ROOT_TEST_FILE_PATTERNS];
|
|
71
|
+
|
|
72
|
+
for (const dir of TEST_SOURCE_DIRS) {
|
|
73
|
+
for (const nestedPattern of NESTED_TEST_FILE_PATTERNS) {
|
|
74
|
+
patterns.push(`${dir}/${nestedPattern}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return patterns;
|
|
79
|
+
};
|
|
80
|
+
const SCOPED_TEST_FILE_PATTERNS = buildScopedTestFilePatterns();
|
|
81
|
+
|
|
82
|
+
const steps: CheckStep[] = [
|
|
83
|
+
{
|
|
84
|
+
command: [process.execPath, "scripts/check-internal.ts"],
|
|
85
|
+
id: "internal",
|
|
86
|
+
name: "Internal",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
command: [process.execPath, "x", "ultracite", "check"],
|
|
90
|
+
id: "lint",
|
|
91
|
+
name: "Lint",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
command: [process.execPath, "run", "typecheck"],
|
|
95
|
+
id: "typecheck",
|
|
96
|
+
name: "Typecheck",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
command: [process.execPath, "run", "test"],
|
|
100
|
+
id: "tests",
|
|
101
|
+
name: "Tests",
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const hasAnyLinterConfig = async (): Promise<boolean> => {
|
|
106
|
+
const candidates = [
|
|
107
|
+
".oxlintrc.json",
|
|
108
|
+
"eslint.config.js",
|
|
109
|
+
"eslint.config.cjs",
|
|
110
|
+
"eslint.config.mjs",
|
|
111
|
+
"eslint.config.ts",
|
|
112
|
+
"eslint.config.cts",
|
|
113
|
+
"eslint.config.mts",
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
for (const file of candidates) {
|
|
117
|
+
// eslint-disable-next-line no-await-in-loop
|
|
118
|
+
if (await Bun.file(file).exists()) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return false;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const hasAnyFilesMatching = async (pattern: string): Promise<boolean> => {
|
|
127
|
+
const glob = new Bun.Glob(pattern);
|
|
128
|
+
for await (const _path of glob.scan(".")) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const hasAnyTestFiles = async (): Promise<boolean> => {
|
|
135
|
+
for (const pattern of SCOPED_TEST_FILE_PATTERNS) {
|
|
136
|
+
// eslint-disable-next-line no-await-in-loop
|
|
137
|
+
if (await hasAnyFilesMatching(pattern)) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const isFailure = (result: StepResult): boolean => result.status === "fail";
|
|
145
|
+
|
|
146
|
+
const runCommandStep = async (step: CheckStep): Promise<StepResult> => {
|
|
147
|
+
const startedAt = performance.now();
|
|
148
|
+
const child = Bun.spawn(step.command, {
|
|
149
|
+
cwd: process.cwd(),
|
|
150
|
+
stderr: "pipe",
|
|
151
|
+
stdout: "pipe",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
155
|
+
new Response(child.stdout).text(),
|
|
156
|
+
new Response(child.stderr).text(),
|
|
157
|
+
child.exited,
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
162
|
+
output: `${stdout}${stderr}`.trim(),
|
|
163
|
+
status: exitCode === 0 ? "pass" : "fail",
|
|
164
|
+
step,
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const lintConfigFailure = (step: CheckStep): StepResult => ({
|
|
169
|
+
durationMs: 0,
|
|
170
|
+
output: LINT_CONFIG_ERROR,
|
|
171
|
+
status: "fail",
|
|
172
|
+
step,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const skippedTestsResult = (step: CheckStep): StepResult => ({
|
|
176
|
+
durationMs: 0,
|
|
177
|
+
output: NO_TESTS_MESSAGE,
|
|
178
|
+
status: "pass",
|
|
179
|
+
step,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const runStep = async (step: CheckStep): Promise<StepResult> => {
|
|
183
|
+
if (step.id === "lint" && !(await hasAnyLinterConfig())) {
|
|
184
|
+
return lintConfigFailure(step);
|
|
185
|
+
}
|
|
186
|
+
if (step.id === "tests" && !(await hasAnyTestFiles())) {
|
|
187
|
+
return skippedTestsResult(step);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return runCommandStep(step);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const runSteps = async (): Promise<StepResult[]> => {
|
|
194
|
+
const results: StepResult[] = [];
|
|
195
|
+
|
|
196
|
+
for (const step of steps) {
|
|
197
|
+
// eslint-disable-next-line no-await-in-loop
|
|
198
|
+
const result = await runStep(step);
|
|
199
|
+
results.push(result);
|
|
200
|
+
|
|
201
|
+
if (isFailure(result)) {
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return results;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export const truncateOutput = (
|
|
210
|
+
output: string,
|
|
211
|
+
maxLines = MAX_OUTPUT_LINES
|
|
212
|
+
): TruncatedOutput => {
|
|
213
|
+
const normalized = output.trim();
|
|
214
|
+
if (normalized.length === 0) {
|
|
215
|
+
return { omittedLines: 0, text: "" };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const lines = normalized.split("\n");
|
|
219
|
+
if (lines.length <= maxLines) {
|
|
220
|
+
return { omittedLines: 0, text: normalized };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
omittedLines: lines.length - maxLines,
|
|
225
|
+
text: lines.slice(0, maxLines).join("\n"),
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const getCounts = (results: StepResult[]): StepCounts => {
|
|
230
|
+
const failed = results.filter(isFailure).length;
|
|
231
|
+
const passed = results.length - failed;
|
|
232
|
+
return {
|
|
233
|
+
failed,
|
|
234
|
+
passed,
|
|
235
|
+
status: failed === 0 ? "pass" : "fail",
|
|
236
|
+
};
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const buildHeaderLines = (results: StepResult[]): string[] => {
|
|
240
|
+
const counts = getCounts(results);
|
|
241
|
+
return [
|
|
242
|
+
"# Check Report",
|
|
243
|
+
"",
|
|
244
|
+
`- status: ${counts.status}`,
|
|
245
|
+
`- steps_total: ${results.length}`,
|
|
246
|
+
`- steps_passed: ${counts.passed}`,
|
|
247
|
+
`- steps_failed: ${counts.failed}`,
|
|
248
|
+
"",
|
|
249
|
+
];
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const buildFailureOutputLines = (output: string): string[] => {
|
|
253
|
+
if (output.length === 0) {
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const truncated = truncateOutput(output);
|
|
258
|
+
const lines = ["", "### Output", "", "```text", truncated.text];
|
|
259
|
+
|
|
260
|
+
if (truncated.omittedLines > 0) {
|
|
261
|
+
lines.push(`[truncated: omitted ${truncated.omittedLines} lines]`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
lines.push("```");
|
|
265
|
+
return lines;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const buildStepLines = (result: StepResult): string[] => {
|
|
269
|
+
const lines = [
|
|
270
|
+
`## ${result.step.name}`,
|
|
271
|
+
"",
|
|
272
|
+
`- status: ${result.status}`,
|
|
273
|
+
`- duration_ms: ${result.durationMs}`,
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
if (isFailure(result)) {
|
|
277
|
+
lines.push(...buildFailureOutputLines(result.output));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
lines.push("");
|
|
281
|
+
return lines;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const buildSummaryRows = (results: StepResult[]): string[] =>
|
|
285
|
+
results.map(
|
|
286
|
+
(result) =>
|
|
287
|
+
`| ${result.step.name} | ${result.status.toUpperCase()} | ${String(
|
|
288
|
+
result.durationMs
|
|
289
|
+
)} |`
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const buildSummaryLines = (results: StepResult[]): string[] => [
|
|
293
|
+
"## Summary",
|
|
294
|
+
"",
|
|
295
|
+
"| Step | Status | Duration (ms) |",
|
|
296
|
+
"| --- | --- | ---: |",
|
|
297
|
+
...buildSummaryRows(results),
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
export const renderReport = (results: StepResult[]): string => {
|
|
301
|
+
const lines = [
|
|
302
|
+
...buildHeaderLines(results),
|
|
303
|
+
...results.flatMap(buildStepLines),
|
|
304
|
+
...buildSummaryLines(results),
|
|
305
|
+
];
|
|
306
|
+
return `${lines.join("\n")}\n`;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const runCheck = async (): Promise<number> => {
|
|
310
|
+
const results = await runSteps();
|
|
311
|
+
process.stdout.write(renderReport(results));
|
|
312
|
+
return results.some(isFailure) ? 1 : 0;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
if (import.meta.main) {
|
|
316
|
+
const code = await runCheck();
|
|
317
|
+
process.exit(code);
|
|
318
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
interface CommandResult {
|
|
2
|
+
code: number;
|
|
3
|
+
stderr: string;
|
|
4
|
+
stdout: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const BASE_URL = process.env.IDCMD_SMOKE_BASE_URL ?? "http://127.0.0.1:4000";
|
|
8
|
+
const CURL_MAX_TIME_SECONDS = "5";
|
|
9
|
+
const READY_TIMEOUT_MS = 60_000;
|
|
10
|
+
const READY_INTERVAL_MS = 500;
|
|
11
|
+
const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
12
|
+
|
|
13
|
+
const delay = (ms: number): Promise<void> => Bun.sleep(ms);
|
|
14
|
+
|
|
15
|
+
const runCommand = async (command: string[]): Promise<CommandResult> => {
|
|
16
|
+
const proc = Bun.spawn(command, {
|
|
17
|
+
cwd: process.cwd(),
|
|
18
|
+
stderr: "pipe",
|
|
19
|
+
stdout: "pipe",
|
|
20
|
+
});
|
|
21
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
22
|
+
new Response(proc.stdout).text(),
|
|
23
|
+
new Response(proc.stderr).text(),
|
|
24
|
+
proc.exited,
|
|
25
|
+
]);
|
|
26
|
+
return { code, stderr, stdout };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const runCurl = (path: string): Promise<CommandResult> =>
|
|
30
|
+
runCommand([
|
|
31
|
+
"curl",
|
|
32
|
+
"-fsS",
|
|
33
|
+
"--max-time",
|
|
34
|
+
CURL_MAX_TIME_SECONDS,
|
|
35
|
+
`${BASE_URL}${path}`,
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const assertCommandOk = (label: string, result: CommandResult): void => {
|
|
39
|
+
if (result.code === 0) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(
|
|
43
|
+
[
|
|
44
|
+
`${label} failed with exit code ${String(result.code)}.`,
|
|
45
|
+
"stdout:",
|
|
46
|
+
result.stdout.trim() || "(empty)",
|
|
47
|
+
"stderr:",
|
|
48
|
+
result.stderr.trim() || "(empty)",
|
|
49
|
+
].join("\n")
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const expectIncludes = (args: {
|
|
54
|
+
haystack: string;
|
|
55
|
+
label: string;
|
|
56
|
+
needle: string;
|
|
57
|
+
}): void => {
|
|
58
|
+
if (args.haystack.includes(args.needle)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`Expected ${args.label} to include ${args.needle}.`);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const waitForReady = async (): Promise<void> => {
|
|
65
|
+
const startedAt = Date.now();
|
|
66
|
+
let lastFailure = "(no attempts yet)";
|
|
67
|
+
|
|
68
|
+
while (Date.now() - startedAt < READY_TIMEOUT_MS) {
|
|
69
|
+
const ready = await runCurl("/");
|
|
70
|
+
if (ready.code === 0) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
lastFailure = ready.stderr.trim() || ready.stdout.trim() || "curl failed";
|
|
74
|
+
await delay(READY_INTERVAL_MS);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
throw new Error(
|
|
78
|
+
`dev server did not become ready within ${String(
|
|
79
|
+
READY_TIMEOUT_MS
|
|
80
|
+
)}ms. Last curl failure: ${lastFailure}`
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const shutdownDev = async (
|
|
85
|
+
proc: ReturnType<typeof Bun.spawn>
|
|
86
|
+
): Promise<void> => {
|
|
87
|
+
try {
|
|
88
|
+
proc.kill("SIGTERM");
|
|
89
|
+
} catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const didExit = await Promise.race([
|
|
94
|
+
proc.exited.then(() => true),
|
|
95
|
+
delay(SHUTDOWN_TIMEOUT_MS).then(() => false),
|
|
96
|
+
]);
|
|
97
|
+
if (!didExit) {
|
|
98
|
+
try {
|
|
99
|
+
proc.kill("SIGKILL");
|
|
100
|
+
} catch {
|
|
101
|
+
// ignore
|
|
102
|
+
}
|
|
103
|
+
await proc.exited;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const assertHomeResponse = async (): Promise<void> => {
|
|
108
|
+
const home = await runCurl("/");
|
|
109
|
+
assertCommandOk("curl /", home);
|
|
110
|
+
expectIncludes({ haystack: home.stdout, label: "/", needle: "<html" });
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const assertAboutResponse = async (): Promise<void> => {
|
|
114
|
+
const about = await runCurl("/about/");
|
|
115
|
+
assertCommandOk("curl /about/", about);
|
|
116
|
+
if (!about.stdout.includes("# About") && !about.stdout.includes(">About<")) {
|
|
117
|
+
throw new Error("Expected /about/ response to include About heading.");
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const assertLlmsResponse = async (): Promise<void> => {
|
|
122
|
+
const llms = await runCurl("/llms.txt");
|
|
123
|
+
assertCommandOk("curl /llms.txt", llms);
|
|
124
|
+
if (llms.stdout.trim().length === 0 || !llms.stdout.includes("about.md")) {
|
|
125
|
+
throw new Error("Expected /llms.txt to be non-empty and include about.md.");
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const assertApiResponse = async (): Promise<void> => {
|
|
130
|
+
const api = await runCurl("/api/hello");
|
|
131
|
+
assertCommandOk("curl /api/hello", api);
|
|
132
|
+
const payload = JSON.parse(api.stdout) as { message?: string; ok?: boolean };
|
|
133
|
+
if (payload.ok !== true || payload.message !== "Hello from idcmd route!") {
|
|
134
|
+
throw new Error("Expected /api/hello payload to match template route.");
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const runSmokeChecks = async (): Promise<void> => {
|
|
139
|
+
await assertHomeResponse();
|
|
140
|
+
await assertAboutResponse();
|
|
141
|
+
await assertLlmsResponse();
|
|
142
|
+
await assertApiResponse();
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const startDev = (): {
|
|
146
|
+
devProc: ReturnType<typeof Bun.spawn>;
|
|
147
|
+
devStderr: Promise<string>;
|
|
148
|
+
devStdout: Promise<string>;
|
|
149
|
+
} => {
|
|
150
|
+
const devProc = Bun.spawn([process.execPath, "run", "dev"], {
|
|
151
|
+
cwd: process.cwd(),
|
|
152
|
+
stderr: "pipe",
|
|
153
|
+
stdout: "pipe",
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
devProc,
|
|
157
|
+
devStderr: new Response(devProc.stderr).text(),
|
|
158
|
+
devStdout: new Response(devProc.stdout).text(),
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const logDevFailure = async (args: {
|
|
163
|
+
error: unknown;
|
|
164
|
+
stderr: Promise<string>;
|
|
165
|
+
stdout: Promise<string>;
|
|
166
|
+
}): Promise<void> => {
|
|
167
|
+
const [stdout, stderr] = await Promise.all([args.stdout, args.stderr]);
|
|
168
|
+
const message =
|
|
169
|
+
args.error instanceof Error ? args.error.message : String(args.error);
|
|
170
|
+
console.error(message);
|
|
171
|
+
console.error("dev stdout:");
|
|
172
|
+
console.error(stdout.trim() || "(empty)");
|
|
173
|
+
console.error("dev stderr:");
|
|
174
|
+
console.error(stderr.trim() || "(empty)");
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const main = async (): Promise<number> => {
|
|
178
|
+
const { devProc, devStderr, devStdout } = startDev();
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
await waitForReady();
|
|
182
|
+
await runSmokeChecks();
|
|
183
|
+
return 0;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
await logDevFailure({ error, stderr: devStderr, stdout: devStdout });
|
|
186
|
+
return 1;
|
|
187
|
+
} finally {
|
|
188
|
+
await shutdownDev(devProc);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const code = await main();
|
|
193
|
+
process.exit(code);
|