@topik/cli 0.1.0-alpha.4 → 0.1.0-alpha.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/dist/cli.d.mts CHANGED
@@ -1 +1 @@
1
- export { };
1
+ export {}
package/dist/cli.mjs CHANGED
@@ -1,14 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import { boolean, command, positional, run, string } from "@drizzle-team/brocli";
3
- import { CompileError, compile, isErrorDiagnostic, lint, validateResources, watch } from "@topik/core";
4
- import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
5
- import { extname, isAbsolute, join, relative, resolve } from "node:path";
6
- import { existsSync, realpathSync } from "node:fs";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, renameSync, rmdirSync, unlinkSync } from "node:fs";
5
+ import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, stat } from "node:fs/promises";
6
+ import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
7
+ import { AssetCompilationError, PublicCompileError, compile, isErrorDiagnostic, lint, parseStrictTopikJson, publicCompileErrorMessage, serializeTopikJson, validateResources, validateStableSourceNamespace, validateTopikMaterializationRecord, watch } from "@topik/core";
8
+ import { sanitizeTopikContentDiagnostic } from "@topik/content-schema";
9
+ import { execFile } from "node:child_process";
10
+ import { promisify } from "node:util";
7
11
  import { createServer } from "node:http";
8
12
  import { parseAllDocuments } from "yaml";
9
13
  //#region src/diagnostics.ts
10
14
  function formatDiagnostic(diagnostic) {
11
- return `${diagnostic.file ?? "content"}${diagnostic.lines.length > 0 ? `:${diagnostic.lines.join(",")}` : ""} ${diagnostic.level} ${diagnostic.id}: ${diagnostic.message}`;
15
+ const sanitized = sanitizeTopikContentDiagnostic(diagnostic);
16
+ return `${sanitized.file ?? "content"}${sanitized.lines.length > 0 ? `:${sanitized.lines.join(",")}` : ""} ${sanitized.level} ${sanitized.id}: ${sanitized.message}`;
12
17
  }
