@cfbender/cesium 0.3.5
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/ARCHITECTURE.md +304 -0
- package/CHANGELOG.md +335 -0
- package/LICENSE +21 -0
- package/README.md +479 -0
- package/agents/cesium.md +39 -0
- package/assets/styleguide.html +857 -0
- package/package.json +61 -0
- package/src/cli/commands/ls.ts +186 -0
- package/src/cli/commands/open.ts +208 -0
- package/src/cli/commands/prune.ts +348 -0
- package/src/cli/commands/restart.ts +38 -0
- package/src/cli/commands/serve.ts +214 -0
- package/src/cli/commands/stop.ts +130 -0
- package/src/cli/commands/theme.ts +333 -0
- package/src/cli/index.ts +78 -0
- package/src/config.ts +94 -0
- package/src/index.ts +35 -0
- package/src/prompt/system-fragment.md +97 -0
- package/src/render/client-js.ts +316 -0
- package/src/render/controls.ts +302 -0
- package/src/render/critique.ts +360 -0
- package/src/render/extract.ts +83 -0
- package/src/render/scrub.ts +141 -0
- package/src/render/theme.ts +712 -0
- package/src/render/validate.ts +524 -0
- package/src/render/wrap.ts +165 -0
- package/src/server/api.ts +166 -0
- package/src/server/http.ts +195 -0
- package/src/server/lifecycle.ts +331 -0
- package/src/server/stop.ts +124 -0
- package/src/storage/index-cache.ts +71 -0
- package/src/storage/index-gen.ts +447 -0
- package/src/storage/lock.ts +108 -0
- package/src/storage/mutate.ts +396 -0
- package/src/storage/paths.ts +159 -0
- package/src/storage/project-summaries.ts +19 -0
- package/src/storage/theme-write.ts +19 -0
- package/src/storage/write.ts +75 -0
- package/src/tools/ask.ts +353 -0
- package/src/tools/critique.ts +66 -0
- package/src/tools/publish.ts +404 -0
- package/src/tools/stop.ts +53 -0
- package/src/tools/styleguide.ts +23 -0
- package/src/tools/wait.ts +192 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// cesium prune — delete artifacts older than a given duration.
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { readdir, unlink as fsUnlink, stat } from "node:fs/promises";
|
|
6
|
+
import { loadConfig, type CesiumConfig } from "../../config.ts";
|
|
7
|
+
import { loadIndex, writeIndex, type IndexEntry } from "../../storage/index-cache.ts";
|
|
8
|
+
import { atomicWrite } from "../../storage/write.ts";
|
|
9
|
+
import {
|
|
10
|
+
renderProjectIndex,
|
|
11
|
+
renderGlobalIndex,
|
|
12
|
+
summarizeProject,
|
|
13
|
+
} from "../../storage/index-gen.ts";
|
|
14
|
+
import { themeFromPreset, mergeTheme } from "../../render/theme.ts";
|
|
15
|
+
import { readEmbeddedMetadata } from "../../storage/write.ts";
|
|
16
|
+
import { readFile } from "node:fs/promises";
|
|
17
|
+
|
|
18
|
+
export interface PruneContext {
|
|
19
|
+
stdout: { write: (s: string) => void };
|
|
20
|
+
stderr: { write: (s: string) => void };
|
|
21
|
+
loadConfig?: () => CesiumConfig;
|
|
22
|
+
now?: () => Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function defaultCtx(): PruneContext {
|
|
26
|
+
return {
|
|
27
|
+
stdout: process.stdout,
|
|
28
|
+
stderr: process.stderr,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Duration parser ──────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/** Parse a duration string like "90d", "2w", "12h", "30m" into milliseconds. */
|
|
35
|
+
export function parseDuration(s: string): number | null {
|
|
36
|
+
const trimmed = s.trim().toLowerCase();
|
|
37
|
+
const match = /^(\d+)(d|w|h|m)$/.exec(trimmed);
|
|
38
|
+
if (!match) return null;
|
|
39
|
+
|
|
40
|
+
const n = parseInt(match[1] ?? "", 10);
|
|
41
|
+
const unit = match[2] ?? "";
|
|
42
|
+
|
|
43
|
+
switch (unit) {
|
|
44
|
+
case "m":
|
|
45
|
+
return n * 60 * 1000;
|
|
46
|
+
case "h":
|
|
47
|
+
return n * 60 * 60 * 1000;
|
|
48
|
+
case "d":
|
|
49
|
+
return n * 24 * 60 * 60 * 1000;
|
|
50
|
+
case "w":
|
|
51
|
+
return n * 7 * 24 * 60 * 60 * 1000;
|
|
52
|
+
default:
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Prune implementation ─────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
interface ArtifactCandidate {
|
|
60
|
+
projectSlug: string;
|
|
61
|
+
projectDir: string;
|
|
62
|
+
filename: string;
|
|
63
|
+
filePath: string;
|
|
64
|
+
createdAt: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Read metadata + fallback from one artifact file. Returns null to skip. */
|
|
68
|
+
async function readArtifactCandidate(
|
|
69
|
+
stateDir: string,
|
|
70
|
+
slug: string,
|
|
71
|
+
filename: string,
|
|
72
|
+
): Promise<ArtifactCandidate | null> {
|
|
73
|
+
const projectsDir = join(stateDir, "projects");
|
|
74
|
+
const filePath = join(projectsDir, slug, "artifacts", filename);
|
|
75
|
+
let createdAt: string | null = null;
|
|
76
|
+
try {
|
|
77
|
+
const html = await readFile(filePath, "utf8");
|
|
78
|
+
const meta = readEmbeddedMetadata(html);
|
|
79
|
+
if (meta !== null && typeof meta["createdAt"] === "string") {
|
|
80
|
+
createdAt = meta["createdAt"];
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (createdAt === null) {
|
|
86
|
+
try {
|
|
87
|
+
const s = await stat(filePath);
|
|
88
|
+
createdAt = s.mtime.toISOString();
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
projectSlug: slug,
|
|
95
|
+
projectDir: join(projectsDir, slug),
|
|
96
|
+
filename,
|
|
97
|
+
filePath,
|
|
98
|
+
createdAt,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Read all artifact filenames for one project slug. Returns [] on missing dir. */
|
|
103
|
+
async function readProjectFilenames(stateDir: string, slug: string): Promise<string[]> {
|
|
104
|
+
const artifactsDir = join(stateDir, "projects", slug, "artifacts");
|
|
105
|
+
try {
|
|
106
|
+
const files = await readdir(artifactsDir);
|
|
107
|
+
return files.filter((f) => f.endsWith(".html"));
|
|
108
|
+
} catch (err) {
|
|
109
|
+
const e = err as NodeJS.ErrnoException;
|
|
110
|
+
if (e.code === "ENOENT") return [];
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function collectAllArtifacts(stateDir: string): Promise<ArtifactCandidate[]> {
|
|
116
|
+
const projectsDir = join(stateDir, "projects");
|
|
117
|
+
let projectSlugs: string[];
|
|
118
|
+
try {
|
|
119
|
+
projectSlugs = await readdir(projectsDir);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const e = err as NodeJS.ErrnoException;
|
|
122
|
+
if (e.code === "ENOENT") return [];
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Read all filenames for each project in parallel
|
|
127
|
+
const filenameLists = await Promise.all(
|
|
128
|
+
projectSlugs.map((slug) => readProjectFilenames(stateDir, slug)),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Read each artifact's metadata in parallel
|
|
132
|
+
const perProjectCandidates = await Promise.all(
|
|
133
|
+
projectSlugs.map((slug, i) => {
|
|
134
|
+
const filenames = filenameLists[i] ?? [];
|
|
135
|
+
return Promise.all(
|
|
136
|
+
filenames.map((filename) => readArtifactCandidate(stateDir, slug, filename)),
|
|
137
|
+
);
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return perProjectCandidates.flat().filter((c): c is ArtifactCandidate => c !== null);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function formatTable(candidates: ArtifactCandidate[]): string {
|
|
145
|
+
const COL_PROJECT = 32;
|
|
146
|
+
const COL_ID = 8;
|
|
147
|
+
|
|
148
|
+
const header = "PROJECT".padEnd(COL_PROJECT) + " " + "ID".padEnd(COL_ID) + " CREATED";
|
|
149
|
+
const sep = "─".repeat(header.length);
|
|
150
|
+
|
|
151
|
+
const rows = candidates.map((c) => {
|
|
152
|
+
// Extract id from filename: <date>__<slug>__<id>.html
|
|
153
|
+
const parts = c.filename.replace(/\.html$/, "").split("__");
|
|
154
|
+
const id = parts[parts.length - 1] ?? c.filename.slice(0, 8);
|
|
155
|
+
|
|
156
|
+
const project =
|
|
157
|
+
c.projectSlug.length > COL_PROJECT
|
|
158
|
+
? c.projectSlug.slice(0, COL_PROJECT - 1) + "…"
|
|
159
|
+
: c.projectSlug.padEnd(COL_PROJECT);
|
|
160
|
+
|
|
161
|
+
return project + " " + id.slice(0, COL_ID).padEnd(COL_ID) + " " + c.createdAt.slice(0, 19);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return [header, sep, ...rows].join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function pruneCommand(argv: string[], ctx?: Partial<PruneContext>): Promise<number> {
|
|
168
|
+
const resolved: PruneContext = { ...defaultCtx(), ...ctx };
|
|
169
|
+
|
|
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`);
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
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);
|
|
226
|
+
if (durationMs === null) {
|
|
227
|
+
resolved.stderr.write(
|
|
228
|
+
`cesium prune: invalid duration "${durationStr}". Use format like 90d, 2w, 12h, 30m\n`,
|
|
229
|
+
);
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const isDryRun = !values.yes;
|
|
234
|
+
const cfg = (resolved.loadConfig ?? loadConfig)();
|
|
235
|
+
const now = (resolved.now ?? (() => new Date()))();
|
|
236
|
+
const cutoff = now.getTime() - durationMs;
|
|
237
|
+
|
|
238
|
+
// Collect all artifacts
|
|
239
|
+
let allArtifacts: ArtifactCandidate[];
|
|
240
|
+
try {
|
|
241
|
+
allArtifacts = await collectAllArtifacts(cfg.stateDir);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
const e = err as Error;
|
|
244
|
+
resolved.stderr.write(`cesium prune: failed to scan artifacts: ${e.message}\n`);
|
|
245
|
+
return 1;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Filter to those older than the cutoff
|
|
249
|
+
const toDelete = allArtifacts.filter((c) => {
|
|
250
|
+
const ts = new Date(c.createdAt).getTime();
|
|
251
|
+
return ts < cutoff;
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
if (toDelete.length === 0) {
|
|
255
|
+
resolved.stdout.write(`No artifacts older than ${durationStr} found.\n`);
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (isDryRun) {
|
|
260
|
+
resolved.stdout.write(
|
|
261
|
+
`Would delete ${toDelete.length} artifact${toDelete.length !== 1 ? "s" : ""} older than ${durationStr}:\n`,
|
|
262
|
+
);
|
|
263
|
+
resolved.stdout.write(formatTable(toDelete) + "\n\n");
|
|
264
|
+
resolved.stdout.write(`Re-run with --yes to delete.\n`);
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Actually delete — in parallel, track counts
|
|
269
|
+
const deleteResults = await Promise.all(
|
|
270
|
+
toDelete.map(async (candidate) => {
|
|
271
|
+
try {
|
|
272
|
+
await fsUnlink(candidate.filePath);
|
|
273
|
+
return true;
|
|
274
|
+
} catch (err) {
|
|
275
|
+
const e = err as NodeJS.ErrnoException;
|
|
276
|
+
if (e.code !== "ENOENT") {
|
|
277
|
+
resolved.stderr.write(
|
|
278
|
+
`cesium prune: failed to delete ${candidate.filePath}: ${e.message}\n`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
const deletedCount = deleteResults.filter(Boolean).length;
|
|
286
|
+
|
|
287
|
+
// Regenerate indexes for affected projects — in parallel
|
|
288
|
+
const affectedSlugs = [...new Set(toDelete.map((c) => c.projectSlug))];
|
|
289
|
+
const theme = mergeTheme(themeFromPreset(cfg.themePreset), cfg.theme);
|
|
290
|
+
|
|
291
|
+
await Promise.all(
|
|
292
|
+
affectedSlugs.map(async (slug) => {
|
|
293
|
+
const projectDir = join(cfg.stateDir, "projects", slug);
|
|
294
|
+
const projectIndexJsonPath = join(projectDir, "index.json");
|
|
295
|
+
try {
|
|
296
|
+
const projectEntries = await loadIndex(projectIndexJsonPath);
|
|
297
|
+
const deletedFilenames = new Set(
|
|
298
|
+
toDelete.filter((c) => c.projectSlug === slug).map((c) => c.filename),
|
|
299
|
+
);
|
|
300
|
+
const surviving: IndexEntry[] = projectEntries.filter(
|
|
301
|
+
(e) => !deletedFilenames.has(e.filename),
|
|
302
|
+
);
|
|
303
|
+
await writeIndex(projectIndexJsonPath, surviving);
|
|
304
|
+
const projectName = surviving[0]?.projectName ?? slug;
|
|
305
|
+
const projectIndexHtml = renderProjectIndex({
|
|
306
|
+
projectSlug: slug,
|
|
307
|
+
projectName,
|
|
308
|
+
entries: surviving,
|
|
309
|
+
theme,
|
|
310
|
+
});
|
|
311
|
+
await atomicWrite(join(projectDir, "index.html"), projectIndexHtml);
|
|
312
|
+
} catch {
|
|
313
|
+
// best-effort — if project index is missing, skip
|
|
314
|
+
}
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
// Regenerate global index
|
|
319
|
+
try {
|
|
320
|
+
const globalJsonPath = join(cfg.stateDir, "index.json");
|
|
321
|
+
const globalEntries = await loadIndex(globalJsonPath);
|
|
322
|
+
const deletedFilenames = new Set(toDelete.map((c) => c.filename));
|
|
323
|
+
const survivingGlobal: IndexEntry[] = globalEntries.filter(
|
|
324
|
+
(e) => !deletedFilenames.has(e.filename),
|
|
325
|
+
);
|
|
326
|
+
await writeIndex(globalJsonPath, survivingGlobal);
|
|
327
|
+
|
|
328
|
+
// Build project summaries from surviving global entries
|
|
329
|
+
const bySlug = new Map<string, { name: string; entries: IndexEntry[] }>();
|
|
330
|
+
for (const e of survivingGlobal) {
|
|
331
|
+
const g = bySlug.get(e.projectSlug) ?? { name: e.projectName, entries: [] };
|
|
332
|
+
g.entries.push(e);
|
|
333
|
+
bySlug.set(e.projectSlug, g);
|
|
334
|
+
}
|
|
335
|
+
const summaries = [...bySlug.entries()].map(([slug, { name, entries }]) =>
|
|
336
|
+
summarizeProject({ slug, name, entries }),
|
|
337
|
+
);
|
|
338
|
+
const globalIndexHtml = renderGlobalIndex({ projects: summaries, theme });
|
|
339
|
+
await atomicWrite(join(cfg.stateDir, "index.html"), globalIndexHtml);
|
|
340
|
+
} catch {
|
|
341
|
+
// best-effort
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
resolved.stdout.write(
|
|
345
|
+
`Deleted ${deletedCount} artifact${deletedCount !== 1 ? "s" : ""}. Indexes regenerated.\n`,
|
|
346
|
+
);
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// cesium restart — stop the running server and start a new one in the foreground.
|
|
2
|
+
|
|
3
|
+
import { type StopContext, stopCommand } from "./stop.ts";
|
|
4
|
+
import { type ServeContext, serveCommand as defaultServeCommand } from "./serve.ts";
|
|
5
|
+
|
|
6
|
+
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>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function defaultSleep(ms: number): Promise<void> {
|
|
12
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function restartCommand(
|
|
16
|
+
argv: string[],
|
|
17
|
+
ctx?: Partial<RestartContext>,
|
|
18
|
+
): Promise<number> {
|
|
19
|
+
const sleepFn = ctx?.sleep ?? defaultSleep;
|
|
20
|
+
const serveFn = ctx?.serveImpl ?? defaultServeCommand;
|
|
21
|
+
|
|
22
|
+
// 1. Stop any running server
|
|
23
|
+
const stopCode = await stopCommand(argv, ctx);
|
|
24
|
+
if (stopCode !== 0) {
|
|
25
|
+
// e.g. EPERM — bail, pass through exit code
|
|
26
|
+
return stopCode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 2. Brief pause to let the port fully release
|
|
30
|
+
await sleepFn(200);
|
|
31
|
+
|
|
32
|
+
// 3. Announce restart
|
|
33
|
+
const stdout = ctx?.stdout ?? process.stdout;
|
|
34
|
+
stdout.write("starting new cesium server...\n");
|
|
35
|
+
|
|
36
|
+
// 4. Start the new server in foreground (blocks until Ctrl-C)
|
|
37
|
+
return serveFn(argv, ctx);
|
|
38
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// cesium serve — start the local HTTP server in the foreground.
|
|
2
|
+
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { loadConfig, type CesiumConfig } from "../../config.ts";
|
|
5
|
+
import { ensureRunning, stopRunning } from "../../server/lifecycle.ts";
|
|
6
|
+
import { resolveDisplayHost } from "../../tools/publish.ts";
|
|
7
|
+
|
|
8
|
+
export interface ServeContext {
|
|
9
|
+
stdout: { write: (s: string) => void };
|
|
10
|
+
stderr: { write: (s: string) => void };
|
|
11
|
+
loadConfig?: () => CesiumConfig;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function defaultCtx(): ServeContext {
|
|
15
|
+
return {
|
|
16
|
+
stdout: process.stdout,
|
|
17
|
+
stderr: process.stderr,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ServeOptions {
|
|
22
|
+
port?: number;
|
|
23
|
+
hostname?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Idle timeout in milliseconds. 0 (the default for `cesium serve`) means the
|
|
26
|
+
* server runs forever until SIGINT/SIGTERM. Override with --idle-timeout to
|
|
27
|
+
* opt back into auto-shutdown — useful for long-lived dev sessions that
|
|
28
|
+
* should still recycle eventually.
|
|
29
|
+
*/
|
|
30
|
+
idleTimeoutMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Parse a duration string like "30m", "2h", "90s", "0", "never". Returns ms or null. */
|
|
34
|
+
function parseDuration(input: string): number | null {
|
|
35
|
+
const trimmed = input.trim().toLowerCase();
|
|
36
|
+
if (trimmed === "0" || trimmed === "never" || trimmed === "off") return 0;
|
|
37
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(trimmed);
|
|
38
|
+
if (match === null) return null;
|
|
39
|
+
const n = parseFloat(match[1] ?? "");
|
|
40
|
+
if (!isFinite(n) || n < 0) return null;
|
|
41
|
+
const unit = match[2] ?? "ms";
|
|
42
|
+
const mul =
|
|
43
|
+
unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60_000 : /* h */ 3_600_000;
|
|
44
|
+
return Math.floor(n * mul);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Parse the argv for serve. Returns null on error (already written to stderr). */
|
|
48
|
+
export function parseServeArgs(
|
|
49
|
+
argv: string[],
|
|
50
|
+
ctx: Pick<ServeContext, "stdout" | "stderr">,
|
|
51
|
+
): ServeOptions | null | "help" {
|
|
52
|
+
let values: {
|
|
53
|
+
port: string | undefined;
|
|
54
|
+
hostname: string | undefined;
|
|
55
|
+
"idle-timeout": string | undefined;
|
|
56
|
+
help: boolean;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const parsed = parseArgs({
|
|
61
|
+
args: argv,
|
|
62
|
+
options: {
|
|
63
|
+
port: { type: "string", short: "p" },
|
|
64
|
+
hostname: { type: "string", short: "H" },
|
|
65
|
+
"idle-timeout": { type: "string" },
|
|
66
|
+
help: { type: "boolean", short: "h", default: false },
|
|
67
|
+
},
|
|
68
|
+
allowPositionals: false,
|
|
69
|
+
strict: true,
|
|
70
|
+
});
|
|
71
|
+
values = parsed.values as typeof values;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
const e = err as Error;
|
|
74
|
+
ctx.stderr.write(`cesium serve: ${e.message}\n`);
|
|
75
|
+
ctx.stderr.write(`Usage: cesium serve [--port N] [--hostname H] [--idle-timeout DUR]\n`);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (values.help) {
|
|
80
|
+
ctx.stdout.write(
|
|
81
|
+
[
|
|
82
|
+
"Usage: cesium serve [options]",
|
|
83
|
+
"",
|
|
84
|
+
"Options:",
|
|
85
|
+
" --port, -p N Override configured port (default: 3030)",
|
|
86
|
+
" --hostname, -H H Override configured bind address (default: 127.0.0.1)",
|
|
87
|
+
" --idle-timeout DUR Auto-shutdown after DUR of inactivity. Accepts plain",
|
|
88
|
+
" milliseconds or a suffixed value (90s, 30m, 2h).",
|
|
89
|
+
" Use 0 / never / off to disable. Default: 0 (never).",
|
|
90
|
+
" --help, -h Show this help message",
|
|
91
|
+
"",
|
|
92
|
+
"Starts the cesium HTTP server in the foreground. Press Ctrl-C to stop.",
|
|
93
|
+
"Uses the same config as the opencode plugin (~/.config/opencode/cesium.json).",
|
|
94
|
+
"",
|
|
95
|
+
"Note: foreground `cesium serve` ignores the configured idleTimeoutMs by",
|
|
96
|
+
"default — the timeout exists for the plugin's lazy-started server, not",
|
|
97
|
+
"for a server you launched explicitly.",
|
|
98
|
+
"",
|
|
99
|
+
].join("\n"),
|
|
100
|
+
);
|
|
101
|
+
return "help";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const opts: ServeOptions = {};
|
|
105
|
+
|
|
106
|
+
if (values.port !== undefined) {
|
|
107
|
+
const p = parseInt(values.port, 10);
|
|
108
|
+
if (isNaN(p) || p < 1 || p > 65535) {
|
|
109
|
+
ctx.stderr.write(`cesium serve: --port must be a number between 1 and 65535\n`);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
opts.port = p;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (values.hostname !== undefined) {
|
|
116
|
+
if (values.hostname.length === 0) {
|
|
117
|
+
ctx.stderr.write(`cesium serve: --hostname must not be empty\n`);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
opts.hostname = values.hostname;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (values["idle-timeout"] !== undefined) {
|
|
124
|
+
const ms = parseDuration(values["idle-timeout"]);
|
|
125
|
+
if (ms === null) {
|
|
126
|
+
ctx.stderr.write(
|
|
127
|
+
`cesium serve: --idle-timeout must be a duration like "30m", "2h", "90s", or "0"/"never" to disable\n`,
|
|
128
|
+
);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
opts.idleTimeoutMs = ms;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return opts;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function serveCommand(argv: string[], ctx?: Partial<ServeContext>): Promise<number> {
|
|
138
|
+
const resolved: ServeContext = { ...defaultCtx(), ...ctx };
|
|
139
|
+
|
|
140
|
+
const parseResult = parseServeArgs(argv, resolved);
|
|
141
|
+
if (parseResult === null) return 1;
|
|
142
|
+
if (parseResult === "help") return 0;
|
|
143
|
+
|
|
144
|
+
const opts = parseResult;
|
|
145
|
+
const cfg = (resolved.loadConfig ?? loadConfig)();
|
|
146
|
+
|
|
147
|
+
// Foreground `cesium serve` defaults to NO idle timeout — when the user
|
|
148
|
+
// launches the server explicitly, they want it to live until they Ctrl-C.
|
|
149
|
+
// The configured idleTimeoutMs only applies to the plugin's lazy-started
|
|
150
|
+
// server. --idle-timeout opts back into auto-shutdown.
|
|
151
|
+
const effectiveIdleTimeoutMs = opts.idleTimeoutMs ?? 0;
|
|
152
|
+
|
|
153
|
+
// Apply overrides from CLI flags
|
|
154
|
+
const effectiveCfg = {
|
|
155
|
+
...cfg,
|
|
156
|
+
...(opts.port !== undefined ? { port: opts.port, portMax: opts.port } : {}),
|
|
157
|
+
...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}),
|
|
158
|
+
idleTimeoutMs: effectiveIdleTimeoutMs,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
let serverInfo: { port: number; url: string };
|
|
162
|
+
try {
|
|
163
|
+
serverInfo = await ensureRunning({
|
|
164
|
+
stateDir: effectiveCfg.stateDir,
|
|
165
|
+
port: effectiveCfg.port,
|
|
166
|
+
portMax: effectiveCfg.portMax,
|
|
167
|
+
idleTimeoutMs: effectiveCfg.idleTimeoutMs,
|
|
168
|
+
hostname: effectiveCfg.hostname,
|
|
169
|
+
});
|
|
170
|
+
} catch (err) {
|
|
171
|
+
const e = err as Error;
|
|
172
|
+
resolved.stderr.write(`cesium serve: failed to start server: ${e.message}\n`);
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const displayHost = resolveDisplayHost(effectiveCfg.hostname);
|
|
177
|
+
const displayUrl = `http://${displayHost}:${serverInfo.port}`;
|
|
178
|
+
|
|
179
|
+
// Expand home dir in stateDir for display
|
|
180
|
+
const home = process.env["HOME"] ?? "";
|
|
181
|
+
const stateDirDisplay = home ? effectiveCfg.stateDir.replace(home, "~") : effectiveCfg.stateDir;
|
|
182
|
+
|
|
183
|
+
resolved.stdout.write(`cesium serve · ${displayUrl}\n`);
|
|
184
|
+
resolved.stdout.write(` serving ${stateDirDisplay}\n`);
|
|
185
|
+
if (effectiveIdleTimeoutMs <= 0) {
|
|
186
|
+
resolved.stdout.write(` no idle timeout — runs until Ctrl-C\n`);
|
|
187
|
+
} else {
|
|
188
|
+
const minutes = Math.round(effectiveIdleTimeoutMs / 60_000);
|
|
189
|
+
resolved.stdout.write(
|
|
190
|
+
` idle timeout: ${minutes >= 1 ? `${minutes}m` : `${effectiveIdleTimeoutMs}ms`} of inactivity\n`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
resolved.stdout.write(` Ctrl-C to stop\n`);
|
|
194
|
+
|
|
195
|
+
// If binding on all interfaces, also print the LAN URL
|
|
196
|
+
if (effectiveCfg.hostname === "0.0.0.0" || effectiveCfg.hostname === "::") {
|
|
197
|
+
// resolveDisplayHost already returns the LAN IP for 0.0.0.0
|
|
198
|
+
resolved.stdout.write(` LAN: ${displayUrl}\n`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Run in the foreground until SIGINT/SIGTERM (handled by lifecycle module's signal handlers)
|
|
202
|
+
// Keep the process alive by returning a promise that never resolves (until signal fires)
|
|
203
|
+
await new Promise<void>((resolve) => {
|
|
204
|
+
const cleanup = () => {
|
|
205
|
+
void stopRunning(effectiveCfg.stateDir).finally(() => {
|
|
206
|
+
resolve();
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
process.once("SIGINT", cleanup);
|
|
210
|
+
process.once("SIGTERM", cleanup);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return 0;
|
|
214
|
+
}
|