@cfbender/cesium 0.5.2 → 0.6.1

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.
@@ -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);
@@ -45,129 +45,78 @@ function parseDuration(input: string): number | null {
45
45
  return Math.floor(n * mul);
46
46
  }
47
47
 
48
- /** Parse the argv for serve. Returns null on error (already written to stderr). */
49
- export function parseServeArgs(
50
- argv: string[],
51
- ctx: Pick<ServeContext, "stdout" | "stderr">,
52
- ): ServeOptions | null | "help" {
53
- let values: {
54
- port: string | undefined;
55
- hostname: string | undefined;
56
- "idle-timeout": string | undefined;
57
- "state-dir": string | undefined;
58
- help: boolean;
59
- };
60
-
61
- try {
62
- const parsed = parseArgs({
63
- args: argv,
64
- options: {
65
- port: { type: "string", short: "p" },
66
- hostname: { type: "string", short: "H" },
67
- "idle-timeout": { type: "string" },
68
- "state-dir": { type: "string" },
69
- help: { type: "boolean", short: "h", default: false },
70
- },
71
- allowPositionals: false,
72
- strict: true,
73
- });
74
- values = parsed.values as typeof values;
75
- } catch (err) {
76
- const e = err as Error;
77
- ctx.stderr.write(`cesium serve: ${e.message}\n`);
78
- ctx.stderr.write(`Usage: cesium serve [--port N] [--hostname H] [--idle-timeout DUR]\n`);
79
- return null;
80
- }
81
-
82
- if (values.help) {
83
- ctx.stdout.write(
84
- [
85
- "Usage: cesium serve [options]",
86
- "",
87
- "Options:",
88
- " --port, -p N Override configured port (default: 3030)",
89
- " --hostname, -H H Override configured bind address (default: 127.0.0.1)",
90
- " --state-dir DIR Override the cesium state directory",
91
- " --idle-timeout DUR Auto-shutdown after DUR of inactivity. Accepts plain",
92
- " milliseconds or a suffixed value (90s, 30m, 2h).",
93
- " Use 0 / never / off to disable. Default: 0 (never).",
94
- " --help, -h Show this help message",
95
- "",
96
- "Starts the cesium HTTP server in the foreground. Press Ctrl-C to stop.",
97
- "Uses the same config as the opencode plugin (~/.config/opencode/cesium.json).",
98
- "",
99
- "Note: foreground `cesium serve` ignores the configured idleTimeoutMs by",
100
- "default — the timeout exists for the plugin's lazy-started server, not",
101
- "for a server you launched explicitly.",
102
- "",
103
- ].join("\n"),
104
- );
105
- return "help";
106
- }
48
+ interface ValidatedServeArgs {
49
+ port?: number;
50
+ hostname?: string;
51
+ stateDir?: string;
52
+ idleTimeoutMs?: number;
53
+ }
107
54
 
108
- 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 = {};
109
61
 
110
- if (values.port !== undefined) {
111
- const p = parseInt(values.port, 10);
112
- if (isNaN(p) || p < 1 || p > 65535) {
62
+ if (args.port !== undefined) {
63
+ if (!Number.isInteger(args.port) || args.port < 1 || args.port > 65535) {
113
64
  ctx.stderr.write(`cesium serve: --port must be a number between 1 and 65535\n`);
114
65
  return null;
115
66
  }
116
- opts.port = p;
67
+ out.port = args.port;
117
68
  }
118
69
 
119
- if (values.hostname !== undefined) {
120
- if (values.hostname.length === 0) {
70
+ if (args.hostname !== undefined) {
71
+ if (args.hostname.length === 0) {
121
72
  ctx.stderr.write(`cesium serve: --hostname must not be empty\n`);
122
73
  return null;
123
74
  }
124
- opts.hostname = values.hostname;
75
+ out.hostname = args.hostname;
125
76
  }
126
77
 
127
- if (values["state-dir"] !== undefined) {
128
- if (values["state-dir"].length === 0) {
78
+ if (args.stateDir !== undefined) {
79
+ if (args.stateDir.length === 0) {
129
80
  ctx.stderr.write(`cesium serve: --state-dir must not be empty\n`);
130
81
  return null;
131
82
  }
132
- opts.stateDir = values["state-dir"];
83
+ out.stateDir = args.stateDir;
133
84
  }
134
85
 
135
- if (values["idle-timeout"] !== undefined) {
136
- const ms = parseDuration(values["idle-timeout"]);
137
- if (ms === null) {
138
- ctx.stderr.write(
139
- `cesium serve: --idle-timeout must be a duration like "30m", "2h", "90s", or "0"/"never" to disable\n`,
140
- );
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`);
141
89
  return null;
142
90
  }
143
- opts.idleTimeoutMs = ms;
91
+ out.idleTimeoutMs = args.idleTimeoutMs;
144
92
  }
145
93
 
146
- return opts;
94
+ return out;
147
95
  }
148
96
 
149
- export async function serveCommand(argv: string[], ctx?: Partial<ServeContext>): Promise<number> {
150
- 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 };
151
102
 
152
- const parseResult = parseServeArgs(argv, resolved);
153
- if (parseResult === null) return 1;
154
- if (parseResult === "help") return 0;
103
+ const validated = validateServeArgs(args, ctx);
104
+ if (validated === null) return 1;
155
105
 
156
- const opts = parseResult;
157
- const cfg = (resolved.loadConfig ?? loadConfig)();
106
+ const cfg = (ctx.loadConfig ?? loadConfig)();
158
107
 
159
108
  // Foreground `cesium serve` defaults to NO idle timeout — when the user
160
109
  // launches the server explicitly, they want it to live until they Ctrl-C.
161
110
  // The configured idleTimeoutMs only applies to the plugin's lazy-started
162
111
  // server. --idle-timeout opts back into auto-shutdown.
163
- const effectiveIdleTimeoutMs = opts.idleTimeoutMs ?? 0;
112
+ const effectiveIdleTimeoutMs = validated.idleTimeoutMs ?? 0;
164
113
 
165
114
  // Apply overrides from CLI flags
166
115
  const effectiveCfg = {
167
116
  ...cfg,
168
- ...(opts.stateDir !== undefined ? { stateDir: opts.stateDir } : {}),
169
- ...(opts.port !== undefined ? { port: opts.port, portMax: opts.port } : {}),
170
- ...(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 } : {}),
171
120
  idleTimeoutMs: effectiveIdleTimeoutMs,
172
121
  };
173
122
 
@@ -184,7 +133,7 @@ export async function serveCommand(argv: string[], ctx?: Partial<ServeContext>):
184
133
  });
185
134
  } catch (err) {
186
135
  const e = err as Error;
187
- 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`);
188
137
  return 1;
189
138
  }
190
139
 
@@ -195,26 +144,24 @@ export async function serveCommand(argv: string[], ctx?: Partial<ServeContext>):
195
144
  const home = process.env["HOME"] ?? "";
196
145
  const stateDirDisplay = home ? effectiveCfg.stateDir.replace(home, "~") : effectiveCfg.stateDir;
197
146
 
198
- resolved.stdout.write(`cesium serve · ${displayUrl}\n`);
199
- resolved.stdout.write(` serving ${stateDirDisplay}\n`);
147
+ ctx.stdout.write(`cesium serve · ${displayUrl}\n`);
148
+ ctx.stdout.write(` serving ${stateDirDisplay}\n`);
200
149
  if (effectiveIdleTimeoutMs <= 0) {
201
- resolved.stdout.write(` no idle timeout — runs until Ctrl-C\n`);
150
+ ctx.stdout.write(` no idle timeout — runs until Ctrl-C\n`);
202
151
  } else {
203
152
  const minutes = Math.round(effectiveIdleTimeoutMs / 60_000);
204
- resolved.stdout.write(
153
+ ctx.stdout.write(
205
154
  ` idle timeout: ${minutes >= 1 ? `${minutes}m` : `${effectiveIdleTimeoutMs}ms`} of inactivity\n`,
206
155
  );
207
156
  }
208
- resolved.stdout.write(` Ctrl-C to stop\n`);
157
+ ctx.stdout.write(` Ctrl-C to stop\n`);
209
158
 
210
159
  // If binding on all interfaces, also print the LAN URL
211
160
  if (effectiveCfg.hostname === "0.0.0.0" || effectiveCfg.hostname === "::") {
212
- // resolveDisplayHost already returns the LAN IP for 0.0.0.0
213
- resolved.stdout.write(` LAN: ${displayUrl}\n`);
161
+ ctx.stdout.write(` LAN: ${displayUrl}\n`);
214
162
  }
215
163
 
216
- // Run in the foreground until SIGINT/SIGTERM (handled by lifecycle module's signal handlers)
217
- // Keep the process alive by returning a promise that never resolves (until signal fires)
164
+ // Run in the foreground until SIGINT/SIGTERM
218
165
  await new Promise<void>((resolve) => {
219
166
  const cleanup = () => {
220
167
  void stopRunning(effectiveCfg.stateDir).finally(() => {
@@ -227,3 +174,60 @@ export async function serveCommand(argv: string[], ctx?: Partial<ServeContext>):
227
174
 
228
175
  return 0;
229
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
+ });