stego-cli 0.2.1 → 0.3.1
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/README.md +14 -0
- package/dist/comments/add-comment.js +177 -0
- package/dist/comments/comments-command.js +207 -0
- package/dist/comments/errors.js +20 -0
- package/dist/stego-cli.js +80 -13
- package/package.json +3 -2
- package/projects/fiction-example/manuscript/200-at-the-wards.md +1 -1
- package/projects/fiction-example/manuscript/300-the-hearing.md +1 -1
- package/projects/stego-docs/spine/commands.md +2 -2
package/README.md
CHANGED
|
@@ -56,8 +56,22 @@ stego check-stage --project fiction-example --stage revise
|
|
|
56
56
|
stego export --project fiction-example --format md
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
`stego new` also supports `--i <prefix>` for numeric prefix override and `--filename <name>` for an explicit manuscript filename.
|
|
60
|
+
|
|
59
61
|
Projects also include local npm scripts so you can work from inside a project directory.
|
|
60
62
|
|
|
63
|
+
## Advanced integration command
|
|
64
|
+
|
|
65
|
+
`stego comments add` is a machine-facing command for editor/tool integrations.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
stego comments add manuscript/100-scene.md --message "Could this transition be clearer?"
|
|
69
|
+
stego comments add manuscript/100-scene.md --input payload.json --format json
|
|
70
|
+
stego comments add manuscript/100-scene.md --input - --format json <<'JSON'
|
|
71
|
+
{"message":"Could this transition be clearer?","range":{"start":{"line":10,"col":4},"end":{"line":10,"col":32}}}
|
|
72
|
+
JSON
|
|
73
|
+
```
|
|
74
|
+
|
|
61
75
|
## VS Code workflow
|
|
62
76
|
|
|
63
77
|
When actively working on one project, open that project directory directly in VS Code (for example `projects/fiction-example`).
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CommentsCommandError } from "./errors.js";
|
|
4
|
+
const COMMENT_HEADING_REGEX = /^###\s+(CMT-(\d{4,}))\s*$/;
|
|
5
|
+
const START_SENTINEL = "<!-- stego-comments:start -->";
|
|
6
|
+
const END_SENTINEL = "<!-- stego-comments:end -->";
|
|
7
|
+
/** Adds one stego comment entry to a manuscript and writes the updated file. */
|
|
8
|
+
export function addCommentToManuscript(request) {
|
|
9
|
+
const absolutePath = path.resolve(request.cwd, request.manuscriptPath);
|
|
10
|
+
if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
|
|
11
|
+
throw new CommentsCommandError("INVALID_USAGE", 2, `Manuscript file not found: ${request.manuscriptPath}`);
|
|
12
|
+
}
|
|
13
|
+
const raw = fs.readFileSync(absolutePath, "utf8");
|
|
14
|
+
const lineEnding = raw.includes("\r\n") ? "\r\n" : "\n";
|
|
15
|
+
const lines = raw.split(/\r?\n/);
|
|
16
|
+
const sentinelState = resolveSentinelState(lines);
|
|
17
|
+
const hasYamlFrontmatter = raw.startsWith(`---${lineEnding}`) || raw.startsWith("---\n");
|
|
18
|
+
if (!sentinelState.hasSentinels && !hasYamlFrontmatter) {
|
|
19
|
+
throw new CommentsCommandError("NOT_STEGO_MANUSCRIPT", 3, "File is not recognized as a Stego manuscript (missing frontmatter and comments sentinels).");
|
|
20
|
+
}
|
|
21
|
+
const commentId = getNextCommentId(lines);
|
|
22
|
+
const createdAt = new Date().toISOString();
|
|
23
|
+
const meta = buildMetaPayload(request.range, request.sourceMeta);
|
|
24
|
+
const meta64 = Buffer.from(JSON.stringify(meta), "utf8").toString("base64url");
|
|
25
|
+
const entryLines = buildCommentEntryLines(commentId, createdAt, request.author, request.message, meta64);
|
|
26
|
+
const nextLines = applyCommentEntry(lines, sentinelState, entryLines);
|
|
27
|
+
const nextContent = `${nextLines.join(lineEnding)}${lineEnding}`;
|
|
28
|
+
try {
|
|
29
|
+
fs.writeFileSync(absolutePath, nextContent, "utf8");
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
33
|
+
throw new CommentsCommandError("WRITE_FAILURE", 6, `Failed to update manuscript: ${message}`);
|
|
34
|
+
}
|
|
35
|
+
const result = {
|
|
36
|
+
ok: true,
|
|
37
|
+
manuscript: absolutePath,
|
|
38
|
+
commentId,
|
|
39
|
+
status: "open",
|
|
40
|
+
anchor: request.range
|
|
41
|
+
? {
|
|
42
|
+
type: "selection",
|
|
43
|
+
excerptStartLine: request.range.startLine,
|
|
44
|
+
excerptStartCol: request.range.startCol,
|
|
45
|
+
excerptEndLine: request.range.endLine,
|
|
46
|
+
excerptEndCol: request.range.endCol
|
|
47
|
+
}
|
|
48
|
+
: { type: "file" },
|
|
49
|
+
createdAt
|
|
50
|
+
};
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
function buildMetaPayload(range, sourceMeta) {
|
|
54
|
+
const payload = {
|
|
55
|
+
status: "open"
|
|
56
|
+
};
|
|
57
|
+
if (range) {
|
|
58
|
+
payload.anchor = "selection";
|
|
59
|
+
payload.excerpt = {
|
|
60
|
+
start_line: range.startLine,
|
|
61
|
+
start_col: range.startCol,
|
|
62
|
+
end_line: range.endLine,
|
|
63
|
+
end_col: range.endCol
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (sourceMeta && Object.keys(sourceMeta).length > 0) {
|
|
67
|
+
payload.signature = sourceMeta;
|
|
68
|
+
}
|
|
69
|
+
return payload;
|
|
70
|
+
}
|
|
71
|
+
function buildCommentEntryLines(commentId, timestamp, author, message, meta64) {
|
|
72
|
+
const safeAuthor = author.trim() || "Saurus";
|
|
73
|
+
const normalizedMessageLines = message
|
|
74
|
+
.replace(/\r\n/g, "\n")
|
|
75
|
+
.split("\n")
|
|
76
|
+
.map((line) => line.trimEnd());
|
|
77
|
+
return [
|
|
78
|
+
`### ${commentId}`,
|
|
79
|
+
`<!-- meta64: ${meta64} -->`,
|
|
80
|
+
`> _${timestamp} | ${safeAuthor}_`,
|
|
81
|
+
">",
|
|
82
|
+
...normalizedMessageLines.map((line) => `> ${line}`)
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
function applyCommentEntry(lines, sentinelState, entryLines) {
|
|
86
|
+
if (!sentinelState.hasSentinels) {
|
|
87
|
+
const baseLines = trimTrailingBlankLines(lines);
|
|
88
|
+
return [
|
|
89
|
+
...baseLines,
|
|
90
|
+
"",
|
|
91
|
+
START_SENTINEL,
|
|
92
|
+
"",
|
|
93
|
+
...entryLines,
|
|
94
|
+
"",
|
|
95
|
+
END_SENTINEL
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
const startIndex = sentinelState.startIndex;
|
|
99
|
+
const endIndex = sentinelState.endIndex;
|
|
100
|
+
const block = lines.slice(startIndex + 1, endIndex);
|
|
101
|
+
const trimmedBlock = trimOuterBlankLines(block);
|
|
102
|
+
const nextBlock = trimmedBlock.length > 0
|
|
103
|
+
? [...trimmedBlock, "", ...entryLines]
|
|
104
|
+
: [...entryLines];
|
|
105
|
+
return [
|
|
106
|
+
...lines.slice(0, startIndex + 1),
|
|
107
|
+
...nextBlock,
|
|
108
|
+
...lines.slice(endIndex)
|
|
109
|
+
];
|
|
110
|
+
}
|
|
111
|
+
function resolveSentinelState(lines) {
|
|
112
|
+
const startIndexes = findTrimmedLineIndexes(lines, START_SENTINEL);
|
|
113
|
+
const endIndexes = findTrimmedLineIndexes(lines, END_SENTINEL);
|
|
114
|
+
const hasAnySentinel = startIndexes.length > 0 || endIndexes.length > 0;
|
|
115
|
+
if (!hasAnySentinel) {
|
|
116
|
+
return {
|
|
117
|
+
hasSentinels: false,
|
|
118
|
+
startIndex: -1,
|
|
119
|
+
endIndex: -1
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (startIndexes.length !== 1 || endIndexes.length !== 1) {
|
|
123
|
+
throw new CommentsCommandError("COMMENT_APPENDIX_INVALID", 5, `Expected exactly one '${START_SENTINEL}' and one '${END_SENTINEL}' marker.`);
|
|
124
|
+
}
|
|
125
|
+
const startIndex = startIndexes[0];
|
|
126
|
+
const endIndex = endIndexes[0];
|
|
127
|
+
if (endIndex <= startIndex) {
|
|
128
|
+
throw new CommentsCommandError("COMMENT_APPENDIX_INVALID", 5, `'${END_SENTINEL}' must appear after '${START_SENTINEL}'.`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
hasSentinels: true,
|
|
132
|
+
startIndex,
|
|
133
|
+
endIndex
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function findTrimmedLineIndexes(lines, target) {
|
|
137
|
+
const indexes = [];
|
|
138
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
139
|
+
if (lines[index].trim() === target) {
|
|
140
|
+
indexes.push(index);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return indexes;
|
|
144
|
+
}
|
|
145
|
+
function getNextCommentId(lines) {
|
|
146
|
+
let maxId = 0;
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
const match = line.trim().match(COMMENT_HEADING_REGEX);
|
|
149
|
+
if (!match) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const numeric = Number.parseInt(match[2], 10);
|
|
153
|
+
if (Number.isFinite(numeric) && numeric > maxId) {
|
|
154
|
+
maxId = numeric;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const next = maxId + 1;
|
|
158
|
+
return `CMT-${String(next).padStart(4, "0")}`;
|
|
159
|
+
}
|
|
160
|
+
function trimTrailingBlankLines(lines) {
|
|
161
|
+
const copy = [...lines];
|
|
162
|
+
while (copy.length > 0 && copy[copy.length - 1].trim().length === 0) {
|
|
163
|
+
copy.pop();
|
|
164
|
+
}
|
|
165
|
+
return copy;
|
|
166
|
+
}
|
|
167
|
+
function trimOuterBlankLines(lines) {
|
|
168
|
+
let start = 0;
|
|
169
|
+
let end = lines.length;
|
|
170
|
+
while (start < end && lines[start].trim().length === 0) {
|
|
171
|
+
start += 1;
|
|
172
|
+
}
|
|
173
|
+
while (end > start && lines[end - 1].trim().length === 0) {
|
|
174
|
+
end -= 1;
|
|
175
|
+
}
|
|
176
|
+
return lines.slice(start, end);
|
|
177
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { addCommentToManuscript } from "./add-comment.js";
|
|
4
|
+
import { CommentsCommandError } from "./errors.js";
|
|
5
|
+
/** Handles `stego comments ...` command group. */
|
|
6
|
+
export async function runCommentsCommand(options, cwd) {
|
|
7
|
+
const [subcommand, manuscriptArg] = options._;
|
|
8
|
+
if (subcommand !== "add") {
|
|
9
|
+
throw new CommentsCommandError("INVALID_USAGE", 2, "Unknown comments subcommand. Use: stego comments add <manuscript> [--message <text> | --input <path|->].");
|
|
10
|
+
}
|
|
11
|
+
if (!manuscriptArg) {
|
|
12
|
+
throw new CommentsCommandError("INVALID_USAGE", 2, "Manuscript path is required. Use: stego comments add <manuscript> ...");
|
|
13
|
+
}
|
|
14
|
+
const outputFormat = parseOutputFormat(readString(options, "format"));
|
|
15
|
+
const messageOption = readString(options, "message");
|
|
16
|
+
const inputOption = readString(options, "input");
|
|
17
|
+
if (messageOption && inputOption) {
|
|
18
|
+
throw new CommentsCommandError("INVALID_USAGE", 2, "Use exactly one payload mode: --message <text> OR --input <path|->.", outputFormat);
|
|
19
|
+
}
|
|
20
|
+
if (!messageOption && !inputOption) {
|
|
21
|
+
throw new CommentsCommandError("INVALID_USAGE", 2, "Missing payload. Provide --message <text> or --input <path|->.", outputFormat);
|
|
22
|
+
}
|
|
23
|
+
const payload = inputOption ? readInputPayload(inputOption, cwd, outputFormat) : undefined;
|
|
24
|
+
const payloadMessage = coerceNonEmptyString(payload?.message, "Input payload 'message' must be a non-empty string.", outputFormat);
|
|
25
|
+
const payloadAuthor = coerceOptionalString(payload?.author, "Input payload 'author' must be a string if provided.", outputFormat);
|
|
26
|
+
const payloadRange = payload?.range !== undefined
|
|
27
|
+
? parsePayloadRange(payload.range, outputFormat)
|
|
28
|
+
: undefined;
|
|
29
|
+
const payloadMeta = payload?.meta !== undefined
|
|
30
|
+
? coerceOptionalObject(payload.meta, "Input payload 'meta' must be an object if provided.", outputFormat)
|
|
31
|
+
: undefined;
|
|
32
|
+
const optionRange = parseRangeFromOptions(options, outputFormat);
|
|
33
|
+
const finalMessage = (messageOption ?? payloadMessage ?? "").trim();
|
|
34
|
+
if (!finalMessage) {
|
|
35
|
+
throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Comment message cannot be empty.", outputFormat);
|
|
36
|
+
}
|
|
37
|
+
const finalAuthor = (readString(options, "author") ?? payloadAuthor ?? "Saurus").trim() || "Saurus";
|
|
38
|
+
const finalRange = optionRange ?? payloadRange;
|
|
39
|
+
let result;
|
|
40
|
+
try {
|
|
41
|
+
result = addCommentToManuscript({
|
|
42
|
+
manuscriptPath: manuscriptArg,
|
|
43
|
+
cwd,
|
|
44
|
+
message: finalMessage,
|
|
45
|
+
author: finalAuthor,
|
|
46
|
+
range: finalRange,
|
|
47
|
+
sourceMeta: payloadMeta
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof CommentsCommandError && outputFormat === "json" && error.outputFormat !== "json") {
|
|
52
|
+
throw new CommentsCommandError(error.code, error.exitCode, error.message, outputFormat);
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
if (outputFormat === "json") {
|
|
57
|
+
console.log(JSON.stringify(result, null, 2));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const relativePath = path.relative(cwd, result.manuscript) || result.manuscript;
|
|
61
|
+
if (result.anchor.type === "selection") {
|
|
62
|
+
console.log(`Added ${result.commentId} to ${relativePath} (selection anchor).`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
console.log(`Added ${result.commentId} to ${relativePath} (file-level anchor).`);
|
|
66
|
+
}
|
|
67
|
+
function readString(options, key) {
|
|
68
|
+
const value = options[key];
|
|
69
|
+
return typeof value === "string" ? value : undefined;
|
|
70
|
+
}
|
|
71
|
+
function parseOutputFormat(raw) {
|
|
72
|
+
if (!raw || raw === "text") {
|
|
73
|
+
return "text";
|
|
74
|
+
}
|
|
75
|
+
if (raw === "json") {
|
|
76
|
+
return "json";
|
|
77
|
+
}
|
|
78
|
+
throw new CommentsCommandError("INVALID_USAGE", 2, "Invalid --format value. Use 'text' or 'json'.");
|
|
79
|
+
}
|
|
80
|
+
function readInputPayload(inputPath, cwd, outputFormat) {
|
|
81
|
+
let rawJson = "";
|
|
82
|
+
try {
|
|
83
|
+
rawJson = inputPath === "-"
|
|
84
|
+
? fs.readFileSync(0, "utf8")
|
|
85
|
+
: fs.readFileSync(path.resolve(cwd, inputPath), "utf8");
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
89
|
+
throw new CommentsCommandError("INVALID_PAYLOAD", 4, `Unable to read input payload: ${message}`, outputFormat);
|
|
90
|
+
}
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(rawJson);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Input payload is not valid JSON.", outputFormat);
|
|
97
|
+
}
|
|
98
|
+
if (!isPlainObject(parsed)) {
|
|
99
|
+
throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Input payload must be a JSON object.", outputFormat);
|
|
100
|
+
}
|
|
101
|
+
return parsed;
|
|
102
|
+
}
|
|
103
|
+
function parseRangeFromOptions(options, outputFormat) {
|
|
104
|
+
const rawStartLine = readString(options, "start-line");
|
|
105
|
+
const rawStartCol = readString(options, "start-col");
|
|
106
|
+
const rawEndLine = readString(options, "end-line");
|
|
107
|
+
const rawEndCol = readString(options, "end-col");
|
|
108
|
+
const values = [rawStartLine, rawStartCol, rawEndLine, rawEndCol];
|
|
109
|
+
const hasAny = values.some((value) => value !== undefined);
|
|
110
|
+
const hasAll = values.every((value) => value !== undefined);
|
|
111
|
+
if (!hasAny) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
if (!hasAll) {
|
|
115
|
+
throw new CommentsCommandError("INVALID_USAGE", 2, "Range options must include all of --start-line, --start-col, --end-line, --end-col.", outputFormat);
|
|
116
|
+
}
|
|
117
|
+
const range = {
|
|
118
|
+
startLine: parseNonNegativeInt(rawStartLine, "--start-line", outputFormat),
|
|
119
|
+
startCol: parseNonNegativeInt(rawStartCol, "--start-col", outputFormat),
|
|
120
|
+
endLine: parseNonNegativeInt(rawEndLine, "--end-line", outputFormat),
|
|
121
|
+
endCol: parseNonNegativeInt(rawEndCol, "--end-col", outputFormat)
|
|
122
|
+
};
|
|
123
|
+
validateRange(range, outputFormat);
|
|
124
|
+
return range;
|
|
125
|
+
}
|
|
126
|
+
function parsePayloadRange(raw, outputFormat) {
|
|
127
|
+
if (!isPlainObject(raw)) {
|
|
128
|
+
throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Input payload 'range' must be an object.", outputFormat);
|
|
129
|
+
}
|
|
130
|
+
const parsed = raw;
|
|
131
|
+
const start = parsed.start;
|
|
132
|
+
const end = parsed.end;
|
|
133
|
+
if (!isPlainObject(start) || !isPlainObject(end)) {
|
|
134
|
+
throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Input payload 'range' must include 'start' and 'end' objects.", outputFormat);
|
|
135
|
+
}
|
|
136
|
+
const normalized = {
|
|
137
|
+
start: {
|
|
138
|
+
line: parseJsonNonNegativeInt(start.line, "range.start.line", outputFormat),
|
|
139
|
+
col: parseJsonNonNegativeInt(start.col, "range.start.col", outputFormat)
|
|
140
|
+
},
|
|
141
|
+
end: {
|
|
142
|
+
line: parseJsonNonNegativeInt(end.line, "range.end.line", outputFormat),
|
|
143
|
+
col: parseJsonNonNegativeInt(end.col, "range.end.col", outputFormat)
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const range = {
|
|
147
|
+
startLine: normalized.start.line,
|
|
148
|
+
startCol: normalized.start.col,
|
|
149
|
+
endLine: normalized.end.line,
|
|
150
|
+
endCol: normalized.end.col
|
|
151
|
+
};
|
|
152
|
+
validateRange(range, outputFormat);
|
|
153
|
+
return range;
|
|
154
|
+
}
|
|
155
|
+
function validateRange(range, outputFormat) {
|
|
156
|
+
const startsBeforeEnd = range.startLine < range.endLine
|
|
157
|
+
|| (range.startLine === range.endLine && range.startCol < range.endCol);
|
|
158
|
+
if (!startsBeforeEnd) {
|
|
159
|
+
throw new CommentsCommandError("INVALID_PAYLOAD", 4, "Range end must be after range start.", outputFormat);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function parseNonNegativeInt(raw, label, outputFormat) {
|
|
163
|
+
if (!/^\d+$/.test(raw)) {
|
|
164
|
+
throw new CommentsCommandError("INVALID_USAGE", 2, `${label} must be a non-negative integer.`, outputFormat);
|
|
165
|
+
}
|
|
166
|
+
const value = Number.parseInt(raw, 10);
|
|
167
|
+
if (!Number.isFinite(value)) {
|
|
168
|
+
throw new CommentsCommandError("INVALID_USAGE", 2, `${label} must be a non-negative integer.`, outputFormat);
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
function parseJsonNonNegativeInt(raw, label, outputFormat) {
|
|
173
|
+
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
|
|
174
|
+
throw new CommentsCommandError("INVALID_PAYLOAD", 4, `${label} must be a non-negative integer.`, outputFormat);
|
|
175
|
+
}
|
|
176
|
+
return raw;
|
|
177
|
+
}
|
|
178
|
+
function coerceNonEmptyString(value, message, outputFormat) {
|
|
179
|
+
if (value === undefined) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
183
|
+
throw new CommentsCommandError("INVALID_PAYLOAD", 4, message, outputFormat);
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
function coerceOptionalString(value, message, outputFormat) {
|
|
188
|
+
if (value === undefined) {
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
if (typeof value !== "string") {
|
|
192
|
+
throw new CommentsCommandError("INVALID_PAYLOAD", 4, message, outputFormat);
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
function coerceOptionalObject(value, message, outputFormat) {
|
|
197
|
+
if (value === undefined) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
if (!isPlainObject(value)) {
|
|
201
|
+
throw new CommentsCommandError("INVALID_PAYLOAD", 4, message, outputFormat);
|
|
202
|
+
}
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
function isPlainObject(value) {
|
|
206
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
207
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Error raised for machine-facing comments command failures. */
|
|
2
|
+
export class CommentsCommandError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
exitCode;
|
|
5
|
+
outputFormat;
|
|
6
|
+
constructor(code, exitCode, message, outputFormat = "text") {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "CommentsCommandError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.exitCode = exitCode;
|
|
11
|
+
this.outputFormat = outputFormat;
|
|
12
|
+
}
|
|
13
|
+
toJson() {
|
|
14
|
+
return {
|
|
15
|
+
ok: false,
|
|
16
|
+
code: this.code,
|
|
17
|
+
message: this.message
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
package/dist/stego-cli.js
CHANGED
|
@@ -8,6 +8,8 @@ import { createInterface } from "node:readline/promises";
|
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { markdownExporter } from "./exporters/markdown-exporter.js";
|
|
10
10
|
import { createPandocExporter } from "./exporters/pandoc-exporter.js";
|
|
11
|
+
import { runCommentsCommand } from "./comments/comments-command.js";
|
|
12
|
+
import { CommentsCommandError } from "./comments/errors.js";
|
|
11
13
|
const STATUS_RANK = {
|
|
12
14
|
draft: 0,
|
|
13
15
|
revise: 1,
|
|
@@ -128,7 +130,7 @@ async function main() {
|
|
|
128
130
|
case "new": {
|
|
129
131
|
activateWorkspace(options);
|
|
130
132
|
const project = resolveProject(readStringOption(options, "project"));
|
|
131
|
-
const createdPath = createNewManuscript(project, readStringOption(options, "i"));
|
|
133
|
+
const createdPath = createNewManuscript(project, readStringOption(options, "i"), readStringOption(options, "filename"));
|
|
132
134
|
logLine(`Created manuscript: ${createdPath}`);
|
|
133
135
|
return;
|
|
134
136
|
}
|
|
@@ -196,11 +198,23 @@ async function main() {
|
|
|
196
198
|
logLine(`Export output: ${outputPath}`);
|
|
197
199
|
return;
|
|
198
200
|
}
|
|
201
|
+
case "comments":
|
|
202
|
+
await runCommentsCommand(options, process.cwd());
|
|
203
|
+
return;
|
|
199
204
|
default:
|
|
200
205
|
throw new Error(`Unknown command '${command}'. Run with 'help' for usage.`);
|
|
201
206
|
}
|
|
202
207
|
}
|
|
203
208
|
catch (error) {
|
|
209
|
+
if (error instanceof CommentsCommandError) {
|
|
210
|
+
if (error.outputFormat === "json") {
|
|
211
|
+
console.error(JSON.stringify(error.toJson(), null, 2));
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
console.error(`ERROR: ${error.message}`);
|
|
215
|
+
}
|
|
216
|
+
process.exit(error.exitCode);
|
|
217
|
+
}
|
|
204
218
|
if (error instanceof Error) {
|
|
205
219
|
console.error(`ERROR: ${error.message}`);
|
|
206
220
|
}
|
|
@@ -372,7 +386,7 @@ function resolveCompileStructure(project) {
|
|
|
372
386
|
issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].titleKey must match /^[a-z][a-z0-9_-]*$/.`, projectFile));
|
|
373
387
|
continue;
|
|
374
388
|
}
|
|
375
|
-
const pageBreakRaw = typeof entry.pageBreak === "string" ? entry.pageBreak.trim() : "
|
|
389
|
+
const pageBreakRaw = typeof entry.pageBreak === "string" ? entry.pageBreak.trim() : "between-groups";
|
|
376
390
|
if (pageBreakRaw !== "none" && pageBreakRaw !== "between-groups") {
|
|
377
391
|
issues.push(makeIssue("error", "metadata", `compileStructure.levels[${index}].pageBreak must be 'none' or 'between-groups'.`, projectFile));
|
|
378
392
|
continue;
|
|
@@ -416,7 +430,7 @@ function parseArgs(argv) {
|
|
|
416
430
|
if (token.startsWith("--")) {
|
|
417
431
|
const key = token.slice(2);
|
|
418
432
|
const next = rest[i + 1];
|
|
419
|
-
if (!next || next
|
|
433
|
+
if (!next || !canBeOptionValue(next)) {
|
|
420
434
|
options[key] = true;
|
|
421
435
|
continue;
|
|
422
436
|
}
|
|
@@ -427,7 +441,7 @@ function parseArgs(argv) {
|
|
|
427
441
|
if (token.startsWith("-") && token.length > 1) {
|
|
428
442
|
const key = token.slice(1);
|
|
429
443
|
const next = rest[i + 1];
|
|
430
|
-
if (!next || next
|
|
444
|
+
if (!next || !canBeOptionValue(next)) {
|
|
431
445
|
options[key] = true;
|
|
432
446
|
continue;
|
|
433
447
|
}
|
|
@@ -442,6 +456,12 @@ function parseArgs(argv) {
|
|
|
442
456
|
}
|
|
443
457
|
return { command, options };
|
|
444
458
|
}
|
|
459
|
+
function canBeOptionValue(token) {
|
|
460
|
+
if (token === "-") {
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
return !token.startsWith("-");
|
|
464
|
+
}
|
|
445
465
|
function resolveWorkspaceContext(rootOption) {
|
|
446
466
|
if (rootOption) {
|
|
447
467
|
const explicitRoot = path.resolve(process.cwd(), rootOption);
|
|
@@ -747,7 +767,7 @@ function writeInitRootPackageJson(targetRoot) {
|
|
|
747
767
|
fs.writeFileSync(path.join(targetRoot, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
748
768
|
}
|
|
749
769
|
function printUsage() {
|
|
750
|
-
console.log(`Stego CLI\n\nCommands:\n init [--force]\n list-projects [--root <path>]\n new-project --project <project-id> [--title <title>] [--root <path>]\n new --project <project-id> [--i <prefix>|-i <prefix>] [--root <path>]\n validate --project <project-id> [--file <project-relative-manuscript-path>] [--root <path>]\n build --project <project-id> [--root <path>]\n check-stage --project <project-id> --stage <draft|revise|line-edit|proof|final> [--file <project-relative-manuscript-path>] [--root <path>]\n lint --project <project-id> [--manuscript|--spine] [--root <path>]\n export --project <project-id> --format <md|docx|pdf|epub> [--output <path>] [--root <path>]\n`);
|
|
770
|
+
console.log(`Stego CLI\n\nCommands:\n init [--force]\n list-projects [--root <path>]\n new-project --project <project-id> [--title <title>] [--root <path>]\n new --project <project-id> [--i <prefix>|-i <prefix>] [--root <path>]\n validate --project <project-id> [--file <project-relative-manuscript-path>] [--root <path>]\n build --project <project-id> [--root <path>]\n check-stage --project <project-id> --stage <draft|revise|line-edit|proof|final> [--file <project-relative-manuscript-path>] [--root <path>]\n lint --project <project-id> [--manuscript|--spine] [--root <path>]\n export --project <project-id> --format <md|docx|pdf|epub> [--output <path>] [--root <path>]\n comments add <manuscript> [--message <text> | --input <path|->] [--author <name>] [--start-line <n> --start-col <n> --end-line <n> --end-col <n>] [--format <text|json>]\n`);
|
|
751
771
|
}
|
|
752
772
|
function listProjects() {
|
|
753
773
|
const ids = getProjectIds();
|
|
@@ -791,7 +811,7 @@ async function createProject(projectIdOption, titleOption) {
|
|
|
791
811
|
titleKey: "chapter_title",
|
|
792
812
|
injectHeading: true,
|
|
793
813
|
headingTemplate: "{label} {value}: {title}",
|
|
794
|
-
pageBreak: "
|
|
814
|
+
pageBreak: "between-groups"
|
|
795
815
|
}
|
|
796
816
|
]
|
|
797
817
|
},
|
|
@@ -849,7 +869,7 @@ Start writing here.
|
|
|
849
869
|
logLine(`- ${path.relative(repoRoot, projectSettingsPath)}`);
|
|
850
870
|
}
|
|
851
871
|
}
|
|
852
|
-
function createNewManuscript(project, requestedPrefixRaw) {
|
|
872
|
+
function createNewManuscript(project, requestedPrefixRaw, requestedFilenameRaw) {
|
|
853
873
|
fs.mkdirSync(project.manuscriptDir, { recursive: true });
|
|
854
874
|
const requiredMetadataState = resolveRequiredMetadata(project, config);
|
|
855
875
|
const requiredMetadataErrors = requiredMetadataState.issues
|
|
@@ -860,13 +880,33 @@ function createNewManuscript(project, requestedPrefixRaw) {
|
|
|
860
880
|
}
|
|
861
881
|
const existingEntries = listManuscriptOrderEntries(project.manuscriptDir);
|
|
862
882
|
const explicitPrefix = parseManuscriptPrefix(requestedPrefixRaw);
|
|
863
|
-
const
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
883
|
+
const requestedFilename = parseRequestedManuscriptFilename(requestedFilenameRaw);
|
|
884
|
+
if (requestedFilename && explicitPrefix != null) {
|
|
885
|
+
throw new Error("Options --filename and --i/-i cannot be used together.");
|
|
886
|
+
}
|
|
887
|
+
let filename;
|
|
888
|
+
if (requestedFilename) {
|
|
889
|
+
const requestedOrder = parseOrderFromManuscriptFilename(requestedFilename);
|
|
890
|
+
if (requestedOrder != null) {
|
|
891
|
+
const collision = existingEntries.find((entry) => entry.order === requestedOrder);
|
|
892
|
+
if (collision) {
|
|
893
|
+
throw new Error(`Manuscript prefix '${requestedOrder}' is already used by '${collision.filename}'. Choose a different filename prefix.`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
filename = requestedFilename;
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
const nextPrefix = explicitPrefix ?? inferNextManuscriptPrefix(existingEntries);
|
|
900
|
+
const collision = existingEntries.find((entry) => entry.order === nextPrefix);
|
|
901
|
+
if (collision) {
|
|
902
|
+
throw new Error(`Manuscript prefix '${nextPrefix}' is already used by '${collision.filename}'. Re-run with --i <number> to choose an unused prefix.`);
|
|
903
|
+
}
|
|
904
|
+
filename = `${nextPrefix}-${DEFAULT_NEW_MANUSCRIPT_SLUG}.md`;
|
|
867
905
|
}
|
|
868
|
-
const filename = `${nextPrefix}-${DEFAULT_NEW_MANUSCRIPT_SLUG}.md`;
|
|
869
906
|
const manuscriptPath = path.join(project.manuscriptDir, filename);
|
|
907
|
+
if (fs.existsSync(manuscriptPath)) {
|
|
908
|
+
throw new Error(`Manuscript already exists: ${filename}`);
|
|
909
|
+
}
|
|
870
910
|
const content = renderNewManuscriptTemplate(requiredMetadataState.requiredMetadata);
|
|
871
911
|
fs.writeFileSync(manuscriptPath, content, "utf8");
|
|
872
912
|
return path.relative(repoRoot, manuscriptPath);
|
|
@@ -913,6 +953,33 @@ function parseManuscriptPrefix(raw) {
|
|
|
913
953
|
}
|
|
914
954
|
return parsed;
|
|
915
955
|
}
|
|
956
|
+
function parseRequestedManuscriptFilename(raw) {
|
|
957
|
+
if (raw == null) {
|
|
958
|
+
return undefined;
|
|
959
|
+
}
|
|
960
|
+
const normalized = raw.trim();
|
|
961
|
+
if (!normalized) {
|
|
962
|
+
throw new Error("Option --filename requires a value.");
|
|
963
|
+
}
|
|
964
|
+
if (/[\\/]/.test(normalized)) {
|
|
965
|
+
throw new Error(`Invalid filename '${raw}'. Use a filename only (no directory separators).`);
|
|
966
|
+
}
|
|
967
|
+
const withExtension = normalized.toLowerCase().endsWith(".md")
|
|
968
|
+
? normalized
|
|
969
|
+
: `${normalized}.md`;
|
|
970
|
+
const stem = withExtension.slice(0, -3).trim();
|
|
971
|
+
if (!stem) {
|
|
972
|
+
throw new Error(`Invalid filename '${raw}'.`);
|
|
973
|
+
}
|
|
974
|
+
return withExtension;
|
|
975
|
+
}
|
|
976
|
+
function parseOrderFromManuscriptFilename(filename) {
|
|
977
|
+
const match = filename.match(/^(\d+)[-_]/);
|
|
978
|
+
if (!match) {
|
|
979
|
+
return undefined;
|
|
980
|
+
}
|
|
981
|
+
return Number(match[1]);
|
|
982
|
+
}
|
|
916
983
|
function inferNextManuscriptPrefix(entries) {
|
|
917
984
|
if (entries.length === 0) {
|
|
918
985
|
return 100;
|
|
@@ -936,7 +1003,7 @@ function renderNewManuscriptTemplate(requiredMetadata) {
|
|
|
936
1003
|
seenKeys.add(normalized);
|
|
937
1004
|
lines.push(`${normalized}:`);
|
|
938
1005
|
}
|
|
939
|
-
lines.push("---", ""
|
|
1006
|
+
lines.push("---", "");
|
|
940
1007
|
return `${lines.join("\n")}\n`;
|
|
941
1008
|
}
|
|
942
1009
|
function getProjectIds() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stego-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Installable CLI for the Stego writing monorepo workflow.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"check-stage": "node --experimental-strip-types tools/stego-cli.ts check-stage",
|
|
51
51
|
"export": "node --experimental-strip-types tools/stego-cli.ts export",
|
|
52
52
|
"test:compile-structure": "node --test tools/test/compile-structure.test.mjs",
|
|
53
|
-
"test": "
|
|
53
|
+
"test:comments": "node --test tools/test/comments-add.test.mjs",
|
|
54
|
+
"test": "npm run test:compile-structure && npm run test:comments"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
56
57
|
"@changesets/cli": "^2.29.8",
|
|
@@ -21,7 +21,7 @@ Matthaeus and Etienne recorded onset dates, households, trades, parish boundarie
|
|
|
21
21
|
|
|
22
22
|
Her map was simple. Neighborhoods tied to the grain quay sickened first. Adjacent streets followed within days. The rest of the city came after. The pattern was consistent across three months of data.
|
|
23
23
|
|
|
24
|
-
At the quay she showed them torn sackcloth from a riverside storehouse and asked what his conjunction tables would have predicted for this street, this month. Matthaeus answered in the terms his system provided: commerce might distribute
|
|
24
|
+
At the quay she showed them torn sackcloth from a riverside storehouse and asked what his conjunction tables would have predicted for this street, this month. Matthaeus answered in the terms his system provided: commerce might distribute corrupted matter, but the disposition of bodies to receive corruption remained celestial. Agnes said that might be true and still leave the wrong people dead first.
|
|
25
25
|
|
|
26
26
|
He wrote her observation in the second ledger. He expected to reconcile it later. The framework had room. It had been designed to have room.
|
|
27
27
|
|
|
@@ -17,7 +17,7 @@ sources:
|
|
|
17
17
|
|
|
18
18
|
The faculty hearing began as a technical disputation and ended as an inquiry into method. Three masters objected to Matthaeus from different directions: one said he conceded too much to natural causes, another said he conceded too much to celestial judgment, a third said he imported suspect languages from Arabic traditions into Christian medicine. The objections were mutually exclusive, which should have cancelled them out but instead compounded them, because the real charge was not error but imprudence.
|
|
19
19
|
|
|
20
|
-
Raoul asked to see all sources.
|
|
20
|
+
Raoul asked to see all sources. Matthaeus presented the conjunction tables and the Galenic commentaries openly. He withheld the quire until asked a second time. When he produced it, the room changed temperature.
|
|
21
21
|
|
|
22
22
|
No one accused him of sorcery. They accused him of carelessness: in a season of mass death, even method could scandalize if its lineage looked foreign or secretive or insufficiently authorized. The problem was not what the quire said. The problem was that Matthaeus had kept it at all — that he had judged for himself which sources were useful and which were not, without waiting to be told.
|
|
23
23
|
|
|
@@ -26,9 +26,9 @@ label: stego new-project --project <project-id> [--title <title>] [--root <path>
|
|
|
26
26
|
- Related concepts: CON-PROJECT, CON-MANUSCRIPT, CON-NOTES, CON-DIST.
|
|
27
27
|
|
|
28
28
|
## CMD-NEW
|
|
29
|
-
label: stego new --project <project-id> [--i <prefix>|-i <prefix>] [--root <path>]
|
|
29
|
+
label: stego new --project <project-id> [--i <prefix>|-i <prefix>] [--filename <name>] [--root <path>]
|
|
30
30
|
|
|
31
|
-
- `stego new --project <project-id> [--i <prefix>|-i <prefix>] [--root <path>]`
|
|
31
|
+
- `stego new --project <project-id> [--i <prefix>|-i <prefix>] [--filename <name>] [--root <path>]`
|
|
32
32
|
- Create a new manuscript file with an inferred numeric prefix and draft frontmatter.
|
|
33
33
|
- Related workflows: FLOW-DAILY-WRITING.
|
|
34
34
|
- Related concepts: CON-MANUSCRIPT, CON-METADATA.
|