@opum-ai/lore 0.1.0 → 0.1.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 +33 -22
- package/bin/lore.cjs +35 -7
- package/package.json +17 -17
- package/src/adapters/backlog.ts +0 -1084
- package/src/adapters/git.ts +0 -221
- package/src/cli.ts +0 -667
- package/src/commands/agent.ts +0 -301
- package/src/commands/agents.ts +0 -302
- package/src/commands/args.ts +0 -209
- package/src/commands/changed.ts +0 -70
- package/src/commands/check.ts +0 -1031
- package/src/commands/codex-bridge.ts +0 -49
- package/src/commands/concurrency.ts +0 -48
- package/src/commands/context.ts +0 -292
- package/src/commands/discover.ts +0 -89
- package/src/commands/explorer.ts +0 -253
- package/src/commands/export.ts +0 -93
- package/src/commands/fswrite.ts +0 -928
- package/src/commands/graph.ts +0 -291
- package/src/commands/help.ts +0 -151
- package/src/commands/impact.ts +0 -59
- package/src/commands/init.ts +0 -583
- package/src/commands/instructions.ts +0 -91
- package/src/commands/link.ts +0 -929
- package/src/commands/new.ts +0 -476
- package/src/commands/orphans.ts +0 -457
- package/src/commands/path.ts +0 -67
- package/src/commands/provenance.ts +0 -68
- package/src/commands/query.ts +0 -312
- package/src/commands/reconcile-shared.ts +0 -280
- package/src/commands/rename.ts +0 -585
- package/src/commands/replace.ts +0 -320
- package/src/commands/scaffold.ts +0 -346
- package/src/commands/schema.ts +0 -293
- package/src/commands/snapshot.ts +0 -130
- package/src/commands/supersede.ts +0 -400
- package/src/commands/sync.ts +0 -371
- package/src/commands/tasks.ts +0 -271
- package/src/commands/traversal.ts +0 -151
- package/src/commands/validate.ts +0 -226
- package/src/config.ts +0 -598
- package/src/core/agent-bridge.ts +0 -287
- package/src/core/agent-context.ts +0 -498
- package/src/core/agent-profile.ts +0 -447
- package/src/core/bundle.ts +0 -893
- package/src/core/check.ts +0 -853
- package/src/core/codex-bridge.ts +0 -100
- package/src/core/concept.ts +0 -597
- package/src/core/consumer-scaffold.ts +0 -433
- package/src/core/context.ts +0 -271
- package/src/core/explorer-contract.ts +0 -441
- package/src/core/explorer-qualification.ts +0 -58
- package/src/core/explorer.ts +0 -518
- package/src/core/finding.ts +0 -31
- package/src/core/graph.ts +0 -201
- package/src/core/indexes.ts +0 -436
- package/src/core/instructions.ts +0 -209
- package/src/core/ladybug-driver.ts +0 -1795
- package/src/core/ladybug-lifecycle.ts +0 -1178
- package/src/core/ladybug-native.ts +0 -95
- package/src/core/ladybug-source.ts +0 -667
- package/src/core/links.ts +0 -681
- package/src/core/log.ts +0 -253
- package/src/core/managed-block.ts +0 -540
- package/src/core/manifest.ts +0 -718
- package/src/core/order.ts +0 -13
- package/src/core/profile.ts +0 -1007
- package/src/core/projection.ts +0 -195
- package/src/core/query.ts +0 -542
- package/src/core/reconcile.ts +0 -236
- package/src/core/replace.ts +0 -419
- package/src/core/retrieval.ts +0 -213
- package/src/core/rewrite.ts +0 -940
- package/src/core/scaffold.ts +0 -255
- package/src/core/schema.ts +0 -366
- package/src/core/snapshot-runtime.ts +0 -52
- package/src/core/snapshot-store.ts +0 -287
- package/src/core/snapshot.ts +0 -711
- package/src/core/template.ts +0 -429
- package/src/core/traversal.ts +0 -487
- package/src/core/validate.ts +0 -517
- package/src/core/workspace-contract.ts +0 -473
- package/src/core/workspace-projection.ts +0 -365
- package/src/core/workspace-retrieval.ts +0 -196
- package/src/core/workspace-source.ts +0 -174
- package/src/errors.ts +0 -697
- package/src/meta.ts +0 -7
- package/src/output.ts +0 -589
- package/src/scripts/upstream-backlog-watch.ts +0 -288
- package/src/state.ts +0 -390
package/src/config.ts
DELETED
|
@@ -1,598 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* config.ts — the `.lore/config.toml` loader.
|
|
3
|
-
*
|
|
4
|
-
* lore keeps its team-chosen knobs in a committed `.lore/config.toml`: the
|
|
5
|
-
* status-reconciliation policy, link/validate toggles, and (non-secret)
|
|
6
|
-
* Confluence target settings ([ADR-0013](../docs/adr/0013-lore-state-directory.md)).
|
|
7
|
-
* This module turns that file into one validated, typed {@link LoreConfig} the
|
|
8
|
-
* rest of lore consumes. The design's `state.ts` (the broader `.lore/` + git
|
|
9
|
-
* owner) imports `loadConfig` rather than re-parsing the file.
|
|
10
|
-
*
|
|
11
|
-
* Three properties define its behavior:
|
|
12
|
-
*
|
|
13
|
-
* - **Zero-config.** A missing `config.toml` is not an error — {@link loadConfig}
|
|
14
|
-
* returns the documented defaults. The file exists only to override them.
|
|
15
|
-
* - **Secrets are environment-only.** The Confluence API token is read solely
|
|
16
|
-
* from `$LORE_CONFLUENCE_TOKEN`, never from the file and never written back. A
|
|
17
|
-
* `token` committed under `[confluence]` is a leak, so it fails loud
|
|
18
|
-
* (ADR-0013).
|
|
19
|
-
* - **Deterministic + injectable.** The two impure inputs — the repo `root` and
|
|
20
|
-
* the `env` — are injectable seams (lore-design §8), so tests drive the loader
|
|
21
|
-
* without touching the real working directory or `process.env`.
|
|
22
|
-
*
|
|
23
|
-
* Bun parses TOML natively (`Bun.TOML.parse`), then Zod validates only the
|
|
24
|
-
* generic parsed shape. Lore retains defaults/projection, committed-secret
|
|
25
|
-
* scanning, reserved override-key policy, page-id precision, environment
|
|
26
|
-
* overlay, and the stable {@link LoreError} mapping. Bad config is a
|
|
27
|
-
* `"validation"` error (exit 6), keeping the diagnostic contract identical to
|
|
28
|
-
* the rest of lore.
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import { readFileSync } from "node:fs";
|
|
32
|
-
import { join } from "node:path";
|
|
33
|
-
import { z } from "zod";
|
|
34
|
-
import { errnoCode, LoreError } from "./errors";
|
|
35
|
-
|
|
36
|
-
/** Where lore's config lives, relative to the repo root. Exported so the `lore init` scaffolder writes to the exact path the loader reads. */
|
|
37
|
-
export const CONFIG_REL_PATH = ".lore/config.toml";
|
|
38
|
-
|
|
39
|
-
/** The single environment variable that may carry the Confluence API token. */
|
|
40
|
-
const TOKEN_ENV = "LORE_CONFLUENCE_TOKEN";
|
|
41
|
-
|
|
42
|
-
/** The status roll-up policies lore understands; only `"task-rollup"` exists in v1 (ADR-0013). */
|
|
43
|
-
const RECONCILE_MODES = ["task-rollup"] as const;
|
|
44
|
-
|
|
45
|
-
/** The Confluence wire formats the (deferred) publish adapter understands (ADR-0013). */
|
|
46
|
-
const CONFLUENCE_FORMATS = ["storage", "adf"] as const;
|
|
47
|
-
|
|
48
|
-
/** Generic parsed-TOML shape for `[reconcile]`; unknown future keys remain tolerated. */
|
|
49
|
-
const ReconcileTableSchema = z.looseObject({
|
|
50
|
-
mode: z.enum(RECONCILE_MODES).optional(),
|
|
51
|
-
overrides: z.record(z.string(), z.string()).optional(),
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
/** Generic parsed-TOML shape for `[validate]`; projection/default policy stays below. */
|
|
55
|
-
const ValidateTableSchema = z.looseObject({
|
|
56
|
-
external_links: z.boolean().optional(),
|
|
57
|
-
promote_portability: z.boolean().optional(),
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
/** Generic parsed-TOML shape for `[confluence]`; secret and page-id policy stay outside Zod. */
|
|
61
|
-
const ConfluenceTableSchema = z.looseObject({
|
|
62
|
-
format: z.enum(CONFLUENCE_FORMATS).optional(),
|
|
63
|
-
base_url: z.string().optional(),
|
|
64
|
-
space: z.string().optional(),
|
|
65
|
-
parent_page_id: z.union([z.string(), z.number()]).optional(),
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* The single declarative shape boundary for Bun's parsed TOML value. Loose
|
|
70
|
-
* objects preserve Lore's additive unknown-key tolerance at every known table.
|
|
71
|
-
*/
|
|
72
|
-
const ParsedConfigSchema = z.looseObject({
|
|
73
|
-
reconcile: ReconcileTableSchema.optional(),
|
|
74
|
-
validate: ValidateTableSchema.optional(),
|
|
75
|
-
confluence: ConfluenceTableSchema.optional(),
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
type ParsedConfig = z.infer<typeof ParsedConfigSchema>;
|
|
79
|
-
type ParsedConfluenceTable = z.infer<typeof ConfluenceTableSchema>;
|
|
80
|
-
|
|
81
|
-
/** The status roll-up policy applied by `lore sync` / `lore check`. */
|
|
82
|
-
export type ReconcileMode = (typeof RECONCILE_MODES)[number];
|
|
83
|
-
|
|
84
|
-
/** The Confluence storage format for the one-way publish adapter. */
|
|
85
|
-
export type ConfluenceFormat = (typeof CONFLUENCE_FORMATS)[number];
|
|
86
|
-
|
|
87
|
-
/** Status-reconciliation configuration (the `[reconcile]` table). */
|
|
88
|
-
export interface ReconcileConfig {
|
|
89
|
-
/** The roll-up policy: all-tasks-Done → done, any In Progress → in-progress, else todo. */
|
|
90
|
-
mode: ReconcileMode;
|
|
91
|
-
/**
|
|
92
|
-
* Per-repo status overrides: a Backlog status name → a rollup status string.
|
|
93
|
-
* Values are carried through verbatim (validated only as strings here);
|
|
94
|
-
* reconcile.ts (LORE-23) owns the rollup-status vocabulary and its semantics.
|
|
95
|
-
*/
|
|
96
|
-
overrides: Readonly<Record<string, string>>;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Validation / coherence-gate configuration (the `[validate]` table). */
|
|
100
|
-
export interface ValidateConfig {
|
|
101
|
-
/** Opt-in external-link liveness checking in `lore check` (default `false`; ADR-0007). */
|
|
102
|
-
externalLinks: boolean;
|
|
103
|
-
/** Promote the portability lint from warning to error in `lore check` (default `false`). */
|
|
104
|
-
promotePortability: boolean;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Confluence publish configuration (the `[confluence]` table). All fields are
|
|
109
|
-
* non-secret; the API {@link ConfluenceConfig.token} is overlaid from the
|
|
110
|
-
* environment and never read from the committed file.
|
|
111
|
-
*/
|
|
112
|
-
export interface ConfluenceConfig {
|
|
113
|
-
/** Wiki base URL, e.g. `https://yourorg.atlassian.net/wiki`. */
|
|
114
|
-
baseUrl?: string;
|
|
115
|
-
/** Target space key, e.g. `ENG`. */
|
|
116
|
-
space?: string;
|
|
117
|
-
/** Parent page id the published tree hangs under. */
|
|
118
|
-
parentPageId?: string;
|
|
119
|
-
/** Wire format; defaults to `"storage"`. */
|
|
120
|
-
format: ConfluenceFormat;
|
|
121
|
-
/** API token — sourced ONLY from `$LORE_CONFLUENCE_TOKEN`, never the file. Absent when unset. */
|
|
122
|
-
token?: string;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** The fully-resolved lore configuration: file values merged over defaults, with the env token overlaid. */
|
|
126
|
-
export interface LoreConfig {
|
|
127
|
-
reconcile: ReconcileConfig;
|
|
128
|
-
validate: ValidateConfig;
|
|
129
|
-
confluence: ConfluenceConfig;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Options for {@link loadConfig}; both fields are injectable seams for determinism in tests. */
|
|
133
|
-
export interface LoadConfigOptions {
|
|
134
|
-
/** Repo root containing `.lore/`; defaults to {@link process.cwd}. */
|
|
135
|
-
root?: string;
|
|
136
|
-
/** Environment source for the secret overlay; defaults to {@link process.env}. */
|
|
137
|
-
env?: Record<string, string | undefined>;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Load and validate `.lore/config.toml` under `root` (default cwd), overlaying
|
|
142
|
-
* the Confluence token from `env` (default `process.env`). A missing file yields
|
|
143
|
-
* the zero-config {@link defaultConfig}; malformed TOML or an out-of-contract
|
|
144
|
-
* value throws a {@link LoreError} of type `"validation"`; a file that exists
|
|
145
|
-
* but cannot be read (`EACCES`/`EPERM`) throws a `"denied"` {@link LoreError}
|
|
146
|
-
* instead (see {@link readConfigText}).
|
|
147
|
-
*/
|
|
148
|
-
export function loadConfig(options: LoadConfigOptions = {}): LoreConfig {
|
|
149
|
-
const root = options.root ?? process.cwd();
|
|
150
|
-
const env = options.env ?? process.env;
|
|
151
|
-
const fromFile = parseConfigFile(join(root, CONFIG_REL_PATH));
|
|
152
|
-
return overlayEnv(fromFile, env);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** The zero-config defaults — what `loadConfig` returns when no `config.toml` exists. */
|
|
156
|
-
export function defaultConfig(): LoreConfig {
|
|
157
|
-
return {
|
|
158
|
-
reconcile: { mode: "task-rollup", overrides: {} },
|
|
159
|
-
validate: { externalLinks: false, promotePortability: false },
|
|
160
|
-
confluence: { format: "storage" },
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** Read → parse → validate the file at `path`, or return defaults when it is absent. */
|
|
165
|
-
function parseConfigFile(path: string): LoreConfig {
|
|
166
|
-
const raw = readConfigText(path);
|
|
167
|
-
if (raw === undefined) {
|
|
168
|
-
return defaultConfig();
|
|
169
|
-
}
|
|
170
|
-
return validateConfig(parseToml(raw));
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Read the config file as UTF-8. One read is the sole source of truth for
|
|
175
|
-
* absent-vs-unreadable: `ENOENT` → `undefined` (the zero-config case — no
|
|
176
|
-
* separate `existsSync`, so no time-of-check/time-of-use window); `EACCES`/`EPERM`
|
|
177
|
-
* → a `denied` {@link LoreError} (the codebase-wide permissions contract — see
|
|
178
|
-
* `errors.ts`'s `ioError`/`readFileIfPresent`); any other failure (a directory
|
|
179
|
-
* at the path, …) is a `validation` error surfaced with its OS reason instead
|
|
180
|
-
* of a blanket "check file permissions".
|
|
181
|
-
*/
|
|
182
|
-
function readConfigText(path: string): string | undefined {
|
|
183
|
-
try {
|
|
184
|
-
return readFileSync(path, "utf8");
|
|
185
|
-
} catch (cause) {
|
|
186
|
-
if (isErrnoCode(cause, "ENOENT")) {
|
|
187
|
-
return undefined;
|
|
188
|
-
}
|
|
189
|
-
if (isErrnoCode(cause, "EACCES") || isErrnoCode(cause, "EPERM")) {
|
|
190
|
-
throw new LoreError(
|
|
191
|
-
"denied",
|
|
192
|
-
withReason(`${CONFIG_REL_PATH} could not be read`, cause),
|
|
193
|
-
`check filesystem permissions on ${CONFIG_REL_PATH}`,
|
|
194
|
-
// Attach the errno `code` (matching errors.ts's ioError/readFileIfPresent),
|
|
195
|
-
// so a --json consumer reading envelope.input.code gets it here too.
|
|
196
|
-
{ path: CONFIG_REL_PATH, code: errnoCode(cause) },
|
|
197
|
-
);
|
|
198
|
-
}
|
|
199
|
-
return fail(
|
|
200
|
-
withReason(`${CONFIG_REL_PATH} could not be read`, cause),
|
|
201
|
-
`ensure ${CONFIG_REL_PATH} is a readable file`,
|
|
202
|
-
{ path: CONFIG_REL_PATH },
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Parse TOML via Bun's native parser, surfacing the parser's own message on failure. */
|
|
208
|
-
function parseToml(raw: string): Record<string, unknown> {
|
|
209
|
-
// Strip leading UTF-8 BOM(s): Windows editors (Notepad, some PowerShell paths)
|
|
210
|
-
// prepend U+FEFF — and some prepend more than one — which Bun.TOML.parse
|
|
211
|
-
// otherwise swallows, parsing the file as an empty document `{}`: every
|
|
212
|
-
// committed setting is silently dropped AND the committed-token guard (ADR-0013)
|
|
213
|
-
// is bypassed. Strip every leading BOM, not just the first.
|
|
214
|
-
let text = raw;
|
|
215
|
-
while (text.charCodeAt(0) === 0xfeff) {
|
|
216
|
-
text = text.slice(1);
|
|
217
|
-
}
|
|
218
|
-
try {
|
|
219
|
-
return Bun.TOML.parse(text) as Record<string, unknown>;
|
|
220
|
-
} catch (cause) {
|
|
221
|
-
return fail(
|
|
222
|
-
withReason(`${CONFIG_REL_PATH} is not valid TOML`, cause),
|
|
223
|
-
`fix the TOML syntax in ${CONFIG_REL_PATH}`,
|
|
224
|
-
{ path: CONFIG_REL_PATH },
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/** Project the parsed TOML table onto a {@link LoreConfig}, validating known keys and merging over defaults. */
|
|
230
|
-
function validateConfig(root: Record<string, unknown>): LoreConfig {
|
|
231
|
-
const parsed = parseConfigShape(root);
|
|
232
|
-
const defaults = defaultConfig();
|
|
233
|
-
|
|
234
|
-
const reconcile: ReconcileConfig = {
|
|
235
|
-
mode: parsed.reconcile?.mode ?? defaults.reconcile.mode,
|
|
236
|
-
overrides: copyOverrideMap(parsed.reconcile?.overrides) ?? defaults.reconcile.overrides,
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
const validate: ValidateConfig = {
|
|
240
|
-
externalLinks: parsed.validate?.external_links ?? defaults.validate.externalLinks,
|
|
241
|
-
promotePortability: parsed.validate?.promote_portability ?? defaults.validate.promotePortability,
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
return {
|
|
245
|
-
reconcile,
|
|
246
|
-
validate,
|
|
247
|
-
confluence: validateConfluence(parsed.confluence, defaults.confluence),
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Validate the generic parsed-TOML shape once. Zod owns recognition; Lore owns
|
|
253
|
-
* the stable public diagnostic contract through {@link failConfigShape}.
|
|
254
|
-
*/
|
|
255
|
-
function parseConfigShape(root: Record<string, unknown>): ParsedConfig {
|
|
256
|
-
const result = ParsedConfigSchema.safeParse(root);
|
|
257
|
-
if (result.success) {
|
|
258
|
-
// Inspect the raw parsed map before Zod copies it: assigning `__proto__` to
|
|
259
|
-
// a normal object can erase that entry, which must not bypass Lore's policy.
|
|
260
|
-
assertNoReservedOverrideKey(root.reconcile, "reconcile.overrides");
|
|
261
|
-
assertNoCommittedToken(root.confluence, "confluence");
|
|
262
|
-
return result.data;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Preserve the hand-written validator's public failure precedence:
|
|
266
|
-
// reconcile shape → reserved override policy → validate shape → committed
|
|
267
|
-
// token policy → confluence shape. Zod recognizes every generic issue in one
|
|
268
|
-
// pass; Lore chooses which established diagnostic to expose.
|
|
269
|
-
const reconcileIssue = result.error.issues.find((issue) => issue.path[0] === "reconcile");
|
|
270
|
-
if (reconcileIssue !== undefined) {
|
|
271
|
-
if (reconcileIssue.path[1] === "overrides" && reconcileIssue.path.length >= 3) {
|
|
272
|
-
assertNoReservedOverrideKey(root.reconcile, "reconcile.overrides", String(reconcileIssue.path[2]));
|
|
273
|
-
}
|
|
274
|
-
return failConfigShape(root, reconcileIssue);
|
|
275
|
-
}
|
|
276
|
-
assertNoReservedOverrideKey(root.reconcile, "reconcile.overrides");
|
|
277
|
-
|
|
278
|
-
const validateIssue = result.error.issues.find((issue) => issue.path[0] === "validate");
|
|
279
|
-
if (validateIssue !== undefined) {
|
|
280
|
-
return failConfigShape(root, validateIssue);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Scan whatever shape `confluence` takes — including an array on a
|
|
284
|
-
// `[[confluence]]` typo — before exposing its generic table/type failure.
|
|
285
|
-
assertNoCommittedToken(root.confluence, "confluence");
|
|
286
|
-
const confluenceIssue = result.error.issues.find((issue) => issue.path[0] === "confluence");
|
|
287
|
-
if (confluenceIssue !== undefined) {
|
|
288
|
-
return failConfigShape(root, confluenceIssue);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (result.error.issues.length === 0) {
|
|
292
|
-
return fail(`${CONFIG_REL_PATH}: config has an invalid value`, `fix ${CONFIG_REL_PATH}`, { path: CONFIG_REL_PATH });
|
|
293
|
-
}
|
|
294
|
-
return failConfigShape(root, result.error.issues[0] as z.core.$ZodIssue);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/** Project the `[confluence]` table over defaults. The committed-token guard runs in {@link validateConfig}. */
|
|
298
|
-
function validateConfluence(table: ParsedConfluenceTable | undefined, defaults: ConfluenceConfig): ConfluenceConfig {
|
|
299
|
-
const confluence: ConfluenceConfig = {
|
|
300
|
-
format: table?.format ?? defaults.format,
|
|
301
|
-
};
|
|
302
|
-
const baseUrl = table?.base_url;
|
|
303
|
-
if (baseUrl !== undefined) {
|
|
304
|
-
confluence.baseUrl = baseUrl;
|
|
305
|
-
}
|
|
306
|
-
const space = table?.space;
|
|
307
|
-
if (space !== undefined) {
|
|
308
|
-
confluence.space = space;
|
|
309
|
-
}
|
|
310
|
-
const parentPageId = asPageId(table?.parent_page_id, "confluence.parent_page_id");
|
|
311
|
-
if (parentPageId !== undefined) {
|
|
312
|
-
confluence.parentPageId = parentPageId;
|
|
313
|
-
}
|
|
314
|
-
return confluence;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Reject a committed Confluence token anywhere under `value` (ADR-0013),
|
|
319
|
-
* recursing through tables and arrays of tables so a token at any depth — a
|
|
320
|
-
* nested `[confluence.auth]` subtable or a `[[confluence]]` array-of-tables typo
|
|
321
|
-
* — fails loud with a pointer to the env var, rather than slipping through or
|
|
322
|
-
* surfacing a generic shape error. The token value is never echoed.
|
|
323
|
-
*/
|
|
324
|
-
function assertNoCommittedToken(value: unknown, path: string): void {
|
|
325
|
-
if (Array.isArray(value)) {
|
|
326
|
-
for (const item of value) {
|
|
327
|
-
assertNoCommittedToken(item, path);
|
|
328
|
-
}
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
if (typeof value !== "object" || value === null) {
|
|
332
|
-
return;
|
|
333
|
-
}
|
|
334
|
-
const table = value as Record<string, unknown>;
|
|
335
|
-
if ("token" in table) {
|
|
336
|
-
fail(
|
|
337
|
-
`${CONFIG_REL_PATH}: a Confluence token must not be committed`,
|
|
338
|
-
`remove \`token\` from [${path}] and set $${TOKEN_ENV} instead`,
|
|
339
|
-
{ key: `${path}.token` },
|
|
340
|
-
);
|
|
341
|
-
}
|
|
342
|
-
for (const [childKey, childValue] of Object.entries(table)) {
|
|
343
|
-
assertNoCommittedToken(childValue, `${path}.${childKey}`);
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Overlay the Confluence token from the environment. Only a value with
|
|
349
|
-
* non-whitespace content counts: an unset, empty, or whitespace-only
|
|
350
|
-
* `$LORE_CONFLUENCE_TOKEN` leaves the token absent so the (deferred) publish
|
|
351
|
-
* adapter fails loud rather than sending a blank credential (ADR-0013). Stray
|
|
352
|
-
* surrounding whitespace (a shell or CI secret can introduce it) is trimmed, and
|
|
353
|
-
* the token is never written back to the file.
|
|
354
|
-
*/
|
|
355
|
-
function overlayEnv(config: LoreConfig, env: Record<string, string | undefined>): LoreConfig {
|
|
356
|
-
const token = env[TOKEN_ENV]?.trim();
|
|
357
|
-
if (!token) {
|
|
358
|
-
return config;
|
|
359
|
-
}
|
|
360
|
-
return { ...config, confluence: { ...config.confluence, token } };
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/** Throw a `"validation"` {@link LoreError}; typed `never` so callers can `return fail(...)`. */
|
|
364
|
-
function fail(message: string, hint: string, input: Record<string, unknown>): never {
|
|
365
|
-
throw new LoreError("validation", message, hint, input);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/** True when `cause` is a Node fs error carrying the given errno `code` (e.g. `"ENOENT"`). */
|
|
369
|
-
function isErrnoCode(cause: unknown, code: string): boolean {
|
|
370
|
-
return errnoCode(cause) === code;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/** Append `: <reason>` to `base` only when a non-empty reason can be derived from `cause`. */
|
|
374
|
-
function withReason(base: string, cause: unknown): string {
|
|
375
|
-
const reason = describeCause(cause);
|
|
376
|
-
return reason ? `${base}: ${reason}` : base;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* A single-line human reason from a thrown cause, for embedding in a diagnostic.
|
|
381
|
-
* An aggregate parse error (Bun throws one for some malformed TOML) is flattened
|
|
382
|
-
* to its sub-messages; otherwise the cause's own `message` is used.
|
|
383
|
-
*/
|
|
384
|
-
function describeCause(cause: unknown): string {
|
|
385
|
-
const parts: string[] = [];
|
|
386
|
-
if (cause !== null && typeof cause === "object" && Array.isArray((cause as { errors?: unknown }).errors)) {
|
|
387
|
-
for (const sub of (cause as { errors: unknown[] }).errors) {
|
|
388
|
-
pushIfNonEmpty(parts, causeMessage(sub));
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
// Fall back to the top-level message when the aggregate yielded nothing usable
|
|
392
|
-
// (an `errors` array whose sub-messages are all empty), so the diagnostic never
|
|
393
|
-
// degrades to a bare base with no reason.
|
|
394
|
-
if (parts.length === 0) {
|
|
395
|
-
pushIfNonEmpty(parts, causeMessage(cause));
|
|
396
|
-
}
|
|
397
|
-
return parts
|
|
398
|
-
.join("; ")
|
|
399
|
-
.replace(/\s*[\r\n]+\s*/g, " ")
|
|
400
|
-
.trim();
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/** Push `message` onto `parts` only when it carries content. */
|
|
404
|
-
function pushIfNonEmpty(parts: string[], message: string): void {
|
|
405
|
-
if (message !== "") {
|
|
406
|
-
parts.push(message);
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* Best-effort human message from a thrown value. Prefers a string `message`
|
|
412
|
-
* property over `String(value)` so a non-`Error` carrier (e.g. Bun's
|
|
413
|
-
* `BuildMessage` TOML error) yields its reason — "Unexpected =" — rather than
|
|
414
|
-
* leaking the runtime class-name prefix "BuildMessage: …".
|
|
415
|
-
*/
|
|
416
|
-
function causeMessage(cause: unknown): string {
|
|
417
|
-
if (cause !== null && typeof cause === "object") {
|
|
418
|
-
const message = (cause as { message?: unknown }).message;
|
|
419
|
-
if (typeof message === "string") {
|
|
420
|
-
return message;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
return typeof cause === "string" ? cause : String(cause);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Translate Zod's first deterministic shape issue into Lore's established
|
|
428
|
-
* credential-safe config diagnostic. This is error-policy mapping, not a
|
|
429
|
-
* second validator: recognition occurs only in {@link ParsedConfigSchema}.
|
|
430
|
-
*/
|
|
431
|
-
function failConfigShape(root: Record<string, unknown>, issue: z.core.$ZodIssue): never {
|
|
432
|
-
const path = issue.path.map(String);
|
|
433
|
-
const key = path.join(".");
|
|
434
|
-
const value = configValueAtPath(root, issue.path);
|
|
435
|
-
|
|
436
|
-
if (path.length === 1 || key === "reconcile.overrides") {
|
|
437
|
-
fail(`${CONFIG_REL_PATH}: ${key} must be a table`, `make ${key} a TOML table ([${key}] with key = value lines)`, {
|
|
438
|
-
key,
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
if (key === "validate.external_links" || key === "validate.promote_portability") {
|
|
443
|
-
fail(`${CONFIG_REL_PATH}: ${key} must be a boolean`, `set ${key} to true or false`, { key, value });
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
if (
|
|
447
|
-
key === "confluence.base_url" ||
|
|
448
|
-
key === "confluence.space" ||
|
|
449
|
-
(path[0] === "reconcile" && path[1] === "overrides")
|
|
450
|
-
) {
|
|
451
|
-
fail(`${CONFIG_REL_PATH}: ${key} must be a string`, `quote ${key} as a string`, { key, value });
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
if (key === "reconcile.mode") {
|
|
455
|
-
fail(
|
|
456
|
-
`${CONFIG_REL_PATH}: ${key} must be one of ${RECONCILE_MODES.map((allowed) => `"${allowed}"`).join(", ")}`,
|
|
457
|
-
`set ${key} to one of: ${RECONCILE_MODES.join(", ")}`,
|
|
458
|
-
{ key, value },
|
|
459
|
-
);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
if (key === "confluence.format") {
|
|
463
|
-
fail(
|
|
464
|
-
`${CONFIG_REL_PATH}: ${key} must be one of ${CONFLUENCE_FORMATS.map((allowed) => `"${allowed}"`).join(", ")}`,
|
|
465
|
-
`set ${key} to one of: ${CONFLUENCE_FORMATS.join(", ")}`,
|
|
466
|
-
{ key, value },
|
|
467
|
-
);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
if (key === "confluence.parent_page_id") {
|
|
471
|
-
fail(
|
|
472
|
-
`${CONFIG_REL_PATH}: ${key} must be a positive integer page id`,
|
|
473
|
-
`set ${key} to a positive integer page id (a quoted string or an unquoted integer)`,
|
|
474
|
-
{ key, value },
|
|
475
|
-
);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
return fail(
|
|
479
|
-
`${CONFIG_REL_PATH}: ${key || "config"} has an invalid value`,
|
|
480
|
-
`fix ${key || CONFIG_REL_PATH}`,
|
|
481
|
-
key ? { key, value } : { path: CONFIG_REL_PATH },
|
|
482
|
-
);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/** Read a parsed value for structured error input without changing or coercing it. */
|
|
486
|
-
function configValueAtPath(root: Record<string, unknown>, path: PropertyKey[]): unknown {
|
|
487
|
-
let value: unknown = root;
|
|
488
|
-
for (const part of path) {
|
|
489
|
-
if (value === null || typeof value !== "object") {
|
|
490
|
-
return undefined;
|
|
491
|
-
}
|
|
492
|
-
value = (value as Record<PropertyKey, unknown>)[part];
|
|
493
|
-
}
|
|
494
|
-
return value;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
/**
|
|
498
|
-
* True for a key that cannot be stored on a plain object without being dropped or
|
|
499
|
-
* shadowing an inherited member: every `Object.prototype` member (`__proto__`,
|
|
500
|
-
* `constructor`, `toString`, `hasOwnProperty`, …) plus `prototype`. (TOML keys are
|
|
501
|
-
* always strings, so this covers every key the loader can see.)
|
|
502
|
-
*/
|
|
503
|
-
function isUnsafeMapKey(key: string): boolean {
|
|
504
|
-
return key === "prototype" || key in Object.prototype;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* Enforce Lore's reserved-key policy against the raw parsed table before Zod
|
|
509
|
-
* copies it. Non-table shapes and non-string values are left to the generic
|
|
510
|
-
* schema so their existing type diagnostics retain precedence.
|
|
511
|
-
*/
|
|
512
|
-
function assertNoReservedOverrideKey(reconcile: unknown, key: string, stopAtEntry?: string): void {
|
|
513
|
-
if (reconcile === null || typeof reconcile !== "object" || Array.isArray(reconcile)) {
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
const value = (reconcile as Record<string, unknown>).overrides;
|
|
517
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
for (const [entryKey, entryValue] of Object.entries(value)) {
|
|
521
|
-
// The old validator checked entries in source order, validating each value
|
|
522
|
-
// before its key policy. Stop where Zod found the first bad value so a
|
|
523
|
-
// preceding reserved key still wins, while a later one cannot jump ahead.
|
|
524
|
-
if (entryKey === stopAtEntry || typeof entryValue !== "string") {
|
|
525
|
-
return;
|
|
526
|
-
}
|
|
527
|
-
if (!isUnsafeMapKey(entryKey)) {
|
|
528
|
-
continue;
|
|
529
|
-
}
|
|
530
|
-
fail(
|
|
531
|
-
`${CONFIG_REL_PATH}: ${key}.${entryKey} uses a reserved object key`,
|
|
532
|
-
`rename the "${entryKey}" entry under [${key}] to a real status name`,
|
|
533
|
-
{ key: `${key}.${entryKey}`, value: entryValue },
|
|
534
|
-
);
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/** Return Zod's validated string map on a normal plain object. */
|
|
539
|
-
function copyOverrideMap(value: Record<string, string> | undefined): Record<string, string> | undefined {
|
|
540
|
-
if (value === undefined) {
|
|
541
|
-
return undefined;
|
|
542
|
-
}
|
|
543
|
-
const out: Record<string, string> = {};
|
|
544
|
-
for (const [entryKey, entryValue] of Object.entries(value)) {
|
|
545
|
-
out[entryKey] = entryValue;
|
|
546
|
-
}
|
|
547
|
-
return out;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
/**
|
|
551
|
-
* Accept a Confluence page id as a positive-integer string (any length, so an id
|
|
552
|
-
* beyond `Number.MAX_SAFE_INTEGER` keeps full precision) or an unquoted positive
|
|
553
|
-
* integer (its most natural form, e.g. `parent_page_id = 98765`). Both forms must
|
|
554
|
-
* resolve to a positive integer — a `0`/negative, non-integer, empty, or
|
|
555
|
-
* non-numeric id is rejected at load time rather than surfacing later as a publish
|
|
556
|
-
* 404. The number form is constrained by its *value* (Bun has already normalized
|
|
557
|
-
* `0x10`/`1_000`/`1e3` to a decimal integer), the string form by its literal text
|
|
558
|
-
* (`/^[1-9][0-9]*$/`); the two are not byte-identical checks, but neither admits an
|
|
559
|
-
* invalid id. The sign is checked before magnitude so a negative id reports "must
|
|
560
|
-
* be positive", not "too large".
|
|
561
|
-
*/
|
|
562
|
-
function asPageId(value: unknown, key: string): string | undefined {
|
|
563
|
-
if (value === undefined) {
|
|
564
|
-
return undefined;
|
|
565
|
-
}
|
|
566
|
-
if (typeof value === "string") {
|
|
567
|
-
if (!/^[1-9][0-9]*$/.test(value)) {
|
|
568
|
-
fail(
|
|
569
|
-
`${CONFIG_REL_PATH}: ${key} must be a positive integer page id`,
|
|
570
|
-
`set ${key} to a positive integer page id, e.g. ${key} = "98765"`,
|
|
571
|
-
{ key, value },
|
|
572
|
-
);
|
|
573
|
-
}
|
|
574
|
-
return value;
|
|
575
|
-
}
|
|
576
|
-
if (typeof value === "number") {
|
|
577
|
-
if (!Number.isInteger(value) || value <= 0) {
|
|
578
|
-
fail(
|
|
579
|
-
`${CONFIG_REL_PATH}: ${key} must be a positive integer page id`,
|
|
580
|
-
`set ${key} to a positive integer page id`,
|
|
581
|
-
{ key, value },
|
|
582
|
-
);
|
|
583
|
-
}
|
|
584
|
-
if (!Number.isSafeInteger(value)) {
|
|
585
|
-
fail(
|
|
586
|
-
`${CONFIG_REL_PATH}: ${key} is too large to represent exactly as a number`,
|
|
587
|
-
`quote ${key} as a string to preserve its precision`,
|
|
588
|
-
{ key, value },
|
|
589
|
-
);
|
|
590
|
-
}
|
|
591
|
-
return String(value);
|
|
592
|
-
}
|
|
593
|
-
fail(
|
|
594
|
-
`${CONFIG_REL_PATH}: ${key} must be a positive integer page id`,
|
|
595
|
-
`set ${key} to a positive integer page id (a quoted string or an unquoted integer)`,
|
|
596
|
-
{ key, value },
|
|
597
|
-
);
|
|
598
|
-
}
|