@publisle/cli 0.0.0-stage → 0.1.0

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/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@publisle/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Optional Node-only validation and explicit source upgrades.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=24"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./run": {
17
+ "types": "./dist/run.d.ts",
18
+ "import": "./dist/run.js",
19
+ "default": "./dist/run.js"
20
+ }
21
+ },
22
+ "bin": {
23
+ "publisle": "./dist/bin.js"
24
+ },
25
+ "dependencies": {
26
+ "@publisle/contracts": "0.1.0",
27
+ "@publisle/blocks-core": "0.1.0",
28
+ "@publisle/core": "0.1.0",
29
+ "@publisle/markdown": "0.1.0",
30
+ "@publisle/schema": "0.1.0"
31
+ },
32
+ "devDependencies": {
33
+ "@publisle/cli-research": "0.1.0"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "src",
41
+ "README.md",
42
+ "NOTICE.md",
43
+ "LICENSE"
44
+ ],
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/VoidReg/publisle.git",
48
+ "directory": "packages/cli"
49
+ },
50
+ "homepage": "https://github.com/VoidReg/publisle#readme",
51
+ "types": "./dist/index.d.ts"
6
52
  }
package/src/bin.ts ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "./run.ts";
3
+
4
+ process.exitCode = await runCli(process.argv.slice(2), {
5
+ stdout: (text) => {
6
+ process.stdout.write(text);
7
+ },
8
+ stderr: (text) => {
9
+ process.stderr.write(text);
10
+ },
11
+ });
package/src/index.ts ADDED
@@ -0,0 +1,279 @@
1
+ import { coreBlockDefinitions } from "@publisle/blocks-core";
2
+ import { lockDocument, type ContractBundle } from "@publisle/contracts";
3
+ import { createRegistry, prepare, type PrepareOptions } from "@publisle/core";
4
+ import {
5
+ fromMarkdown,
6
+ toMarkdown,
7
+ type MarkdownBlockCodec,
8
+ } from "@publisle/markdown";
9
+ import {
10
+ canonicalizeJson,
11
+ locateDiagnostic,
12
+ parseDocument,
13
+ parseJson,
14
+ JsonBoundaryError,
15
+ SchemaParseError,
16
+ type Diagnostic,
17
+ type Document,
18
+ type DocumentSourceMap,
19
+ } from "@publisle/schema";
20
+
21
+ export interface CliConfig {
22
+ readonly contractBundle?: ContractBundle;
23
+ /** Trusted host configuration, not reader-side code. Core blocks are the default registry. */
24
+ readonly prepare?: PrepareOptions;
25
+ readonly markdown?: { readonly codecs?: readonly MarkdownBlockCodec[] };
26
+ /**
27
+ * Research template registries are read by the optional @publisle/cli-research
28
+ * plugin; this Core config type stays free of Research imports.
29
+ */
30
+ readonly templates?: unknown;
31
+ }
32
+
33
+ export interface SourceOperation {
34
+ readonly command: "validate" | "upgrade";
35
+ readonly format: "json" | "markdown";
36
+ readonly sourceName: string;
37
+ readonly config?: CliConfig;
38
+ }
39
+
40
+ export interface SourceResult {
41
+ readonly diagnostics: readonly Diagnostic[];
42
+ /** Present only for a successful upgrade. This API never writes files. */
43
+ readonly output?: string;
44
+ }
45
+
46
+ function stable(value: unknown): string {
47
+ if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
48
+ if (typeof value === "object" && value !== null)
49
+ return `{${Object.entries(value)
50
+ .sort(([a], [b]) => a.localeCompare(b))
51
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stable(entry)}`)
52
+ .join(",")}}`;
53
+ return value === undefined ? "undefined" : JSON.stringify(value);
54
+ }
55
+
56
+ function semantics(document: Document): unknown {
57
+ return {
58
+ schemaVersion: document.schemaVersion,
59
+ metadata: document.metadata,
60
+ blocks: document.blocks.map(({ type, schemaVersion, data }) => ({
61
+ type,
62
+ schemaVersion,
63
+ data,
64
+ })),
65
+ };
66
+ }
67
+
68
+ /** Pure orchestration of the public import/parse/prepare/export boundaries. */
69
+ export function processSource(
70
+ source: string,
71
+ options: SourceOperation,
72
+ ): SourceResult {
73
+ const diagnostics: Diagnostic[] = [];
74
+ let sourceMap: DocumentSourceMap | undefined;
75
+ const location = { source: options.sourceName, line: 1, column: 1 };
76
+ const error = (code: string, message: string): SourceResult => ({
77
+ diagnostics: [
78
+ ...diagnostics,
79
+ { level: "error", code, message, sourceLocation: location },
80
+ ],
81
+ });
82
+ const config = options.config ?? {};
83
+ const preparation: PrepareOptions = {
84
+ registry: createRegistry(coreBlockDefinitions),
85
+ unknownBlocks: options.command === "validate" ? "error" : "preserve",
86
+ ...config.prepare,
87
+ };
88
+ const markdownOptions = {
89
+ ...config.markdown,
90
+ sourceName: options.sourceName,
91
+ resolveSchemaVersion: (type: `${string}:${string}`) =>
92
+ preparation.registry.get(type)?.schemaVersion,
93
+ };
94
+ try {
95
+ let input: Document;
96
+ if (options.format === "markdown") {
97
+ const imported = fromMarkdown(source, markdownOptions);
98
+ diagnostics.push(...imported.diagnostics);
99
+ if (!imported.document) return { diagnostics };
100
+ input = imported.document;
101
+ sourceMap = imported.sourceMap;
102
+ } else {
103
+ const raw: unknown = parseJson(source);
104
+ input = parseDocument(raw);
105
+ // Parsing intentionally models only portable fields. An upgrade must not
106
+ // silently discard unmodeled author data at the envelope/block boundary.
107
+ if (options.command === "upgrade" && stable(raw) !== stable(input))
108
+ return error(
109
+ "unmodeled-source-fields",
110
+ "JSON contains fields outside the portable schema; move them into metadata.extensions or a block payload before upgrading.",
111
+ );
112
+ }
113
+ const originalPayloads = new Map(
114
+ input.blocks.map((block) => [
115
+ block.id,
116
+ {
117
+ schemaVersion: block.schemaVersion,
118
+ data: stable(block.data),
119
+ },
120
+ ]),
121
+ );
122
+ const prepared = prepare(input, {
123
+ ...preparation,
124
+ ...(sourceMap ? { sourceMap } : {}),
125
+ });
126
+ diagnostics.push(
127
+ ...prepared.diagnostics.map((item) =>
128
+ locateDiagnostic(item, sourceMap ?? { document: location, blocks: {} }),
129
+ ),
130
+ );
131
+ if (
132
+ !prepared.document ||
133
+ diagnostics.some(({ level }) => level === "error")
134
+ )
135
+ return { diagnostics };
136
+ if (options.command === "validate") return { diagnostics };
137
+
138
+ // Explicitly remove preparation flags/plans and serialize only the public wire envelope.
139
+ const upgraded = parseDocument({
140
+ ...(input.dependencies === undefined
141
+ ? {}
142
+ : { dependencies: input.dependencies }),
143
+ ...(input.extensions === undefined
144
+ ? {}
145
+ : { extensions: input.extensions }),
146
+ schemaVersion: prepared.document.schemaVersion,
147
+ ...(prepared.document.metadata === undefined
148
+ ? {}
149
+ : { metadata: prepared.document.metadata }),
150
+ blocks: prepared.document.blocks.map(
151
+ ({ id, type, schemaVersion, data, readable }) => ({
152
+ ...(readable === undefined ? {} : { readable }),
153
+ id,
154
+ type,
155
+ schemaVersion,
156
+ data,
157
+ }),
158
+ ),
159
+ });
160
+ for (const block of upgraded.blocks) {
161
+ const original = originalPayloads.get(block.id);
162
+ if (
163
+ original?.schemaVersion === block.schemaVersion &&
164
+ original.data !== stable(block.data)
165
+ )
166
+ diagnostics.push(
167
+ locateDiagnostic(
168
+ {
169
+ level: "warning",
170
+ code: "block-data-normalized",
171
+ message:
172
+ "Registered parsing/defaults/normalization changed this payload without a version change. Review the upgrade preview before writing.",
173
+ blockId: block.id,
174
+ },
175
+ sourceMap ?? { document: location, blocks: {} },
176
+ ),
177
+ );
178
+ }
179
+ if (options.format === "json")
180
+ return { diagnostics, output: `${JSON.stringify(upgraded, null, 2)}\n` };
181
+ const exported = toMarkdown(upgraded, {
182
+ ...config.markdown,
183
+ policy: "fallback",
184
+ });
185
+ diagnostics.push(
186
+ ...exported.diagnostics.map((item) => locateDiagnostic(item, sourceMap)),
187
+ );
188
+ if (
189
+ exported.markdown === undefined ||
190
+ diagnostics.some(({ level }) => level === "error")
191
+ )
192
+ return { diagnostics };
193
+ const roundTrip = fromMarkdown(exported.markdown, markdownOptions);
194
+ diagnostics.push(...roundTrip.diagnostics);
195
+ if (
196
+ !roundTrip.document ||
197
+ stable(semantics(roundTrip.document)) !== stable(semantics(upgraded))
198
+ )
199
+ return error(
200
+ "markdown-roundtrip-loss",
201
+ "Canonical Markdown cannot preserve this document's upgraded semantics. No output was produced; upgrade the JSON representation instead or fix the host codec.",
202
+ );
203
+ if (diagnostics.some(({ level }) => level === "error"))
204
+ return { diagnostics };
205
+ if (
206
+ upgraded.blocks.some(
207
+ (block, index) => block.id !== roundTrip.document?.blocks[index]?.id,
208
+ )
209
+ )
210
+ diagnostics.push({
211
+ level: "warning",
212
+ code: "markdown-block-ids-changed",
213
+ message:
214
+ "Canonical native Markdown regenerates some block IDs. Stable labels remain; use JSON when block identity must be retained.",
215
+ sourceLocation: location,
216
+ });
217
+ return { diagnostics, output: exported.markdown };
218
+ } catch (cause) {
219
+ return error(
220
+ cause instanceof SchemaParseError || cause instanceof JsonBoundaryError
221
+ ? cause.code
222
+ : "source-operation-failed",
223
+ cause instanceof Error ? cause.message : "Source processing failed.",
224
+ );
225
+ }
226
+ }
227
+
228
+ /** Explicit pinning preview/output; never mutates the original input or repins locked content. */
229
+ export async function lockSource(
230
+ source: string,
231
+ options: Omit<SourceOperation, "command">,
232
+ ): Promise<SourceResult> {
233
+ try {
234
+ const imported =
235
+ options.format === "markdown"
236
+ ? fromMarkdown(source, {
237
+ ...options.config?.markdown,
238
+ sourceName: options.sourceName,
239
+ })
240
+ : undefined;
241
+ if (imported && !imported.document)
242
+ return { diagnostics: imported.diagnostics };
243
+ const raw = imported?.document ?? parseJson(source);
244
+ const document = parseDocument(raw);
245
+ if (canonicalizeJson(raw) !== canonicalizeJson(document))
246
+ throw new Error(
247
+ "Preserve original source: unsupported envelope fields must not be dropped while locking.",
248
+ );
249
+ const registry =
250
+ options.config?.prepare?.registry ?? createRegistry(coreBlockDefinitions);
251
+ const locked = await lockDocument(document, registry);
252
+ if (options.format === "json")
253
+ return {
254
+ output: JSON.stringify(locked.document, null, 2) + "\n",
255
+ diagnostics: imported?.diagnostics ?? [],
256
+ };
257
+ const exported = toMarkdown(locked.document, { policy: "archival" });
258
+ return {
259
+ ...(exported.markdown === undefined ? {} : { output: exported.markdown }),
260
+ diagnostics: [...(imported?.diagnostics ?? []), ...exported.diagnostics],
261
+ };
262
+ } catch (error) {
263
+ return {
264
+ diagnostics: [
265
+ {
266
+ level: "error",
267
+ code:
268
+ error instanceof Error &&
269
+ "code" in error &&
270
+ typeof error.code === "string"
271
+ ? error.code
272
+ : "document-lock-failed",
273
+ message: error instanceof Error ? error.message : "Locking failed.",
274
+ sourceLocation: { source: options.sourceName, line: 1, column: 1 },
275
+ },
276
+ ],
277
+ };
278
+ }
279
+ }
package/src/run.ts ADDED
@@ -0,0 +1,355 @@
1
+ import {
2
+ chmod,
3
+ lstat,
4
+ mkdtemp,
5
+ readFile,
6
+ rename,
7
+ rmdir,
8
+ unlink,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { dirname, extname, join, resolve } from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ import {
14
+ isPlainObject,
15
+ parseDocument,
16
+ parseJson,
17
+ type Diagnostic,
18
+ } from "@publisle/schema";
19
+ import { toReadingMarkdown } from "@publisle/markdown";
20
+ import { processSource, lockSource, type CliConfig } from "./index.ts";
21
+ import {
22
+ exportSemanticDocument,
23
+ inspectDocument,
24
+ validateContractBundle,
25
+ } from "@publisle/contracts";
26
+ import { createRegistry } from "@publisle/core";
27
+ import { coreBlockDefinitions } from "@publisle/blocks-core";
28
+
29
+ /**
30
+ * The only sanctioned seam between the Core CLI and the Research profile: the
31
+ * optional plugin is loaded dynamically, so a Core-only install carries no
32
+ * research, template, or TeX tooling. Type-only knowledge of the plugin shape
33
+ * is erased at runtime.
34
+ */
35
+ type ResearchPlugin = typeof import("@publisle/cli-research");
36
+
37
+ const researchCommands = ["export", "bibliography", "doctor", "setup"] as const;
38
+
39
+ type ResearchCommandName = (typeof researchCommands)[number];
40
+
41
+ function isResearchCommand(
42
+ value: string | undefined,
43
+ ): value is ResearchCommandName {
44
+ return (researchCommands as readonly string[]).includes(value ?? "");
45
+ }
46
+
47
+ async function loadResearchPlugin(): Promise<ResearchPlugin | undefined> {
48
+ try {
49
+ return await import("@publisle/cli-research");
50
+ } catch (cause) {
51
+ if (
52
+ cause instanceof Error &&
53
+ "code" in cause &&
54
+ (cause.code === "ERR_MODULE_NOT_FOUND" ||
55
+ cause.code === "ERR_UNSUPPORTED_RESOLVE")
56
+ )
57
+ return undefined;
58
+ throw cause;
59
+ }
60
+ }
61
+
62
+ export interface CliIo {
63
+ readonly stdout: (text: string) => void;
64
+ readonly stderr: (text: string) => void;
65
+ }
66
+
67
+ const help = `Usage: publisle validate|upgrade|lock <file.json|file.md> [--config <host.mjs|host.ts>] [--format json|markdown]
68
+ JSON-only inspection: publisle inspect|semantic <source.json> [--bundle <contracts.json>] [--mode linked|standalone]
69
+ Reading projection: publisle reading <source.json> (not a round trip).
70
+ Research profile (requires the optional @publisle/cli-research plugin):
71
+ publisle export <file.json|file.md> --to bibtex|csl-json|latex|jats|pdf|submission [--template ...] [--output <new-file>]
72
+ publisle bibliography <file.bib> | publisle doctor | publisle setup compiler
73
+ Upgrade: [--output <new-file> | --in-place]. Lock: [--output <new-file>] (never in-place).
74
+ --output refuses existing files.
75
+ Config is trusted executable host code exporting a default CliConfig. Node >=24 required.
76
+ Exit codes: 0 success (warnings allowed), 1 document errors, 2 usage/config/I/O errors.
77
+ `;
78
+
79
+ function diagnosticText(item: Diagnostic, file: string): string {
80
+ const where = item.sourceLocation;
81
+ return `${where?.source ?? file}${where ? `:${String(where.line)}:${String(where.column)}` : ""}: ${item.level} [${item.code}]${item.blockId ? ` (${item.blockId})` : ""}${item.profile ? ` {${item.profile}}` : ""}: ${item.message}\n`;
82
+ }
83
+
84
+ async function loadConfig(file: string | undefined): Promise<CliConfig> {
85
+ if (!file) return {};
86
+ const imported: unknown = await import(pathToFileURL(resolve(file)).href);
87
+ if (
88
+ !isPlainObject(imported) &&
89
+ !(typeof imported === "object" && imported !== null)
90
+ )
91
+ throw new Error("Config must export a default CliConfig object.");
92
+ const config: unknown = (imported as Record<string, unknown>)["default"];
93
+ if (!isPlainObject(config))
94
+ throw new Error("Config must export a default CliConfig object.");
95
+ const preparation = config["prepare"];
96
+ if (
97
+ preparation !== undefined &&
98
+ (!isPlainObject(preparation) ||
99
+ typeof (preparation["registry"] as { get?: unknown } | undefined)?.get !==
100
+ "function")
101
+ )
102
+ throw new Error(
103
+ "config.prepare must provide a registry created with createRegistry().",
104
+ );
105
+ if (
106
+ isPlainObject(preparation) &&
107
+ preparation["unknownBlocks"] !== undefined &&
108
+ preparation["unknownBlocks"] !== "preserve" &&
109
+ preparation["unknownBlocks"] !== "error"
110
+ )
111
+ throw new Error("config.prepare.unknownBlocks must be preserve or error.");
112
+ const markdown = config["markdown"];
113
+ if (
114
+ markdown !== undefined &&
115
+ (!isPlainObject(markdown) ||
116
+ (markdown["codecs"] !== undefined && !Array.isArray(markdown["codecs"])))
117
+ )
118
+ throw new Error("config.markdown must contain an optional codecs array.");
119
+ return config;
120
+ }
121
+
122
+ async function replaceSource(
123
+ file: string,
124
+ original: string,
125
+ output: string,
126
+ ): Promise<void> {
127
+ const stat = await lstat(file);
128
+ if (!stat.isFile() || stat.nlink !== 1)
129
+ throw new Error(
130
+ "--in-place requires a regular non-symlink file with one hard link.",
131
+ );
132
+ const temporary = await mkdtemp(join(dirname(file), ".publisle-upgrade-"));
133
+ const staged = join(temporary, "source");
134
+ try {
135
+ await writeFile(staged, output, { flag: "wx", mode: stat.mode & 0o777 });
136
+ await chmod(staged, stat.mode & 0o777);
137
+ const current = await lstat(file);
138
+ if (
139
+ current.dev !== stat.dev ||
140
+ current.ino !== stat.ino ||
141
+ current.nlink !== 1 ||
142
+ !current.isFile() ||
143
+ (await readFile(file, "utf8")) !== original
144
+ )
145
+ throw new Error(
146
+ "Source changed while upgrading; refusing to overwrite it.",
147
+ );
148
+ await rename(staged, file);
149
+ } finally {
150
+ await unlink(staged).catch((cause: unknown) => {
151
+ if (!(
152
+ typeof cause === "object" &&
153
+ cause !== null &&
154
+ "code" in cause &&
155
+ cause.code === "ENOENT"
156
+ ))
157
+ throw cause;
158
+ });
159
+ await rmdir(temporary);
160
+ }
161
+ }
162
+
163
+ /** Optional Node-only runner. Validation and preview never invoke a write operation. */
164
+ export async function runCli(
165
+ args: readonly string[],
166
+ io: CliIo,
167
+ ): Promise<number> {
168
+ if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) {
169
+ io.stdout(help);
170
+ return 0;
171
+ }
172
+ try {
173
+ const command = args[0];
174
+ if (isResearchCommand(command)) {
175
+ const plugin = await loadResearchPlugin();
176
+ if (plugin === undefined) {
177
+ io.stderr(
178
+ `publisle: ${command} belongs to the Publisle Research profile. Install @publisle/cli-research alongside @publisle/cli to use it.\n`,
179
+ );
180
+ return 2;
181
+ }
182
+ return await plugin.runResearchCommand(command, args, io, {
183
+ loadConfig: (file) => loadConfig(file),
184
+ diagnosticText,
185
+ });
186
+ }
187
+ if (command === "reading") {
188
+ if (args.length !== 2 || !args[1] || args[1].startsWith("-"))
189
+ throw new Error("reading requires only canonical source.json");
190
+ const source = parseDocument(parseJson(await readFile(resolve(args[1]))));
191
+ const result = toReadingMarkdown(source);
192
+ for (const item of result.diagnostics)
193
+ io.stderr(diagnosticText(item, args[1]));
194
+ if (result.markdown === undefined) return 1;
195
+ io.stdout(result.markdown);
196
+ return 0;
197
+ }
198
+ if (command === "inspect" || command === "semantic") {
199
+ const file = args[1];
200
+ if (!file || file.startsWith("-"))
201
+ throw new Error("Inspection requires canonical source.json");
202
+ let bundleFile: string | undefined;
203
+ let mode: "linked" | "standalone" = "linked";
204
+ const seen = new Set<string>();
205
+ for (let index = 2; index < args.length; index += 2) {
206
+ const key = args[index];
207
+ const value = args[index + 1];
208
+ if (!key || seen.has(key) || !value || value.startsWith("-"))
209
+ throw new Error("Invalid or duplicate inspection option");
210
+ seen.add(key);
211
+ if (key === "--bundle") bundleFile = value;
212
+ else if (
213
+ key === "--mode" &&
214
+ (value === "linked" || value === "standalone")
215
+ )
216
+ mode = value;
217
+ else
218
+ throw new Error(
219
+ "Inspection accepts only --bundle and --mode linked|standalone; no executable config",
220
+ );
221
+ }
222
+ const source = parseJson(await readFile(resolve(file)));
223
+ const bundle = bundleFile
224
+ ? await validateContractBundle(
225
+ parseJson(await readFile(resolve(bundleFile))),
226
+ )
227
+ : undefined;
228
+ const options = { mode, offline: true, ...(bundle ? { bundle } : {}) };
229
+ const output =
230
+ command === "inspect"
231
+ ? await inspectDocument(source, options)
232
+ : await exportSemanticDocument(source, options);
233
+ io.stdout(JSON.stringify(output, null, 2) + "\n");
234
+ return 0;
235
+ }
236
+ if (command !== "validate" && command !== "upgrade" && command !== "lock")
237
+ throw new Error(
238
+ "Expected validate, upgrade, lock, inspect, semantic, or reading. Research commands (export, bibliography, doctor, setup) require @publisle/cli-research.",
239
+ );
240
+ let file: string | undefined;
241
+ let configFile: string | undefined;
242
+ let outputFile: string | undefined;
243
+ let format: "json" | "markdown" | undefined;
244
+ let inPlace = false;
245
+ const seen = new Set<string>();
246
+ for (let index = 1; index < args.length; index++) {
247
+ const arg = args[index];
248
+ if (arg === undefined) continue;
249
+ if (!arg.startsWith("-")) {
250
+ if (file) throw new Error("Only one input file is supported.");
251
+ file = arg;
252
+ continue;
253
+ }
254
+ if (seen.has(arg)) throw new Error(`Duplicate option ${arg}.`);
255
+ seen.add(arg);
256
+ if (arg === "--in-place") {
257
+ inPlace = true;
258
+ continue;
259
+ }
260
+ if (arg !== "--config" && arg !== "--output" && arg !== "--format")
261
+ throw new Error(`Unknown option ${arg}.`);
262
+ const value = args[++index];
263
+ if (!value || value.startsWith("-"))
264
+ throw new Error(`Missing value for ${arg}.`);
265
+ if (arg === "--config") configFile = value;
266
+ if (arg === "--output") outputFile = value;
267
+ if (arg === "--format") {
268
+ if (value !== "json" && value !== "markdown")
269
+ throw new Error("--format must be json or markdown.");
270
+ format = value;
271
+ }
272
+ }
273
+ if (!file) throw new Error("An input file is required.");
274
+ if (command === "validate" && (outputFile || inPlace))
275
+ throw new Error("validate does not accept write options.");
276
+ if (command === "lock" && inPlace)
277
+ throw new Error(
278
+ "lock preserves the original source: use stdout preview or --output <new-file>.",
279
+ );
280
+ if (outputFile && inPlace)
281
+ throw new Error("Choose --output or --in-place, not both.");
282
+ file = resolve(file);
283
+ if (outputFile && resolve(outputFile) === file)
284
+ throw new Error("Use --in-place to authorize replacing the input.");
285
+ if (!format) {
286
+ const extension = extname(file).toLowerCase();
287
+ if (extension === ".json") format = "json";
288
+ else if (extension === ".md" || extension === ".markdown")
289
+ format = "markdown";
290
+ else
291
+ throw new Error(
292
+ "Unknown file extension; provide --format json|markdown.",
293
+ );
294
+ }
295
+ const bytes = await readFile(file);
296
+ let original: string;
297
+ try {
298
+ original = new TextDecoder("utf-8", {
299
+ fatal: true,
300
+ ignoreBOM: true,
301
+ }).decode(bytes);
302
+ } catch {
303
+ io.stderr(
304
+ `${file}: error [invalid-json-unicode]: Input is not valid UTF-8.\n`,
305
+ );
306
+ return 1;
307
+ }
308
+ let config = await loadConfig(configFile);
309
+ if (config.contractBundle !== undefined) {
310
+ const bundle = await validateContractBundle(config.contractBundle);
311
+ config = {
312
+ ...config,
313
+ prepare: {
314
+ ...(config.prepare ?? {
315
+ registry: createRegistry(coreBlockDefinitions),
316
+ }),
317
+ contractPins: bundle.contracts.map((entry) => ({
318
+ type: entry.contract.identity.type,
319
+ schemaVersion: entry.contract.identity.schemaVersion,
320
+ id: entry.id,
321
+ digest: entry.digest,
322
+ })),
323
+ },
324
+ };
325
+ }
326
+ const operation = {
327
+ format,
328
+ sourceName: file,
329
+ config,
330
+ };
331
+ const result =
332
+ command === "lock"
333
+ ? await lockSource(original, operation)
334
+ : processSource(original, { ...operation, command });
335
+ for (const item of result.diagnostics)
336
+ io.stderr(diagnosticText(item, file));
337
+ if (result.diagnostics.some(({ level }) => level === "error")) return 1;
338
+ if (command === "validate") {
339
+ io.stderr(`${file}: valid\n`);
340
+ return 0;
341
+ }
342
+ if (result.output === undefined)
343
+ throw new Error("Upgrade produced no output.");
344
+ if (inPlace) await replaceSource(file, original, result.output);
345
+ else if (outputFile)
346
+ await writeFile(resolve(outputFile), result.output, { flag: "wx" });
347
+ else io.stdout(result.output);
348
+ return 0;
349
+ } catch (cause) {
350
+ io.stderr(
351
+ `publisle: ${cause instanceof Error ? cause.message : "CLI operation failed."}\n${help}`,
352
+ );
353
+ return 2;
354
+ }
355
+ }