@topik/cli 0.1.0-alpha.5 → 0.1.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.mjs +60 -145
  2. package/package.json +9 -9
package/dist/cli.mjs CHANGED
@@ -3,14 +3,12 @@ import { boolean, command, positional, run, string } from "@drizzle-team/brocli"
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync, renameSync, rmdirSync, unlinkSync } from "node:fs";
5
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";
6
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
7
+ import { ManifestSourceError, PublicCompileError, TOPIK_PREVIEW_PROTOCOL, compile, isErrorDiagnostic, lint, parseStrictTopikJson, publicCompileErrorMessage, serializeTopikJson, validateResources, validateTopikMaterializationRecord, watch } from "@topik/core";
8
+ import { sanitizeTopikContentDiagnostic } from "@topik/content";
11
9
  import { createServer } from "node:http";
12
10
  import { parseAllDocuments } from "yaml";
13
- //#region src/diagnostics.ts
11
+ // src/diagnostics.ts
14
12
  function formatDiagnostic(diagnostic) {
15
13
  const sanitized = sanitizeTopikContentDiagnostic(diagnostic);
16
14
  return `${sanitized.file ?? "content"}${sanitized.lines.length > 0 ? `:${sanitized.lines.join(",")}` : ""} ${sanitized.level} ${sanitized.id}: ${sanitized.message}`;
@@ -22,8 +20,7 @@ function printDiagnostics(diagnostics) {
22
20
  else console.warn(output);
23
21
  }
24
22
  }
