idcmd 0.0.5 → 0.0.7

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 (51) hide show
  1. package/README.md +29 -9
  2. package/package.json +2 -2
  3. package/src/build.ts +6 -5
  4. package/src/cli/commands/build.ts +11 -7
  5. package/src/cli/commands/client.ts +328 -0
  6. package/src/cli/commands/dev.ts +92 -34
  7. package/src/cli/commands/init.ts +93 -2
  8. package/src/cli/main.ts +12 -0
  9. package/src/cli/runtime-assets.ts +89 -0
  10. package/src/cli.ts +0 -0
  11. package/src/client/index.ts +7 -1
  12. package/src/content/icons.ts +1 -1
  13. package/src/content/paths.ts +1 -1
  14. package/src/project/paths.ts +26 -30
  15. package/src/render/layout-loader.ts +7 -4
  16. package/src/render/layout.tsx +10 -2
  17. package/src/render/page-renderer.ts +12 -2
  18. package/src/render/right-rail-loader.ts +49 -0
  19. package/src/render/right-rail.tsx +10 -6
  20. package/src/search/page.tsx +4 -2
  21. package/src/search/search-page-loader.ts +51 -0
  22. package/src/search/server-page.ts +52 -18
  23. package/src/server/live-reload.ts +2 -6
  24. package/src/server/static.ts +1 -1
  25. package/src/server.ts +0 -1
  26. package/src/site/config.ts +2 -10
  27. package/templates/default/.github/workflows/ci.yml +24 -0
  28. package/templates/default/README.md +31 -5
  29. package/templates/default/package.json +2 -1
  30. package/templates/default/scripts/check-internal.ts +56 -0
  31. package/templates/default/scripts/check.ts +332 -0
  32. package/templates/default/scripts/smoke.ts +223 -0
  33. package/templates/default/site/{public/_idcmd/llm-menu.js → code/runtime/llm-menu.ts} +27 -18
  34. package/templates/default/site/{public/_idcmd/nav-prefetch.js → code/runtime/nav-prefetch.ts} +3 -3
  35. package/templates/default/site/{public/_idcmd/right-rail-scrollspy.js → code/runtime/right-rail-scrollspy.ts} +73 -32
  36. package/templates/default/site/{server → code}/server.ts +1 -1
  37. package/templates/default/site/code/ui/layout.tsx +237 -0
  38. package/templates/default/site/code/ui/right-rail.tsx +246 -0
  39. package/templates/default/site/code/ui/search-page.tsx +87 -0
  40. package/templates/default/tsconfig.json +1 -1
  41. package/templates/default/site/client/layout.tsx +0 -2
  42. package/templates/default/site/client/right-rail.tsx +0 -1
  43. package/templates/default/site/client/search-page.tsx +0 -1
  44. /package/templates/default/site/{public → assets}/anthropic-white.svg +0 -0
  45. /package/templates/default/site/{public → assets}/favicon.svg +0 -0
  46. /package/templates/default/site/{icons → assets/icons}/file.svg +0 -0
  47. /package/templates/default/site/{icons → assets/icons}/home.svg +0 -0
  48. /package/templates/default/site/{icons → assets/icons}/info.svg +0 -0
  49. /package/templates/default/site/{public → assets}/openai-white.svg +0 -0
  50. /package/templates/default/site/{server → code}/routes/api/hello.ts +0 -0
  51. /package/templates/default/site/{public/_idcmd/live-reload.js → code/runtime/live-reload.ts} +0 -0
@@ -10,7 +10,7 @@ import {
10
10
  } from "../site/config";
11
11
  import { resolveCanonicalUrl } from "../site/urls";
12
12
  import { loadSearchIndex, search as runSearch } from "./index";
13
- import { renderSearchPageContent } from "./page";
13
+ import { getRenderSearchPageContent } from "./search-page-loader";
14
14
 
