stego-cli 0.3.4 → 0.4.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/README.md +10 -0
- package/dist/metadata/metadata-command.js +127 -0
- package/dist/metadata/metadata-domain.js +209 -0
- package/dist/spine/spine-command.js +129 -0
- package/dist/spine/spine-domain.js +274 -0
- package/dist/stego-cli.js +187 -173
- package/package.json +3 -2
- package/projects/fiction-example/spine/characters/CHAR-AGNES.md +17 -0
- package/projects/fiction-example/spine/characters/CHAR-ETIENNE.md +17 -0
- package/projects/fiction-example/spine/characters/CHAR-MATTHAEUS.md +17 -0
- package/projects/fiction-example/spine/characters/CHAR-RAOUL.md +17 -0
- package/projects/fiction-example/spine/characters/_category.md +6 -0
- package/projects/fiction-example/spine/locations/LOC-CHARNEL.md +17 -0
- package/projects/fiction-example/spine/locations/LOC-COLLEGE.md +17 -0
- package/projects/fiction-example/spine/locations/LOC-HOTELDIEU.md +17 -0
- package/projects/fiction-example/spine/locations/LOC-QUAY.md +17 -0
- package/projects/fiction-example/spine/locations/_category.md +6 -0
- package/projects/fiction-example/spine/sources/SRC-CONJUNCTION.md +20 -0
- package/projects/fiction-example/spine/sources/SRC-GALEN.md +20 -0
- package/projects/fiction-example/spine/sources/SRC-WARD-DATA.md +20 -0
- package/projects/fiction-example/spine/sources/_category.md +6 -0
- package/projects/fiction-example/stego-project.json +1 -18
- package/projects/stego-docs/manuscript/500-project-configuration.md +3 -3
- package/projects/stego-docs/spine/commands/CMD-BUILD.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-CHECK-STAGE.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-EXPORT.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-INIT.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-LIST-PROJECTS.md +10 -0
- package/projects/stego-docs/spine/commands/CMD-NEW-PROJECT.md +10 -0
- package/projects/stego-docs/spine/commands/CMD-NEW.md +11 -0
- package/projects/stego-docs/spine/commands/CMD-VALIDATE.md +11 -0
- package/projects/stego-docs/spine/commands/_category.md +6 -0
- package/projects/stego-docs/spine/concepts/CON-COMPILE-STRUCTURE.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-DIST.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-MANUSCRIPT.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-METADATA.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-NOTES.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-PROJECT.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-SPINE-CATEGORY.md +11 -0
- package/projects/stego-docs/spine/concepts/CON-SPINE.md +9 -0
- package/projects/stego-docs/spine/concepts/CON-STAGE-GATE.md +10 -0
- package/projects/stego-docs/spine/concepts/CON-WORKSPACE.md +9 -0
- package/projects/stego-docs/spine/concepts/_category.md +6 -0
- package/projects/stego-docs/spine/configuration/CFG-ALLOWED-STATUSES.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-COMPILE-LEVELS.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-COMPILE-STRUCTURE.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-REQUIRED-METADATA.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-SPINE-CATEGORIES.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-STAGE-POLICIES.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-STEGO-CONFIG.md +9 -0
- package/projects/stego-docs/spine/configuration/CFG-STEGO-PROJECT.md +9 -0
- package/projects/stego-docs/spine/configuration/_category.md +6 -0
- package/projects/stego-docs/spine/integrations/INT-CSPELL.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-MARKDOWNLINT.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-PANDOC.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-SAURUS-EXTENSION.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-STEGO-EXTENSION.md +9 -0
- package/projects/stego-docs/spine/integrations/INT-VSCODE.md +9 -0
- package/projects/stego-docs/spine/integrations/_category.md +6 -0
- package/projects/stego-docs/spine/workflows/FLOW-BUILD-EXPORT.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-DAILY-WRITING.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-INIT-WORKSPACE.md +9 -0
- package/projects/stego-docs/spine/workflows/FLOW-NEW-PROJECT.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-PROOF-RELEASE.md +10 -0
- package/projects/stego-docs/spine/workflows/FLOW-STAGE-PROMOTION.md +10 -0
- package/projects/stego-docs/spine/workflows/_category.md +6 -0
- package/projects/stego-docs/stego-project.json +1 -28
- package/projects/fiction-example/spine/characters.md +0 -35
- package/projects/fiction-example/spine/locations.md +0 -37
- package/projects/fiction-example/spine/sources.md +0 -31
- package/projects/stego-docs/spine/commands.md +0 -71
- package/projects/stego-docs/spine/concepts.md +0 -72
- package/projects/stego-docs/spine/configuration.md +0 -57
- package/projects/stego-docs/spine/integrations.md +0 -43
- package/projects/stego-docs/spine/workflows.md +0 -48
package/README.md
CHANGED
|
@@ -54,10 +54,20 @@ stego validate --project fiction-example
|
|
|
54
54
|
stego build --project fiction-example
|
|
55
55
|
stego check-stage --project fiction-example --stage revise
|
|
56
56
|
stego export --project fiction-example --format md
|
|
57
|
+
stego spine read --project fiction-example
|
|
58
|
+
stego spine new-category --project fiction-example --key characters
|
|
59
|
+
stego spine new --project fiction-example --category characters --filename supporting/abigail
|
|
60
|
+
stego metadata read projects/fiction-example/manuscript/100-the-commission.md --format json
|
|
57
61
|
```
|
|
58
62
|
|
|
59
63
|
`stego new` also supports `--i <prefix>` for numeric prefix override and `--filename <name>` for an explicit manuscript filename.
|
|
60
64
|
|
|
65
|
+
Spine V2 is directory-inferred:
|
|
66
|
+
|
|
67
|
+
- categories are directories in `spine/<category>/`
|
|
68
|
+
- category metadata lives at `spine/<category>/_category.md`
|
|
69
|
+
- entries are markdown files in each category directory tree
|
|
70
|
+
|
|
61
71
|
Projects also include local npm scripts so you can work from inside a project directory.
|
|
62
72
|
|
|
63
73
|
## Advanced integration command
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { normalizeFrontmatterRecord, parseMarkdownDocument, serializeMarkdownDocument } from "./metadata-domain.js";
|
|
5
|
+
export async function runMetadataCommand(options, cwd) {
|
|
6
|
+
const [subcommand, markdownArg] = options._;
|
|
7
|
+
if (!subcommand) {
|
|
8
|
+
throw new Error("Metadata subcommand is required. Use: read, apply.");
|
|
9
|
+
}
|
|
10
|
+
if (!markdownArg) {
|
|
11
|
+
throw new Error("Markdown path is required. Use: stego metadata <subcommand> <path>.");
|
|
12
|
+
}
|
|
13
|
+
const outputFormat = parseOutputFormat(readString(options, "format"));
|
|
14
|
+
const absolutePath = path.resolve(cwd, markdownArg);
|
|
15
|
+
const raw = readFile(absolutePath, markdownArg);
|
|
16
|
+
switch (subcommand) {
|
|
17
|
+
case "read": {
|
|
18
|
+
const parsed = parseMarkdownDocument(raw);
|
|
19
|
+
emit({
|
|
20
|
+
ok: true,
|
|
21
|
+
operation: "read",
|
|
22
|
+
state: {
|
|
23
|
+
path: absolutePath,
|
|
24
|
+
hasFrontmatter: parsed.hasFrontmatter,
|
|
25
|
+
lineEnding: parsed.lineEnding,
|
|
26
|
+
frontmatter: parsed.frontmatter,
|
|
27
|
+
body: parsed.body
|
|
28
|
+
}
|
|
29
|
+
}, outputFormat);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
case "apply": {
|
|
33
|
+
const inputPath = requireInputPath(options);
|
|
34
|
+
const payload = readInputPayload(inputPath, cwd);
|
|
35
|
+
const frontmatter = normalizeFrontmatterRecord(payload.frontmatter);
|
|
36
|
+
const body = typeof payload.body === "string" ? payload.body : undefined;
|
|
37
|
+
const hasFrontmatter = typeof payload.hasFrontmatter === "boolean" ? payload.hasFrontmatter : undefined;
|
|
38
|
+
const existing = parseMarkdownDocument(raw);
|
|
39
|
+
const next = {
|
|
40
|
+
lineEnding: existing.lineEnding,
|
|
41
|
+
hasFrontmatter: hasFrontmatter ?? (existing.hasFrontmatter || Object.keys(frontmatter).length > 0),
|
|
42
|
+
frontmatter,
|
|
43
|
+
body: body ?? existing.body
|
|
44
|
+
};
|
|
45
|
+
const nextText = serializeMarkdownDocument(next);
|
|
46
|
+
const changed = nextText !== raw;
|
|
47
|
+
if (changed) {
|
|
48
|
+
fs.writeFileSync(absolutePath, nextText, "utf8");
|
|
49
|
+
}
|
|
50
|
+
const reparsed = parseMarkdownDocument(nextText);
|
|
51
|
+
emit({
|
|
52
|
+
ok: true,
|
|
53
|
+
operation: "apply",
|
|
54
|
+
changed,
|
|
55
|
+
state: {
|
|
56
|
+
path: absolutePath,
|
|
57
|
+
hasFrontmatter: reparsed.hasFrontmatter,
|
|
58
|
+
lineEnding: reparsed.lineEnding,
|
|
59
|
+
frontmatter: reparsed.frontmatter,
|
|
60
|
+
body: reparsed.body
|
|
61
|
+
}
|
|
62
|
+
}, outputFormat);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
default:
|
|
66
|
+
throw new Error(`Unknown metadata subcommand '${subcommand}'. Use: read, apply.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function emit(payload, format) {
|
|
70
|
+
if (format === "json") {
|
|
71
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (payload.operation === "read") {
|
|
75
|
+
process.stdout.write(`Read metadata for ${payload.state.path} (${Object.keys(payload.state.frontmatter).length} keys).\n`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
process.stdout.write(`${payload.changed ? "Updated" : "No changes for"} metadata in ${payload.state.path}.\n`);
|
|
79
|
+
}
|
|
80
|
+
function parseOutputFormat(raw) {
|
|
81
|
+
if (!raw || raw === "text") {
|
|
82
|
+
return "text";
|
|
83
|
+
}
|
|
84
|
+
if (raw === "json") {
|
|
85
|
+
return "json";
|
|
86
|
+
}
|
|
87
|
+
throw new Error("Invalid --format value. Use 'text' or 'json'.");
|
|
88
|
+
}
|
|
89
|
+
function readString(options, key) {
|
|
90
|
+
const value = options[key];
|
|
91
|
+
return typeof value === "string" ? value : undefined;
|
|
92
|
+
}
|
|
93
|
+
function requireInputPath(options) {
|
|
94
|
+
const inputPath = readString(options, "input");
|
|
95
|
+
if (!inputPath) {
|
|
96
|
+
throw new Error("--input <path|-> is required for 'metadata apply'.");
|
|
97
|
+
}
|
|
98
|
+
return inputPath;
|
|
99
|
+
}
|
|
100
|
+
function readInputPayload(inputPath, cwd) {
|
|
101
|
+
const raw = inputPath === "-"
|
|
102
|
+
? fs.readFileSync(process.stdin.fd, "utf8")
|
|
103
|
+
: fs.readFileSync(path.resolve(cwd, inputPath), "utf8");
|
|
104
|
+
let parsed;
|
|
105
|
+
try {
|
|
106
|
+
parsed = JSON.parse(raw);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new Error("Input payload is not valid JSON.");
|
|
110
|
+
}
|
|
111
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
112
|
+
throw new Error("Input payload must be a JSON object.");
|
|
113
|
+
}
|
|
114
|
+
return parsed;
|
|
115
|
+
}
|
|
116
|
+
function readFile(filePath, originalArg) {
|
|
117
|
+
try {
|
|
118
|
+
const stat = fs.statSync(filePath);
|
|
119
|
+
if (!stat.isFile()) {
|
|
120
|
+
throw new Error();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new Error(`Markdown file not found: ${originalArg}`);
|
|
125
|
+
}
|
|
126
|
+
return fs.readFileSync(filePath, "utf8");
|
|
127
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export function parseMarkdownDocument(raw) {
|
|
3
|
+
const lineEnding = raw.includes("\r\n") ? "\r\n" : "\n";
|
|
4
|
+
if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) {
|
|
5
|
+
return {
|
|
6
|
+
lineEnding,
|
|
7
|
+
hasFrontmatter: false,
|
|
8
|
+
frontmatter: {},
|
|
9
|
+
body: raw
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
13
|
+
if (!match) {
|
|
14
|
+
throw new Error("Metadata opening delimiter found, but closing delimiter is missing.");
|
|
15
|
+
}
|
|
16
|
+
const frontmatterText = match[1];
|
|
17
|
+
const body = raw.slice(match[0].length);
|
|
18
|
+
const frontmatter = {};
|
|
19
|
+
const lines = frontmatterText.split(/\r?\n/);
|
|
20
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
21
|
+
const line = lines[i].trim();
|
|
22
|
+
if (!line || line.startsWith("#")) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const separatorIndex = line.indexOf(":");
|
|
26
|
+
if (separatorIndex < 0) {
|
|
27
|
+
throw new Error(`Invalid metadata line '${line}'. Expected 'key: value'.`);
|
|
28
|
+
}
|
|
29
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
30
|
+
const value = line.slice(separatorIndex + 1).trim();
|
|
31
|
+
if (!key) {
|
|
32
|
+
throw new Error(`Invalid metadata line '${line}'. Missing key.`);
|
|
33
|
+
}
|
|
34
|
+
if (!value) {
|
|
35
|
+
let lookahead = i + 1;
|
|
36
|
+
while (lookahead < lines.length) {
|
|
37
|
+
const nextTrimmed = lines[lookahead].trim();
|
|
38
|
+
if (!nextTrimmed || nextTrimmed.startsWith("#")) {
|
|
39
|
+
lookahead += 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
if (lookahead < lines.length) {
|
|
45
|
+
const firstItemLine = lines[lookahead];
|
|
46
|
+
const firstItemTrimmed = firstItemLine.trim();
|
|
47
|
+
const firstItemIndent = firstItemLine.length - firstItemLine.trimStart().length;
|
|
48
|
+
if (firstItemIndent > 0 && firstItemTrimmed.startsWith("- ")) {
|
|
49
|
+
const items = [];
|
|
50
|
+
let j = lookahead;
|
|
51
|
+
while (j < lines.length) {
|
|
52
|
+
const rawCandidate = lines[j];
|
|
53
|
+
const trimmed = rawCandidate.trim();
|
|
54
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
55
|
+
j += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const indent = rawCandidate.length - rawCandidate.trimStart().length;
|
|
59
|
+
if (indent === 0) {
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
if (!trimmed.startsWith("- ")) {
|
|
63
|
+
throw new Error(`Unsupported metadata list line '${trimmed}'. Expected '- value'.`);
|
|
64
|
+
}
|
|
65
|
+
items.push(coerceScalarValue(trimmed.slice(2).trim()));
|
|
66
|
+
j += 1;
|
|
67
|
+
}
|
|
68
|
+
frontmatter[key] = items;
|
|
69
|
+
i = j - 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
frontmatter[key] = coerceScalarValue(value);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
lineEnding,
|
|
78
|
+
hasFrontmatter: true,
|
|
79
|
+
frontmatter,
|
|
80
|
+
body
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function serializeMarkdownDocument(parsed) {
|
|
84
|
+
const lineEnding = parsed.lineEnding || "\n";
|
|
85
|
+
const includeFrontmatter = parsed.hasFrontmatter || Object.keys(parsed.frontmatter).length > 0;
|
|
86
|
+
const normalizedBody = normalizeBodyLineEndings(parsed.body || "", lineEnding);
|
|
87
|
+
if (!includeFrontmatter) {
|
|
88
|
+
return normalizedBody;
|
|
89
|
+
}
|
|
90
|
+
const ordered = orderFrontmatterStatusFirst(parsed.frontmatter);
|
|
91
|
+
const yamlLines = [];
|
|
92
|
+
for (const key of Object.keys(ordered)) {
|
|
93
|
+
const value = ordered[key];
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
yamlLines.push(`${key}:`);
|
|
96
|
+
for (const item of value) {
|
|
97
|
+
yamlLines.push(` - ${formatScalar(item)}`);
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
yamlLines.push(`${key}: ${formatScalar(value)}`);
|
|
102
|
+
}
|
|
103
|
+
const frontmatterBlock = yamlLines.length > 0
|
|
104
|
+
? `---${lineEnding}${yamlLines.join(lineEnding)}${lineEnding}---`
|
|
105
|
+
: `---${lineEnding}---`;
|
|
106
|
+
if (!normalizedBody.trim()) {
|
|
107
|
+
return `${frontmatterBlock}${lineEnding}`;
|
|
108
|
+
}
|
|
109
|
+
const trimmedBody = normalizedBody.replace(/^(\r?\n)+/, "");
|
|
110
|
+
return `${frontmatterBlock}${lineEnding}${lineEnding}${trimmedBody}`;
|
|
111
|
+
}
|
|
112
|
+
export function normalizeFrontmatterRecord(raw) {
|
|
113
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
114
|
+
throw new Error("Input payload 'frontmatter' must be a JSON object.");
|
|
115
|
+
}
|
|
116
|
+
const result = {};
|
|
117
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
118
|
+
const normalizedKey = key.trim();
|
|
119
|
+
if (!normalizedKey) {
|
|
120
|
+
throw new Error("Frontmatter keys cannot be empty.");
|
|
121
|
+
}
|
|
122
|
+
result[normalizedKey] = normalizeFrontmatterValue(value, normalizedKey);
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
export function deriveDefaultLabelFromFilename(filePath) {
|
|
127
|
+
const basename = path.basename(filePath, path.extname(filePath));
|
|
128
|
+
const normalized = basename
|
|
129
|
+
.replace(/[_-]+/g, " ")
|
|
130
|
+
.trim();
|
|
131
|
+
if (!normalized) {
|
|
132
|
+
return "New Entry";
|
|
133
|
+
}
|
|
134
|
+
return normalized.replace(/\b\w/g, (value) => value.toUpperCase());
|
|
135
|
+
}
|
|
136
|
+
function orderFrontmatterStatusFirst(frontmatter) {
|
|
137
|
+
if (!Object.hasOwn(frontmatter, "status")) {
|
|
138
|
+
return { ...frontmatter };
|
|
139
|
+
}
|
|
140
|
+
const ordered = {
|
|
141
|
+
status: frontmatter.status
|
|
142
|
+
};
|
|
143
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
144
|
+
if (key === "status") {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
ordered[key] = value;
|
|
148
|
+
}
|
|
149
|
+
return ordered;
|
|
150
|
+
}
|
|
151
|
+
function normalizeBodyLineEndings(body, lineEnding) {
|
|
152
|
+
return body.replace(/\r?\n/g, lineEnding);
|
|
153
|
+
}
|
|
154
|
+
function normalizeFrontmatterValue(value, key) {
|
|
155
|
+
if (Array.isArray(value)) {
|
|
156
|
+
return value.map((item) => normalizeFrontmatterScalar(item, key));
|
|
157
|
+
}
|
|
158
|
+
return normalizeFrontmatterScalar(value, key);
|
|
159
|
+
}
|
|
160
|
+
function normalizeFrontmatterScalar(value, key) {
|
|
161
|
+
if (value === null) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
throw new Error(`Metadata key '${key}' must be a scalar or array of scalars.`);
|
|
168
|
+
}
|
|
169
|
+
function coerceScalarValue(rawValue) {
|
|
170
|
+
const value = rawValue.trim();
|
|
171
|
+
if (!value) {
|
|
172
|
+
return "";
|
|
173
|
+
}
|
|
174
|
+
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
175
|
+
return value.slice(1, -1);
|
|
176
|
+
}
|
|
177
|
+
if (value === "null") {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
if (value === "true") {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
if (value === "false") {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
if (/^-?\d+$/.test(value)) {
|
|
187
|
+
return Number(value);
|
|
188
|
+
}
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
function formatScalar(value) {
|
|
192
|
+
if (value === null) {
|
|
193
|
+
return "null";
|
|
194
|
+
}
|
|
195
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
196
|
+
return String(value);
|
|
197
|
+
}
|
|
198
|
+
const normalized = value.trim();
|
|
199
|
+
if (!normalized) {
|
|
200
|
+
return "\"\"";
|
|
201
|
+
}
|
|
202
|
+
if (/^-?\d+$/.test(normalized) || normalized === "true" || normalized === "false" || normalized === "null") {
|
|
203
|
+
return JSON.stringify(normalized);
|
|
204
|
+
}
|
|
205
|
+
if (/^[A-Za-z0-9._/@:+-]+$/.test(normalized)) {
|
|
206
|
+
return normalized;
|
|
207
|
+
}
|
|
208
|
+
return JSON.stringify(normalized);
|
|
209
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { createSpineCategory, createSpineEntry, readSpineCatalog } from "./spine-domain.js";
|
|
5
|
+
export function runSpineCommand(options, project) {
|
|
6
|
+
const [subcommand] = options._;
|
|
7
|
+
if (!subcommand) {
|
|
8
|
+
throw new Error("Spine subcommand is required. Use: read, new-category, new.");
|
|
9
|
+
}
|
|
10
|
+
if (subcommand === "add-category") {
|
|
11
|
+
throw new Error("`stego spine add-category` is deprecated. Use `stego spine new-category`.");
|
|
12
|
+
}
|
|
13
|
+
if (subcommand === "new-entry") {
|
|
14
|
+
throw new Error("`stego spine new-entry` is deprecated. Use `stego spine new`.");
|
|
15
|
+
}
|
|
16
|
+
const outputFormat = parseOutputFormat(readString(options, "format"));
|
|
17
|
+
switch (subcommand) {
|
|
18
|
+
case "read": {
|
|
19
|
+
const catalog = readSpineCatalog(project.root, project.spineDir);
|
|
20
|
+
emit({
|
|
21
|
+
ok: true,
|
|
22
|
+
operation: "read",
|
|
23
|
+
state: {
|
|
24
|
+
projectId: project.id,
|
|
25
|
+
categories: catalog.categories,
|
|
26
|
+
issues: catalog.issues
|
|
27
|
+
}
|
|
28
|
+
}, outputFormat);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
case "new-category": {
|
|
32
|
+
const key = readString(options, "key");
|
|
33
|
+
if (!key) {
|
|
34
|
+
throw new Error("--key is required for `stego spine new-category`.");
|
|
35
|
+
}
|
|
36
|
+
const label = readString(options, "label");
|
|
37
|
+
const requireMetadata = options["require-metadata"] === true;
|
|
38
|
+
const requiredMetadata = readRequiredMetadata(project.meta);
|
|
39
|
+
const result = createSpineCategory(project.root, project.spineDir, key, label, requiredMetadata, requireMetadata);
|
|
40
|
+
if (requireMetadata && result.requiredMetadataUpdated) {
|
|
41
|
+
writeRequiredMetadata(project.root, project.meta, requiredMetadata);
|
|
42
|
+
}
|
|
43
|
+
emit({
|
|
44
|
+
ok: true,
|
|
45
|
+
operation: "new-category",
|
|
46
|
+
result
|
|
47
|
+
}, outputFormat);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
case "new": {
|
|
51
|
+
const category = readString(options, "category");
|
|
52
|
+
if (!category) {
|
|
53
|
+
throw new Error("--category is required for `stego spine new`.");
|
|
54
|
+
}
|
|
55
|
+
if (options.entry !== undefined) {
|
|
56
|
+
throw new Error("Unknown option '--entry' for `stego spine new`. Use `--filename`.");
|
|
57
|
+
}
|
|
58
|
+
const filename = readString(options, "filename");
|
|
59
|
+
const result = createSpineEntry(project.root, project.spineDir, category, filename);
|
|
60
|
+
emit({
|
|
61
|
+
ok: true,
|
|
62
|
+
operation: "new",
|
|
63
|
+
result
|
|
64
|
+
}, outputFormat);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
default:
|
|
68
|
+
throw new Error(`Unknown spine subcommand '${subcommand}'. Use: read, new-category, new.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function emit(payload, outputFormat) {
|
|
72
|
+
if (outputFormat === "json") {
|
|
73
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
switch (payload.operation) {
|
|
77
|
+
case "read":
|
|
78
|
+
process.stdout.write(`Spine categories: ${payload.state.categories.length}. ` +
|
|
79
|
+
`Entries: ${payload.state.categories.reduce((sum, category) => sum + category.entries.length, 0)}.\n`);
|
|
80
|
+
return;
|
|
81
|
+
case "new-category":
|
|
82
|
+
process.stdout.write(`Created spine category '${payload.result.key}' (${payload.result.metadataPath}).\n`);
|
|
83
|
+
return;
|
|
84
|
+
case "new":
|
|
85
|
+
process.stdout.write(`Created spine entry: ${payload.result.filePath}\n`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function parseOutputFormat(raw) {
|
|
90
|
+
if (!raw || raw === "text") {
|
|
91
|
+
return "text";
|
|
92
|
+
}
|
|
93
|
+
if (raw === "json") {
|
|
94
|
+
return "json";
|
|
95
|
+
}
|
|
96
|
+
throw new Error("Invalid --format value. Use 'text' or 'json'.");
|
|
97
|
+
}
|
|
98
|
+
function readString(options, key) {
|
|
99
|
+
const value = options[key];
|
|
100
|
+
return typeof value === "string" ? value : undefined;
|
|
101
|
+
}
|
|
102
|
+
function readRequiredMetadata(projectMeta) {
|
|
103
|
+
const raw = projectMeta.requiredMetadata;
|
|
104
|
+
if (!Array.isArray(raw)) {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
const keys = [];
|
|
109
|
+
for (const entry of raw) {
|
|
110
|
+
if (typeof entry !== "string") {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const key = entry.trim();
|
|
114
|
+
if (!key || seen.has(key)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
seen.add(key);
|
|
118
|
+
keys.push(key);
|
|
119
|
+
}
|
|
120
|
+
return keys;
|
|
121
|
+
}
|
|
122
|
+
function writeRequiredMetadata(projectRoot, projectMeta, requiredMetadata) {
|
|
123
|
+
const projectJsonPath = path.join(projectRoot, "stego-project.json");
|
|
124
|
+
const next = {
|
|
125
|
+
...projectMeta,
|
|
126
|
+
requiredMetadata
|
|
127
|
+
};
|
|
128
|
+
fs.writeFileSync(projectJsonPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
129
|
+
}
|