13
18
  function printDiagnostics(diagnostics) {
14
19
  for (const diagnostic of diagnostics) {
@@ -25,6 +30,33 @@ var CliError = class extends Error {
25
30
  this.name = "CliError";
26
31
  }
27
32
  };
33
+ const PUBLIC_CLI_ERROR_MESSAGES = Object.freeze({
34
+ "browser-origin-invalid": "Browser origin configuration is invalid",
35
+ "resource-access-failed": "Resource input could not be accessed.",
36
+ "resource-format-unsupported": "Resource input format is unsupported.",
37
+ "resource-json-invalid": "JSON resource input could not be parsed.",
38
+ "resource-jsonl-invalid": "JSONL resource input could not be parsed.",
39
+ "resource-read-failed": "Resource input could not be read.",
40
+ "resource-validation-failed": "Resource validation failed.",
41
+ "resource-yaml-invalid": "YAML resource input could not be parsed."
42
+ });
43
+ /** CLI failure admitted to presentation only through fixed catalog wording. */
44
+ var PublicCliError = class extends CliError {
45
+ id;
46
+ constructor(id) {
47
+ super(publicCliErrorMessage(id) ?? "Topik command failed.");
48
+ this.name = "PublicCliError";
49
+ this.id = id;
50
+ }
51
+ };
52
+ function publicCliErrorMessage(id) {
53
+ return typeof id === "string" && Object.hasOwn(PUBLIC_CLI_ERROR_MESSAGES, id) ? PUBLIC_CLI_ERROR_MESSAGES[id] : void 0;
54
+ }
55
+ function formatPublicCliError(error) {
56
+ if (error instanceof PublicCliError) return publicCliErrorMessage(error.id) ?? "Topik command failed.";
57
+ if (error instanceof PublicCompileError) return publicCompileErrorMessage(error.id) ?? "Topik command failed.";
58
+ return "Topik command failed.";
59
+ }
28
60
  //#endregion
29
61
  //#region src/validation-output.ts
30
62
  function formatValidationFailure(errors, resourceCount, context) {
@@ -34,98 +66,592 @@ function formatValidationFailure(errors, resourceCount, context) {
34
66
  return lines.join("\n");
35
67
  }
36
68
  //#endregion
37
- //#region src/compile/index.ts
38
- const formatExtensions = {
39
- json: ".json",
40
- jsonl: ".jsonl",
41
- yaml: ".yaml"
42
- };
43
- async function toYaml(value) {
44
- if (process.versions.bun) return Bun.YAML.stringify(value);
45
- const { stringify } = await import("yaml");
46
- return stringify(value);
69
+ //#region src/source-namespace.ts
70
+ const execFileAsync = promisify(execFile);
71
+ function requiresSourceNamespace(error) {
72
+ return error instanceof AssetCompilationError && error.diagnostics.some((diagnostic) => diagnostic.id === "TOPIK_ASSET_SOURCE_NAMESPACE_REQUIRED");
73
+ }
74
+ function sourceNamespaceOptions(value) {
75
+ if (value === void 0) return void 0;
76
+ const validation = validateStableSourceNamespace(value);
77
+ if (!validation.ok) throw new CliError("--source-namespace must be normalized portable text");
78
+ return { sourceNamespace: validation.value };
79
+ }
80
+ /** Derive only after automatic discovery proves that an Asset needs a namespace. */
81
+ async function deriveGitSourceNamespace(dir) {
82
+ let worktree;
83
+ let remotes;
84
+ try {
85
+ worktree = (await git(dir, ["rev-parse", "--show-toplevel"])).trim();
86
+ remotes = (await git(dir, ["remote"])).split(/\r?\n/u).filter(Boolean).sort();
87
+ } catch {
88
+ throw namespaceRequired();
89
+ }
90
+ const remoteName = remotes.includes("origin") ? "origin" : remotes.length === 1 ? remotes[0] : void 0;
91
+ if (remoteName === void 0) throw namespaceRequired();
92
+ let remote;
93
+ try {
94
+ remote = (await git(dir, [
95
+ "remote",
96
+ "get-url",
97
+ remoteName
98
+ ])).trim();
99
+ } catch {
100
+ throw namespaceRequired();
101
+ }
102
+ const identity = normalizeRemote(remote);
103
+ if (identity === void 0) throw namespaceRequired();
104
+ const rootRelative = relative(resolve(worktree), resolve(dir)).split(sep).join(posix.sep);
105
+ if (rootRelative.startsWith("../") || rootRelative === "..") throw namespaceRequired();
106
+ const namespace = `topik-git-v1:${identity}#${rootRelative || "."}`;
107
+ const validation = validateStableSourceNamespace(namespace);
108
+ if (!validation.ok) throw namespaceRequired();
109
+ return validation.value;
47
110
  }
48
- function serialize(resource, format) {
49
- switch (format) {
50
- case "json": return Promise.resolve(JSON.stringify(resource, null, 2) + "\n");
51
- case "jsonl": return Promise.resolve(JSON.stringify(resource) + "\n");
52
- case "yaml": return toYaml(resource);
111
+ function normalizeRemote(value) {
112
+ const scp = value.includes("://") ? null : /^(?:[^@/:]+@)?([^/:]+):(.+)$/u.exec(value);
113
+ const candidate = scp !== null ? `ssh://${scp[1]}/${scp[2]}` : value;
114
+ let url;
115
+ try {
116
+ url = new URL(candidate);
117
+ } catch {
118
+ return;
53
119
  }
120
+ if (url.protocol !== "https:" && url.protocol !== "ssh:" && url.protocol !== "git:") return void 0;
121
+ if (url.hostname.length === 0 || url.password !== "" || url.protocol === "https:" && url.username !== "" || url.search || url.hash) return;
122
+ const path = url.pathname.replace(/^\/+|\/+$/gu, "").replace(/\.git$/u, "");
123
+ if (path.length === 0 || path.split("/").some((part) => part.length === 0 || part === "." || part === "..")) return;
124
+ const port = url.port && !(url.protocol === "https:" && url.port === "443" || url.protocol === "ssh:" && url.port === "22") ? `:${url.port}` : "";
125
+ return `${url.hostname.toLowerCase()}${port}/${path}`;
126
+ }
127
+ async function git(dir, args) {
128
+ const { stdout } = await execFileAsync("git", [
129
+ "-C",
130
+ dir,
131
+ ...args
132
+ ], {
133
+ encoding: "utf8",
134
+ timeout: 1e4,
135
+ windowsHide: true
136
+ });
137
+ return stdout;
138
+ }
139
+ function namespaceRequired() {
140
+ return new CliError("Automatic local Assets require --source-namespace because no stable Git remote identity could be derived");
54
141
  }
142
+ //#endregion
143
+ //#region src/compile/index.ts
144
+ const COMPILATION_GENERATION_PREFIX = ".topik-compilation-generation-";
145
+ const COMPILATION_PRIOR_PREFIX = ".topik-compilation-prior-";
146
+ const COMPILATION_DIRECTORIES = ["blobs", "resources"];
147
+ const ownedDescriptorDecoder = new TextDecoder("utf-8", { fatal: true });
148
+ const ownedDescriptorEncoder = new TextEncoder();
55
149
  const compile$1 = command({
56
150
  name: "compile",
57
151
  desc: "Compile wiki content into Topik resource files",
58
152
  options: {
59
153
  dir: positional("dir").desc("Path to the content directory").default("."),
60
154
  outDir: string("out-dir").alias("o").desc("Output directory for compiled resources"),
61
- format: string("format").alias("f").desc("Output format").enum("json", "jsonl", "yaml").default("json"),
155
+ format: string("format").alias("f").desc("Output format (canonical JSON only)").enum("json").default("json"),
62
156
  dryRun: boolean("dry-run").desc("Show what would be compiled without writing files").default(false),
63
- clean: boolean("clean").desc("Remove existing output before compiling").default(false),
64
157
  validate: boolean("validate").desc("Validate compiled resources against schemas").default(false),
65
- links: string("links").desc("How unresolved wiki links and local guide fragments are handled").enum("error", "warning", "off").default("error")
158
+ links: string("links").desc("How unresolved wiki links and local guide fragments are handled").enum("error", "warning", "off").default("error"),
159
+ sourceNamespace: string("source-namespace").desc("Stable source namespace for automatically discovered local Assets (derived from Git when omitted)")
66
160
  },
67
161
  handler: async (options) => {
68
162
  const dir = resolve(options.dir);
69
- const format = options.format;
70
163
  const links = options.links;
71
- const { diagnostics, resources } = await compile({
72
- dir,
73
- validation: { links }
74
- });
164
+ const outDir = options.outDir ? resolve(options.outDir) : join(dir, ".topik");
165
+ await assertCompilationOutputScope(dir, outDir);
166
+ const assetOptions = sourceNamespaceOptions(options.sourceNamespace);
167
+ let result;
168
+ try {
169
+ result = await compile({
170
+ dir,
171
+ validation: { links },
172
+ assets: assetOptions
173
+ });
174
+ } catch (error) {
175
+ if (assetOptions !== void 0 || !requiresSourceNamespace(error)) throw error;
176
+ result = await compile({
177
+ dir,
178
+ validation: { links },
179
+ assets: { sourceNamespace: await deriveGitSourceNamespace(dir) }
180
+ });
181
+ }
182
+ const { diagnostics, materialization, payloads, resources, semantic } = result;
75
183
  printDiagnostics(diagnostics);
76
184
  if (options.validate) {
77
185
  const { valid, errors } = validateResources(resources);
78
186
  if (!valid) throw new CliError(formatValidationFailure(errors, resources.length, "validating compiled output"));
79
187
  }
80
- const ext = formatExtensions[format];
81
188
  if (options.dryRun) {
82
- for (const resource of resources) console.log(`${resource.type}/${resource.name}${ext}`);
83
- console.log(`\n${resources.length} resources (dry run)`);
189
+ const paths = [
190
+ ...resources.map((resource) => `resources/${resource.type}/${resource.name}.json`),
191
+ ...payloads.map((payload) => payload.path),
192
+ "materialization.json",
193
+ "semantic.json"
194
+ ].sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
195
+ for (const path of paths) console.log(path);
196
+ console.log(`\n${resources.length} resources and ${payloads.length} payloads (dry run)`);
84
197
  return;
85
198
  }
86
- const outDir = options.outDir ? resolve(options.outDir) : join(dir, ".topik", "resources");
87
- if (options.clean) await rm(outDir, {
88
- recursive: true,
89
- force: true
199
+ const materializedResources = new Map(materialization.resources.map((resource) => [resource.resource, resource]));
200
+ const files = resources.map((resource) => {
201
+ const materialized = materializedResources.get(`${resource.type}/${resource.name}`);
202
+ if (materialized === void 0) throw new CliError("Compiled materialization omits a resource descriptor");
203
+ return {
204
+ path: materialized.path,
205
+ bytes: serializeTopikJson(resource)
206
+ };
90
207
  });
91
- const types = new Set(resources.map((r) => r.type));
92
- await Promise.all([...types].map((type) => mkdir(join(outDir, type), { recursive: true })));
93
- await Promise.all(resources.map(async (resource) => writeFile(join(outDir, resource.type, `${resource.name}${ext}`), await serialize(resource, format))));
94
- console.log(`Compiled ${resources.length} resources to ${outDir}`);
208
+ files.push(...payloads.map((payload) => ({
209
+ path: payload.path,
210
+ bytes: payload.bytes
211
+ })), {
212
+ path: "materialization.json",
213
+ bytes: serializeTopikJson(materialization)
214
+ }, {
215
+ path: "semantic.json",
216
+ bytes: serializeTopikJson(semantic)
217
+ });
218
+ await replaceCompilationTree(outDir, files);
219
+ console.log(`Compiled ${resources.length} resources and ${payloads.length} payloads to ${outDir}`);
95
220
  }
96
221
  });
222
+ async function replaceCompilationTree(absolutePath, files) {
223
+ const stagedFiles = snapshotCompilationFiles(files);
224
+ const target = basename(absolutePath);
225
+ if (target.length === 0 || target === "." || target === "..") throw new CliError("Compilation output root is invalid");
226
+ const anchoredParent = await openAnchoredOutputDirectory(dirname(absolutePath), true);
227
+ if (anchoredParent === void 0) throw new CliError("Compilation output parent could not be created");
228
+ const parent = anchoredParent.handle;
229
+ let existing;
230
+ let generation;
231
+ let published = false;
232
+ let existingRemoved = false;
233
+ let operationError;
234
+ try {
235
+ const targetPath = procFdChild(parent.fd, target);
236
+ existing = await openOwnedCompilationOutput(parent, target);
237
+ generation = await createOwnedTemporaryDirectory(parent, COMPILATION_GENERATION_PREFIX);
238
+ for (const directory of COMPILATION_DIRECTORIES) {
239
+ const handle = await openAnchoredChildDirectory(generation.handle, directory, true);
240
+ if (handle === void 0) throw new CliError("Compilation output directory is unavailable");
241
+ await handle.close();
242
+ }
243
+ for (const file of stagedFiles) await writeAnchoredFile(generation.handle, file.path, file.bytes);
244
+ generation.tree = bindCompleteCompilationTreeSync(generation.handle);
245
+ assertExpectedStagedTree(generation.tree, stagedFiles);
246
+ if (existing !== void 0) {
247
+ const priorName = `${COMPILATION_PRIOR_PREFIX}${randomUUID()}`;
248
+ assertOutputPathAbsent(procFdChild(parent.fd, priorName));
249
+ renameSync(targetPath, procFdChild(parent.fd, priorName));
250
+ existing.name = priorName;
251
+ }
252
+ renameSync(generation.path, targetPath);
253
+ generation.path = targetPath;
254
+ generation.name = target;
255
+ published = true;
256
+ if (existing !== void 0) {
257
+ cleanupCompilationDirectory(parent, existing);
258
+ existingRemoved = true;
259
+ }
260
+ } catch (error) {
261
+ operationError = error;
262
+ }
263
+ if (generation !== void 0 && !published) try {
264
+ generation.tree = bindCompleteCompilationTreeSync(generation.handle);
265
+ cleanupCompilationDirectory(parent, generation);
266
+ } catch (error) {
267
+ operationError ??= error;
268
+ }
269
+ if (existing !== void 0 && !existingRemoved && existing.name !== target) try {
270
+ cleanupCompilationDirectory(parent, existing);
271
+ } catch (error) {
272
+ operationError ??= error;
273
+ }
274
+ await generation?.handle.close().catch(() => void 0);
275
+ await existing?.handle.close().catch(() => void 0);
276
+ await parent.close().catch(() => void 0);
277
+ if (operationError !== void 0) throw operationError;
278
+ }
279
+ async function createOwnedTemporaryDirectory(parent, prefix) {
280
+ const path = await mkdtemp(procFdChild(parent.fd, prefix));
281
+ let handle;
282
+ try {
283
+ handle = await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
284
+ if (!(await handle.stat({ bigint: true })).isDirectory()) throw new CliError("Compilation staging identity is invalid");
285
+ return {
286
+ name: basename(path),
287
+ path,
288
+ handle,
289
+ tree: { entries: [] }
290
+ };
291
+ } catch (error) {
292
+ await handle?.close().catch(() => void 0);
293
+ try {
294
+ rmdirSync(procFdChild(parent.fd, basename(path)));
295
+ } catch {}
296
+ if (error instanceof CliError) throw error;
297
+ throw new CliError("Compilation staging directory could not be opened safely");
298
+ }
299
+ }
300
+ async function openOwnedCompilationOutput(parent, target) {
301
+ const targetPath = procFdChild(parent.fd, target);
302
+ let stat;
303
+ try {
304
+ stat = await lstat(targetPath);
305
+ } catch (error) {
306
+ if (error.code === "ENOENT") return void 0;
307
+ throw new CliError("Compilation output target could not be inspected safely");
308
+ }
309
+ if (!stat.isDirectory()) throw new CliError("Compilation output contains a link or non-directory collision");
310
+ const directory = await openAnchoredChildDirectory(parent, target, false);
311
+ if (directory === void 0) throw new CliError("Compilation output directory could not be opened safely");
312
+ try {
313
+ const tree = bindCompleteCompilationTreeSync(directory);
314
+ assertOwnedCompilationTree(directory, tree);
315
+ return {
316
+ handle: directory,
317
+ name: target,
318
+ path: targetPath,
319
+ tree
320
+ };
321
+ } catch (error) {
322
+ await directory.close().catch(() => void 0);
323
+ throw error;
324
+ }
325
+ }
326
+ function cleanupCompilationDirectory(parent, directory) {
327
+ for (const entry of [...directory.tree.entries].reverse()) {
328
+ if (entry.path.length === 0) continue;
329
+ const path = `${procFd(directory.handle.fd)}/${entry.path}`;
330
+ if (entry.kind === "file") unlinkSync(path);
331
+ else rmdirSync(path);
332
+ }
333
+ rmdirSync(procFdChild(parent.fd, directory.name));
334
+ }
335
+ function assertOutputPathAbsent(path) {
336
+ try {
337
+ lstatSync(path);
338
+ } catch (error) {
339
+ if (error.code === "ENOENT") return;
340
+ throw new CliError("Compilation replacement storage could not be inspected safely");
341
+ }
342
+ throw new CliError("Compilation replacement storage is already occupied");
343
+ }
344
+ async function assertCompilationOutputScope(sourceDir, outDir) {
345
+ let canonicalOutput = outDir;
346
+ try {
347
+ canonicalOutput = await realpath(outDir);
348
+ } catch (error) {
349
+ if (error.code !== "ENOENT") throw new CliError("Compilation output scope could not be proven safely");
350
+ }
351
+ let canonicalSource;
352
+ try {
353
+ canonicalSource = await realpath(sourceDir);
354
+ } catch {
355
+ throw new CliError("Compilation source scope could not be proven safely");
356
+ }
357
+ const relation = relative(canonicalOutput, canonicalSource);
358
+ if (relation === "" || !relation.startsWith("..") && !isAbsolute(relation)) throw new CliError("Compilation output cannot equal or contain the source directory");
359
+ }
360
+ function assertOwnedCompilationTree(directory, tree) {
361
+ try {
362
+ const materialization = parseBoundCanonicalJson(directory, tree, "materialization.json");
363
+ const semantic = parseBoundCanonicalJson(directory, tree, "semantic.json");
364
+ if (!isRecord(materialization) || !Array.isArray(materialization.resources)) throw new TypeError("Materialization resource inventory is unavailable");
365
+ const resources = materialization.resources.map((entry) => {
366
+ if (!isRecord(entry) || typeof entry.path !== "string") throw new TypeError("Materialization resource inventory is malformed");
367
+ return parseBoundCanonicalJson(directory, tree, entry.path);
368
+ });
369
+ if (!isValidResourceSet(resources)) throw new TypeError("Compiled resource inventory is invalid");
370
+ const closure = validateTopikMaterializationRecord(materialization, resources, semantic);
371
+ if (!closure.ok) throw new TypeError("Compilation identity records do not close");
372
+ assertBoundTreeMatchesInventory(tree, closure.value);
373
+ } catch {
374
+ throw new CliError("Existing compilation output is populated but is not recognized as owned");
375
+ }
376
+ }
377
+ function parseBoundCanonicalJson(root, tree, path) {
378
+ const bytes = readBoundCompilationFileSync(root, tree, path);
379
+ const text = ownedDescriptorDecoder.decode(bytes);
380
+ const value = parseStrictTopikJson(text, Number.POSITIVE_INFINITY);
381
+ const canonicalBytes = ownedDescriptorEncoder.encode(serializeTopikJson(value));
382
+ if (!Buffer.from(bytes).equals(canonicalBytes)) throw new TypeError("Compilation descriptor is not canonical");
383
+ return value;
384
+ }
385
+ function readBoundCompilationFileSync(root, tree, path) {
386
+ safeOutputComponents(path);
387
+ const expected = tree.entries.find((entry) => entry.kind === "file" && entry.path === path);
388
+ if (expected === void 0) throw new TypeError("Compilation descriptor is unavailable");
389
+ let fileFd;
390
+ try {
391
+ const anchoredPath = `${procFd(root.fd)}/${path}`;
392
+ fileFd = openSync(anchoredPath, constants.O_RDONLY | constants.O_NOFOLLOW | (typeof constants.O_NONBLOCK === "number" ? constants.O_NONBLOCK : 0));
393
+ const before = fstatSync(fileFd, { bigint: true });
394
+ if (!before.isFile() || !sameStoredOutputIdentity(toBoundOutputIdentity(before), expected.identity)) throw new TypeError("Compilation descriptor identity changed");
395
+ const bytes = readFileSync(fileFd);
396
+ const after = fstatSync(fileFd, { bigint: true });
397
+ if (!sameBoundOutputIdentity(before, after) || sha256OutputBytes(bytes) !== expected.digest) throw new TypeError("Compilation descriptor bytes changed");
398
+ assertPathStillBindsIdentitySync(anchoredPath, after, "file");
399
+ return Uint8Array.from(bytes);
400
+ } finally {
401
+ if (fileFd !== void 0) closeSync(fileFd);
402
+ }
403
+ }
404
+ function assertBoundTreeMatchesInventory(tree, materialization) {
405
+ const expectedDirectories = /* @__PURE__ */ new Set(["", ...COMPILATION_DIRECTORIES]);
406
+ const expectedFiles = /* @__PURE__ */ new Map([["materialization.json", {}], ["semantic.json", {}]]);
407
+ for (const entry of [...materialization.resources, ...materialization.payloads]) {
408
+ safeOutputComponents(entry.path);
409
+ if (expectedFiles.has(entry.path)) throw new TypeError("Compilation inventory repeats a path");
410
+ expectedFiles.set(entry.path, {
411
+ sha256: entry.sha256,
412
+ size: entry.size
413
+ });
414
+ const components = entry.path.split("/");
415
+ for (let index = 1; index < components.length; index++) expectedDirectories.add(components.slice(0, index).join("/"));
416
+ }
417
+ if (tree.entries.length !== expectedDirectories.size + expectedFiles.size) throw new TypeError("Compilation tree contains an unexpected path");
418
+ for (const entry of tree.entries) {
419
+ if (entry.kind === "directory") {
420
+ if (!expectedDirectories.has(entry.path)) throw new TypeError("Compilation tree contains an unexpected directory");
421
+ continue;
422
+ }
423
+ const expected = expectedFiles.get(entry.path);
424
+ if (expected === void 0 || expected.size !== void 0 && entry.identity.size !== BigInt(expected.size) || expected.sha256 !== void 0 && entry.digest !== expected.sha256) throw new TypeError("Compilation tree does not match its inventory");
425
+ }
426
+ }
427
+ function isRecord(value) {
428
+ return typeof value === "object" && value !== null && !Array.isArray(value);
429
+ }
430
+ function isValidResourceSet(resources) {
431
+ return validateResources(resources).valid;
432
+ }
433
+ async function openAnchoredOutputDirectory(absolutePath, create) {
434
+ if (process.platform !== "linux" || typeof constants.O_NOFOLLOW !== "number" || typeof constants.O_DIRECTORY !== "number") throw new CliError("This platform cannot prove descriptor-anchored output containment");
435
+ const normalized = resolve(absolutePath);
436
+ if (normalized === "/") throw new CliError("Compilation output root cannot be the filesystem root");
437
+ const components = normalized.split("/").filter(Boolean);
438
+ let current = await open("/", constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
439
+ try {
440
+ for (const component of components) {
441
+ const child = await openAnchoredChildDirectory(current, component, create);
442
+ if (child === void 0) {
443
+ await current.close();
444
+ return;
445
+ }
446
+ await current.close();
447
+ current = child;
448
+ }
449
+ return { handle: current };
450
+ } catch (error) {
451
+ await current.close().catch(() => void 0);
452
+ if (error instanceof CliError) throw error;
453
+ throw new CliError("Compilation output has an unsafe or unresolvable ancestor");
454
+ }
455
+ }
456
+ function snapshotCompilationFiles(files) {
457
+ const paths = /* @__PURE__ */ new Set();
458
+ const snapshots = files.map((file) => {
459
+ safeOutputComponents(file.path);
460
+ if (paths.has(file.path)) throw new CliError("Compilation output repeats a staged file path");
461
+ paths.add(file.path);
462
+ const bytes = typeof file.bytes === "string" ? Uint8Array.from(Buffer.from(file.bytes, "utf8")) : Uint8Array.from(file.bytes);
463
+ return {
464
+ path: file.path,
465
+ bytes,
466
+ digest: sha256OutputBytes(bytes)
467
+ };
468
+ });
469
+ for (const path of paths) {
470
+ const components = safeOutputComponents(path);
471
+ for (let index = 1; index < components.length; index++) if (paths.has(components.slice(0, index).join("/"))) throw new CliError("Compilation output has a parent/file collision");
472
+ }
473
+ return snapshots;
474
+ }
475
+ function bindCompleteCompilationTreeSync(root) {
476
+ const entries = [];
477
+ try {
478
+ inspectBoundDirectorySync(root.fd, "", entries);
479
+ } catch (error) {
480
+ if (error instanceof CliError) throw error;
481
+ throw new CliError("Compilation generation contents could not be inspected safely");
482
+ }
483
+ entries.sort((left, right) => Buffer.compare(Buffer.from(left.path, "utf8"), Buffer.from(right.path, "utf8")));
484
+ return { entries };
485
+ }
486
+ function inspectBoundDirectorySync(directoryFd, prefix, entries) {
487
+ const before = fstatSync(directoryFd, { bigint: true });
488
+ if (!before.isDirectory()) throw new CliError("Compilation generation contains an unsafe directory entry");
489
+ const children = readdirSync(procFd(directoryFd), { withFileTypes: true }).sort((left, right) => Buffer.compare(Buffer.from(left.name, "utf8"), Buffer.from(right.name, "utf8")));
490
+ for (const child of children) {
491
+ const path = prefix.length === 0 ? child.name : `${prefix}/${child.name}`;
492
+ const anchoredPath = procFdChild(directoryFd, child.name);
493
+ if (child.isDirectory()) {
494
+ let childFd;
495
+ try {
496
+ childFd = openSync(anchoredPath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
497
+ inspectBoundDirectorySync(childFd, path, entries);
498
+ assertPathStillBindsIdentitySync(anchoredPath, fstatSync(childFd, { bigint: true }), "directory");
499
+ } finally {
500
+ if (childFd !== void 0) closeSync(childFd);
501
+ }
502
+ continue;
503
+ }
504
+ if (!child.isFile()) throw new CliError("Compilation generation contains a link or special-node entry");
505
+ let fileFd;
506
+ try {
507
+ fileFd = openSync(anchoredPath, constants.O_RDONLY | constants.O_NOFOLLOW | (typeof constants.O_NONBLOCK === "number" ? constants.O_NONBLOCK : 0));
508
+ const fileBefore = fstatSync(fileFd, { bigint: true });
509
+ if (!fileBefore.isFile() || fileBefore.nlink !== 1n) throw new CliError("Compilation generation contains a hard link or unsafe file entry");
510
+ const bytes = readFileSync(fileFd);
511
+ const fileAfter = fstatSync(fileFd, { bigint: true });
512
+ if (!sameBoundOutputIdentity(fileBefore, fileAfter)) throw new CliError("Compilation generation file changed while it was inspected");
513
+ assertPathStillBindsIdentitySync(anchoredPath, fileAfter, "file");
514
+ entries.push({
515
+ path,
516
+ kind: "file",
517
+ identity: toBoundOutputIdentity(fileAfter),
518
+ digest: sha256OutputBytes(bytes)
519
+ });
520
+ } finally {
521
+ if (fileFd !== void 0) closeSync(fileFd);
522
+ }
523
+ }
524
+ const after = fstatSync(directoryFd, { bigint: true });
525
+ if (!sameBoundOutputIdentity(before, after)) throw new CliError("Compilation generation directory changed while it was inspected");
526
+ entries.push({
527
+ path: prefix,
528
+ kind: "directory",
529
+ identity: toBoundOutputIdentity(after)
530
+ });
531
+ }
532
+ function assertPathStillBindsIdentitySync(path, expected, kind) {
533
+ const actual = lstatSync(path, { bigint: true });
534
+ if ((kind === "directory" ? !actual.isDirectory() : !actual.isFile()) || !sameBoundOutputIdentity(actual, expected)) throw new CliError("Compilation generation path binding changed while it was inspected");
535
+ }
536
+ function assertExpectedStagedTree(tree, files) {
537
+ const expectedDirectories = /* @__PURE__ */ new Set([""]);
538
+ for (const directory of COMPILATION_DIRECTORIES) expectedDirectories.add(directory);
539
+ const expectedFiles = new Map(files.map((file) => [file.path, file]));
540
+ for (const file of files) {
541
+ const components = safeOutputComponents(file.path);
542
+ for (let index = 1; index < components.length; index++) expectedDirectories.add(components.slice(0, index).join("/"));
543
+ }
544
+ if (tree.entries.length !== expectedDirectories.size + expectedFiles.size) throw new CliError("Compilation staged generation contains an unexpected path");
545
+ for (const entry of tree.entries) {
546
+ if (entry.kind === "directory") {
547
+ if (!expectedDirectories.has(entry.path)) throw new CliError("Compilation staged generation contains an unexpected directory");
548
+ continue;
549
+ }
550
+ const expected = expectedFiles.get(entry.path);
551
+ if (expected === void 0 || entry.identity.size !== BigInt(expected.bytes.byteLength) || entry.digest !== expected.digest) throw new CliError("Compilation staged generation bytes do not match expected output");
552
+ }
553
+ }
554
+ function sameBoundOutputIdentity(left, right) {
555
+ return sameStoredOutputIdentity(toBoundOutputIdentity(left), toBoundOutputIdentity(right));
556
+ }
557
+ function sameStoredOutputIdentity(left, right) {
558
+ return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode && left.nlink === right.nlink && left.size === right.size && left.ctimeNs === right.ctimeNs && left.mtimeNs === right.mtimeNs;
559
+ }
560
+ function toBoundOutputIdentity(stat) {
561
+ return {
562
+ dev: stat.dev,
563
+ ino: stat.ino,
564
+ mode: stat.mode,
565
+ nlink: stat.nlink,
566
+ size: stat.size,
567
+ ctimeNs: stat.ctimeNs,
568
+ mtimeNs: stat.mtimeNs
569
+ };
570
+ }
571
+ function sha256OutputBytes(bytes) {
572
+ return createHash("sha256").update(bytes).digest("hex");
573
+ }
574
+ async function openAnchoredChildDirectory(parent, component, create) {
575
+ if (component.length === 0 || component === "." || component === ".." || component.includes("/")) throw new CliError("Compilation output path is invalid");
576
+ const path = procFdChild(parent.fd, component);
577
+ try {
578
+ return await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
579
+ } catch (error) {
580
+ if (error.code !== "ENOENT" || !create) {
581
+ if (error.code === "ENOENT") return void 0;
582
+ throw new CliError("Compilation output contains a link or non-directory collision");
583
+ }
584
+ await mkdir(path, { mode: 448 }).catch((mkdirError) => {
585
+ if (mkdirError.code !== "EEXIST") throw mkdirError;
586
+ });
587
+ try {
588
+ return await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
589
+ } catch {
590
+ throw new CliError("Compilation output directory identity could not be proven");
591
+ }
592
+ }
593
+ }
594
+ async function writeAnchoredFile(root, relativePath, bytes) {
595
+ const components = safeOutputComponents(relativePath);
596
+ const directories = [];
597
+ let parent = root;
598
+ let output;
599
+ try {
600
+ for (const component of components.slice(0, -1)) {
601
+ const child = await openAnchoredChildDirectory(parent, component, true);
602
+ if (child === void 0) throw new CliError("Compilation output directory is unavailable");
603
+ directories.push(child);
604
+ parent = child;
605
+ }
606
+ const path = procFdChild(parent.fd, components.at(-1) ?? "");
607
+ output = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 384);
608
+ await output.writeFile(bytes);
609
+ await output.chmod(420);
610
+ } finally {
611
+ await output?.close().catch(() => void 0);
612
+ await Promise.all(directories.map((handle) => handle.close().catch(() => void 0)));
613
+ }
614
+ }
615
+ function safeOutputComponents(relativePath) {
616
+ const components = relativePath.split("/");
617
+ if (relativePath.startsWith("/") || components.some((component) => component.length === 0 || component === "." || component === ".." || component.includes("\\"))) throw new CliError("Compilation output path is not safely relative");
618
+ return components;
619
+ }
620
+ function procFd(fd) {
621
+ return `/proc/self/fd/${fd}`;
622
+ }
623
+ function procFdChild(fd, component) {
624
+ return `${procFd(fd)}/${component}`;
625
+ }
97
626
  //#endregion
98
627
  //#region src/dev/index.ts
99
- const ASSET_EXTENSIONS = {
100
- ".png": "image/png",
101
- ".jpg": "image/jpeg",
102
- ".jpeg": "image/jpeg",
103
- ".gif": "image/gif",
104
- ".svg": "image/svg+xml",
105
- ".webp": "image/webp",
106
- ".avif": "image/avif",
107
- ".ico": "image/x-icon",
108
- ".mp4": "video/mp4",
109
- ".webm": "video/webm"
110
- };
628
+ const DEV_HOST = "127.0.0.1";
629
+ const DEFAULT_ALLOWED_ORIGIN = "https://write.subtopik.com";
630
+ const REQUEST_VARY_HEADERS = "Origin, Sec-Fetch-Site";
111
631
  function sendSSE(res, event, data) {
112
632
  res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
113
633
  }
114
- function handleResources(watcher, _req, res) {
634
+ function handleResources(watcher, res, corsHeaders) {
115
635
  res.writeHead(200, {
116
636
  "Content-Type": "application/json",
117
- "Access-Control-Allow-Origin": "*"
637
+ ...corsHeaders
118
638
  });
119
- res.end(JSON.stringify({ resources: [...watcher.resources.values()] }));
639
+ res.end(JSON.stringify({
640
+ resources: [...watcher.resources.values()],
641
+ payloads: [...watcher.payloads.values()].map(({ bytes: _bytes, ...payload }) => payload)
642
+ }));
120
643
  }
121
- function handleEvents(watcher, _req, res) {
644
+ function handleEvents(watcher, res, corsHeaders) {
122
645
  res.writeHead(200, {
123
646
  "Content-Type": "text/event-stream",
124
647
  "Cache-Control": "no-cache",
125
648
  Connection: "keep-alive",
126
- "Access-Control-Allow-Origin": "*"
649
+ ...corsHeaders
650
+ });
651
+ sendSSE(res, "sync", {
652
+ resources: Object.fromEntries(watcher.resources),
653
+ payloads: Object.fromEntries([...watcher.payloads].map(([path, { bytes: _bytes, ...payload }]) => [path, payload]))
127
654
  });
128
- sendSSE(res, "sync", { resources: Object.fromEntries(watcher.resources) });
129
655
  const onUpdate = (key, resource) => {
130
656
  sendSSE(res, "update", {
131
657
  key,
@@ -137,121 +663,229 @@ function handleEvents(watcher, _req, res) {
137
663
  watcher.off("update", onUpdate);
138
664
  });
139
665
  }
140
- function getAsset(watcher, name) {
141
- const resource = watcher.resources.get(`Asset/${name}`);
142
- return resource?.type === "Asset" ? resource : null;
143
- }
144
- function handleAsset(watcher, dir, url, res) {
145
- if (!url.pathname.startsWith("/assets/")) return false;
146
- let name;
147
- try {
148
- name = decodeURIComponent(url.pathname.slice(8));
149
- } catch {
150
- res.writeHead(404, { "Access-Control-Allow-Origin": "*" });
666
+ function handleAssetPayload(watcher, rawPathname, method, res, corsHeaders) {
667
+ if (!rawPathname.startsWith("/blobs/")) return false;
668
+ const relativePath = rawPathname.slice(1);
669
+ if (!/^blobs\/[0-9a-f]{64}$/u.test(relativePath)) {
670
+ res.writeHead(404, corsHeaders);
151
671
  res.end();
152
672
  return true;
153
673
  }
154
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
155
- res.writeHead(404, { "Access-Control-Allow-Origin": "*" });
674
+ const payload = watcher.payloads.get(relativePath);
675
+ if (payload === void 0) {
676
+ res.writeHead(404, corsHeaders);
156
677
  res.end();
157
678
  return true;
158
679
  }
159
- const asset = getAsset(watcher, name);
160
- if (!asset) {
161
- res.writeHead(404, { "Access-Control-Allow-Origin": "*" });
162
- res.end();
163
- return true;
164
- }
165
- let filePath;
166
- let realDir;
167
- let realFilePath;
680
+ const contentType = payload.mediaType;
681
+ res.writeHead(200, {
682
+ "Content-Type": contentType,
683
+ "Content-Length": String(payload.size),
684
+ ...corsHeaders,
685
+ "Cache-Control": "no-cache",
686
+ "X-Content-Type-Options": "nosniff",
687
+ ...isDownloadMediaType(contentType) ? { "Content-Disposition": "attachment" } : {}
688
+ });
689
+ res.end(method === "HEAD" ? void 0 : payload.bytes);
690
+ return true;
691
+ }
692
+ function isDownloadMediaType(mediaType) {
693
+ return [
694
+ "application/octet-stream",
695
+ "text/html",
696
+ "image/svg+xml",
697
+ "application/javascript",
698
+ "text/javascript",
699
+ "application/x-executable",
700
+ "application/wasm",
701
+ "application/x-topik-active-content"
702
+ ].includes(mediaType);
703
+ }
704
+ function normalizeAllowedOrigin(value) {
705
+ let url;
168
706
  try {
169
- filePath = resolve(join(dir, asset.spec.uri));
170
- realDir = realpathSync(dir);
171
- realFilePath = realpathSync(filePath);
707
+ url = new URL(value);
172
708
  } catch {
173
- res.writeHead(404, { "Access-Control-Allow-Origin": "*" });
174
- res.end();
175
- return true;
176
- }
177
- const relativeAssetPath = relative(realDir, realFilePath);
178
- if (relativeAssetPath !== "" && (relativeAssetPath.startsWith("..") || isAbsolute(relativeAssetPath)) || !existsSync(realFilePath)) {
179
- res.writeHead(404, { "Access-Control-Allow-Origin": "*" });
180
- res.end();
181
- return true;
709
+ throw new PublicCliError("browser-origin-invalid");
182
710
  }
183
- readFile(filePath).then((data) => {
184
- res.writeHead(200, {
185
- "Content-Type": asset.spec.mediaType ?? ASSET_EXTENSIONS[extname(filePath)] ?? "application/octet-stream",
186
- "Access-Control-Allow-Origin": "*",
187
- "Cache-Control": "no-cache"
711
+ if (url.protocol !== "http:" && url.protocol !== "https:" || url.origin === "null" || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") throw new PublicCliError("browser-origin-invalid");
712
+ return url.origin;
713
+ }
714
+ function isAllowedHost(host, port) {
715
+ if (!host) return false;
716
+ const normalizedHost = host.toLowerCase();
717
+ if (normalizedHost === `localhost:${port}` || normalizedHost === `127.0.0.1:${port}` || normalizedHost === `[::1]:${port}`) return true;
718
+ return port === 80 && (normalizedHost === "localhost" || normalizedHost === "127.0.0.1" || normalizedHost === "[::1]");
719
+ }
720
+ function isLoopbackAddress(address) {
721
+ return address === "127.0.0.1" || address === "::1";
722
+ }
723
+ function getCorsHeaders(origin, allowedOrigin) {
724
+ return origin === allowedOrigin ? {
725
+ "Access-Control-Allow-Origin": allowedOrigin,
726
+ Vary: REQUEST_VARY_HEADERS
727
+ } : { Vary: REQUEST_VARY_HEADERS };
728
+ }
729
+ function rejectRequest(res) {
730
+ res.writeHead(403, {
731
+ "Content-Type": "text/plain",
732
+ Vary: REQUEST_VARY_HEADERS
733
+ });
734
+ res.end("Forbidden");
735
+ }
736
+ function createRequestHandler(watcher, getPort, allowedOrigin) {
737
+ return (req, res) => {
738
+ if (!isAllowedHost(req.headers.host, getPort())) {
739
+ rejectRequest(res);
740
+ return;
741
+ }
742
+ const origin = req.headers.origin;
743
+ const fetchSite = req.headers["sec-fetch-site"];
744
+ if (origin !== void 0 && origin !== allowedOrigin || origin === void 0 && fetchSite !== void 0 && fetchSite !== "same-origin" && fetchSite !== "none") {
745
+ rejectRequest(res);
746
+ return;
747
+ }
748
+ const corsHeaders = getCorsHeaders(origin, allowedOrigin);
749
+ if (req.method === "OPTIONS") {
750
+ res.writeHead(204, {
751
+ ...corsHeaders,
752
+ "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
753
+ "Access-Control-Allow-Headers": "Content-Type"
754
+ });
755
+ res.end();
756
+ return;
757
+ }
758
+ const requestTarget = req.url ?? "/";
759
+ let url;
760
+ try {
761
+ url = new URL(requestTarget, `http://localhost:${getPort()}`);
762
+ } catch {
763
+ res.writeHead(400, {
764
+ "Content-Type": "text/plain",
765
+ ...corsHeaders
766
+ });
767
+ res.end("Bad Request");
768
+ return;
769
+ }
770
+ if (req.method === "GET" && url.pathname === "/health") {
771
+ res.writeHead(200, {
772
+ "Content-Type": "application/json",
773
+ ...corsHeaders
774
+ });
775
+ res.end(JSON.stringify({ status: "ok" }));
776
+ return;
777
+ }
778
+ if (req.method === "GET" && url.pathname === "/resources") {
779
+ handleResources(watcher, res, corsHeaders);
780
+ return;
781
+ }
782
+ if (req.method === "GET" && url.pathname === "/events") {
783
+ handleEvents(watcher, res, corsHeaders);
784
+ return;
785
+ }
786
+ const queryOffset = requestTarget.indexOf("?");
787
+ const rawPathname = queryOffset === -1 ? requestTarget : requestTarget.slice(0, queryOffset);
788
+ if ((req.method === "GET" || req.method === "HEAD") && handleAssetPayload(watcher, rawPathname, req.method, res, corsHeaders)) return;
789
+ res.writeHead(404, {
790
+ "Content-Type": "text/plain",
791
+ ...corsHeaders
188
792
  });
189
- res.end(data);
190
- }).catch(() => {
191
- res.writeHead(500, { "Access-Control-Allow-Origin": "*" });
192
- res.end();
793
+ res.end("Not Found");
794
+ };
795
+ }
796
+ /** @internal */
797
+ async function startDevServer(options) {
798
+ const dir = resolve(options.dir);
799
+ const allowedOrigin = normalizeAllowedOrigin(options.allowOrigin ?? DEFAULT_ALLOWED_ORIGIN);
800
+ console.log("Watching content for changes...");
801
+ const assetOptions = sourceNamespaceOptions(options.sourceNamespace);
802
+ let watcher;
803
+ try {
804
+ watcher = await watch({
805
+ dir,
806
+ assets: assetOptions
807
+ });
808
+ } catch (error) {
809
+ if (assetOptions !== void 0 || !requiresSourceNamespace(error)) throw error;
810
+ watcher = await watch({
811
+ dir,
812
+ assets: { sourceNamespace: await deriveGitSourceNamespace(dir) }
813
+ });
814
+ }
815
+ console.log(`Compiled ${watcher.resources.size} resources`);
816
+ watcher.on("error", (error) => {
817
+ console.error("Compile error:", formatPublicCliError(error));
193
818
  });
194
- return true;
819
+ watcher.on("update", (key) => {
820
+ console.log(`Updated ${key}`);
821
+ });
822
+ let activePort = options.port;
823
+ const server = createServer(createRequestHandler(watcher, () => activePort, allowedOrigin));
824
+ try {
825
+ await new Promise((resolveListening, rejectListening) => {
826
+ const onError = (error) => {
827
+ server.off("listening", onListening);
828
+ rejectListening(error);
829
+ };
830
+ const onListening = () => {
831
+ server.off("error", onError);
832
+ const address = server.address();
833
+ if (!address || typeof address === "string" || !isLoopbackAddress(address.address)) {
834
+ server.close(() => {
835
+ rejectListening(/* @__PURE__ */ new Error("Dev server did not bind to a loopback address"));
836
+ });
837
+ return;
838
+ }
839
+ activePort = address.port;
840
+ resolveListening();
841
+ };
842
+ server.once("error", onError);
843
+ server.once("listening", onListening);
844
+ server.listen(options.port, DEV_HOST);
845
+ });
846
+ } catch (error) {
847
+ await watcher.close();
848
+ throw error;
849
+ }
850
+ let closePromise;
851
+ return {
852
+ port: activePort,
853
+ server,
854
+ watcher,
855
+ close: () => {
856
+ closePromise ??= (async () => {
857
+ const serverClosed = server.listening ? new Promise((resolveClosed, rejectClosed) => {
858
+ server.close((error) => {
859
+ if (error) rejectClosed(error);
860
+ else resolveClosed();
861
+ });
862
+ server.closeAllConnections();
863
+ }) : Promise.resolve();
864
+ await Promise.all([serverClosed, watcher.close()]);
865
+ })();
866
+ return closePromise;
867
+ }
868
+ };
195
869
  }
196
870
  const dev = command({
197
871
  name: "dev",
198
872
  desc: "Start a development server that watches for content changes",
199
873
  options: {
200
874
  dir: positional("dir").desc("Path to the content directory").default("."),
201
- port: string("port").alias("p").desc("Port to listen on").default("50001")
875
+ port: string("port").alias("p").desc("Port to listen on").default("50001"),
876
+ allowOrigin: string("allow-origin").desc(`Browser origin allowed to connect (default: ${DEFAULT_ALLOWED_ORIGIN})`),
877
+ sourceNamespace: string("source-namespace").desc("Stable source namespace for automatically discovered local Assets (derived from Git when omitted)")
202
878
  },
203
879
  handler: async (options) => {
204
- const dir = resolve(options.dir);
205
- const port = parseInt(options.port, 10);
206
- console.log(`Watching ${dir} for changes...`);
207
- const watcher = await watch({ dir });
208
- console.log(`Compiled ${watcher.resources.size} resources`);
209
- watcher.on("error", (error) => {
210
- console.error("Compile error:", error.message);
211
- });
212
- watcher.on("update", (key) => {
213
- console.log(`Updated ${key}`);
214
- });
215
- const server = createServer((req, res) => {
216
- if (req.method === "OPTIONS") {
217
- res.writeHead(204, {
218
- "Access-Control-Allow-Origin": "*",
219
- "Access-Control-Allow-Methods": "GET, OPTIONS",
220
- "Access-Control-Allow-Headers": "Content-Type"
221
- });
222
- res.end();
223
- return;
224
- }
225
- const url = new URL(req.url ?? "/", `http://localhost:${port}`);
226
- if (req.method === "GET" && url.pathname === "/health") {
227
- res.writeHead(200, {
228
- "Content-Type": "application/json",
229
- "Access-Control-Allow-Origin": "*"
230
- });
231
- res.end(JSON.stringify({ status: "ok" }));
232
- return;
233
- }
234
- if (req.method === "GET" && url.pathname === "/resources") {
235
- handleResources(watcher, req, res);
236
- return;
237
- }
238
- if (req.method === "GET" && url.pathname === "/events") {
239
- handleEvents(watcher, req, res);
240
- return;
241
- }
242
- if (req.method === "GET" && handleAsset(watcher, dir, url, res)) return;
243
- res.writeHead(404, {
244
- "Content-Type": "text/plain",
245
- "Access-Control-Allow-Origin": "*"
246
- });
247
- res.end("Not Found");
248
- });
249
- server.listen(port, () => {
250
- console.log(`Dev server listening on http://localhost:${port}`);
880
+ const runningServer = await startDevServer({
881
+ dir: resolve(options.dir),
882
+ port: parseInt(options.port, 10),
883
+ allowOrigin: options.allowOrigin,
884
+ sourceNamespace: options.sourceNamespace
251
885
  });
252
- process.on("SIGINT", async () => {
253
- server.close();
254
- await watcher.close();
886
+ console.log(`Dev server listening on http://localhost:${runningServer.port}`);
887
+ process.once("SIGINT", async () => {
888
+ await runningServer.close();
255
889
  process.exit(0);
256
890
  });
257
891
  }
@@ -280,52 +914,53 @@ const lint$1 = command({
280
914
  });
281
915
  //#endregion
282
916
  //#region src/validate/index.ts
283
- function parseJson(path, content) {
917
+ function parseJson(content) {
284
918
  try {
285
919
  return JSON.parse(content);
286
- } catch (error) {
287
- throw new CliError(`Failed to parse JSON resource file ${path}`, { cause: error });
920
+ } catch {
921
+ throw new PublicCliError("resource-json-invalid");
288
922
  }
289
923
  }
290
- function parseJsonl(path, content) {
291
- return content.split("\n").map((line, index) => ({
292
- line,
293
- index: index + 1
294
- })).filter(({ line }) => line.trim().length > 0).map(({ line, index }) => {
924
+ function parseJsonl(content) {
925
+ return content.split("\n").filter((line) => line.trim().length > 0).map((line) => {
295
926
  try {
296
927
  return JSON.parse(line);
297
- } catch (error) {
298
- throw new CliError(`Failed to parse JSONL resource file ${path} at line ${index}`, { cause: error });
928
+ } catch {
929
+ throw new PublicCliError("resource-jsonl-invalid");
299
930
  }
300
931
  });
301
932
  }
302
- function parseYaml(path, content) {
933
+ function parseYaml(content) {
303
934
  try {
304
935
  return parseAllDocuments(content).filter((document) => document.contents !== null).map((document) => {
305
936
  if (document.errors.length > 0) throw document.errors[0];
306
937
  return document.toJS();
307
938
  });
308
- } catch (error) {
309
- throw new CliError(`Failed to parse YAML resource file ${path}`, { cause: error });
939
+ } catch {
940
+ throw new PublicCliError("resource-yaml-invalid");
310
941
  }
311
942
  }
312
943
  async function readResourceFile(path) {
313
- const content = await readFile(path, "utf-8");
944
+ const content = await readFile(path, "utf-8").catch(() => {
945
+ throw new PublicCliError("resource-read-failed");
946
+ });
314
947
  switch (extname(path).toLowerCase()) {
315
- case ".json": return [parseJson(path, content)];
316
- case ".jsonl": return parseJsonl(path, content);
948
+ case ".json": return [parseJson(content)];
949
+ case ".jsonl": return parseJsonl(content);
317
950
  case ".yaml":
318
- case ".yml": return parseYaml(path, content);
319
- default: throw new CliError(`Unsupported resource file format for ${path}`);
951
+ case ".yml": return parseYaml(content);
952
+ default: throw new PublicCliError("resource-format-unsupported");
320
953
  }
321
954
  }
322
955
  async function loadResources(path) {
323
- if ((await stat(path).catch((error) => {
324
- throw new CliError(`Failed to access ${path}`, { cause: error });
956
+ if ((await stat(path).catch(() => {
957
+ throw new PublicCliError("resource-access-failed");
325
958
  })).isFile()) return readResourceFile(path);
326
959
  const resourceFiles = (await readdir(path, {
327
960
  withFileTypes: true,
328
961
  recursive: true
962
+ }).catch(() => {
963
+ throw new PublicCliError("resource-read-failed");
329
964
  })).filter((entry) => {
330
965
  if (!entry.isFile()) return false;
331
966
  const extension = extname(entry.name).toLowerCase();
@@ -343,9 +978,9 @@ const validate = command({
343
978
  console.log("No resource files found.");
344
979
  return;
345
980
  }
346
- const { valid, errors } = validateResources(resources);
981
+ const { valid } = validateResources(resources);
347
982
  if (valid) console.log(`Validated ${resources.length} resources`);
348
- else throw new CliError(formatValidationFailure(errors, resources.length, "validating resources"));
983
+ else throw new PublicCliError("resource-validation-failed");
349
984
  }
350
985
  });
351
986
  //#endregion
@@ -361,11 +996,8 @@ try {
361
996
  description: "Topik CLI"
362
997
  });
363
998
  } catch (error) {
364
- if (error instanceof CliError || error instanceof CompileError) {
365
- console.error(error.message);
366
- process.exit(1);
367
- }
368
- throw error;
999
+ console.error(formatPublicCliError(error));
1000
+ process.exitCode = 1;
369
1001
  }
370
1002
  //#endregion
371
1003
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topik/cli",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "0.1.0-alpha.5",
4
4
  "description": "Topik CLI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,15 +25,15 @@
25
25
  "dependencies": {
26
26
  "@drizzle-team/brocli": "^0.12.0",
27
27
  "yaml": "^2.9.0",
28
- "@topik/content-schema": "0.1.0-alpha.4",
29
- "@topik/core": "0.1.0-alpha.4"
28
+ "@topik/content-schema": "0.1.0-alpha.5",
29
+ "@topik/core": "0.1.0-alpha.5"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/bun": "^1.3.14",
33
33
  "@types/node": "22.20.1",
34
- "@typescript/native-preview": "7.0.0-dev.20260707.2",
35
- "typescript": "^6.0.3",
36
- "vite-plus": "0.2.4"
34
+ "typescript": "^7.0.2",
35
+ "vite": "npm:@voidzero-dev/vite-plus-core@0.2.9",
36
+ "vite-plus": "0.2.9"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "vp pack",