15
15
  export interface SearchPageHandlerEnv {
16
16
  cacheHeaders: HeadersInit;
@@ -37,40 +37,74 @@ const getResults = (
37
37
  ? runSearch(index, query, scope).slice(0, env.maxResults)
38
38
  : [];
39
39
 
40
+ const getSearchQuery = (url: URL): string =>
41
+ url.searchParams.get("q")?.trim() ?? "";
42
+
43
+ const loadSearchPageDependencies = async (options: {
44
+ isDev: boolean;
45
+ siteConfig: Awaited<ReturnType<typeof loadSiteConfig>>;
46
+ }): Promise<{
47
+ navigation: Awaited<ReturnType<typeof getNavigation>>;
48
+ index: Awaited<ReturnType<typeof loadSearchIndex>>;
49
+ renderSearchPageContent: Awaited<
50
+ ReturnType<typeof getRenderSearchPageContent>
51
+ >;
52
+ }> => {
53
+ const [navigation, index, renderSearchPageContent] = await Promise.all([
54
+ getNavigation(options.isDev),
55
+ loadSearchIndex({
56
+ forceRefresh: options.isDev,
57
+ siteConfig: options.siteConfig,
58
+ }),
59
+ getRenderSearchPageContent(),
60
+ ]);
61
+
62
+ return { index, navigation, renderSearchPageContent };
63
+ };
64
+
65
+ const getCanonicalSearchPageUrl = (options: {
66
+ baseUrl?: string;
67
+ env: SearchPageHandlerEnv;
68
+ url: URL;
69
+ }): string | undefined =>
70
+ resolveCanonicalUrl(
71
+ {
72
+ configuredBaseUrl: options.baseUrl,
73
+ isDev: options.env.isDev,
74
+ requestOrigin: options.url.origin,
75
+ },
76
+ "/search/"
77
+ );
78
+
40
79
  const buildSearchPageHtml = async (
41
80
  url: URL,
42
81
  env: SearchPageHandlerEnv
43
82
  ): Promise<string> => {
44
83
  const siteConfig = await loadSiteConfig();
45
84
  const scope = getSearchScope(siteConfig);
46
- const query = url.searchParams.get("q")?.trim() ?? "";
85
+ const query = getSearchQuery(url);
47
86
  const rightRail = resolveRightRailConfig(siteConfig.rightRail);
48
87
 
49
- const [navigation, index] = await Promise.all([
50
- getNavigation(env.isDev),
51
- loadSearchIndex({ forceRefresh: env.isDev, siteConfig }),
52
- ]);
88
+ const { index, navigation, renderSearchPageContent } =
89
+ await loadSearchPageDependencies({
90
+ isDev: env.isDev,
91
+ siteConfig,
92
+ });
53
93
 
54
94
  const results = getResults(index, query, scope, env);
55
- const topPages = getTopPages(navigation);
56
95
  const content = renderSearchPageContent({
57
96
  minQueryLength: env.minQueryLength,
58
97
  query,
59
98
  results,
60
- topPages,
99
+ topPages: getTopPages(navigation),
61
100
  });
62
101
 
63
- const canonicalUrl = resolveCanonicalUrl(
64
- {
65
- configuredBaseUrl: siteConfig.baseUrl,
66
- isDev: env.isDev,
67
- requestOrigin: url.origin,
68
- },
69
- "/search/"
70
- );
71
-
72
102
  return renderDocument({
73
- canonicalUrl,
103
+ canonicalUrl: getCanonicalSearchPageUrl({
104
+ baseUrl: siteConfig.baseUrl,
105
+ env,
106
+ url,
107
+ }),
74
108
  contentHtml: content,
75
109
  currentPath: "/search/",
76
110
  description: siteConfig.description,
@@ -59,7 +59,7 @@ export const createLiveReload = (env: LiveReloadEnv): LiveReloadController => {
59
59
  return;
60
60
  }
61
61
 
62
- console.log("Watching content/ for changes...");
62
+ console.log("Watching site/content/ for changes...");
63
63
  let snapshot = await getContentSnapshot();
64
64
 
65
65
  const poll = async (): Promise<void> => {
@@ -85,11 +85,7 @@ export const createLiveReload = (env: LiveReloadEnv): LiveReloadController => {
85
85
  server: ServerInstance,
86
86
  pathname: string
87
87
  ): "handled" | Response | undefined => {
88
- // Backward compatible: accept both legacy and new websocket paths.
89
- if (
90
- !env.isDev ||
91
- (pathname !== env.websocketPath && pathname !== "/__live-reload")
92
- ) {
88
+ if (!env.isDev || pathname !== env.websocketPath) {
93
89
  return undefined;
94
90
  }
95
91
 
@@ -46,7 +46,7 @@ export const serveStaticFile = async (
46
46
  pathname: string,
47
47
  env: ServeStaticEnv
48
48
  ): Promise<Response | null> => {
49
- const roots = env.isDev ? [env.publicDir] : [env.distDir, env.publicDir];
49
+ const roots = [env.distDir, env.publicDir];
50
50
 
51
51
  for (const root of roots) {
52
52
  const served = await tryServeFileFromRoot(root, pathname, env);
package/src/server.ts CHANGED
@@ -155,7 +155,6 @@ const maybeHandleCanonicalRedirect = (url: URL): Response | undefined => {
155
155
  const { pathname } = url;
156
156
 
157
157
  if (
158
- pathname === "/__live-reload" ||
159
158
  pathname === `${project.assetPrefix}/live-reload` ||
160
159
  pathname.startsWith("/api/")
161
160
  ) {
@@ -91,8 +91,7 @@ export interface ResolvedRightRailConfig {
91
91
  };
92
92
  }
93
93
 
94
- const LEGACY_SITE_CONFIG_PATH = "site.jsonc";
95
- const NEW_SITE_CONFIG_PATH = "site/site.jsonc";
94
+ const SITE_CONFIG_PATH = "site/site.jsonc";
96
95
  const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
97
96
 
98
97
  const DEFAULT_RIGHT_RAIL_CONFIG: ResolvedRightRailConfig = {
@@ -220,15 +219,8 @@ const parseSiteConfigUnknown = (
220
219
  }
221
220
  };
222
221
 
223
- const resolveSiteConfigPath = async (): Promise<string> => {
224
- if (await Bun.file(NEW_SITE_CONFIG_PATH).exists()) {
225
- return NEW_SITE_CONFIG_PATH;
226
- }
227
- return LEGACY_SITE_CONFIG_PATH;
228
- };
229
-
230
222
  export const loadSiteConfig = async (): Promise<SiteConfig> => {
231
- const configPath = await resolveSiteConfigPath();
223
+ const configPath = SITE_CONFIG_PATH;
232
224
  const file = Bun.file(configPath);
233
225
  if (!(await file.exists())) {
234
226
  return DEFAULT_SITE_CONFIG;
@@ -0,0 +1,24 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ ci:
10
+ runs-on: ubuntu-22.04
11
+ env:
12
+ CI: true
13
+ steps:
14
+ - name: Checkout
15
+ uses: actions/checkout@v4
16
+ - name: Setup Bun
17
+ uses: oven-sh/setup-bun@v2
18
+ - name: Install dependencies
19
+ run: bun install
20
+ - name: Run checks
21
+ run: bun run check
22
+ - name: Run smoke
23
+ timeout-minutes: 15
24
+ run: bun run smoke
@@ -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,13 +11,37 @@ 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
- - `site/content/` markdown pages (`index.md` -> `/`, `about.md` -> `/about/`)
15
- - `site/styles/tailwind.css` Tailwind entrypoint (compiled to `site/public/styles.css`)
16
- - `site/public/` static assets
17
- - `site/server/routes/` file-based server routes (dev/server-host only)
18
- - `site/site.jsonc` site configuration
23
+ - Content: `site/content/` markdown pages (`index.md` -> `/`, `about.md` -> `/about/`)
24
+ - Code: `site/code/ui/` (`layout.tsx`, `right-rail.tsx`, `search-page.tsx`)
25
+ - Code: `site/code/runtime/` browser runtime TS (`*_idcmd` scripts compile from here)
26
+ - Code: `site/code/routes/` file-based server routes (dev/server-host only)
27
+ - Assets: `site/assets/` static files you own (icons, images, favicon, etc.)
28
+ - Styles source: `site/styles/tailwind.css`
29
+ - Config: `site/site.jsonc`
30
+ - Generated output: `dist/` (`dist/styles.css`, `dist/_idcmd/*.js`, built pages)
31
+
32
+ The mental model is simple: edit `site/content` and `site/code`, treat `dist/` as generated output.
33
+
34
+ ## Sync Local Client Files
35
+
36
+ ```bash
37
+ idcmd client add all
38
+ idcmd client update all --dry-run
39
+ idcmd client update layout --yes
40
+ idcmd client update runtime --yes
41
+ ```
42
+
43
+ These commands copy the latest baseline implementations from `idcmd` into `site/code/ui/` and `site/code/runtime/`.
44
+ Runtime files in `site/code/runtime/` are compiled automatically by `idcmd dev` and `idcmd build`.
19
45
 
20
46
  ## Deploy (Vercel static)
21
47
 
@@ -7,7 +7,8 @@
7
7
  "build": "idcmd build",
8
8
  "preview": "idcmd preview",
9
9
  "deploy": "idcmd deploy",
10
- "check": "ultracite check && bun run typecheck && bun run test",
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)",
15
+ run: () => fileExists("site/site.jsonc"),
16
+ },
17
+ {
18
+ description: "tailwind input must exist (site/styles/tailwind.css)",
19
+ run: () => fileExists("site/styles/tailwind.css"),
20
+ },
21
+ {
22
+ description: "site code UI entry must exist (site/code/ui/layout.tsx)",
23
+ run: () => fileExists("site/code/ui/layout.tsx"),
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,332 @@
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 LINT_TARGETS = [
32
+ "README.md",
33
+ "package.json",
34
+ "tsconfig.json",
35
+ "vercel.json",
36
+ ".oxlintrc.json",
37
+ ".oxfmtrc.jsonc",
38
+ "scripts",
39
+ "site/code",
40
+ "site/content",
41
+ "site/assets",
42
+ "site/styles",
43
+ "site/site.jsonc",
44
+ ];
45
+ const ROOT_TEST_FILE_PATTERNS = [
46
+ "*.test.ts",
47
+ "*.test.tsx",
48
+ "*.test.js",
49
+ "*.test.jsx",
50
+ "*_test_*.ts",
51
+ "*_test_*.tsx",
52
+ "*_test_*.js",
53
+ "*_test_*.jsx",
54
+ "*.spec.ts",
55
+ "*.spec.tsx",
56
+ "*.spec.js",
57
+ "*.spec.jsx",
58
+ "*_spec_*.ts",
59
+ "*_spec_*.tsx",
60
+ "*_spec_*.js",
61
+ "*_spec_*.jsx",
62
+ ];
63
+ const NESTED_TEST_FILE_PATTERNS = [
64
+ "**/*.test.ts",
65
+ "**/*.test.tsx",
66
+ "**/*.test.js",
67
+ "**/*.test.jsx",
68
+ "**/*_test_*.ts",
69
+ "**/*_test_*.tsx",
70
+ "**/*_test_*.js",
71
+ "**/*_test_*.jsx",
72
+ "**/*.spec.ts",
73
+ "**/*.spec.tsx",
74
+ "**/*.spec.js",
75
+ "**/*.spec.jsx",
76
+ "**/*_spec_*.ts",
77
+ "**/*_spec_*.tsx",
78
+ "**/*_spec_*.js",
79
+ "**/*_spec_*.jsx",
80
+ ];
81
+ const TEST_SOURCE_DIRS = ["site", "scripts", "src", "tests"];
82
+
83
+ const buildScopedTestFilePatterns = (): string[] => {
84
+ const patterns = [...ROOT_TEST_FILE_PATTERNS];
85
+
86
+ for (const dir of TEST_SOURCE_DIRS) {
87
+ for (const nestedPattern of NESTED_TEST_FILE_PATTERNS) {
88
+ patterns.push(`${dir}/${nestedPattern}`);
89
+ }
90
+ }
91
+
92
+ return patterns;
93
+ };
94
+ const SCOPED_TEST_FILE_PATTERNS = buildScopedTestFilePatterns();
95
+
96
+ const steps: CheckStep[] = [
97
+ {
98
+ command: [process.execPath, "scripts/check-internal.ts"],
99
+ id: "internal",
100
+ name: "Internal",
101
+ },
102
+ {
103
+ command: [process.execPath, "x", "ultracite", "check", ...LINT_TARGETS],
104
+ id: "lint",
105
+ name: "Lint",
106
+ },
107
+ {
108
+ command: [process.execPath, "run", "typecheck"],
109
+ id: "typecheck",
110
+ name: "Typecheck",
111
+ },
112
+ {
113
+ command: [process.execPath, "run", "test"],
114
+ id: "tests",
115
+ name: "Tests",
116
+ },
117
+ ];
118
+
119
+ const hasAnyLinterConfig = async (): Promise<boolean> => {
120
+ const candidates = [
121
+ ".oxlintrc.json",
122
+ "eslint.config.js",
123
+ "eslint.config.cjs",
124
+ "eslint.config.mjs",
125
+ "eslint.config.ts",
126
+ "eslint.config.cts",
127
+ "eslint.config.mts",
128
+ ];
129
+
130
+ for (const file of candidates) {
131
+ // eslint-disable-next-line no-await-in-loop
132
+ if (await Bun.file(file).exists()) {
133
+ return true;
134
+ }
135
+ }
136
+
137
+ return false;
138
+ };
139
+
140
+ const hasAnyFilesMatching = async (pattern: string): Promise<boolean> => {
141
+ const glob = new Bun.Glob(pattern);
142
+ for await (const _path of glob.scan(".")) {
143
+ return true;
144
+ }
145
+ return false;
146
+ };
147
+
148
+ const hasAnyTestFiles = async (): Promise<boolean> => {
149
+ for (const pattern of SCOPED_TEST_FILE_PATTERNS) {
150
+ // eslint-disable-next-line no-await-in-loop
151
+ if (await hasAnyFilesMatching(pattern)) {
152
+ return true;
153
+ }
154
+ }
155
+ return false;
156
+ };
157
+
158
+ const isFailure = (result: StepResult): boolean => result.status === "fail";
159
+
160
+ const runCommandStep = async (step: CheckStep): Promise<StepResult> => {
161
+ const startedAt = performance.now();
162
+ const child = Bun.spawn(step.command, {
163
+ cwd: process.cwd(),
164
+ stderr: "pipe",
165
+ stdout: "pipe",
166
+ });
167
+
168
+ const [stdout, stderr, exitCode] = await Promise.all([
169
+ new Response(child.stdout).text(),
170
+ new Response(child.stderr).text(),
171
+ child.exited,
172
+ ]);
173
+
174
+ return {
175
+ durationMs: Math.round(performance.now() - startedAt),
176
+ output: `${stdout}${stderr}`.trim(),
177
+ status: exitCode === 0 ? "pass" : "fail",
178
+ step,
179
+ };
180
+ };
181
+
182
+ const lintConfigFailure = (step: CheckStep): StepResult => ({
183
+ durationMs: 0,
184
+ output: LINT_CONFIG_ERROR,
185
+ status: "fail",
186
+ step,
187
+ });
188
+
189
+ const skippedTestsResult = (step: CheckStep): StepResult => ({
190
+ durationMs: 0,
191
+ output: NO_TESTS_MESSAGE,
192
+ status: "pass",
193
+ step,
194
+ });
195
+
196
+ const runStep = async (step: CheckStep): Promise<StepResult> => {
197
+ if (step.id === "lint" && !(await hasAnyLinterConfig())) {
198
+ return lintConfigFailure(step);
199
+ }
200
+ if (step.id === "tests" && !(await hasAnyTestFiles())) {
201
+ return skippedTestsResult(step);
202
+ }
203
+
204
+ return runCommandStep(step);
205
+ };
206
+
207
+ const runSteps = async (): Promise<StepResult[]> => {
208
+ const results: StepResult[] = [];
209
+
210
+ for (const step of steps) {
211
+ // eslint-disable-next-line no-await-in-loop
212
+ const result = await runStep(step);
213
+ results.push(result);
214
+
215
+ if (isFailure(result)) {
216
+ break;
217
+ }
218
+ }
219
+
220
+ return results;
221
+ };
222
+
223
+ export const truncateOutput = (
224
+ output: string,
225
+ maxLines = MAX_OUTPUT_LINES
226
+ ): TruncatedOutput => {
227
+ const normalized = output.trim();
228
+ if (normalized.length === 0) {
229
+ return { omittedLines: 0, text: "" };
230
+ }
231
+
232
+ const lines = normalized.split("\n");
233
+ if (lines.length <= maxLines) {
234
+ return { omittedLines: 0, text: normalized };
235
+ }
236
+
237
+ return {
238
+ omittedLines: lines.length - maxLines,
239
+ text: lines.slice(0, maxLines).join("\n"),
240
+ };
241
+ };
242
+
243
+ const getCounts = (results: StepResult[]): StepCounts => {
244
+ const failed = results.filter(isFailure).length;
245
+ const passed = results.length - failed;
246
+ return {
247
+ failed,
248
+ passed,
249
+ status: failed === 0 ? "pass" : "fail",
250
+ };
251
+ };
252
+
253
+ const buildHeaderLines = (results: StepResult[]): string[] => {
254
+ const counts = getCounts(results);
255
+ return [
256
+ "# Check Report",
257
+ "",
258
+ `- status: ${counts.status}`,
259
+ `- steps_total: ${results.length}`,
260
+ `- steps_passed: ${counts.passed}`,
261
+ `- steps_failed: ${counts.failed}`,
262
+ "",
263
+ ];
264
+ };
265
+
266
+ const buildFailureOutputLines = (output: string): string[] => {
267
+ if (output.length === 0) {
268
+ return [];
269
+ }
270
+
271
+ const truncated = truncateOutput(output);
272
+ const lines = ["", "### Output", "", "```text", truncated.text];
273
+
274
+ if (truncated.omittedLines > 0) {
275
+ lines.push(`[truncated: omitted ${truncated.omittedLines} lines]`);
276
+ }
277
+
278
+ lines.push("```");
279
+ return lines;
280
+ };
281
+
282
+ const buildStepLines = (result: StepResult): string[] => {
283
+ const lines = [
284
+ `## ${result.step.name}`,
285
+ "",
286
+ `- status: ${result.status}`,
287
+ `- duration_ms: ${result.durationMs}`,
288
+ ];
289
+
290
+ if (isFailure(result)) {
291
+ lines.push(...buildFailureOutputLines(result.output));
292
+ }
293
+
294
+ lines.push("");
295
+ return lines;
296
+ };
297
+
298
+ const buildSummaryRows = (results: StepResult[]): string[] =>
299
+ results.map(
300
+ (result) =>
301
+ `| ${result.step.name} | ${result.status.toUpperCase()} | ${String(
302
+ result.durationMs
303
+ )} |`
304
+ );
305
+
306
+ const buildSummaryLines = (results: StepResult[]): string[] => [
307
+ "## Summary",
308
+ "",
309
+ "| Step | Status | Duration (ms) |",
310
+ "| --- | --- | ---: |",
311
+ ...buildSummaryRows(results),
312
+ ];
313
+
314
+ export const renderReport = (results: StepResult[]): string => {
315
+ const lines = [
316
+ ...buildHeaderLines(results),
317
+ ...results.flatMap(buildStepLines),
318
+ ...buildSummaryLines(results),
319
+ ];
320
+ return `${lines.join("\n")}\n`;
321
+ };
322
+
323
+ const runCheck = async (): Promise<number> => {
324
+ const results = await runSteps();
325
+ process.stdout.write(renderReport(results));
326
+ return results.some(isFailure) ? 1 : 0;
327
+ };
328
+
329
+ if (import.meta.main) {
330
+ const code = await runCheck();
331
+ process.exit(code);
332
+ }