idcmd 0.0.5 → 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.
Files changed (32) hide show
  1. package/README.md +23 -4
  2. package/package.json +2 -2
  3. package/src/build.ts +4 -3
  4. package/src/cli/commands/build.ts +7 -0
  5. package/src/cli/commands/client.ts +317 -0
  6. package/src/cli/commands/dev.ts +87 -23
  7. package/src/cli/commands/init.ts +93 -2
  8. package/src/cli/main.ts +12 -0
  9. package/src/cli/runtime-assets.ts +92 -0
  10. package/src/client/index.ts +7 -1
  11. package/src/render/layout-loader.ts +6 -3
  12. package/src/render/layout.tsx +10 -2
  13. package/src/render/page-renderer.ts +12 -2
  14. package/src/render/right-rail-loader.ts +49 -0
  15. package/src/render/right-rail.tsx +10 -6
  16. package/src/search/page.tsx +4 -2
  17. package/src/search/search-page-loader.ts +51 -0
  18. package/src/search/server-page.ts +52 -18
  19. package/templates/default/.github/workflows/ci.yml +24 -0
  20. package/templates/default/README.md +23 -0
  21. package/templates/default/package.json +2 -1
  22. package/templates/default/scripts/check-internal.ts +56 -0
  23. package/templates/default/scripts/check.ts +318 -0
  24. package/templates/default/scripts/smoke.ts +193 -0
  25. package/templates/default/site/client/layout.tsx +237 -2
  26. package/templates/default/site/client/right-rail.tsx +246 -1
  27. package/templates/default/site/{public/_idcmd/llm-menu.js → client/runtime/llm-menu.ts} +27 -18
  28. package/templates/default/site/{public/_idcmd/nav-prefetch.js → client/runtime/nav-prefetch.ts} +3 -3
  29. package/templates/default/site/{public/_idcmd/right-rail-scrollspy.js → client/runtime/right-rail-scrollspy.ts} +73 -32
  30. package/templates/default/site/client/search-page.tsx +87 -1
  31. package/templates/default/tsconfig.json +1 -1
  32. /package/templates/default/site/{public/_idcmd/live-reload.js → client/runtime/live-reload.ts} +0 -0
@@ -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);