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

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 +53 -129
  2. package/package.json +8 -8
package/dist/cli.mjs CHANGED
@@ -3,11 +3,9 @@ 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
11
  //#region src/diagnostics.ts
@@ -32,6 +30,7 @@ var CliError = class extends Error {
32
30
  };
33
31
  const PUBLIC_CLI_ERROR_MESSAGES = Object.freeze({
34
32
  "browser-origin-invalid": "Browser origin configuration is invalid",
33
+ "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
34
  "resource-access-failed": "Resource input could not be accessed.",
36
35
  "resource-format-unsupported": "Resource input format is unsupported.",
37
36
  "resource-json-invalid": "JSON resource input could not be parsed.",
@@ -43,8 +42,8 @@ const PUBLIC_CLI_ERROR_MESSAGES = Object.freeze({
43
42
  /** CLI failure admitted to presentation only through fixed catalog wording. */
44
43
  var PublicCliError = class extends CliError {
45
44
  id;
46
- constructor(id) {
47
- super(publicCliErrorMessage(id) ?? "Topik command failed.");
45
+ constructor(id, options) {
46
+ super(publicCliErrorMessage(id) ?? "Topik command failed.", options);
48
47
  this.name = "PublicCliError";
49
48
  this.id = id;
50
49
  }
@@ -54,6 +53,11 @@ function publicCliErrorMessage(id) {
54
53
  }
55
54
  function formatPublicCliError(error) {
56
55
  if (error instanceof PublicCliError) return publicCliErrorMessage(error.id) ?? "Topik command failed.";
56
+ if (error instanceof ManifestSourceError) {
57
+ const message = publicCompileErrorMessage(error.id) ?? "Topik command failed.";
58
+ const location = new PublicCompileError(error.id, error.location).location;
59
+ 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}`;
60
+ }
57
61
  if (error instanceof PublicCompileError) return publicCompileErrorMessage(error.id) ?? "Topik command failed.";
58
62
  return "Topik command failed.";
59
63
  }
@@ -66,80 +70,6 @@ function formatValidationFailure(errors, resourceCount, context) {
66
70
  return lines.join("\n");
67
71
  }
68
72
  //#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
73
  //#region src/compile/index.ts
144
74
  const COMPILATION_GENERATION_PREFIX = ".topik-compilation-generation-";
145
75
  const COMPILATION_PRIOR_PREFIX = ".topik-compilation-prior-";
@@ -148,38 +78,24 @@ const ownedDescriptorDecoder = new TextDecoder("utf-8", { fatal: true });
148
78
  const ownedDescriptorEncoder = new TextEncoder();
149
79
  const compile$1 = command({
150
80
  name: "compile",
151
- desc: "Compile wiki content into Topik resource files",
81
+ desc: "Compile project content into Topik resource files",
152
82
  options: {
153
- dir: positional("dir").desc("Path to the content directory").default("."),
83
+ dir: positional("dir").desc("Project directory containing .topik.yaml").default("."),
154
84
  outDir: string("out-dir").alias("o").desc("Output directory for compiled resources"),
155
85
  format: string("format").alias("f").desc("Output format (canonical JSON only)").enum("json").default("json"),
156
86
  dryRun: boolean("dry-run").desc("Show what would be compiled without writing files").default(false),
157
87
  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)")
88
+ links: string("links").desc("How unresolved wiki links and local guide fragments are handled").enum("error", "warning", "off").default("error")
160
89
  },
161
90
  handler: async (options) => {
162
91
  const dir = resolve(options.dir);
163
92
  const links = options.links;
164
93
  const outDir = options.outDir ? resolve(options.outDir) : join(dir, ".topik");
165
94
  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;
95
+ const { diagnostics, materialization, payloads, resources, semantic } = await compile({
96
+ dir,
97
+ validation: { links }
98
+ });
183
99
  printDiagnostics(diagnostics);
184
100
  if (options.validate) {
185
101
  const { valid, errors } = validateResources(resources);
@@ -260,13 +176,14 @@ async function replaceCompilationTree(absolutePath, files) {
260
176
  } catch (error) {
261
177
  operationError = error;
262
178
  }
179
+ if (!published && existing !== void 0 && existing.name !== target) operationError = new PublicCliError("compilation-publication-failed", { cause: operationError });
263
180
  if (generation !== void 0 && !published) try {
264
181
  generation.tree = bindCompleteCompilationTreeSync(generation.handle);
265
182
  cleanupCompilationDirectory(parent, generation);
266
183
  } catch (error) {
267
184
  operationError ??= error;
268
185
  }
269
- if (existing !== void 0 && !existingRemoved && existing.name !== target) try {
186
+ if (published && existing !== void 0 && !existingRemoved && existing.name !== target) try {
270
187
  cleanupCompilationDirectory(parent, existing);
271
188
  } catch (error) {
272
189
  operationError ??= error;
@@ -624,6 +541,9 @@ function procFdChild(fd, component) {
624
541
  return `${procFd(fd)}/${component}`;
625
542
  }
626
543
  //#endregion
544
+ //#region package.json
545
+ var version = "0.1.0-alpha.6";
546
+ //#endregion
627
547
  //#region src/dev/index.ts
628
548
  const DEV_HOST = "127.0.0.1";
629
549
  const DEFAULT_ALLOWED_ORIGIN = "https://write.subtopik.com";
@@ -634,12 +554,10 @@ function sendSSE(res, event, data) {
634
554
  function handleResources(watcher, res, corsHeaders) {
635
555
  res.writeHead(200, {
636
556
  "Content-Type": "application/json",
557
+ "Cache-Control": "no-store",
637
558
  ...corsHeaders
638
559
  });
639
- res.end(JSON.stringify({
640
- resources: [...watcher.resources.values()],
641
- payloads: [...watcher.payloads.values()].map(({ bytes: _bytes, ...payload }) => payload)
642
- }));
560
+ res.end(JSON.stringify(watcher.state));
643
561
  }
644
562
  function handleEvents(watcher, res, corsHeaders) {
645
563
  res.writeHead(200, {
@@ -648,6 +566,9 @@ function handleEvents(watcher, res, corsHeaders) {
648
566
  Connection: "keep-alive",
649
567
  ...corsHeaders
650
568
  });
569
+ sendSSE(res, "preview-state", watcher.state);
570
+ const onState = (state) => sendSSE(res, "preview-state", state);
571
+ watcher.on("preview-state", onState);
651
572
  sendSSE(res, "sync", {
652
573
  resources: Object.fromEntries(watcher.resources),
653
574
  payloads: Object.fromEntries([...watcher.payloads].map(([path, { bytes: _bytes, ...payload }]) => [path, payload]))
@@ -661,6 +582,7 @@ function handleEvents(watcher, res, corsHeaders) {
661
582
  watcher.on("update", onUpdate);
662
583
  res.on("close", () => {
663
584
  watcher.off("update", onUpdate);
585
+ watcher.off("preview-state", onState);
664
586
  });
665
587
  }
666
588
  function handleAssetPayload(watcher, rawPathname, method, res, corsHeaders) {
@@ -770,9 +692,15 @@ function createRequestHandler(watcher, getPort, allowedOrigin) {
770
692
  if (req.method === "GET" && url.pathname === "/health") {
771
693
  res.writeHead(200, {
772
694
  "Content-Type": "application/json",
695
+ "Cache-Control": "no-store",
773
696
  ...corsHeaders
774
697
  });
775
- res.end(JSON.stringify({ status: "ok" }));
698
+ res.end(JSON.stringify({
699
+ status: "ok",
700
+ version,
701
+ capabilities: [TOPIK_PREVIEW_PROTOCOL],
702
+ sessionId: watcher.state.sessionId
703
+ }));
776
704
  return;
777
705
  }
778
706
  if (req.method === "GET" && url.pathname === "/resources") {
@@ -783,6 +711,16 @@ function createRequestHandler(watcher, getPort, allowedOrigin) {
783
711
  handleEvents(watcher, res, corsHeaders);
784
712
  return;
785
713
  }
714
+ if (url.pathname.startsWith("/blobs/") && (url.searchParams.has("sessionId") || url.searchParams.has("generation"))) {
715
+ 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)) {
716
+ res.writeHead(409, {
717
+ ...corsHeaders,
718
+ "Cache-Control": "no-store"
719
+ });
720
+ res.end();
721
+ return;
722
+ }
723
+ }
786
724
  const queryOffset = requestTarget.indexOf("?");
787
725
  const rawPathname = queryOffset === -1 ? requestTarget : requestTarget.slice(0, queryOffset);
788
726
  if ((req.method === "GET" || req.method === "HEAD") && handleAssetPayload(watcher, rawPathname, req.method, res, corsHeaders)) return;
@@ -798,21 +736,9 @@ async function startDevServer(options) {
798
736
  const dir = resolve(options.dir);
799
737
  const allowedOrigin = normalizeAllowedOrigin(options.allowOrigin ?? DEFAULT_ALLOWED_ORIGIN);
800
738
  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`);
739
+ const watcher = await watch({ dir });
740
+ if (watcher.state.status === "error") console.error("Compile error:", watcher.state.diagnostics.map((diagnostic) => diagnostic.message).join(" "));
741
+ else console.log(`Compiled ${watcher.resources.size} resources`);
816
742
  watcher.on("error", (error) => {
817
743
  console.error("Compile error:", formatPublicCliError(error));
818
744
  });
@@ -871,17 +797,15 @@ const dev = command({
871
797
  name: "dev",
872
798
  desc: "Start a development server that watches for content changes",
873
799
  options: {
874
- dir: positional("dir").desc("Path to the content directory").default("."),
800
+ dir: positional("dir").desc("Project directory containing .topik.yaml").default("."),
875
801
  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)")
802
+ allowOrigin: string("allow-origin").desc(`Browser origin allowed to connect (default: ${DEFAULT_ALLOWED_ORIGIN})`)
878
803
  },
879
804
  handler: async (options) => {
880
805
  const runningServer = await startDevServer({
881
806
  dir: resolve(options.dir),
882
807
  port: parseInt(options.port, 10),
883
- allowOrigin: options.allowOrigin,
884
- sourceNamespace: options.sourceNamespace
808
+ allowOrigin: options.allowOrigin
885
809
  });
886
810
  console.log(`Dev server listening on http://localhost:${runningServer.port}`);
887
811
  process.once("SIGINT", async () => {
@@ -896,7 +820,7 @@ const lint$1 = command({
896
820
  name: "lint",
897
821
  desc: "Lint Topik source content without writing compiled resources",
898
822
  options: {
899
- dir: positional("dir").desc("Path to the content directory").default("."),
823
+ dir: positional("dir").desc("Project directory containing .topik.yaml").default("."),
900
824
  links: string("links").desc("How unresolved wiki links and local guide fragments are handled").enum("error", "warning", "off").default("error")
901
825
  },
902
826
  handler: async (options) => {
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.6",
4
4
  "description": "Topik CLI",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,16 +24,16 @@
24
24
  },
25
25
  "dependencies": {
26
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"
27
+ "yaml": "^2.9.1",
28
+ "@topik/content": "0.1.0-alpha.6",
29
+ "@topik/core": "0.1.0-alpha.6"
30
30
  },
31
31
  "devDependencies": {
32
- "@types/bun": "^1.3.14",
33
- "@types/node": "22.20.1",
32
+ "@types/bun": "^1.4.3",
33
+ "@types/node": "22.20.4",
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.2.0",
36
+ "vite-plus": "1.2.0"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "vp pack",