neurain 0.1.0-alpha.0 → 0.1.0-alpha.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/docs/release-checklist.en.md +34 -1
- package/package.json +1 -1
- package/src/cli.mjs +52 -1
- package/src/core/area_index.mjs +281 -0
- package/src/core/backup.mjs +171 -0
- package/src/core/capture_durable.mjs +128 -42
- package/src/core/capture_enrich.mjs +180 -0
- package/src/core/freeze.mjs +38 -0
- package/src/core/health.mjs +92 -0
- package/src/core/hubs.mjs +92 -0
- package/src/core/label.mjs +87 -0
- package/src/core/label_intel.mjs +385 -0
- package/src/core/lint.mjs +46 -0
- package/src/core/memory.mjs +39 -0
- package/src/core/memory_intel.mjs +203 -0
- package/src/core/memory_lock.mjs +64 -0
- package/src/core/memory_write.mjs +357 -0
- package/src/core/memory_write_cli.mjs +51 -0
- package/src/core/orphans.mjs +139 -0
- package/src/core/retention.mjs +67 -0
- package/src/core/session_lint.mjs +119 -0
- package/src/core/session_pulse.mjs +73 -0
- package/src/core/session_write.mjs +231 -0
- package/src/core/sync.mjs +140 -0
|
@@ -2,7 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
Use this checklist before publishing an alpha release.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Automated Release (preferred)
|
|
6
|
+
|
|
7
|
+
Releases are automated. You only decide WHEN to ship:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm version prerelease --preid=alpha # 0.1.0-alpha.0 -> 0.1.0-alpha.1 (commits + tags)
|
|
11
|
+
git push origin main --follow-tags # push the commit AND the new tag
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The tag push triggers `.github/workflows/release.yml`, which runs the full
|
|
15
|
+
readiness gate and then `npm publish --tag alpha` via npm Trusted Publishing
|
|
16
|
+
(OIDC) — no stored token, no interactive 2FA. Every push (untagged) is gated by
|
|
17
|
+
`.github/workflows/test.yml` (tests + fast readiness + `npm publish --dry-run`).
|
|
18
|
+
|
|
19
|
+
One-time setup: on npmjs.com, the package -> Settings -> Trusted Publisher ->
|
|
20
|
+
GitHub Actions, with this repo's owner/name and workflow `release.yml`.
|
|
21
|
+
|
|
22
|
+
## Versioning policy
|
|
23
|
+
|
|
24
|
+
- Pre-1.0 alpha: increment with `npm version prerelease --preid=alpha`
|
|
25
|
+
(`-alpha.0 -> -alpha.1 -> ...`). Publish ALWAYS with `--tag alpha`; never move
|
|
26
|
+
the `latest` dist-tag to a prerelease. Only a deliberate stable (>= the first
|
|
27
|
+
non-prerelease) should ever hold `latest`.
|
|
28
|
+
|
|
29
|
+
## Rollback / yank
|
|
30
|
+
|
|
31
|
+
- A published version cannot be overwritten and cannot be unpublished after 72h.
|
|
32
|
+
To pull a bad alpha: `npm deprecate neurain@<bad> "broken, use <good>"` and ship
|
|
33
|
+
a fixed `-alpha.<next>`.
|
|
34
|
+
- To roll the consuming vault back to a prior engine: point `NEURAIN_ENGINE_BIN`
|
|
35
|
+
at the previous tag's checkout (or pin the previous npm version), or set
|
|
36
|
+
`NEURAIN_LEGACY=1` to fall back to the vault's preserved implementations.
|
|
37
|
+
|
|
38
|
+
## Local Checks (manual fallback)
|
|
6
39
|
|
|
7
40
|
```bash
|
|
8
41
|
npm ci
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -31,6 +31,20 @@ import { queueArchiveCommand } from './core/queue_archive.mjs';
|
|
|
31
31
|
import { captureCommand } from './core/capture_durable.mjs';
|
|
32
32
|
import { completeCommand } from './core/complete.mjs';
|
|
33
33
|
import { linkCheckCommand } from './core/link_check.mjs';
|
|
34
|
+
import { sessionLintCommand } from './core/session_lint.mjs';
|
|
35
|
+
import { labelCommand } from './core/label.mjs';
|
|
36
|
+
import { orphansCommand } from './core/orphans.mjs';
|
|
37
|
+
import { hubsCommand } from './core/hubs.mjs';
|
|
38
|
+
import { areaIndexCommand } from './core/area_index.mjs';
|
|
39
|
+
import { lintCommand } from './core/lint.mjs';
|
|
40
|
+
import { sessionPulseCommand } from './core/session_pulse.mjs';
|
|
41
|
+
import { syncCommand } from './core/sync.mjs';
|
|
42
|
+
import { healthCommand } from './core/health.mjs';
|
|
43
|
+
import { memoryCommand } from './core/memory.mjs';
|
|
44
|
+
import { retentionCommand } from './core/retention.mjs';
|
|
45
|
+
import { freezeCommand } from './core/freeze.mjs';
|
|
46
|
+
import { memoryWriteCommand } from './core/memory_write_cli.mjs';
|
|
47
|
+
import { backupCommand } from './core/backup.mjs';
|
|
34
48
|
import { schedulerCommand } from './core/scheduler.mjs';
|
|
35
49
|
import { searchCommand } from './core/search.mjs';
|
|
36
50
|
import { watchCommand } from './core/watch.mjs';
|
|
@@ -83,6 +97,20 @@ export async function runCli(argv) {
|
|
|
83
97
|
if (command === 'capture') return render(await captureCommand(args));
|
|
84
98
|
if (command === 'complete') return render(await completeCommand(args));
|
|
85
99
|
if (command === 'link-check') return render(await linkCheckCommand(args));
|
|
100
|
+
if (command === 'session-lint') return render(await sessionLintCommand(args));
|
|
101
|
+
if (command === 'label') return render(await labelCommand(args));
|
|
102
|
+
if (command === 'orphans') return render(await orphansCommand(args));
|
|
103
|
+
if (command === 'hubs') return render(await hubsCommand(args));
|
|
104
|
+
if (command === 'area-index') return render(await areaIndexCommand(args));
|
|
105
|
+
if (command === 'lint') return render(await lintCommand(args));
|
|
106
|
+
if (command === 'session-pulse') return render(await sessionPulseCommand(args));
|
|
107
|
+
if (command === 'sync') return render(await syncCommand(args));
|
|
108
|
+
if (command === 'health') return render(await healthCommand(args));
|
|
109
|
+
if (command === 'memory') return render(await memoryCommand(args));
|
|
110
|
+
if (command === 'retention') return render(await retentionCommand(args));
|
|
111
|
+
if (command === 'freeze') return render(await freezeCommand(args));
|
|
112
|
+
if (command === 'memory-write') return render(await memoryWriteCommand(args));
|
|
113
|
+
if (command === 'backup') return render(await backupCommand(args));
|
|
86
114
|
if (command === 'capabilities') return render(await capabilitiesCommand(args));
|
|
87
115
|
if (command === 'recap') return render(await recapCommand(args));
|
|
88
116
|
if (command === 'watch') return render(await watchCommand(args));
|
|
@@ -203,6 +231,20 @@ Usage:
|
|
|
203
231
|
neurain capture <folder> [--text "..." | <text> | --file rel] [--session-id id] [--type memo] [--title t] [--area name] [--sensitivity s] [--intent i] [--allow-secret] [--dry-run] [--json]
|
|
204
232
|
neurain complete <folder> --source-id id --compiled-to "a.md,b.md" [--status compiled] [--completed-at ts] [--dry-run] [--json]
|
|
205
233
|
neurain link-check <folder> [--scope .] [--top 20] [--include-raw] [--include-archive] [--include-output] [--json]
|
|
234
|
+
neurain session-lint <folder> [--stale-days 7] [--now ms] [--json]
|
|
235
|
+
neurain label <folder> [--check | --apply] [--area name | --all] [--glob substr] [--limit N] [--json]
|
|
236
|
+
neurain orphans <folder> [--area name] [--fix] [--json]
|
|
237
|
+
neurain hubs <folder> [--check | --apply] [--area name] [--json]
|
|
238
|
+
neurain area-index <folder> [--build area | --refresh area | --register-curated area [--sensitivity private] | --detect [--fix] [--restamp-curated]] [--force] [--dry-run] [--json]
|
|
239
|
+
neurain lint <folder> [--json]
|
|
240
|
+
neurain session-pulse <folder> --session-id id --summary "..." [--focus t] [--next t] [--max-notes 8] [--no-area-brief] [--queue ...] [--now ts] [--dry-run] [--allow-secret] [--json]
|
|
241
|
+
neurain sync <folder> --session-id id [--summary "..."] [--level light|standard|full] [--no-pulse] [--no-area-brief] [--queue ...] [--now ts] [--dry-run] [--json]
|
|
242
|
+
neurain health <folder> [--fix] [--now ts] [--json]
|
|
243
|
+
neurain memory <folder> [--facts q | --tasks q | --conflicts | --verify | --stats] [--area name] [--top 10] [--json]
|
|
244
|
+
neurain retention <folder> [--area name] [--stale-days 120] [--write] [--now ms] [--json]
|
|
245
|
+
neurain freeze <folder> <acquire|release|status> [--owner name] [--json]
|
|
246
|
+
neurain memory-write <folder> --area name --file ledger.md --op add-fact|add-task|set-status|append-event [--fields json] [--changes json] [--event json] [--fact-id id] [--id id] [--apply] [--json]
|
|
247
|
+
neurain backup <folder> --target <dir> [--label name] | --verify <name> | --restore <name> [--to <dir>] | --drill <name> [--target <dir>] | --list [--json]
|
|
206
248
|
neurain recap <folder> [--area name] [--json]
|
|
207
249
|
neurain watch <folder> [--area name] [--since-minutes 1440] [--top 10] [--poll-once] [--json]
|
|
208
250
|
neurain review <folder> [--area name] [--since-minutes 1440] [--top 10] [--json]
|
|
@@ -249,7 +291,16 @@ Alpha principles:
|
|
|
249
291
|
- Source-digest and plan-receipt are W-B writers that touch only their own non-canonical artifacts: source-digest writes the rebuildable source-digest manifest (dry by default, --write to update) and plan-receipt writes an optional receipt under output/receipts/. Every durable write goes through an atomic temp+fsync+rename plus a cross-process lock, and the manifest's path sensitivity is decided by the label resolver, never hardcoded area names.
|
|
250
292
|
- Stage is the secret gate: content lands in .neurain-staging (excluded from every walk), is scanned fail-closed for high-confidence secret shapes, mnemonics, context-aware hex keys, and high-entropy tokens, and is promoted to its canonical target by an atomic rename ONLY if clean; a hit holds the content in staging, leaves the target untouched, and exits 2. Queue-archive moves terminal queue rows into an append-only archive under one lock (archive appended before the hot-queue rewrite, so a crash never loses a row).
|
|
251
293
|
- Capture and complete are the durable pipeline writers. They write raw markdown (capture, routed through the stage secret gate), the _inbox envelope, the writeback queue (capture appends, complete rewrites - both under a lock), and a wiki/log line, but they NEVER write session-state, handoff, or area-brief files: those stay W-D-owned, returned as an unapplied session_state_delta whose pending_count is advisory (the future vault shuttle recomputes at apply time). Capture is text-only in this increment; file capture and overlap/numeric enrichment are the documented flip gate.
|
|
252
|
-
- Link-check (W-C)
|
|
294
|
+
- Link-check and session-lint (W-C) are read-only validators that write nothing. Link-check resolves every markdown link and wikilink against the vault and reports unresolved targets by file and area. Session-lint validates the session layer: required paths, session-state vs handoff consistency, pending-count drift vs the queue, handoff frontmatter, and queue rows referencing known sessions. Both use the configured structural dirs, not hardcoded area names.
|
|
295
|
+
- Label (W-C) is the label-system CLI: --check (default, read-only) proposes the managed label block (type/areas/entities/domains/sensitivity/tags) per knowledge file and reports private leaks; --apply writes it additively and idempotently, atomic per file, refusing symlinks. The label brain (label_intel) hard-codes nothing vault-specific — vocabulary comes from each area's entity dictionary and tuning from an optional label-config.json. A private/published file never gets entity/domain tags.
|
|
296
|
+
- Backup (W-E) is the local snapshot + EXACT restore + restore-drill: the manifest records files (sha256+size) and dirs, symlinks/special files are refused, and restore reconstructs the target byte-exactly (and proves it) via stage-verify-rename-aside-reverify, so a no-git vault still has a verifiable rollback. The macOS/iCloud encrypted external backup stays vault-side.
|
|
297
|
+
- Memory-write (W-E) is the registry-driven fact/task ledger writer: schemas, ID templates, mandatory/dash columns and paths come from memory-write-registry.json, so every area is handled by the same code. It appends rows or edits a fact's lifecycle columns while keeping every other row byte-identical, under an O_EXCL lock with a sha256 compare-and-swap (concurrent modification aborts) + fsync. Default dry-run; a live write needs the area's write_enabled:true, a realpath inside area_root, a fail-closed secret scan, and the freeze lock not held.
|
|
298
|
+
- Memory and retention (W-E, read-only) query the per-area fact/task ledgers via a config-driven reader that maps heterogeneous table schemas by column name. Memory does status-aware fact/task lookup, conflicts, and verify; retention flags active facts for human review (contradicted / unverified / stale / low-confidence / missing-metadata) and never deletes (optional --write emits a report). Neither touches the ledgers.
|
|
299
|
+
- Health (W-D) composes doctor + lint into one verdict and reconciles session-state drift: pending_count's source of truth is the writeback queue and last_pulse's is the handoff frontmatter, so a session can drift. Read-only without --fix; with --fix it repairs drift via the locked session-state path and runs the W-C maintenance fixers (orphans, label, area-index) before a final lint.
|
|
300
|
+
- Session-pulse (W-D) is the session working-memory writer: it pulses one durable note into the handoff, optionally the area brief and a queue row, then advances session-state. Hardened per cross-review: markdown is written first and the authoritative session-state.json last (a crash leaves the markdown ahead but state recoverable), every write is atomic, the area brief is rendered + labelled in memory and written once, and the note is secret-scanned fail-closed. session-state writes are this wave's (W-D) responsibility; W-B's capture/complete only returned a delta for it to apply.
|
|
301
|
+
- Lint (W-C) is a read-only STRUCTURAL aggregator: it composes the ported validators (link-check, session-lint, advisory orphans) into one health verdict. It deliberately does NOT re-implement the vault's documentation-governance checks (master version, clipper templates, instruction lengths, progressive disclosure) — those stay vault-side.
|
|
302
|
+
- Area-index (W-C) generates a per-area search index from the area's own signals (wiki/entities pages, frontmatter entity_candidates, area-tagged raw envelopes, salience-gated content terms), registers it, detects staleness via a file signature, and refreshes drifted generated indexes. Curated indexes are never auto-overwritten; --detect is read-only, writes happen only with --build/--refresh/--register-curated/--detect --fix.
|
|
303
|
+
- Orphans and hubs (W-C) are the graph connection layer. Orphans reports knowledge files with no inbound and no outbound wikilink, classified (structural / has-entities / no-entities); --fix adds one additive per-area graph-backlog page (source files untouched). Hubs generates per-entity hub pages linking member notes (dry-run by default, --apply writes and prunes only its own hub_generated pages); private areas and private files are excluded, member files are never modified.
|
|
253
304
|
- Live-cases scaffold creates a redacted E23 reviewed-case pack scaffold with hash-only source refs. It does not claim human evidence and stores no raw source text or absolute paths.
|
|
254
305
|
- Onboard is a read-only first-run guide for non-developers. It explains the next command without creating files or calling a model.
|
|
255
306
|
- Answer eval checks faithfulness, citation accuracy, conflict surfacing, abstention, private boundaries, and stale-source handling without model calls or writes.
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// `area-index` command (W-C). Per-area search-index automation: generates a
|
|
2
|
+
// starter entity/domain index from an area's own high-confidence signals
|
|
3
|
+
// (wiki/entities pages, frontmatter entity_candidates, area-tagged raw envelopes,
|
|
4
|
+
// and salience-gated content terms), registers it, detects staleness via a file
|
|
5
|
+
// signature, and refreshes drifted generated indexes. Curated indexes are never
|
|
6
|
+
// auto-overwritten. Faithful, root-injected port of the vault neurain-area-index;
|
|
7
|
+
// writes go through the engine atomic primitive. Self-contained, config-driven,
|
|
8
|
+
// no vault identifiers.
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { absPath, readText, relPath, timestamp, walkFiles } from './fs.mjs';
|
|
13
|
+
import { vaultConfig } from './config.mjs';
|
|
14
|
+
import { readJsonSafe } from './vault_state.mjs';
|
|
15
|
+
import { atomicWriteJson } from './durable.mjs';
|
|
16
|
+
|
|
17
|
+
const STOPLIST = new Set([
|
|
18
|
+
'the', 'and', 'for', 'with', 'this', 'that', 'from', 'into', 'over', 'current', 'status', 'index', 'area', 'note', 'notes',
|
|
19
|
+
'overview', 'summary', 'guide', 'report', 'readme', 'draft', 'final', 'update', 'system', 'data', 'file', 'files',
|
|
20
|
+
'그리고', '하지만', '현재', '관련', '내용', '정리', '요약', '개요', '문서', '항목', '경우', '대한', '위한',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function parseFrontmatter(text) {
|
|
24
|
+
const m = String(text || '').match(/^---\n([\s\S]*?)\n---/);
|
|
25
|
+
if (!m) return {};
|
|
26
|
+
const fm = {};
|
|
27
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
28
|
+
const mm = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
|
|
29
|
+
if (!mm) continue;
|
|
30
|
+
let val = mm[2].trim();
|
|
31
|
+
if (val.startsWith('[') && val.endsWith(']')) val = val.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
32
|
+
else val = val.replace(/^["']|["']$/g, '');
|
|
33
|
+
fm[mm[1]] = val;
|
|
34
|
+
}
|
|
35
|
+
return fm;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toList(v) {
|
|
39
|
+
if (!v) return [];
|
|
40
|
+
return (Array.isArray(v) ? v : String(v).replace(/^\[|\]$/g, '').split(',')).map((s) => String(s).trim()).filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
function slug(v) {
|
|
43
|
+
return String(v || '').toLowerCase().replace(/[^a-z0-9가-힣]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'x';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function mdFiles(root, relDir) {
|
|
47
|
+
return walkFiles(path.join(root, relDir), { includeRaw: true, maxFiles: 200000 })
|
|
48
|
+
.filter((abs) => abs.endsWith('.md'))
|
|
49
|
+
.map((abs) => relPath(root, abs));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function areaFiles(ctx, area) {
|
|
53
|
+
return mdFiles(ctx.root, `${ctx.areasDir}/${area}`)
|
|
54
|
+
.filter((rel) => !/(^|\/)(search-index|_trash|_archive)\//.test(rel))
|
|
55
|
+
.filter((rel) => !/(^|\/)current\/[^/]*-area-brief\.md$/.test(rel)); // rolling brief excluded from the signature
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function signatureOf(ctx, relFiles) {
|
|
59
|
+
const parts = relFiles
|
|
60
|
+
.map((rel) => { try { const st = fs.statSync(path.join(ctx.root, rel)); return `${rel}|${Math.round(st.mtimeMs)}|${st.size}`; } catch { return ''; } })
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.sort();
|
|
63
|
+
return crypto.createHash('sha256').update(parts.join('\n')).digest('hex');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function stripMarkup(text) {
|
|
67
|
+
return String(text || '')
|
|
68
|
+
.replace(/^---\n[\s\S]*?\n---/, ' ')
|
|
69
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
70
|
+
.replace(/`[^`]*`/g, ' ')
|
|
71
|
+
.replace(/!?\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
72
|
+
.replace(/https?:\/\/\S+/g, ' ')
|
|
73
|
+
.replace(/[#>*_~|]/g, ' ');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function contentCandidates(ctx, relFiles) {
|
|
77
|
+
const freq = new Map();
|
|
78
|
+
const bump = (term, rel) => {
|
|
79
|
+
const key = String(term || '').trim().replace(/\s+/g, ' ').replace(/^(the|a|an)\s+/i, '');
|
|
80
|
+
if (key.length < 2 || key.length > 40) return;
|
|
81
|
+
const low = key.toLowerCase();
|
|
82
|
+
if (STOPLIST.has(low)) return;
|
|
83
|
+
const c = freq.get(low) || { term: key, count: 0, docs: new Set() };
|
|
84
|
+
c.count += 1; c.docs.add(rel); freq.set(low, c);
|
|
85
|
+
};
|
|
86
|
+
for (const rel of relFiles) {
|
|
87
|
+
const text = stripMarkup(readText(path.join(ctx.root, rel), ''));
|
|
88
|
+
for (const m of text.matchAll(/\b[A-Z][A-Z0-9]{1,7}\b/g)) bump(m[0], rel);
|
|
89
|
+
for (const m of text.matchAll(/\b[A-Z][a-z0-9]+(?:\s+[A-Z][a-z0-9]+){0,3}\b/g)) bump(m[0], rel);
|
|
90
|
+
for (const m of text.matchAll(/[가-힣-ヿ一-鿿]{2,12}/g)) bump(m[0], rel);
|
|
91
|
+
}
|
|
92
|
+
return [...freq.values()].filter((c) => c.docs.size >= 2 || c.count >= 3).sort((a, b) => b.count - a.count).slice(0, 60);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function highConfidenceEntities(ctx, area, relFiles) {
|
|
96
|
+
const byCanonical = new Map();
|
|
97
|
+
const add = (canonical, type, confidence, evidence) => {
|
|
98
|
+
const key = String(canonical || '').trim();
|
|
99
|
+
if (!key || key.length < 2 || STOPLIST.has(key.toLowerCase())) return;
|
|
100
|
+
const cur = byCanonical.get(key.toLowerCase()) || { canonical: key, type: type || 'entity', aliases: new Set([key]), confidence, evidence: new Set(), count: 0 };
|
|
101
|
+
cur.count += 1;
|
|
102
|
+
if (evidence) cur.evidence.add(evidence);
|
|
103
|
+
if (confidence === 'high') cur.confidence = 'high';
|
|
104
|
+
byCanonical.set(key.toLowerCase(), cur);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
for (const rel of mdFiles(ctx.root, `${ctx.wikiDir}/entities`)) {
|
|
108
|
+
const text = readText(path.join(ctx.root, rel), '');
|
|
109
|
+
const fm = parseFrontmatter(text);
|
|
110
|
+
const areas = Array.isArray(fm.areas) ? fm.areas : String(fm.areas || '').split(',').map((s) => s.trim());
|
|
111
|
+
if (areas.includes(area)) add(fm.title || path.basename(rel, '.md'), 'entity', 'high', rel);
|
|
112
|
+
}
|
|
113
|
+
for (const rel of relFiles) {
|
|
114
|
+
const fm = parseFrontmatter(readText(path.join(ctx.root, rel), ''));
|
|
115
|
+
for (const c of toList(fm.entity_candidates)) add(c, 'entity', 'medium', rel);
|
|
116
|
+
}
|
|
117
|
+
const inboxAbs = path.join(ctx.root, ctx.rawInbox);
|
|
118
|
+
for (const file of (fs.existsSync(inboxAbs) ? fs.readdirSync(inboxAbs) : [])) {
|
|
119
|
+
if (!file.endsWith('.json')) continue;
|
|
120
|
+
const env = readJsonSafe(path.join(inboxAbs, file), null);
|
|
121
|
+
if (env && toList(env.area_candidates).includes(area)) {
|
|
122
|
+
for (const c of toList(env.entity_candidates)) add(c, 'entity', 'medium', `${ctx.rawInbox}/${file}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const c of contentCandidates(ctx, relFiles)) {
|
|
126
|
+
const low = c.term.toLowerCase();
|
|
127
|
+
const cur = byCanonical.get(low) || { canonical: c.term, type: 'term', aliases: new Set([c.term]), confidence: 'medium', evidence: new Set(), count: 0 };
|
|
128
|
+
cur.count += Math.max(2, c.docs.size);
|
|
129
|
+
for (const d of c.docs) cur.evidence.add(d);
|
|
130
|
+
byCanonical.set(low, cur);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return [...byCanonical.values()]
|
|
134
|
+
.filter((e) => e.confidence === 'high' || e.count >= 2)
|
|
135
|
+
.map((e) => ({ id: `entity:${area}:${slug(e.canonical)}`, type: e.type, canonical: e.canonical, aliases: [...e.aliases], confidence: e.confidence, evidence_docs: [...e.evidence].slice(0, 5) }));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function keywordsFromFolder(name) {
|
|
139
|
+
return name.replace(/^\d+[_-]?\d*[_-]?/, '').split(/[_\-\s]+/).map((s) => s.trim()).filter((s) => s.length >= 3 && !STOPLIST.has(s.toLowerCase()) && !/^\d+$/.test(s));
|
|
140
|
+
}
|
|
141
|
+
function domainsFromFolders(ctx, area) {
|
|
142
|
+
const dir = path.join(ctx.root, ctx.areasDir, area);
|
|
143
|
+
if (!fs.existsSync(dir)) return [];
|
|
144
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
145
|
+
.filter((d) => d.isDirectory() && !/^(search-index|_trash|_archive|node_modules)/.test(d.name) && !d.name.startsWith('.'))
|
|
146
|
+
.map((d) => ({ id: `domain:${area}:${slug(d.name)}`, label: d.name, keywords: keywordsFromFolder(d.name), boost_paths: [`${ctx.areasDir}/${area}/${d.name}`] }))
|
|
147
|
+
.filter((d) => d.keywords.length);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function loadRegistry(ctx) {
|
|
151
|
+
return readJsonSafe(path.join(ctx.root, ctx.registryPath), { areas: {} }) || { areas: {} };
|
|
152
|
+
}
|
|
153
|
+
function saveRegistry(ctx, reg) {
|
|
154
|
+
atomicWriteJson(path.join(ctx.root, ctx.registryPath), reg);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildArea(ctx, area, { force = false } = {}) {
|
|
158
|
+
const reg = loadRegistry(ctx);
|
|
159
|
+
const existing = reg.areas?.[area];
|
|
160
|
+
if (existing?.curated) return { ok: false, area, error: 'curated index, refusing to overwrite (use the curated source)' };
|
|
161
|
+
if (existing && !force) return { ok: false, area, error: 'already registered; pass --force to rebuild a generated index' };
|
|
162
|
+
|
|
163
|
+
const relFiles = areaFiles(ctx, area);
|
|
164
|
+
const entities = highConfidenceEntities(ctx, area, relFiles);
|
|
165
|
+
const domains = domainsFromFolders(ctx, area);
|
|
166
|
+
const sig = signatureOf(ctx, relFiles);
|
|
167
|
+
const dir = `${ctx.areasDir}/${area}/search-index`;
|
|
168
|
+
|
|
169
|
+
if (!ctx.dryRun) {
|
|
170
|
+
atomicWriteJson(path.join(ctx.root, `${dir}/entities.json`), entities);
|
|
171
|
+
atomicWriteJson(path.join(ctx.root, `${dir}/domain-routing.json`), domains);
|
|
172
|
+
atomicWriteJson(path.join(ctx.root, `${dir}/meta.json`), {
|
|
173
|
+
area, generated: true, generated_at: timestamp(), signature: sig, candidate_count: entities.length, scanned_files: relFiles.length,
|
|
174
|
+
note: 'Auto-generated starter index. confidence high|medium only. Curate for precision; set "curated": true in the registry to stop auto-refresh.',
|
|
175
|
+
});
|
|
176
|
+
reg.areas = reg.areas || {};
|
|
177
|
+
const previous = reg.areas[area] || {};
|
|
178
|
+
reg.areas[area] = { ...previous, area_root: `${ctx.areasDir}/${area}`, index_dir: 'search-index', entities: 'entities.json', domain_routing: 'domain-routing.json', generated: true, signature: sig, path_map: previous.path_map || [] };
|
|
179
|
+
saveRegistry(ctx, reg);
|
|
180
|
+
}
|
|
181
|
+
return { ok: true, area, entities: entities.length, domains: domains.length, scanned: relFiles.length, signature: sig.slice(0, 12), dry_run: ctx.dryRun };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function registerCurated(ctx, area, { sensitivity } = {}) {
|
|
185
|
+
const dir = `${ctx.areasDir}/${area}/search-index`;
|
|
186
|
+
const entPath = path.join(ctx.root, `${dir}/entities.json`);
|
|
187
|
+
const domPath = path.join(ctx.root, `${dir}/domain-routing.json`);
|
|
188
|
+
if (!fs.existsSync(entPath) || !fs.existsSync(domPath)) return { ok: false, area, error: `curated source missing: write ${dir}/entities.json and ${dir}/domain-routing.json first` };
|
|
189
|
+
const entities = readJsonSafe(entPath, []);
|
|
190
|
+
const domains = readJsonSafe(domPath, []);
|
|
191
|
+
const relFiles = areaFiles(ctx, area);
|
|
192
|
+
const sig = signatureOf(ctx, relFiles);
|
|
193
|
+
if (!ctx.dryRun) {
|
|
194
|
+
atomicWriteJson(path.join(ctx.root, `${dir}/meta.json`), {
|
|
195
|
+
area, curated: true, generated: false, ...(sensitivity ? { sensitivity } : {}), curated_at: timestamp(), signature: sig,
|
|
196
|
+
entity_count: entities.length, domain_count: domains.length, scanned_files: relFiles.length,
|
|
197
|
+
note: 'Hand-curated entity/domain index. Do not auto-refresh; update curation when staleness is flagged.',
|
|
198
|
+
});
|
|
199
|
+
const reg = loadRegistry(ctx);
|
|
200
|
+
reg.areas = reg.areas || {};
|
|
201
|
+
const previous = reg.areas[area] || {};
|
|
202
|
+
reg.areas[area] = { ...previous, area_root: `${ctx.areasDir}/${area}`, index_dir: 'search-index', entities: 'entities.json', domain_routing: 'domain-routing.json', curated: true, ...(sensitivity ? { sensitivity } : {}), signature: sig, path_map: previous.path_map || [] };
|
|
203
|
+
saveRegistry(ctx, reg);
|
|
204
|
+
}
|
|
205
|
+
return { ok: true, area, mode: 'register-curated', entities: entities.length, domains: domains.length, scanned: relFiles.length, signature: sig.slice(0, 12), sensitivity: sensitivity || null, dry_run: ctx.dryRun };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function detect(ctx) {
|
|
209
|
+
const reg = loadRegistry(ctx);
|
|
210
|
+
const areasAbs = path.join(ctx.root, ctx.areasDir);
|
|
211
|
+
let present = [];
|
|
212
|
+
try {
|
|
213
|
+
present = fs.readdirSync(areasAbs, { withFileTypes: true })
|
|
214
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.')).map((d) => d.name)
|
|
215
|
+
.filter((a) => fs.existsSync(path.join(ctx.root, `${ctx.areasDir}/${a}/_area.md`)));
|
|
216
|
+
} catch { present = []; }
|
|
217
|
+
const missing = []; const stale_curated = []; const stale_generated = [];
|
|
218
|
+
for (const area of present) {
|
|
219
|
+
const entry = reg.areas?.[area];
|
|
220
|
+
if (!entry) { missing.push(area); continue; }
|
|
221
|
+
if (!entry.signature) continue;
|
|
222
|
+
if (entry.signature === signatureOf(ctx, areaFiles(ctx, area))) continue;
|
|
223
|
+
if (entry.curated) stale_curated.push(area); else stale_generated.push(area);
|
|
224
|
+
}
|
|
225
|
+
return { missing, stale_curated, stale_generated };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function refresh(ctx, area) {
|
|
229
|
+
const reg = loadRegistry(ctx);
|
|
230
|
+
const entry = reg.areas?.[area];
|
|
231
|
+
if (!entry) return { ok: false, area, error: 'not registered; use --build' };
|
|
232
|
+
if (entry.curated) return { ok: false, area, error: 'curated index, refusing to refresh' };
|
|
233
|
+
return buildArea(ctx, area, { force: true });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function autoTidy(ctx, restampCurated) {
|
|
237
|
+
const { missing, stale_curated, stale_generated } = detect(ctx);
|
|
238
|
+
const reg = loadRegistry(ctx);
|
|
239
|
+
const built = []; const refreshed = []; const restamped_curated = [];
|
|
240
|
+
for (const area of missing) if (buildArea(ctx, area, {}).ok) built.push(area);
|
|
241
|
+
for (const area of stale_generated) if (refresh(ctx, area).ok) refreshed.push(area);
|
|
242
|
+
if (restampCurated) {
|
|
243
|
+
for (const area of stale_curated) {
|
|
244
|
+
const sensitivity = reg.areas?.[area]?.sensitivity || null;
|
|
245
|
+
if (registerCurated(ctx, area, { sensitivity }).ok) restamped_curated.push(area);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const restamped = new Set(restamped_curated);
|
|
249
|
+
return { built, refreshed, restamped_curated, needs_recuration: stale_curated.filter((a) => !restamped.has(a)) };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function areaIndexCommand(args) {
|
|
253
|
+
const root = absPath(args._[0] || args.root || process.cwd());
|
|
254
|
+
const vaultCfg = vaultConfig(root);
|
|
255
|
+
const ctx = {
|
|
256
|
+
root,
|
|
257
|
+
registryPath: vaultCfg.search_index_registry,
|
|
258
|
+
areasDir: vaultCfg.areas_dir,
|
|
259
|
+
wikiDir: vaultCfg.wiki_dir,
|
|
260
|
+
rawInbox: vaultCfg.raw_inbox_dir,
|
|
261
|
+
dryRun: Boolean(args['dry-run']),
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
let result;
|
|
265
|
+
let durable = false;
|
|
266
|
+
if (args.detect && args.fix) { const t = autoTidy(ctx, Boolean(args['restamp-curated'])); result = { ok: true, mode: 'detect-fix', ...t }; durable = !ctx.dryRun && (t.built.length + t.refreshed.length + t.restamped_curated.length > 0); }
|
|
267
|
+
else if (args.detect) { result = { ok: true, mode: 'detect', ...detect(ctx) }; }
|
|
268
|
+
else if (args['register-curated']) { result = registerCurated(ctx, String(args['register-curated']), { sensitivity: args.sensitivity ? String(args.sensitivity) : null }); durable = !ctx.dryRun && result.ok !== false; }
|
|
269
|
+
else if (args.refresh) { result = { mode: 'refresh', ...refresh(ctx, String(args.refresh)) }; durable = !ctx.dryRun && result.ok !== false; }
|
|
270
|
+
else if (args.build) { result = { mode: 'build', ...buildArea(ctx, String(args.build), { force: Boolean(args.force) }) }; durable = !ctx.dryRun && result.ok !== false; }
|
|
271
|
+
else {
|
|
272
|
+
process.exitCode = 1;
|
|
273
|
+
const payload = { ok: false, command: 'area-index', durable_write: false, error: 'Use --build <area> | --register-curated <area> [--sensitivity private] | --detect [--fix] [--restamp-curated] | --refresh <area>' };
|
|
274
|
+
return args.json ? { json: true, payload } : { text: payload.error };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const payload = { command: 'area-index', durable_write: durable, ...result };
|
|
278
|
+
if (payload.ok === false) process.exitCode = 1;
|
|
279
|
+
if (args.json) return { json: true, payload };
|
|
280
|
+
return { text: JSON.stringify(payload, null, 2) };
|
|
281
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// `backup` command (W-E). Local snapshot + EXACT restore + restore-drill. In a
|
|
2
|
+
// no-git vault this is the only rollback, so restore reconstructs the target
|
|
3
|
+
// EXACTLY (every backed-up file byte-identical AND no extra files) and proves it.
|
|
4
|
+
// The manifest records files (sha256+size) and dirs; symlinks/special files are
|
|
5
|
+
// REFUSED (never silently dropped). Restore is atomic-ish: stage into a sibling
|
|
6
|
+
// temp, verify, rename the live tree aside, move the staged tree in, re-verify,
|
|
7
|
+
// drop the aside. Faithful, root-injected port of the vault neurain-backup. (The
|
|
8
|
+
// macOS/iCloud external-encrypted-backup tool stays vault-side.)
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { absPath } from './fs.mjs';
|
|
13
|
+
import { vaultConfig } from './config.mjs';
|
|
14
|
+
|
|
15
|
+
const sha256File = (abs) => crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex');
|
|
16
|
+
const stamp = () => new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
|
|
17
|
+
|
|
18
|
+
function walkTree(absRoot) {
|
|
19
|
+
const files = []; const dirs = [];
|
|
20
|
+
(function rec(absDir) {
|
|
21
|
+
for (const e of fs.readdirSync(absDir, { withFileTypes: true })) {
|
|
22
|
+
if (e.name === '.DS_Store') continue;
|
|
23
|
+
const abs = path.join(absDir, e.name);
|
|
24
|
+
const rel = path.relative(absRoot, abs).split(path.sep).join('/');
|
|
25
|
+
if (e.isSymbolicLink()) throw new Error(`symlink not supported in backup target: ${rel}`);
|
|
26
|
+
if (e.isDirectory()) { dirs.push(rel); rec(abs); }
|
|
27
|
+
else if (e.isFile()) files.push({ rel, sha256: sha256File(abs), size: fs.statSync(abs).size });
|
|
28
|
+
else throw new Error(`unsupported entry (not a regular file or dir): ${rel}`);
|
|
29
|
+
}
|
|
30
|
+
})(absRoot);
|
|
31
|
+
files.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
32
|
+
dirs.sort();
|
|
33
|
+
return { files, dirs };
|
|
34
|
+
}
|
|
35
|
+
function copyTree(srcRoot, dstRoot, manifest) {
|
|
36
|
+
fs.mkdirSync(dstRoot, { recursive: true });
|
|
37
|
+
for (const d of manifest.dirs || []) fs.mkdirSync(path.join(dstRoot, d), { recursive: true });
|
|
38
|
+
for (const f of manifest.files) {
|
|
39
|
+
const dst = path.join(dstRoot, f.rel);
|
|
40
|
+
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
41
|
+
fs.copyFileSync(path.join(srcRoot, f.rel), dst);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function compareExact(rootAbs, manifest) {
|
|
45
|
+
const missing = []; const mismatched = []; const extra = [];
|
|
46
|
+
const expected = new Set(manifest.files.map((f) => f.rel));
|
|
47
|
+
for (const f of manifest.files) {
|
|
48
|
+
const abs = path.join(rootAbs, f.rel);
|
|
49
|
+
if (!fs.existsSync(abs)) missing.push(f.rel);
|
|
50
|
+
else if (sha256File(abs) !== f.sha256) mismatched.push(f.rel);
|
|
51
|
+
}
|
|
52
|
+
let actual;
|
|
53
|
+
try { actual = walkTree(rootAbs); } catch (e) { return { missing, mismatched, extra: [`<walk error: ${e.message}>`] }; }
|
|
54
|
+
for (const f of actual.files) if (!expected.has(f.rel)) extra.push(f.rel);
|
|
55
|
+
const expectedDirs = new Set(manifest.dirs || []);
|
|
56
|
+
for (const d of manifest.dirs || []) { const abs = path.join(rootAbs, d); if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) missing.push(`${d}/`); }
|
|
57
|
+
for (const d of actual.dirs) if (!expectedDirs.has(d)) extra.push(`${d}/`);
|
|
58
|
+
return { missing, mismatched, extra };
|
|
59
|
+
}
|
|
60
|
+
const isExact = (c) => c.missing.length === 0 && c.mismatched.length === 0 && c.extra.length === 0;
|
|
61
|
+
function assertSafeRel(rel, kind) {
|
|
62
|
+
if (typeof rel !== 'string' || rel === '' || path.isAbsolute(rel)) throw new Error(`unsafe manifest ${kind}: ${JSON.stringify(rel)}`);
|
|
63
|
+
const norm = path.normalize(rel).split(path.sep).join('/');
|
|
64
|
+
if (norm === '..' || norm.startsWith('../') || norm.includes('/../') || norm !== rel) throw new Error(`unsafe/non-normalized manifest ${kind}: ${rel}`);
|
|
65
|
+
}
|
|
66
|
+
function loadManifest(backupRoot, name) {
|
|
67
|
+
const m = JSON.parse(fs.readFileSync(path.join(backupRoot, name, 'manifest.json'), 'utf8'));
|
|
68
|
+
for (const f of m.files || []) assertSafeRel(f.rel, 'file');
|
|
69
|
+
for (const d of m.dirs || []) assertSafeRel(d, 'dir');
|
|
70
|
+
return m;
|
|
71
|
+
}
|
|
72
|
+
function realTargetUnderRoot(root, toAbs) {
|
|
73
|
+
const rootReal = fs.realpathSync(root);
|
|
74
|
+
const abs = path.resolve(toAbs);
|
|
75
|
+
let real;
|
|
76
|
+
try { real = fs.realpathSync(abs); } catch {
|
|
77
|
+
let parent = path.dirname(abs); let realParent;
|
|
78
|
+
try { realParent = fs.realpathSync(parent); } catch { realParent = parent; }
|
|
79
|
+
real = path.join(realParent, path.basename(abs));
|
|
80
|
+
}
|
|
81
|
+
if (real !== rootReal && !real.startsWith(rootReal + path.sep)) throw new Error(`restore target escapes the vault (symlink ancestor?): ${toAbs}`);
|
|
82
|
+
return real;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function doBackup(root, backupRoot, targetRel, label) {
|
|
86
|
+
const targetAbs = path.join(root, targetRel);
|
|
87
|
+
if (!fs.existsSync(targetAbs) || !fs.statSync(targetAbs).isDirectory()) throw new Error(`target must be an existing directory: ${targetRel}`);
|
|
88
|
+
const tree = walkTree(targetAbs);
|
|
89
|
+
const name = `${stamp()}${label ? `_${label}` : ''}`;
|
|
90
|
+
const dataAbs = path.join(backupRoot, name, 'data');
|
|
91
|
+
const manifest = { name, target: targetRel, created: new Date().toISOString(), dirs: tree.dirs, files: tree.files };
|
|
92
|
+
copyTree(targetAbs, dataAbs, manifest);
|
|
93
|
+
fs.writeFileSync(path.join(backupRoot, name, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
94
|
+
const cmp = compareExact(dataAbs, manifest);
|
|
95
|
+
return { name, file_count: tree.files.length, dir_count: tree.dirs.length, verified: isExact(cmp), cmp };
|
|
96
|
+
}
|
|
97
|
+
function restore(root, backupRoot, name, toRel) {
|
|
98
|
+
const m = loadManifest(backupRoot, name);
|
|
99
|
+
const dataAbs = path.join(backupRoot, name, 'data');
|
|
100
|
+
if (!isExact(compareExact(dataAbs, m))) throw new Error(`backup ${name} failed its own integrity check; refusing to restore`);
|
|
101
|
+
const toAbs = path.join(root, toRel || m.target);
|
|
102
|
+
realTargetUnderRoot(root, toAbs);
|
|
103
|
+
if (fs.existsSync(toAbs) && !fs.statSync(toAbs).isDirectory()) throw new Error(`restore target exists and is not a directory: ${toRel || m.target}`);
|
|
104
|
+
const tmp = `${toAbs}.restore-tmp-${process.pid}`;
|
|
105
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
106
|
+
copyTree(dataAbs, tmp, m);
|
|
107
|
+
if (!isExact(compareExact(tmp, m))) { fs.rmSync(tmp, { recursive: true, force: true }); throw new Error('staged restore tree did not match the manifest; aborting'); }
|
|
108
|
+
const aside = `${toAbs}.replaced-${stamp()}`;
|
|
109
|
+
if (fs.existsSync(toAbs)) fs.renameSync(toAbs, aside);
|
|
110
|
+
try { fs.renameSync(tmp, toAbs); } catch (e) { if (fs.existsSync(aside)) fs.renameSync(aside, toAbs); fs.rmSync(tmp, { recursive: true, force: true }); throw e; }
|
|
111
|
+
const final = compareExact(toAbs, m);
|
|
112
|
+
const ok = isExact(final);
|
|
113
|
+
if (ok && fs.existsSync(aside)) fs.rmSync(aside, { recursive: true, force: true });
|
|
114
|
+
return { name, to: toRel || m.target, ok, final, aside_kept: ok ? null : aside };
|
|
115
|
+
}
|
|
116
|
+
function drill(root, backupRoot, name, targetRel) {
|
|
117
|
+
const m = loadManifest(backupRoot, name);
|
|
118
|
+
const dataAbs = path.join(backupRoot, name, 'data');
|
|
119
|
+
const backupIntegrity = compareExact(dataAbs, m);
|
|
120
|
+
const targetAbs = path.join(root, targetRel || m.target);
|
|
121
|
+
const liveBefore = walkTree(targetAbs);
|
|
122
|
+
const scratch = `${targetAbs}.drill-${process.pid}`;
|
|
123
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
124
|
+
copyTree(dataAbs, scratch, m);
|
|
125
|
+
const vsManifest = compareExact(scratch, m);
|
|
126
|
+
const vsLive = compareExact(scratch, { dirs: liveBefore.dirs, files: liveBefore.files });
|
|
127
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
128
|
+
const liveUnchanged = JSON.stringify(liveBefore) === JSON.stringify(walkTree(targetAbs));
|
|
129
|
+
return { name, target: targetRel || m.target, backupIntegrity, vsManifest, vsLive, liveUnchanged };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function backupCommand(args) {
|
|
133
|
+
const root = absPath(args._[0] || args.root || process.cwd());
|
|
134
|
+
const vaultCfg = vaultConfig(root);
|
|
135
|
+
const backupRoot = path.join(root, vaultCfg.archive_dir, '_backups');
|
|
136
|
+
|
|
137
|
+
let payload;
|
|
138
|
+
try {
|
|
139
|
+
if (args.list) {
|
|
140
|
+
const names = fs.existsSync(backupRoot) ? fs.readdirSync(backupRoot).filter((n) => fs.existsSync(path.join(backupRoot, n, 'manifest.json'))) : [];
|
|
141
|
+
payload = { ok: true, command: 'backup', durable_write: false, op: 'list', backups: names.map((n) => { const m = loadManifest(backupRoot, n); return { name: n, target: m.target, files: m.files.length, dirs: m.dirs.length, created: m.created }; }) };
|
|
142
|
+
} else if (args.verify) {
|
|
143
|
+
const m = loadManifest(backupRoot, String(args.verify));
|
|
144
|
+
const c = compareExact(path.join(backupRoot, String(args.verify), 'data'), m);
|
|
145
|
+
payload = { ok: isExact(c), command: 'backup', durable_write: false, op: 'verify', name: String(args.verify), files: m.files.length, missing: c.missing.length, changed: c.mismatched.length, extra: c.extra.length };
|
|
146
|
+
if (!payload.ok) process.exitCode = 1;
|
|
147
|
+
} else if (args.drill) {
|
|
148
|
+
const r = drill(root, backupRoot, String(args.drill), args.target);
|
|
149
|
+
const ok = isExact(r.backupIntegrity) && isExact(r.vsManifest) && isExact(r.vsLive) && r.liveUnchanged;
|
|
150
|
+
payload = { ok, command: 'backup', durable_write: false, op: 'drill', name: r.name, target: r.target, backup_integrity: isExact(r.backupIntegrity), restored_eq_manifest: isExact(r.vsManifest), restored_eq_live: isExact(r.vsLive), live_unchanged: r.liveUnchanged };
|
|
151
|
+
if (!ok) process.exitCode = 1;
|
|
152
|
+
} else if (args.restore) {
|
|
153
|
+
const r = restore(root, backupRoot, String(args.restore), args.to);
|
|
154
|
+
payload = { ok: r.ok, command: 'backup', durable_write: true, op: 'restore', name: r.name, to: r.to, exact: r.ok, aside_kept: r.aside_kept };
|
|
155
|
+
if (!r.ok) process.exitCode = 1;
|
|
156
|
+
} else if (args.target) {
|
|
157
|
+
const r = doBackup(root, backupRoot, String(args.target), args.label);
|
|
158
|
+
payload = { ok: r.verified, command: 'backup', durable_write: true, op: 'snapshot', name: r.name, file_count: r.file_count, dir_count: r.dir_count, verified: r.verified };
|
|
159
|
+
if (!r.verified) process.exitCode = 1;
|
|
160
|
+
} else {
|
|
161
|
+
process.exitCode = 1;
|
|
162
|
+
payload = { ok: false, command: 'backup', durable_write: false, error: 'usage: --target <dir> [--label] | --verify <name> | --restore <name> [--to <dir>] | --drill <name> [--target <dir>] | --list' };
|
|
163
|
+
}
|
|
164
|
+
} catch (e) {
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
payload = { ok: false, command: 'backup', durable_write: false, error: e.message };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (args.json) return { json: true, payload };
|
|
170
|
+
return { text: payload.error ? `# Neurain Backup\n\n- ${payload.error}` : `# Neurain Backup\n\n- Op: ${payload.op}\n- OK: ${payload.ok ? 'yes' : 'no'}${payload.name ? `\n- Name: ${payload.name}` : ''}` };
|
|
171
|
+
}
|