25
- //#endregion
26
- //#region src/errors.ts
23
+ // src/errors.ts
27
24
  var CliError = class extends Error {
28
25
  constructor(message, options) {
29
26
  super(message, options);
@@ -32,6 +29,7 @@ var CliError = class extends Error {
32
29
  };
33
30
  const PUBLIC_CLI_ERROR_MESSAGES = Object.freeze({
34
31
  "browser-origin-invalid": "Browser origin configuration is invalid",
32
+ "compilation-publication-failed": "Compilation output publication failed after moving the existing output aside. For manual recovery, first stop concurrent writers, then inspect .topik-compilation-prior-* directories beside the output path. Verify the backup contents and do not overwrite an existing output path.",
35
33
  "resource-access-failed": "Resource input could not be accessed.",
36
34
  "resource-format-unsupported": "Resource input format is unsupported.",
37
35
  "resource-json-invalid": "JSON resource input could not be parsed.",
@@ -43,8 +41,8 @@ const PUBLIC_CLI_ERROR_MESSAGES = Object.freeze({
43
41
  /** CLI failure admitted to presentation only through fixed catalog wording. */
44
42
  var PublicCliError = class extends CliError {
45
43
  id;
46
- constructor(id) {
47
- super(publicCliErrorMessage(id) ?? "Topik command failed.");
44
+ constructor(id, options) {
45
+ super(publicCliErrorMessage(id) ?? "Topik command failed.", options);
48
46
  this.name = "PublicCliError";
49
47
  this.id = id;
50
48
  }
@@ -54,93 +52,22 @@ function publicCliErrorMessage(id) {
54
52
  }
55
53
  function formatPublicCliError(error) {
56
54
  if (error instanceof PublicCliError) return publicCliErrorMessage(error.id) ?? "Topik command failed.";
55
+ if (error instanceof ManifestSourceError) {
56
+ const message = publicCompileErrorMessage(error.id) ?? "Topik command failed.";
57
+ const location = new PublicCompileError(error.id, error.location).location;
58
+ return `.topik.yaml ${Number.isSafeInteger(error.sourceIndex) && error.sourceIndex >= 0 ? `sources[${error.sourceIndex}]` : "source"}${error.kind === "wiki" || error.kind === "collection" || error.kind === "course" ? ` (${error.kind})` : ""}${location ? ` ${location}` : ""}: ${message}`;
59
+ }
57
60
  if (error instanceof PublicCompileError) return publicCompileErrorMessage(error.id) ?? "Topik command failed.";
58
61
  return "Topik command failed.";
59
62
  }
60
- //#endregion
61
- //#region src/validation-output.ts
63
+ // src/validation-output.ts
62
64
  function formatValidationFailure(errors, resourceCount, context) {
63
65
  const lines = errors.map((error) => `${error.resource}: ${error.path} ${error.message}`);
64
66
  lines.push("");
65
67
  lines.push(`${errors.length} validation error(s) in ${resourceCount} resources while ${context}`);
66
68
  return lines.join("\n");
67
69
  }
68
- //#endregion
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;
110
- }
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;
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");
141
- }
142
- //#endregion
143
- //#region src/compile/index.ts
70
+ // src/compile/index.ts
144
71
  const COMPILATION_GENERATION_PREFIX = ".topik-compilation-generation-";
145
72
  const COMPILATION_PRIOR_PREFIX = ".topik-compilation-prior-";
146
73
  const COMPILATION_DIRECTORIES = ["blobs", "resources"];
@@ -148,38 +75,24 @@ const ownedDescriptorDecoder = new TextDecoder("utf-8", { fatal: true });
148
75
  const ownedDescriptorEncoder = new TextEncoder();
149
76
  const compile$1 = command({
150
77
  name: "compile",
151
- desc: "Compile wiki content into Topik resource files",
78
+ desc: "Compile project content into Topik resource files",
152
79
  options: {
153
- dir: positional("dir").desc("Path to the content directory").default("."),
80
+ dir: positional("dir").desc("Project directory containing .topik.yaml").default("."),
154
81
  outDir: string("out-dir").alias("o").desc("Output directory for compiled resources"),
155
82
  format: string("format").alias("f").desc("Output format (canonical JSON only)").enum("json").default("json"),
156
83
  dryRun: boolean("dry-run").desc("Show what would be compiled without writing files").default(false),
157
84
  validate: boolean("validate").desc("Validate compiled resources against schemas").default(false),
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)")
85
+ links: string("links").desc("How unresolved wiki links and local guide fragments are handled").enum("error", "warning", "off").default("error")
160
86
  },
161
87
  handler: async (options) => {
162
88
  const dir = resolve(options.dir);
163
89
  const links = options.links;
164
90
  const outDir = options.outDir ? resolve(options.outDir) : join(dir, ".topik");
165
91
  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;
92
+ const { diagnostics, materialization, payloads, resources, semantic } = await compile({
93
+ dir,
94
+ validation: { links }
95
+ });
183
96
  printDiagnostics(diagnostics);
184
97
  if (options.validate) {
185
98
  const { valid, errors } = validateResources(resources);
@@ -260,13 +173,14 @@ async function replaceCompilationTree(absolutePath, files) {
260
173
  } catch (error) {
261
174
  operationError = error;
262
175
  }
176
+ if (!published && existing !== void 0 && existing.name !== target) operationError = new PublicCliError("compilation-publication-failed", { cause: operationError });
263
177
  if (generation !== void 0 && !published) try {
264
178
  generation.tree = bindCompleteCompilationTreeSync(generation.handle);
265
179
  cleanupCompilationDirectory(parent, generation);
266
180
  } catch (error) {
267
181
  operationError ??= error;
268
182
  }
269
- if (existing !== void 0 && !existingRemoved && existing.name !== target) try {
183
+ if (published && existing !== void 0 && !existingRemoved && existing.name !== target) try {
270
184
  cleanupCompilationDirectory(parent, existing);
271
185
  } catch (error) {
272
186
  operationError ??= error;
@@ -623,8 +537,9 @@ function procFd(fd) {
623
537
  function procFdChild(fd, component) {
624
538
  return `${procFd(fd)}/${component}`;
625
539
  }
626
- //#endregion
627
- //#region src/dev/index.ts
540
+ // package.json
541
+ var version = "0.1.0-alpha.7";
542
+ // src/dev/index.ts
628
543
  const DEV_HOST = "127.0.0.1";
629
544
  const DEFAULT_ALLOWED_ORIGIN = "https://write.subtopik.com";
630
545
  const REQUEST_VARY_HEADERS = "Origin, Sec-Fetch-Site";
@@ -634,12 +549,10 @@ function sendSSE(res, event, data) {
634
549
  function handleResources(watcher, res, corsHeaders) {
635
550
  res.writeHead(200, {
636
551
  "Content-Type": "application/json",
552
+ "Cache-Control": "no-store",
637
553
  ...corsHeaders
638
554
  });
639
- res.end(JSON.stringify({
640
- resources: [...watcher.resources.values()],
641
- payloads: [...watcher.payloads.values()].map(({ bytes: _bytes, ...payload }) => payload)
642
- }));
555
+ res.end(JSON.stringify(watcher.state));
643
556
  }
644
557
  function handleEvents(watcher, res, corsHeaders) {
645
558
  res.writeHead(200, {
@@ -648,6 +561,9 @@ function handleEvents(watcher, res, corsHeaders) {
648
561
  Connection: "keep-alive",
649
562
  ...corsHeaders
650
563
  });
564
+ sendSSE(res, "preview-state", watcher.state);
565
+ const onState = (state) => sendSSE(res, "preview-state", state);
566
+ watcher.on("preview-state", onState);
651
567
  sendSSE(res, "sync", {
652
568
  resources: Object.fromEntries(watcher.resources),
653
569
  payloads: Object.fromEntries([...watcher.payloads].map(([path, { bytes: _bytes, ...payload }]) => [path, payload]))
@@ -661,6 +577,7 @@ function handleEvents(watcher, res, corsHeaders) {
661
577
  watcher.on("update", onUpdate);
662
578
  res.on("close", () => {
663
579
  watcher.off("update", onUpdate);
580
+ watcher.off("preview-state", onState);
664
581
  });
665
582
  }
666
583
  function handleAssetPayload(watcher, rawPathname, method, res, corsHeaders) {
@@ -770,9 +687,15 @@ function createRequestHandler(watcher, getPort, allowedOrigin) {
770
687
  if (req.method === "GET" && url.pathname === "/health") {
771
688
  res.writeHead(200, {
772
689
  "Content-Type": "application/json",
690
+ "Cache-Control": "no-store",
773
691
  ...corsHeaders
774
692
  });
775
- res.end(JSON.stringify({ status: "ok" }));
693
+ res.end(JSON.stringify({
694
+ status: "ok",
695
+ version,
696
+ capabilities: [TOPIK_PREVIEW_PROTOCOL],
697
+ sessionId: watcher.state.sessionId
698
+ }));
776
699
  return;
777
700
  }
778
701
  if (req.method === "GET" && url.pathname === "/resources") {
@@ -783,6 +706,16 @@ function createRequestHandler(watcher, getPort, allowedOrigin) {
783
706
  handleEvents(watcher, res, corsHeaders);
784
707
  return;
785
708
  }
709
+ if (url.pathname.startsWith("/blobs/") && (url.searchParams.has("sessionId") || url.searchParams.has("generation"))) {
710
+ if (url.searchParams.getAll("sessionId").length !== 1 || url.searchParams.getAll("generation").length !== 1 || url.searchParams.get("sessionId") !== watcher.state.sessionId || url.searchParams.get("generation") !== String(watcher.state.generation)) {
711
+ res.writeHead(409, {
712
+ ...corsHeaders,
713
+ "Cache-Control": "no-store"
714
+ });
715
+ res.end();
716
+ return;
717
+ }
718
+ }
786
719
  const queryOffset = requestTarget.indexOf("?");
787
720
  const rawPathname = queryOffset === -1 ? requestTarget : requestTarget.slice(0, queryOffset);
788
721
  if ((req.method === "GET" || req.method === "HEAD") && handleAssetPayload(watcher, rawPathname, req.method, res, corsHeaders)) return;
@@ -798,21 +731,9 @@ async function startDevServer(options) {
798
731
  const dir = resolve(options.dir);
799
732
  const allowedOrigin = normalizeAllowedOrigin(options.allowOrigin ?? DEFAULT_ALLOWED_ORIGIN);
800
733
  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`);
734
+ const watcher = await watch({ dir });
735
+ if (watcher.state.status === "error") console.error("Compile error:", watcher.state.diagnostics.map((diagnostic) => diagnostic.message).join(" "));
736
+ else console.log(`Compiled ${watcher.resources.size} resources`);
816
737
  watcher.on("error", (error) => {
817
738
  console.error("Compile error:", formatPublicCliError(error));
818
739
  });
@@ -871,17 +792,15 @@ const dev = command({
871
792
  name: "dev",
872
793
  desc: "Start a development server that watches for content changes",
873
794
  options: {
874
- dir: positional("dir").desc("Path to the content directory").default("."),
795
+ dir: positional("dir").desc("Project directory containing .topik.yaml").default("."),
875
796
  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)")
797
+ allowOrigin: string("allow-origin").desc(`Browser origin allowed to connect (default: ${DEFAULT_ALLOWED_ORIGIN})`)
878
798
  },
879
799
  handler: async (options) => {
880
800
  const runningServer = await startDevServer({
881
801
  dir: resolve(options.dir),
882
802
  port: parseInt(options.port, 10),
883
- allowOrigin: options.allowOrigin,
884
- sourceNamespace: options.sourceNamespace
803
+ allowOrigin: options.allowOrigin
885
804
  });
886
805
  console.log(`Dev server listening on http://localhost:${runningServer.port}`);
887
806
  process.once("SIGINT", async () => {
@@ -890,13 +809,12 @@ const dev = command({
890
809
  });
891
810
  }
892
811
  });
893
- //#endregion
894
- //#region src/lint/index.ts
812
+ // src/lint/index.ts
895
813
  const lint$1 = command({
896
814
  name: "lint",
897
815
  desc: "Lint Topik source content without writing compiled resources",
898
816
  options: {
899
- dir: positional("dir").desc("Path to the content directory").default("."),
817
+ dir: positional("dir").desc("Project directory containing .topik.yaml").default("."),
900
818
  links: string("links").desc("How unresolved wiki links and local guide fragments are handled").enum("error", "warning", "off").default("error")
901
819
  },
902
820
  handler: async (options) => {
@@ -912,8 +830,7 @@ const lint$1 = command({
912
830
  console.log(`Lint passed${warnings > 0 ? ` with ${warnings} warning(s)` : ""}`);
913
831
  }
914
832
  });
915
- //#endregion
916
- //#region src/validate/index.ts
833
+ // src/validate/index.ts
917
834
  function parseJson(content) {
918
835
  try {
919
836
  return JSON.parse(content);
@@ -983,8 +900,7 @@ const validate = command({
983
900
  else throw new PublicCliError("resource-validation-failed");
984
901
  }
985
902
  });
986
- //#endregion
987
- //#region src/cli.ts
903
+ // src/cli.ts
988
904
  try {
989
905
  await run([
990
906
  compile$1,
@@ -999,5 +915,4 @@ try {
999
915
  console.error(formatPublicCliError(error));
1000
916
  process.exitCode = 1;
1001
917
  }
1002
- //#endregion
1003
918
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topik/cli",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.7",
4
4
  "description": "Topik CLI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -23,17 +23,17 @@
23
23
  "access": "public"
24
24
  },
25
25
  "dependencies": {
26
- "@drizzle-team/brocli": "^0.12.0",
27
- "yaml": "^2.9.0",
28
- "@topik/content-schema": "0.1.0-alpha.5",
29
- "@topik/core": "0.1.0-alpha.5"
26
+ "@drizzle-team/brocli": "^0.12.1",
27
+ "@topik/content": "0.1.0-alpha.7",
28
+ "@topik/core": "0.1.0-alpha.7",
29
+ "yaml": "^2.9.1"
30
30
  },
31
31
  "devDependencies": {
32
- "@types/bun": "^1.3.14",
33
- "@types/node": "22.20.1",
32
+ "@types/bun": "^1.4.4",
33
+ "@types/node": "22.20.5",
34
34
  "typescript": "^7.0.2",
35
- "vite": "npm:@voidzero-dev/vite-plus-core@0.2.9",
36
- "vite-plus": "0.2.9"
35
+ "vite": "npm:@voidzero-dev/vite-plus-core@1.4.0",
36
+ "vite-plus": "1.4.0"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "vp pack",