@opum-ai/lore 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/LICENSE +21 -0
- package/README.md +306 -0
- package/bin/lore.cjs +109 -0
- package/package.json +67 -0
- package/src/adapters/backlog.ts +1084 -0
- package/src/adapters/git.ts +221 -0
- package/src/cli.ts +667 -0
- package/src/commands/agent.ts +301 -0
- package/src/commands/agents.ts +302 -0
- package/src/commands/args.ts +209 -0
- package/src/commands/changed.ts +70 -0
- package/src/commands/check.ts +1031 -0
- package/src/commands/codex-bridge.ts +49 -0
- package/src/commands/concurrency.ts +48 -0
- package/src/commands/context.ts +292 -0
- package/src/commands/discover.ts +89 -0
- package/src/commands/explorer.ts +253 -0
- package/src/commands/export.ts +93 -0
- package/src/commands/fswrite.ts +928 -0
- package/src/commands/graph.ts +291 -0
- package/src/commands/help.ts +151 -0
- package/src/commands/impact.ts +59 -0
- package/src/commands/init.ts +583 -0
- package/src/commands/instructions.ts +91 -0
- package/src/commands/link.ts +929 -0
- package/src/commands/new.ts +476 -0
- package/src/commands/orphans.ts +457 -0
- package/src/commands/path.ts +67 -0
- package/src/commands/provenance.ts +68 -0
- package/src/commands/query.ts +312 -0
- package/src/commands/reconcile-shared.ts +280 -0
- package/src/commands/rename.ts +585 -0
- package/src/commands/replace.ts +320 -0
- package/src/commands/scaffold.ts +346 -0
- package/src/commands/schema.ts +293 -0
- package/src/commands/snapshot.ts +130 -0
- package/src/commands/supersede.ts +400 -0
- package/src/commands/sync.ts +371 -0
- package/src/commands/tasks.ts +271 -0
- package/src/commands/traversal.ts +151 -0
- package/src/commands/validate.ts +226 -0
- package/src/config.ts +598 -0
- package/src/core/agent-bridge.ts +287 -0
- package/src/core/agent-context.ts +498 -0
- package/src/core/agent-profile.ts +447 -0
- package/src/core/bundle.ts +893 -0
- package/src/core/check.ts +853 -0
- package/src/core/codex-bridge.ts +100 -0
- package/src/core/concept.ts +597 -0
- package/src/core/consumer-scaffold.ts +433 -0
- package/src/core/context.ts +271 -0
- package/src/core/explorer-contract.ts +441 -0
- package/src/core/explorer-qualification.ts +58 -0
- package/src/core/explorer.ts +518 -0
- package/src/core/finding.ts +31 -0
- package/src/core/graph.ts +201 -0
- package/src/core/indexes.ts +436 -0
- package/src/core/instructions.ts +209 -0
- package/src/core/ladybug-driver.ts +1795 -0
- package/src/core/ladybug-lifecycle.ts +1178 -0
- package/src/core/ladybug-native.ts +95 -0
- package/src/core/ladybug-source.ts +667 -0
- package/src/core/links.ts +681 -0
- package/src/core/log.ts +253 -0
- package/src/core/managed-block.ts +540 -0
- package/src/core/manifest.ts +718 -0
- package/src/core/order.ts +13 -0
- package/src/core/profile.ts +1007 -0
- package/src/core/projection.ts +195 -0
- package/src/core/query.ts +542 -0
- package/src/core/reconcile.ts +236 -0
- package/src/core/replace.ts +419 -0
- package/src/core/retrieval.ts +213 -0
- package/src/core/rewrite.ts +940 -0
- package/src/core/scaffold.ts +255 -0
- package/src/core/schema.ts +366 -0
- package/src/core/snapshot-runtime.ts +52 -0
- package/src/core/snapshot-store.ts +287 -0
- package/src/core/snapshot.ts +711 -0
- package/src/core/template.ts +429 -0
- package/src/core/traversal.ts +487 -0
- package/src/core/validate.ts +517 -0
- package/src/core/workspace-contract.ts +473 -0
- package/src/core/workspace-projection.ts +365 -0
- package/src/core/workspace-retrieval.ts +196 -0
- package/src/core/workspace-source.ts +174 -0
- package/src/errors.ts +697 -0
- package/src/meta.ts +7 -0
- package/src/output.ts +589 -0
- package/src/scripts/upstream-backlog-watch.ts +288 -0
- package/src/state.ts +390 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands/new.ts — `lore new <type> "<title>"`: scaffold a typed concept file.
|
|
3
|
+
*
|
|
4
|
+
* The thin command layer over the pure {@link buildNewConcept} (lore-design §2.2): it parses
|
|
5
|
+
* the command's own arguments, resolves *which* template to render (a user template under
|
|
6
|
+
* `.lore/templates/`, else the built-in for the type), computes the conventional output path,
|
|
7
|
+
* asks core for the bytes, and writes them **never-clobbering** through the shared
|
|
8
|
+
* {@link createIfAbsent}. All side effects (read the template, write the file) live here; all
|
|
9
|
+
* byte computation lives in `core/template.ts`.
|
|
10
|
+
*
|
|
11
|
+
* The two load-bearing behaviors (LORE-18 ACs): a no-flag run renders from a built-in that
|
|
12
|
+
* **validates clean by construction** (AC#1, guaranteed by the templates in core), and a
|
|
13
|
+
* user template at `.lore/templates/<name>.md` **overrides** the built-in wholesale (AC#2).
|
|
14
|
+
* Unlike `init`, an existing target is a `conflict` (exit `5`), not an idempotent skip: `new`
|
|
15
|
+
* creates a *new* concept and must never overwrite or silently no-op onto an existing file.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
20
|
+
import { idFromPath } from "../core/concept";
|
|
21
|
+
import { loadProfile, type Profile, templateConfinementViolation } from "../core/profile";
|
|
22
|
+
import { DOCS_DIR } from "../core/scaffold";
|
|
23
|
+
import { canonicalType, isKnownType, SCHEMAS_DIR, schemaFileName, schemaModeline, typeDirectory } from "../core/schema";
|
|
24
|
+
import { buildNewConcept, builtinTemplateFor, slugify } from "../core/template";
|
|
25
|
+
import { EXIT_OK, errnoCode, LoreError, WarningCollector, type Writer } from "../errors";
|
|
26
|
+
import { emit, type OutputContext, type Renderable } from "../output";
|
|
27
|
+
import { assertNotReservedStem, optionValues, parseCommandArgs } from "./args";
|
|
28
|
+
import { createIfAbsent, ensureDir, findSymlinkSegment } from "./fswrite";
|
|
29
|
+
|
|
30
|
+
/** Where user templates live, relative to the repo root. */
|
|
31
|
+
const TEMPLATES_DIR = ".lore/templates";
|
|
32
|
+
|
|
33
|
+
/** The reserved bundle-root index `lore init` owns (the sole `okf_version` carrier); `lore new` must not write it. */
|
|
34
|
+
const RESERVED_ROOT_INDEX = `${DOCS_DIR}/index.md`;
|
|
35
|
+
|
|
36
|
+
/** A valid concept type token: starts with a letter, then letters/digits/dashes/underscores — no spaces or path separators. */
|
|
37
|
+
const VALID_TYPE = /^[A-Za-z][A-Za-z0-9_-]*$/;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The stub `summary` injected when no `--summary` is given. Short and present, so a no-flag
|
|
41
|
+
* run validates clean (a missing or over-long summary would warn), while clearly flagging
|
|
42
|
+
* the one line the author should replace.
|
|
43
|
+
*/
|
|
44
|
+
const SUMMARY_STUB = "Add a one-line summary of this concept.";
|
|
45
|
+
|
|
46
|
+
/** The result of a `new` run: the created concept's id, path, and type. */
|
|
47
|
+
export interface NewResult {
|
|
48
|
+
/** The new concept's bundle-relative id (path under `docs/` minus `.md`), e.g. `stories/bulk-archive`. */
|
|
49
|
+
id: string;
|
|
50
|
+
/** The repo-relative POSIX path written, e.g. `docs/stories/bulk-archive.md`. */
|
|
51
|
+
path: string;
|
|
52
|
+
/** The resolved concept `type` (canonical for a known type, verbatim for a producer extension). */
|
|
53
|
+
type: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Options for {@link runNew}; `root`, `clock`, and the streams are injectable for tests. */
|
|
57
|
+
export interface NewOptions {
|
|
58
|
+
/** The repo root to scaffold into. */
|
|
59
|
+
root: string;
|
|
60
|
+
/** The resolved output mode/color (from `output.ts`). */
|
|
61
|
+
output: OutputContext;
|
|
62
|
+
/** The command's normalized positional + flag tokens from Commander. */
|
|
63
|
+
args: readonly string[];
|
|
64
|
+
/** Clock seam for the `timestamp` token; defaults to the real wall clock. */
|
|
65
|
+
clock?: () => Date;
|
|
66
|
+
/** stdout sink; defaults to `process.stdout`. */
|
|
67
|
+
stdout?: Writer;
|
|
68
|
+
/** stderr sink for advisory warnings; defaults to `process.stderr`. */
|
|
69
|
+
stderr?: Writer;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The parsed form of `lore new`'s arguments. */
|
|
73
|
+
interface NewArgs {
|
|
74
|
+
type: string;
|
|
75
|
+
title: string;
|
|
76
|
+
vars: Record<string, string>;
|
|
77
|
+
template?: string;
|
|
78
|
+
summary?: string;
|
|
79
|
+
tags?: string;
|
|
80
|
+
out?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Run `lore new`: parse the arguments, resolve the template, render the concept, and write it
|
|
85
|
+
* never-clobbering. Returns the exit code (`0`). A missing positional or bad flag throws a
|
|
86
|
+
* `usage` {@link LoreError} (exit `2`); an unfilled `{{placeholder}}` or invalid frontmatter a
|
|
87
|
+
* `validation` error (exit `6`); an existing target a `conflict` (exit `5`).
|
|
88
|
+
*/
|
|
89
|
+
export function runNew(options: NewOptions): number {
|
|
90
|
+
const clock = options.clock ?? (() => new Date());
|
|
91
|
+
const parsed = parseNewArgs(options.args);
|
|
92
|
+
const profile = loadProfile({ root: options.root });
|
|
93
|
+
const type = canonicalType(parsed.type, profile);
|
|
94
|
+
// A profile-declared type is valid by definition — including a multi-word/space-containing name
|
|
95
|
+
// like "QA Plan" (its path segments come from the LOWER-KEBAB slug, which is always safe). The
|
|
96
|
+
// VALID_TYPE shape check only gates an *ad-hoc* unknown type, whose raw token would otherwise
|
|
97
|
+
// become a directory/filename segment verbatim.
|
|
98
|
+
if (!isKnownType(type, profile) && !VALID_TYPE.test(type)) {
|
|
99
|
+
throw usage(
|
|
100
|
+
`"${parsed.type}" is not a valid type`,
|
|
101
|
+
"a type must start with a letter and contain only letters, digits, dashes, or underscores — or be declared in .lore/profile.toml",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const docPath = resolveDocPath(parsed, type, options.root);
|
|
106
|
+
const bodyTemplate = resolveTemplate(parsed, type, options.root, profile);
|
|
107
|
+
|
|
108
|
+
const build = buildNewConcept({
|
|
109
|
+
docPath,
|
|
110
|
+
type,
|
|
111
|
+
title: parsed.title,
|
|
112
|
+
summary: parsed.summary ?? SUMMARY_STUB,
|
|
113
|
+
timestamp: clock().toISOString(),
|
|
114
|
+
tags: parseTags(parsed.tags),
|
|
115
|
+
bodyTemplate,
|
|
116
|
+
vars: parsed.vars,
|
|
117
|
+
modeline: resolveModeline(type, docPath, options.root, profile),
|
|
118
|
+
profile,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const absPath = join(options.root, docPath);
|
|
122
|
+
// Check for an existing target before creating any parent directories, so an aborted
|
|
123
|
+
// (conflicting) run leaves no empty scaffold dirs behind. `createIfAbsent`'s atomic `wx`
|
|
124
|
+
// write remains the authority that closes the time-of-check/time-of-use race.
|
|
125
|
+
if (existsSync(absPath)) {
|
|
126
|
+
throw conflict(docPath);
|
|
127
|
+
}
|
|
128
|
+
ensureDir(options.root, posix.dirname(docPath));
|
|
129
|
+
if (!createIfAbsent(absPath, build.contents, docPath)) {
|
|
130
|
+
throw conflict(docPath);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
flushWarnings(build.warnings, options.output, options.stderr);
|
|
134
|
+
emit(newRenderable({ id: bundleId(docPath), path: docPath, type: build.type }), options.output, options.stdout);
|
|
135
|
+
return EXIT_OK;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A `conflict` {@link LoreError} (exit `5`) for a target path that already exists. */
|
|
139
|
+
function conflict(docPath: string): LoreError {
|
|
140
|
+
return new LoreError(
|
|
141
|
+
"conflict",
|
|
142
|
+
`${docPath} already exists`,
|
|
143
|
+
"choose a different title, pass --out <path>, or remove the existing file",
|
|
144
|
+
{ path: docPath },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The editor modeline for a known type whose exported schema actually exists on disk, else
|
|
150
|
+
* `undefined`. An unknown type has no schema; a doc written into a never-`init`-ed bundle has no
|
|
151
|
+
* `.lore/schemas/` either — in both cases lore writes no modeline rather than one pointing at a
|
|
152
|
+
* `$schema` file that is not there.
|
|
153
|
+
*/
|
|
154
|
+
function resolveModeline(type: string, docPath: string, root: string, profile: Profile): string | undefined {
|
|
155
|
+
if (!isKnownType(type, profile)) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
if (!existsSync(join(root, SCHEMAS_DIR, schemaFileName(type)))) {
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
return schemaModeline(docPath, type);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Argument parsing ───────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Parse `new`'s tokens into positionals (`<type> <title>`) and its flags. The router has
|
|
168
|
+
* already stripped lore's global flags, so anything `--`-prefixed here is a command flag: an
|
|
169
|
+
* unrecognized one is a `usage` error, as is a malformed `--var` or the wrong positional count.
|
|
170
|
+
* Both `--flag value` and `--flag=value` forms are accepted; a value-taking flag refuses to
|
|
171
|
+
* consume a following flag-looking token as its value (so `--summary --tags x` reports the
|
|
172
|
+
* missing summary value rather than silently eating `--tags`). A `--` ends option parsing so a
|
|
173
|
+
* title may begin with `-` (`lore new adr -- "-5 minute timeout"`).
|
|
174
|
+
*/
|
|
175
|
+
function parseNewArgs(args: readonly string[]): NewArgs {
|
|
176
|
+
const parsed = parseCommandArgs(args, "new");
|
|
177
|
+
const positionals = parsed.positionals;
|
|
178
|
+
const vars: Record<string, string> = Object.create(null);
|
|
179
|
+
for (const raw of optionValues(parsed, "var")) addVar(vars, raw);
|
|
180
|
+
const template = optionValues(parsed, "template").at(-1);
|
|
181
|
+
const summary = optionValues(parsed, "summary").at(-1);
|
|
182
|
+
const tags = optionValues(parsed, "tags").at(-1);
|
|
183
|
+
const out = optionValues(parsed, "out").at(-1);
|
|
184
|
+
|
|
185
|
+
const type = positionals[0];
|
|
186
|
+
if (type === undefined || type.trim() === "") {
|
|
187
|
+
throw usage("`lore new` needs a type", 'run `lore new <type> "<title>"`, e.g. lore new adr "Use soft deletes"');
|
|
188
|
+
}
|
|
189
|
+
const title = positionals[1];
|
|
190
|
+
if (title === undefined || title.trim() === "") {
|
|
191
|
+
throw usage("`lore new` needs a title", 'run `lore new <type> "<title>"` with a quoted title');
|
|
192
|
+
}
|
|
193
|
+
if (positionals.length > 2) {
|
|
194
|
+
throw usage(
|
|
195
|
+
`unexpected argument "${positionals[2]}"`,
|
|
196
|
+
'pass exactly a type and a title; quote a multi-word title: lore new <type> "<title>"',
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return { type: type.trim(), title: title.trim(), vars, template, summary, tags, out };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Record one `--var key=value` pair. The key must be a non-empty placeholder name
|
|
204
|
+
* (`[A-Za-z0-9_.-]+`, the {@link renderTemplate} token grammar); the value is everything
|
|
205
|
+
* after the first `=` (so it may itself contain `=`). `vars` is a null-prototype object, so a
|
|
206
|
+
* key like `__proto__` lands as an own property without polluting any prototype.
|
|
207
|
+
*/
|
|
208
|
+
function addVar(vars: Record<string, string>, raw: string): void {
|
|
209
|
+
const eq = raw.indexOf("=");
|
|
210
|
+
if (eq <= 0) {
|
|
211
|
+
throw usage(`--var must be key=value, got "${raw}"`, "supply a non-empty key, e.g. --var owner=payments");
|
|
212
|
+
}
|
|
213
|
+
const key = raw.slice(0, eq);
|
|
214
|
+
if (!/^[A-Za-z0-9_.-]+$/.test(key)) {
|
|
215
|
+
throw usage(
|
|
216
|
+
`--var key "${key}" is not a valid placeholder name`,
|
|
217
|
+
"use letters, digits, dots, dashes, or underscores for the key",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
vars[key] = raw.slice(eq + 1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Split a `--tags a,b,c` value into a trimmed, non-empty list (absent flag → `undefined`). */
|
|
224
|
+
function parseTags(tags: string | undefined): string[] | undefined {
|
|
225
|
+
if (tags === undefined) {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
return tags
|
|
229
|
+
.split(",")
|
|
230
|
+
.map((tag) => tag.trim())
|
|
231
|
+
.filter((tag) => tag !== "");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Path + template resolution ───────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Compute the new doc's repo-relative POSIX path. With `--out` the caller's path wins
|
|
238
|
+
* (resolved and confined to the repo by {@link resolveOutPath}); otherwise it is the
|
|
239
|
+
* conventional `docs/<typeDirectory>/<slug-of-title>.md`. A title with no slug-able content
|
|
240
|
+
* and no `--out` is a `usage` error rather than a `-.md` file.
|
|
241
|
+
*
|
|
242
|
+
* The default path is checked against the same {@link assertNotReservedStem} guard
|
|
243
|
+
* {@link resolveOutPath} applies (LORE-174): a title that slugifies to `index` or `log` (e.g.
|
|
244
|
+
* `lore new reference "Index"`) would otherwise land on a stem `rename`/`supersede`/`link` treat
|
|
245
|
+
* as lore-generated and refuse to touch, bypassing the policy LORE-114 added only on the `--out`
|
|
246
|
+
* path. The default path always carries a non-empty type-directory segment (every known
|
|
247
|
+
* {@link typeDirectory} maps to a non-empty string, and every ad-hoc type's slug is non-empty
|
|
248
|
+
* since `VALID_TYPE` requires a leading letter), so it can never collide with the bundle-root
|
|
249
|
+
* index `resolveOutPath` guards separately — no `RESERVED_ROOT_INDEX` check is needed here.
|
|
250
|
+
*/
|
|
251
|
+
function resolveDocPath(parsed: NewArgs, type: string, root: string): string {
|
|
252
|
+
if (parsed.out !== undefined) {
|
|
253
|
+
return resolveOutPath(parsed.out, root);
|
|
254
|
+
}
|
|
255
|
+
const slug = slugify(parsed.title);
|
|
256
|
+
if (slug === "") {
|
|
257
|
+
throw usage(`could not derive a filename from title "${parsed.title}"`, "pass an explicit path with --out <path>");
|
|
258
|
+
}
|
|
259
|
+
const docPath = posix.join(DOCS_DIR, typeDirectory(type), `${slug}.md`);
|
|
260
|
+
assertNotReservedStem(idFromPath(docPath), "create");
|
|
261
|
+
return docPath;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Resolve a `--out` value to a repo-relative POSIX path, confining it to the **bundle root**
|
|
266
|
+
* (`docs/`) and ending it in `.md` (appended when omitted). A path that escapes the repo
|
|
267
|
+
* (`../…`, an absolute path elsewhere), lands outside `docs/`, or targets the reserved
|
|
268
|
+
* bundle-root index (`docs/index.md`, owned by `lore init`) is a `usage` error — so `lore new`
|
|
269
|
+
* can never write an orphaned file the bundle walk won't see, nor clobber the conformance root.
|
|
270
|
+
* The `..` escape is matched by path **segment** (`..` exactly or a leading `../`), so a real
|
|
271
|
+
* in-repo path whose first segment merely starts with `..` (e.g. `..notes/x`) is not rejected.
|
|
272
|
+
*
|
|
273
|
+
* Beyond the root index, ANY basename of `index`/`log` — at any nesting depth — is also rejected,
|
|
274
|
+
* via the same {@link assertNotReservedStem} `rename`/`supersede`/`link` share (LORE-114): those
|
|
275
|
+
* stems are lore's own generated file names wherever they sit, not just at the bundle root, so
|
|
276
|
+
* `lore new` must not let a user create a doc that collides with one. Checked AFTER the
|
|
277
|
+
* root-index-specific check above, so `docs/index.md` keeps its own message unaffected.
|
|
278
|
+
*/
|
|
279
|
+
function resolveOutPath(out: string, root: string): string {
|
|
280
|
+
const rel = relative(root, resolve(root, out));
|
|
281
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
282
|
+
throw usage(`--out path "${out}" must be inside the repo`, "give a path relative to the repo root");
|
|
283
|
+
}
|
|
284
|
+
let posixRel = rel.split(sep).join("/");
|
|
285
|
+
if (!posixRel.endsWith(".md")) {
|
|
286
|
+
posixRel = `${posixRel}.md`;
|
|
287
|
+
}
|
|
288
|
+
if (posixRel !== DOCS_DIR && !posixRel.startsWith(`${DOCS_DIR}/`)) {
|
|
289
|
+
throw usage(
|
|
290
|
+
`--out path "${out}" must be inside the bundle root (${DOCS_DIR}/)`,
|
|
291
|
+
`give a path under ${DOCS_DIR}/, e.g. --out ${DOCS_DIR}/reference/orders.md`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (posixRel === RESERVED_ROOT_INDEX) {
|
|
295
|
+
throw usage(
|
|
296
|
+
`${RESERVED_ROOT_INDEX} is the reserved bundle-root index`,
|
|
297
|
+
"choose another path; `lore init` owns the root index that carries okf_version",
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
assertNotReservedStem(idFromPath(posixRel), "create");
|
|
301
|
+
return posixRel;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Resolve the template text. The file base is `--template <name>` when given, else the type;
|
|
306
|
+
* the file is `.lore/templates/<base>.md`. The base precedence is: an explicit `--template <name>`,
|
|
307
|
+
* else the **profile type's declared `template`** (its filename minus `.md`), else the type name.
|
|
308
|
+
* To be correct on **case-sensitive** filesystems (Linux/CI) while staying convenient on
|
|
309
|
+
* case-insensitive ones, the lookup tries the name as given (e.g. `Reference.md`, matching the
|
|
310
|
+
* docs' canonical-case `<type>` spelling) and then its lower-cased form (`reference.md`, matching
|
|
311
|
+
* the schema filenames). A present file is the user template (override, AC#2). If none exists: an
|
|
312
|
+
* explicit `--template` is a `not_found` error (the caller asked for a specific template); a
|
|
313
|
+
* profile-declared-but-missing template, like the default, falls back to the built-in body.
|
|
314
|
+
*
|
|
315
|
+
* An explicit `--template` is a user-facing CLI flag (unlike `declared`, a repo-config value) and
|
|
316
|
+
* is validated with {@link assertTemplateNameConfined} BEFORE it ever reaches a file path (LORE-69):
|
|
317
|
+
* `--template` is documented as a bare name, never a path, so a `..` segment or an absolute value
|
|
318
|
+
* is rejected outright rather than spliced into `${TEMPLATES_DIR}/${base}.md` and hoped safe. The
|
|
319
|
+
* profile-declared `template` went through the analogous {@link templateConfinementViolation}
|
|
320
|
+
* check already, at profile PARSE time (LORE-139), so it is not re-checked for confinement here —
|
|
321
|
+
* only the two named-by-user-or-repo-config sources (`--template`, `declared`) get the symlink
|
|
322
|
+
* refusal below; the bare-type-name convention lookup (neither given) does not (LORE-91 scope).
|
|
323
|
+
*/
|
|
324
|
+
function resolveTemplate(parsed: NewArgs, type: string, root: string, profile: Profile): string {
|
|
325
|
+
const explicitTemplate = parsed.template !== undefined;
|
|
326
|
+
if (parsed.template !== undefined) {
|
|
327
|
+
assertTemplateNameConfined(parsed.template);
|
|
328
|
+
}
|
|
329
|
+
const declared = profile.types.get(type)?.template?.replace(/\.md$/i, "");
|
|
330
|
+
const base = parsed.template ?? declared ?? type;
|
|
331
|
+
// A named template source — the CLI flag or a profile's own declared filename — is refused if
|
|
332
|
+
// it resolves through a symlink (LORE-91, widened to `declared` by LORE-185's AC#2); the bare
|
|
333
|
+
// type-name convention lookup below carries no such refusal, matching the pre-LORE-185 scope.
|
|
334
|
+
const checkSymlink = explicitTemplate || declared !== undefined;
|
|
335
|
+
for (const candidate of templateCandidates(base)) {
|
|
336
|
+
const relPath = `${TEMPLATES_DIR}/${candidate}.md`;
|
|
337
|
+
const text = readTemplateFile(join(root, relPath), relPath, root, checkSymlink);
|
|
338
|
+
if (text !== undefined) {
|
|
339
|
+
return text;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (parsed.template !== undefined) {
|
|
343
|
+
throw new LoreError(
|
|
344
|
+
"not_found",
|
|
345
|
+
`template "${parsed.template}" not found in ${TEMPLATES_DIR}/`,
|
|
346
|
+
`create ${TEMPLATES_DIR}/${parsed.template}.md, or omit --template to use the built-in`,
|
|
347
|
+
{ path: `${TEMPLATES_DIR}/${parsed.template}.md` },
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return builtinTemplateFor(type);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Reject a `--template` value that could escape `.lore/templates/` once spliced into a file
|
|
355
|
+
* path, via the shared {@link templateConfinementViolation} predicate `core/profile.ts` also uses
|
|
356
|
+
* for the profile-declared `[[types]].template` value (LORE-139) — LORE-185 consolidated what
|
|
357
|
+
* used to be this function's own host-`resolve()`/`relative()` arithmetic (no backslash
|
|
358
|
+
* normalization) onto that one pure-posix, backslash-aware implementation, closing a cross-host
|
|
359
|
+
* drift: a Windows-style `--template ..\..\secret` escape was only ever caught on an actual win32
|
|
360
|
+
* run before — a POSIX test/CI run saw it as one inert, non-escaping filename segment — and is now
|
|
361
|
+
* rejected identically on every host. See {@link templateConfinementViolation}'s own docstring for
|
|
362
|
+
* the absolute-path and `..`-escape rationale in full (both apply unchanged here).
|
|
363
|
+
*/
|
|
364
|
+
function assertTemplateNameConfined(name: string): void {
|
|
365
|
+
const violation = templateConfinementViolation(name);
|
|
366
|
+
if (violation === "absolute") {
|
|
367
|
+
throw usage(
|
|
368
|
+
`--template value "${name}" must not be an absolute path`,
|
|
369
|
+
"pass a bare template name, e.g. --template adr",
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (violation === "escape") {
|
|
373
|
+
throw usage(
|
|
374
|
+
`--template value "${name}" must not escape ${TEMPLATES_DIR}/`,
|
|
375
|
+
"pass a bare template name, e.g. --template adr",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The template filenames to try for a base, the name as given first then its lower-cased form (deduped). */
|
|
381
|
+
function templateCandidates(base: string): string[] {
|
|
382
|
+
const lower = base.toLowerCase();
|
|
383
|
+
return base === lower ? [base] : [base, lower];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Read a template file as UTF-8, returning `undefined` when it does not exist (`ENOENT`) so
|
|
388
|
+
* the caller can fall back to a built-in. A permission failure becomes a `denied`
|
|
389
|
+
* {@link LoreError}; any other read fault propagates as an uncaught error.
|
|
390
|
+
*
|
|
391
|
+
* `checkSymlink` (true for an explicit `--template` (LORE-91) AND, as of LORE-185's AC#2, a
|
|
392
|
+
* profile-declared `template` — `resolveTemplate`'s `declared` fallback) refuses — rather than
|
|
393
|
+
* silently reading through — a symlinked candidate anywhere in `relPath`'s segments, closing an
|
|
394
|
+
* information-disclosure gap the purely syntactic {@link templateConfinementViolation} containment
|
|
395
|
+
* check cannot: a bare, unsuspicious `--template evil` (or a profile declaring `template = "evil"`)
|
|
396
|
+
* whose resolved `.lore/templates/evil.md` is itself a symlink to an arbitrary file outside the
|
|
397
|
+
* repo would otherwise have that file's exact content silently embedded in the generated concept.
|
|
398
|
+
* Mirrors this codebase's established write-path precedent (`fswrite.ts`'s
|
|
399
|
+
* `assertNoSymlinkInPath`/`findSymlinkSegment`, LORE-76/77) rather than inventing a new pattern,
|
|
400
|
+
* and its own READ-path precedent (`core/bundle.ts`'s `walkMarkdown`, `commands/replace.ts`) of
|
|
401
|
+
* never following a symlink that could resolve outside the repo. `checkSymlink` is still `false`
|
|
402
|
+
* for the bare-type-name convention lookup (neither `--template` nor a profile `declared` value) —
|
|
403
|
+
* LORE-139 hardened that path's PARSE-time traversal check (via the profile's own compiled
|
|
404
|
+
* `template`, confined before `resolveTemplate` ever sees it) but, unlike the two named sources
|
|
405
|
+
* above, that implicit lookup names no untrusted value at all, so it was intentionally left out of
|
|
406
|
+
* this task's AC#2 scope rather than "left untouched" wholesale, as an earlier draft of this
|
|
407
|
+
* comment (pre-LORE-185) claimed.
|
|
408
|
+
*/
|
|
409
|
+
function readTemplateFile(absPath: string, relPath: string, root: string, checkSymlink: boolean): string | undefined {
|
|
410
|
+
if (checkSymlink) {
|
|
411
|
+
const symlink = findSymlinkSegment(root, relPath);
|
|
412
|
+
if (symlink !== null) {
|
|
413
|
+
throw new LoreError(
|
|
414
|
+
"conflict",
|
|
415
|
+
`refusing to read ${relPath}: "${symlink}" is a symlink, not a real directory or file`,
|
|
416
|
+
"lore does not read through a symlink (it may resolve outside the repo) — remove or replace it, then re-run",
|
|
417
|
+
{ path: relPath, symlink },
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
return readFileSync(absPath, "utf8");
|
|
423
|
+
} catch (cause) {
|
|
424
|
+
const code = errnoCode(cause);
|
|
425
|
+
if (code === "ENOENT") {
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
429
|
+
throw new LoreError("denied", `permission denied reading ${relPath}`, `make ${relPath} readable`, {
|
|
430
|
+
path: relPath,
|
|
431
|
+
code,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
throw cause;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── Output ───────────────────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
/** The bundle-relative id for a written doc: the path under `docs/` minus `.md`. */
|
|
441
|
+
function bundleId(docPath: string): string {
|
|
442
|
+
const prefix = `${DOCS_DIR}/`;
|
|
443
|
+
return idFromPath(docPath.startsWith(prefix) ? docPath.slice(prefix.length) : docPath);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Flush advisory warnings to stderr in the shared `warning:` format (non-fatal; never changes the exit code). */
|
|
447
|
+
function flushWarnings(warnings: readonly string[], output: OutputContext, stderr: Writer | undefined): void {
|
|
448
|
+
if (warnings.length === 0) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const collector = new WarningCollector();
|
|
452
|
+
for (const warning of warnings) {
|
|
453
|
+
collector.add(warning);
|
|
454
|
+
}
|
|
455
|
+
collector.flush({ color: output.color, stderr });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** The per-result-type rendering bundle for `new` (output.ts dispatches on the mode). */
|
|
459
|
+
function newRenderable(data: NewResult): Renderable<NewResult> {
|
|
460
|
+
return { kind: "new", data, pretty: renderPretty, plain: renderPlain };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Human view: a one-line confirmation naming the type, id, and path. */
|
|
464
|
+
function renderPretty(data: NewResult): string {
|
|
465
|
+
return `Created ${data.type} ${data.id}\n ${data.path}`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** ANSI-free, diff-stable view: one `created <path>` line. */
|
|
469
|
+
function renderPlain(data: NewResult): string {
|
|
470
|
+
return `created ${data.path}`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** A `usage` {@link LoreError} (exit `2`) with an actionable hint. */
|
|
474
|
+
function usage(message: string, hint: string): LoreError {
|
|
475
|
+
return new LoreError("usage", message, hint);
|
|
476
|
+
}
|