@cfbender/cesium 0.5.1 → 0.6.0

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 (42) hide show
  1. package/CHANGELOG.md +97 -3
  2. package/README.md +8 -8
  3. package/package.json +19 -17
  4. package/src/cli/commands/ls.ts +62 -65
  5. package/src/cli/commands/open.ts +47 -62
  6. package/src/cli/commands/prune.ts +59 -71
  7. package/src/cli/commands/restart.ts +100 -12
  8. package/src/cli/commands/serve.ts +119 -116
  9. package/src/cli/commands/stop.ts +51 -84
  10. package/src/cli/commands/theme.ts +54 -92
  11. package/src/cli/index.ts +17 -70
  12. package/src/index.ts +4 -1
  13. package/src/prompt/field-reference.ts +2 -2
  14. package/src/prompt/system-fragment.md +46 -16
  15. package/src/render/blocks/catalog.ts +2 -0
  16. package/src/render/blocks/diff/myers.ts +221 -0
  17. package/src/render/blocks/diff/parse-unified.ts +101 -0
  18. package/src/render/blocks/highlight.ts +8 -11
  19. package/src/render/blocks/markdown.ts +28 -7
  20. package/src/render/blocks/render.ts +3 -0
  21. package/src/render/blocks/renderers/code.ts +1 -3
  22. package/src/render/blocks/renderers/compare-table.ts +3 -4
  23. package/src/render/blocks/renderers/diagram.ts +2 -5
  24. package/src/render/blocks/renderers/diff.ts +378 -0
  25. package/src/render/blocks/renderers/prose.ts +1 -2
  26. package/src/render/blocks/renderers/timeline.ts +2 -1
  27. package/src/render/blocks/themes/claret-dark.ts +1 -6
  28. package/src/render/blocks/themes/claret-light.ts +1 -6
  29. package/src/render/blocks/types.ts +13 -1
  30. package/src/render/blocks/validate-block.ts +19 -9
  31. package/src/render/theme.ts +149 -0
  32. package/src/render/validate.ts +53 -9
  33. package/src/server/api.ts +112 -124
  34. package/src/server/favicon.ts +8 -16
  35. package/src/server/http.ts +101 -106
  36. package/src/server/lifecycle.ts +12 -6
  37. package/src/storage/assets.ts +8 -10
  38. package/src/storage/index-gen.ts +2 -3
  39. package/src/storage/theme-write.ts +17 -3
  40. package/src/tools/publish.ts +1 -3
  41. package/src/tools/styleguide.ts +3 -7
  42. package/src/tools/wait.ts +1 -0
@@ -1,6 +1,6 @@
1
1
  // cesium prune — delete artifacts older than a given duration.
2
2
 
3
- import { parseArgs } from "node:util";
3
+ import { defineCommand } from "citty";
4
4
  import { join } from "node:path";
5
5
  import { readdir, unlink as fsUnlink, stat } from "node:fs/promises";
6
6
  import { loadConfig, type CesiumConfig } from "../../config.ts";
@@ -15,6 +15,11 @@ import { themeFromPreset, mergeTheme } from "../../render/theme.ts";
15
15
  import { readEmbeddedMetadata } from "../../storage/write.ts";
16
16
  import { readFile } from "node:fs/promises";
17
17
 
