@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.
- package/dist/cli.mjs +53 -129
- 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,
|
|
7
|
-
import {
|
|
8
|
-
import { sanitizeTopikContentDiagnostic } from "@topik/content
|
|
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
|
|
81
|
+
desc: "Compile project content into Topik resource files",
|
|
152
82
|
options: {
|
|
153
|
-
dir: positional("dir").desc("
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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({
|
|
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
|
|
802
|
-
|
|
803
|
-
|
|
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("
|
|
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("
|
|
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.
|
|
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.
|
|
28
|
-
"@topik/content
|
|
29
|
-
"@topik/core": "0.1.0-alpha.
|
|
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
|
|
33
|
-
"@types/node": "22.20.
|
|
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@
|
|
36
|
-
"vite-plus": "
|
|
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",
|