18
+ export interface PruneArgs {
19
+ olderThan: string;
20
+ yes: boolean;
21
+ }
22
+
18
23
  export interface PruneContext {
19
24
  stdout: { write: (s: string) => void };
20
25
  stderr: { write: (s: string) => void };
@@ -164,75 +169,28 @@ function formatTable(candidates: ArtifactCandidate[]): string {
164
169
  return [header, sep, ...rows].join("\n");
165
170
  }
166
171
 
167
- export async function pruneCommand(argv: string[], ctx?: Partial<PruneContext>): Promise<number> {
168
- const resolved: PruneContext = { ...defaultCtx(), ...ctx };
172
+ export async function runPrune(
173
+ args: PruneArgs,
174
+ ctxOverride?: Partial<PruneContext>,
175
+ ): Promise<number> {
176
+ const ctx: PruneContext = { ...defaultCtx(), ...ctxOverride };
169
177
 
170
- let values: {
171
- "older-than": string | undefined;
172
- "dry-run": boolean;
173
- yes: boolean;
174
- help: boolean;
175
- };
176
-
177
- try {
178
- const parsed = parseArgs({
179
- args: argv,
180
- options: {
181
- "older-than": { type: "string" },
182
- "dry-run": { type: "boolean", default: false },
183
- yes: { type: "boolean", short: "y", default: false },
184
- help: { type: "boolean", short: "h", default: false },
185
- },
186
- allowPositionals: false,
187
- strict: true,
188
- });
189
- values = parsed.values as typeof values;
190
- } catch (err) {
191
- const e = err as Error;
192
- resolved.stderr.write(`cesium prune: ${e.message}\n`);
193
- resolved.stderr.write(`Usage: cesium prune --older-than <duration> [--yes]\n`);
178
+ if (args.olderThan.length === 0) {
179
+ ctx.stderr.write(`cesium prune: --older-than is required\n`);
194
180
  return 1;
195
181
  }
196
182
 
197
- if (values.help) {
198
- resolved.stdout.write(
199
- [
200
- "Usage: cesium prune --older-than <duration> [options]",
201
- "",
202
- "Options:",
203
- " --older-than <dur> Delete artifacts older than this duration (e.g. 90d, 2w, 12h, 30m)",
204
- " --yes, -y Actually delete (default is dry-run)",
205
- " --dry-run Explicit dry-run (same as omitting --yes)",
206
- " --help, -h Show this help message",
207
- "",
208
- "Duration format: <N><unit> where unit is d (days), w (weeks), h (hours), m (minutes).",
209
- "",
210
- "Note: prune deletes by age only. It does not check revision chains.",
211
- " Deleting an early version in a supersedes chain does not affect the newer version.",
212
- "",
213
- ].join("\n"),
214
- );
215
- return 0;
216
- }
217
-
218
- const durationStr = values["older-than"];
219
- if (durationStr === undefined || durationStr.length === 0) {
220
- resolved.stderr.write(`cesium prune: --older-than is required\n`);
221
- resolved.stderr.write(`Usage: cesium prune --older-than <duration> [--yes]\n`);
222
- return 1;
223
- }
224
-
225
- const durationMs = parseDuration(durationStr);
183
+ const durationMs = parseDuration(args.olderThan);
226
184
  if (durationMs === null) {
227
- resolved.stderr.write(
228
- `cesium prune: invalid duration "${durationStr}". Use format like 90d, 2w, 12h, 30m\n`,
185
+ ctx.stderr.write(
186
+ `cesium prune: invalid duration "${args.olderThan}". Use format like 90d, 2w, 12h, 30m\n`,
229
187
  );
230
188
  return 1;
231
189
  }
232
190
 
233
- const isDryRun = !values.yes;
234
- const cfg = (resolved.loadConfig ?? loadConfig)();
235
- const now = (resolved.now ?? (() => new Date()))();
191
+ const isDryRun = !args.yes;
192
+ const cfg = (ctx.loadConfig ?? loadConfig)();
193
+ const now = (ctx.now ?? (() => new Date()))();
236
194
  const cutoff = now.getTime() - durationMs;
237
195
 
238
196
  // Collect all artifacts
@@ -241,7 +199,7 @@ export async function pruneCommand(argv: string[], ctx?: Partial<PruneContext>):
241
199
  allArtifacts = await collectAllArtifacts(cfg.stateDir);
242
200
  } catch (err) {
243
201
  const e = err as Error;
244
- resolved.stderr.write(`cesium prune: failed to scan artifacts: ${e.message}\n`);
202
+ ctx.stderr.write(`cesium prune: failed to scan artifacts: ${e.message}\n`);
245
203
  return 1;
246
204
  }
247
205
 
@@ -252,16 +210,16 @@ export async function pruneCommand(argv: string[], ctx?: Partial<PruneContext>):
252
210
  });
253
211
 
254
212
  if (toDelete.length === 0) {
255
- resolved.stdout.write(`No artifacts older than ${durationStr} found.\n`);
213
+ ctx.stdout.write(`No artifacts older than ${args.olderThan} found.\n`);
256
214
  return 0;
257
215
  }
258
216
 
259
217
  if (isDryRun) {
260
- resolved.stdout.write(
261
- `Would delete ${toDelete.length} artifact${toDelete.length !== 1 ? "s" : ""} older than ${durationStr}:\n`,
218
+ ctx.stdout.write(
219
+ `Would delete ${toDelete.length} artifact${toDelete.length !== 1 ? "s" : ""} older than ${args.olderThan}:\n`,
262
220
  );
263
- resolved.stdout.write(formatTable(toDelete) + "\n\n");
264
- resolved.stdout.write(`Re-run with --yes to delete.\n`);
221
+ ctx.stdout.write(formatTable(toDelete) + "\n\n");
222
+ ctx.stdout.write(`Re-run with --yes to delete.\n`);
265
223
  return 0;
266
224
  }
267
225
 
@@ -274,9 +232,7 @@ export async function pruneCommand(argv: string[], ctx?: Partial<PruneContext>):
274
232
  } catch (err) {
275
233
  const e = err as NodeJS.ErrnoException;
276
234
  if (e.code !== "ENOENT") {
277
- resolved.stderr.write(
278
- `cesium prune: failed to delete ${candidate.filePath}: ${e.message}\n`,
279
- );
235
+ ctx.stderr.write(`cesium prune: failed to delete ${candidate.filePath}: ${e.message}\n`);
280
236
  }
281
237
  return false;
282
238
  }
@@ -341,8 +297,40 @@ export async function pruneCommand(argv: string[], ctx?: Partial<PruneContext>):
341
297
  // best-effort
342
298
  }
343
299
 
344
- resolved.stdout.write(
300
+ ctx.stdout.write(
345
301
  `Deleted ${deletedCount} artifact${deletedCount !== 1 ? "s" : ""}. Indexes regenerated.\n`,
346
302
  );
347
303
  return 0;
348
304
  }
305
+
306
+ export const pruneCmd = defineCommand({
307
+ meta: {
308
+ name: "prune",
309
+ description: "Delete artifacts older than a given duration.",
310
+ },
311
+ args: {
312
+ "older-than": {
313
+ type: "string",
314
+ required: true,
315
+ description: "Delete artifacts older than this duration (e.g. 90d, 2w, 12h, 30m)",
316
+ },
317
+ yes: {
318
+ type: "boolean",
319
+ alias: "y",
320
+ default: false,
321
+ description: "Actually delete (default is dry-run)",
322
+ },
323
+ "dry-run": {
324
+ type: "boolean",
325
+ default: false,
326
+ description: "Explicit dry-run (same as omitting --yes)",
327
+ },
328
+ },
329
+ async run({ args }) {
330
+ const code = await runPrune({
331
+ olderThan: args["older-than"],
332
+ yes: args.yes,
333
+ });
334
+ if (code !== 0) process.exit(code);
335
+ },
336
+ });
@@ -1,26 +1,31 @@
1
1
  // cesium restart — stop the running server and start a new one in the foreground.
2
2
 
3
- import { type StopContext, stopCommand } from "./stop.ts";
4
- import { type ServeContext, serveCommand as defaultServeCommand } from "./serve.ts";
3
+ import { defineCommand } from "citty";
4
+ import { type StopContext, type StopArgs, runStop } from "./stop.ts";
5
+ import { type ServeContext, type ServeArgs, runServe, parseDuration } from "./serve.ts";
5
6
 
6
7
  export interface RestartContext extends StopContext, ServeContext {
7
- /** Test injection: replace serveCommand with a mock so tests don't block. */
8
- serveImpl?: (argv: string[], ctx?: Partial<RestartContext>) => Promise<number>;
8
+ /** Test injection: replace runServe with a mock so tests don't block. */
9
+ serveImpl?: (args: ServeArgs, ctx?: Partial<RestartContext>) => Promise<number>;
9
10
  }
10
11
 
12
+ export interface RestartArgs extends StopArgs, ServeArgs {}
13
+
11
14
  function defaultSleep(ms: number): Promise<void> {
12
15
  return new Promise((r) => setTimeout(r, ms));
13
16
  }
14
17
 
15
- export async function restartCommand(
16
- argv: string[],
17
- ctx?: Partial<RestartContext>,
18
+ export async function runRestart(
19
+ args: RestartArgs,
20
+ ctxOverride?: Partial<RestartContext>,
18
21
  ): Promise<number> {
19
- const sleepFn = ctx?.sleep ?? defaultSleep;
20
- const serveFn = ctx?.serveImpl ?? defaultServeCommand;
22
+ const sleepFn = ctxOverride?.sleep ?? defaultSleep;
23
+ const serveFn = ctxOverride?.serveImpl ?? runServe;
24
+
25
+ const stopArgs: StopArgs = { force: args.force, timeoutMs: args.timeoutMs };
21
26
 
22
27
  // 1. Stop any running server
23
- const stopCode = await stopCommand(argv, ctx);
28
+ const stopCode = await runStop(stopArgs, ctxOverride);
24
29
  if (stopCode !== 0) {
25
30
  // e.g. EPERM — bail, pass through exit code
26
31
  return stopCode;
@@ -30,9 +35,92 @@ export async function restartCommand(
30
35
  await sleepFn(200);
31
36
 
32
37
  // 3. Announce restart
33
- const stdout = ctx?.stdout ?? process.stdout;
38
+ const stdout = ctxOverride?.stdout ?? process.stdout;
34
39
  stdout.write("starting new cesium server...\n");
35
40
 
36
41
  // 4. Start the new server in foreground (blocks until Ctrl-C)
37
- return serveFn(argv, ctx);
42
+ const serveArgs: ServeArgs = {};
43
+ if (args.port !== undefined) serveArgs.port = args.port;
44
+ if (args.hostname !== undefined) serveArgs.hostname = args.hostname;
45
+ if (args.stateDir !== undefined) serveArgs.stateDir = args.stateDir;
46
+ if (args.idleTimeoutMs !== undefined) serveArgs.idleTimeoutMs = args.idleTimeoutMs;
47
+
48
+ return serveFn(serveArgs, ctxOverride);
38
49
  }
50
+
51
+ export const restartCmd = defineCommand({
52
+ meta: {
53
+ name: "restart",
54
+ description: "Stop the running cesium server and start a new one in the foreground.",
55
+ },
56
+ args: {
57
+ // Stop args
58
+ force: {
59
+ type: "boolean",
60
+ alias: "f",
61
+ default: false,
62
+ description: "SIGKILL immediately — skip the SIGTERM grace period",
63
+ },
64
+ timeout: {
65
+ type: "string",
66
+ default: "3000",
67
+ description: "Grace period in ms before SIGKILL",
68
+ },
69
+ // Serve args (mirrored)
70
+ port: {
71
+ type: "string",
72
+ alias: "p",
73
+ description: "Override configured port (default: 3030)",
74
+ },
75
+ hostname: {
76
+ type: "string",
77
+ alias: "H",
78
+ description: "Override configured bind address",
79
+ },
80
+ "state-dir": {
81
+ type: "string",
82
+ description: "Override the cesium state directory",
83
+ },
84
+ "idle-timeout": {
85
+ type: "string",
86
+ description: 'Auto-shutdown after DUR of inactivity (e.g. "30m"). Default: never.',
87
+ },
88
+ },
89
+ async run({ args }) {
90
+ const timeoutMs = parseInt(args.timeout, 10);
91
+ if (isNaN(timeoutMs) || timeoutMs < 0) {
92
+ process.stderr.write(`cesium restart: --timeout must be a non-negative integer\n`);
93
+ process.exit(1);
94
+ }
95
+
96
+ const restartArgs: RestartArgs = {
97
+ force: args.force,
98
+ timeoutMs,
99
+ };
100
+
101
+ if (args.port !== undefined) {
102
+ const p = parseInt(args.port, 10);
103
+ if (isNaN(p) || p < 1 || p > 65535) {
104
+ process.stderr.write(`cesium restart: --port must be a number between 1 and 65535\n`);
105
+ process.exit(1);
106
+ }
107
+ restartArgs.port = p;
108
+ }
109
+ if (args.hostname !== undefined) restartArgs.hostname = args.hostname;
110
+ if (args["state-dir"] !== undefined) restartArgs.stateDir = args["state-dir"];
111
+
112
+ if (args["idle-timeout"] !== undefined) {
113
+ const ms = parseDuration(args["idle-timeout"]);
114
+ if (ms === null) {
115
+ process.stderr.write(
116
+ `cesium restart: --idle-timeout must be a duration like "30m", "2h", "90s", or "0"/"never" to disable\n`,
117
+ );
118
+ process.exit(1);
119
+ }
120
+ restartArgs.idleTimeoutMs = ms;
121
+ }
122
+
123
+ const code = await runRestart(restartArgs);
124
+ if (code !== 0) process.exit(code);
125
+ },
126
+ });
@@ -1,11 +1,24 @@
1
1
  // cesium serve — start the local HTTP server in the foreground.
2
2
 
3
- import { parseArgs } from "node:util";
3
+ import { defineCommand } from "citty";
4
4
  import { loadConfig, type CesiumConfig } from "../../config.ts";
5
5
  import { runServerForeground, stopRunning } from "../../server/lifecycle.ts";
6
6
  import { resolveDisplayHost } from "../../tools/publish.ts";
7
7
  import { themeFromPreset, mergeTheme } from "../../render/theme.ts";
8
8
 
9
+ export interface ServeArgs {
10
+ port?: number;
11
+ hostname?: string;
12
+ stateDir?: string;
13
+ /**
14
+ * Idle timeout in milliseconds. 0 (the default for `cesium serve`) means the
15
+ * server runs forever until SIGINT/SIGTERM. Override with --idle-timeout to
16
+ * opt back into auto-shutdown — useful for long-lived dev sessions that
17
+ * should still recycle eventually.
18
+ */
19
+ idleTimeoutMs?: number;
20
+ }
21
+
9
22
  export interface ServeContext {
10
23
  stdout: { write: (s: string) => void };
11
24
  stderr: { write: (s: string) => void };
@@ -19,21 +32,8 @@ function defaultCtx(): ServeContext {
19
32
  };
20
33
  }
21
34
 
22
- export interface ServeOptions {
23
- port?: number;
24
- hostname?: string;
25
- stateDir?: string;
26
- /**
27
- * Idle timeout in milliseconds. 0 (the default for `cesium serve`) means the
28
- * server runs forever until SIGINT/SIGTERM. Override with --idle-timeout to
29
- * opt back into auto-shutdown — useful for long-lived dev sessions that
30
- * should still recycle eventually.
31
- */
32
- idleTimeoutMs?: number;
33
- }
34
-
35
35
  /** Parse a duration string like "30m", "2h", "90s", "0", "never". Returns ms or null. */
36
- function parseDuration(input: string): number | null {
36
+ export function parseDuration(input: string): number | null {
37
37
  const trimmed = input.trim().toLowerCase();
38
38
  if (trimmed === "0" || trimmed === "never" || trimmed === "off") return 0;
39
39
  const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(trimmed);
@@ -41,134 +41,82 @@ function parseDuration(input: string): number | null {
41
41
  const n = parseFloat(match[1] ?? "");
42
42
  if (!isFinite(n) || n < 0) return null;
43
43
  const unit = match[2] ?? "ms";
44
- const mul =
45
- unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : /* h */ 3_600_000;
44
+ const mul = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : /* h */ 3_600_000;
46
45
  return Math.floor(n * mul);
47
46
  }
48
47
 
49
- /** Parse the argv for serve. Returns null on error (already written to stderr). */
50
- export function parseServeArgs(
51
- argv: string[],
52
- ctx: Pick<ServeContext, "stdout" | "stderr">,
53
- ): ServeOptions | null | "help" {
54
- let values: {
55
- port: string | undefined;
56
- hostname: string | undefined;
57
- "idle-timeout": string | undefined;
58
- "state-dir": string | undefined;
59
- help: boolean;
60
- };
61
-
62
- try {
63
- const parsed = parseArgs({
64
- args: argv,
65
- options: {
66
- port: { type: "string", short: "p" },
67
- hostname: { type: "string", short: "H" },
68
- "idle-timeout": { type: "string" },
69
- "state-dir": { type: "string" },
70
- help: { type: "boolean", short: "h", default: false },
71
- },
72
- allowPositionals: false,
73
- strict: true,
74
- });
75
- values = parsed.values as typeof values;
76
- } catch (err) {
77
- const e = err as Error;
78
- ctx.stderr.write(`cesium serve: ${e.message}\n`);
79
- ctx.stderr.write(`Usage: cesium serve [--port N] [--hostname H] [--idle-timeout DUR]\n`);
80
- return null;
81
- }
82
-
83
- if (values.help) {
84
- ctx.stdout.write(
85
- [
86
- "Usage: cesium serve [options]",
87
- "",
88
- "Options:",
89
- " --port, -p N Override configured port (default: 3030)",
90
- " --hostname, -H H Override configured bind address (default: 127.0.0.1)",
91
- " --state-dir DIR Override the cesium state directory",
92
- " --idle-timeout DUR Auto-shutdown after DUR of inactivity. Accepts plain",
93
- " milliseconds or a suffixed value (90s, 30m, 2h).",
94
- " Use 0 / never / off to disable. Default: 0 (never).",
95
- " --help, -h Show this help message",
96
- "",
97
- "Starts the cesium HTTP server in the foreground. Press Ctrl-C to stop.",
98
- "Uses the same config as the opencode plugin (~/.config/opencode/cesium.json).",
99
- "",
100
- "Note: foreground `cesium serve` ignores the configured idleTimeoutMs by",
101
- "default — the timeout exists for the plugin's lazy-started server, not",
102
- "for a server you launched explicitly.",
103
- "",
104
- ].join("\n"),
105
- );
106
- return "help";
107
- }
48
+ interface ValidatedServeArgs {
49
+ port?: number;
50
+ hostname?: string;
51
+ stateDir?: string;
52
+ idleTimeoutMs?: number;
53
+ }
108
54
 
109
- const opts: ServeOptions = {};
55
+ /**
56
+ * Validate ServeArgs and return a ValidatedServeArgs object, or null on error.
57
+ * Writes the error message to ctx.stderr.
58
+ */
59
+ export function validateServeArgs(args: ServeArgs, ctx: ServeContext): ValidatedServeArgs | null {
60
+ const out: ValidatedServeArgs = {};
110
61
 
111
- if (values.port !== undefined) {
112
- const p = parseInt(values.port, 10);
113
- if (isNaN(p) || p < 1 || p > 65535) {
62
+ if (args.port !== undefined) {
63
+ if (!Number.isInteger(args.port) || args.port < 1 || args.port > 65535) {
114
64
  ctx.stderr.write(`cesium serve: --port must be a number between 1 and 65535\n`);
115
65
  return null;
116
66
  }
117
- opts.port = p;
67
+ out.port = args.port;
118
68
  }
119
69
 
120
- if (values.hostname !== undefined) {
121
- if (values.hostname.length === 0) {
70
+ if (args.hostname !== undefined) {
71
+ if (args.hostname.length === 0) {
122
72
  ctx.stderr.write(`cesium serve: --hostname must not be empty\n`);
123
73
  return null;
124
74
  }
125
- opts.hostname = values.hostname;
75
+ out.hostname = args.hostname;
126
76
  }
127
77
 
128
- if (values["state-dir"] !== undefined) {
129
- if (values["state-dir"].length === 0) {
78
+ if (args.stateDir !== undefined) {
79
+ if (args.stateDir.length === 0) {
130
80
  ctx.stderr.write(`cesium serve: --state-dir must not be empty\n`);
131
81
  return null;
132
82
  }
133
- opts.stateDir = values["state-dir"];
83
+ out.stateDir = args.stateDir;
134
84
  }
135
85
 
136
- if (values["idle-timeout"] !== undefined) {
137
- const ms = parseDuration(values["idle-timeout"]);
138
- if (ms === null) {
139
- ctx.stderr.write(
140
- `cesium serve: --idle-timeout must be a duration like "30m", "2h", "90s", or "0"/"never" to disable\n`,
141
- );
86
+ if (args.idleTimeoutMs !== undefined) {
87
+ if (!Number.isFinite(args.idleTimeoutMs) || args.idleTimeoutMs < 0) {
88
+ ctx.stderr.write(`cesium serve: --idle-timeout must be a non-negative duration\n`);
142
89
  return null;
143
90
  }
144
- opts.idleTimeoutMs = ms;
91
+ out.idleTimeoutMs = args.idleTimeoutMs;
145
92
  }
146
93
 
147
- return opts;
94
+ return out;
148
95
  }
149
96
 
150
- export async function serveCommand(argv: string[], ctx?: Partial<ServeContext>): Promise<number> {
151
- const resolved: ServeContext = { ...defaultCtx(), ...ctx };
97
+ export async function runServe(
98
+ args: ServeArgs,
99
+ ctxOverride?: Partial<ServeContext>,
100
+ ): Promise<number> {
101
+ const ctx: ServeContext = { ...defaultCtx(), ...ctxOverride };
152
102
 
153
- const parseResult = parseServeArgs(argv, resolved);
154
- if (parseResult === null) return 1;
155
- if (parseResult === "help") return 0;
103
+ const validated = validateServeArgs(args, ctx);
104
+ if (validated === null) return 1;
156
105
 
157
- const opts = parseResult;
158
- const cfg = (resolved.loadConfig ?? loadConfig)();
106
+ const cfg = (ctx.loadConfig ?? loadConfig)();
159
107
 
160
108
  // Foreground `cesium serve` defaults to NO idle timeout — when the user
161
109
  // launches the server explicitly, they want it to live until they Ctrl-C.
162
110
  // The configured idleTimeoutMs only applies to the plugin's lazy-started
163
111
  // server. --idle-timeout opts back into auto-shutdown.
164
- const effectiveIdleTimeoutMs = opts.idleTimeoutMs ?? 0;
112
+ const effectiveIdleTimeoutMs = validated.idleTimeoutMs ?? 0;
165
113
 
166
114
  // Apply overrides from CLI flags
167
115
  const effectiveCfg = {
168
116
  ...cfg,
169
- ...(opts.stateDir !== undefined ? { stateDir: opts.stateDir } : {}),
170
- ...(opts.port !== undefined ? { port: opts.port, portMax: opts.port } : {}),
171
- ...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}),
117
+ ...(validated.stateDir !== undefined ? { stateDir: validated.stateDir } : {}),
118
+ ...(validated.port !== undefined ? { port: validated.port, portMax: validated.port } : {}),
119
+ ...(validated.hostname !== undefined ? { hostname: validated.hostname } : {}),
172
120
  idleTimeoutMs: effectiveIdleTimeoutMs,
173
121
  };
174
122
 
@@ -185,7 +133,7 @@ export async function serveCommand(argv: string[], ctx?: Partial<ServeContext>):
185
133
  });
186
134
  } catch (err) {
187
135
  const e = err as Error;
188
- resolved.stderr.write(`cesium serve: failed to start server: ${e.message}\n`);
136
+ ctx.stderr.write(`cesium serve: failed to start server: ${e.message}\n`);
189
137
  return 1;
190
138
  }
191
139
 
@@ -196,26 +144,24 @@ export async function serveCommand(argv: string[], ctx?: Partial<ServeContext>):
196
144
  const home = process.env["HOME"] ?? "";
197
145
  const stateDirDisplay = home ? effectiveCfg.stateDir.replace(home, "~") : effectiveCfg.stateDir;
198
146
 
199
- resolved.stdout.write(`cesium serve · ${displayUrl}\n`);
200
- resolved.stdout.write(` serving ${stateDirDisplay}\n`);
147
+ ctx.stdout.write(`cesium serve · ${displayUrl}\n`);
148
+ ctx.stdout.write(` serving ${stateDirDisplay}\n`);
201
149
  if (effectiveIdleTimeoutMs <= 0) {
202
- resolved.stdout.write(` no idle timeout — runs until Ctrl-C\n`);
150
+ ctx.stdout.write(` no idle timeout — runs until Ctrl-C\n`);
203
151
  } else {
204
152
  const minutes = Math.round(effectiveIdleTimeoutMs / 60_000);
205
- resolved.stdout.write(
153
+ ctx.stdout.write(
206
154
  ` idle timeout: ${minutes >= 1 ? `${minutes}m` : `${effectiveIdleTimeoutMs}ms`} of inactivity\n`,
207
155
  );
208
156
  }
209
- resolved.stdout.write(` Ctrl-C to stop\n`);
157
+ ctx.stdout.write(` Ctrl-C to stop\n`);
210
158
 
211
159
  // If binding on all interfaces, also print the LAN URL
212
160
  if (effectiveCfg.hostname === "0.0.0.0" || effectiveCfg.hostname === "::") {
213
- // resolveDisplayHost already returns the LAN IP for 0.0.0.0
214
- resolved.stdout.write(` LAN: ${displayUrl}\n`);
161
+ ctx.stdout.write(` LAN: ${displayUrl}\n`);
215
162
  }
216
163
 
217
- // Run in the foreground until SIGINT/SIGTERM (handled by lifecycle module's signal handlers)
218
- // Keep the process alive by returning a promise that never resolves (until signal fires)
164
+ // Run in the foreground until SIGINT/SIGTERM
219
165
  await new Promise<void>((resolve) => {
220
166
  const cleanup = () => {
221
167
  void stopRunning(effectiveCfg.stateDir).finally(() => {
@@ -228,3 +174,60 @@ export async function serveCommand(argv: string[], ctx?: Partial<ServeContext>):
228
174
 
229
175
  return 0;
230
176
  }
177
+
178
+ export const serveCmd = defineCommand({
179
+ meta: {
180
+ name: "serve",
181
+ description: "Start the cesium HTTP server in the foreground. Press Ctrl-C to stop.",
182
+ },
183
+ args: {
184
+ port: {
185
+ type: "string",
186
+ alias: "p",
187
+ description: "Override configured port (default: 3030)",
188
+ },
189
+ hostname: {
190
+ type: "string",
191
+ alias: "H",
192
+ description: "Override configured bind address (default: 127.0.0.1)",
193
+ },
194
+ "state-dir": {
195
+ type: "string",
196
+ description: "Override the cesium state directory",
197
+ },
198
+ "idle-timeout": {
199
+ type: "string",
200
+ description:
201
+ 'Auto-shutdown after DUR of inactivity (e.g. "30m", "2h", "90s"). Use 0/never/off to disable. Default: never.',
202
+ },
203
+ },
204
+ async run({ args }) {
205
+ const serveArgs: ServeArgs = {};
206
+
207
+ if (args.port !== undefined) {
208
+ const p = parseInt(args.port, 10);
209
+ if (isNaN(p) || p < 1 || p > 65535) {
210
+ process.stderr.write(`cesium serve: --port must be a number between 1 and 65535\n`);
211
+ process.exit(1);
212
+ }
213
+ serveArgs.port = p;
214
+ }
215
+
216
+ if (args.hostname !== undefined) serveArgs.hostname = args.hostname;
217
+ if (args["state-dir"] !== undefined) serveArgs.stateDir = args["state-dir"];
218
+
219
+ if (args["idle-timeout"] !== undefined) {
220
+ const ms = parseDuration(args["idle-timeout"]);
221
+ if (ms === null) {
222
+ process.stderr.write(
223
+ `cesium serve: --idle-timeout must be a duration like "30m", "2h", "90s", or "0"/"never" to disable\n`,
224
+ );
225
+ process.exit(1);
226
+ }
227
+ serveArgs.idleTimeoutMs = ms;
228
+ }
229
+
230
+ const code = await runServe(serveArgs);
231
+ if (code !== 0) process.exit(code);
232
+ },
233
+ });