ultimate-brain 0.0.0-stage → 0.2.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/.agents/skills/README.md +3 -0
- package/.agents/skills/brain-apply/SKILL.md +80 -0
- package/.agents/skills/brain-connect-notion/SKILL.md +55 -0
- package/.agents/skills/brain-howto/SKILL.md +36 -0
- package/.agents/skills/brain-howto/references/guide.md +176 -0
- package/.agents/skills/brain-ingest/SKILL.md +105 -0
- package/.agents/skills/brain-ingest/references/cli-inputs.md +129 -0
- package/.agents/skills/brain-ingest/references/example-batch.json +165 -0
- package/.agents/skills/brain-ingest/references/extraction.md +118 -0
- package/.agents/skills/brain-ingest/references/notion.md +65 -0
- package/.agents/skills/brain-ingest/references/sources.md +70 -0
- package/.agents/skills/brain-notion-change/SKILL.md +57 -0
- package/.agents/skills/brain-query/SKILL.md +62 -0
- package/.agents/skills/brain-sync/SKILL.md +66 -0
- package/.agents/skills/brain-sync/references/formats.md +53 -0
- package/LICENSE +21 -0
- package/README.md +40 -2
- package/bin/brain.js +5 -0
- package/dist/cli.js +83 -0
- package/dist/commands/apply.js +34 -0
- package/dist/commands/config.js +96 -0
- package/dist/commands/id.js +32 -0
- package/dist/commands/index.js +58 -0
- package/dist/commands/init.js +233 -0
- package/dist/commands/link.js +285 -0
- package/dist/commands/lint.js +47 -0
- package/dist/commands/log.js +48 -0
- package/dist/commands/note.js +36 -0
- package/dist/commands/proposal.js +57 -0
- package/dist/commands/query.js +38 -0
- package/dist/commands/status.js +98 -0
- package/dist/commands/sync.js +53 -0
- package/dist/commands/upgrade.js +143 -0
- package/dist/commands/views.js +111 -0
- package/dist/lib/apply.js +524 -0
- package/dist/lib/args.js +63 -0
- package/dist/lib/brain-path.js +91 -0
- package/dist/lib/brain.js +36 -0
- package/dist/lib/command.js +61 -0
- package/dist/lib/errors.js +33 -0
- package/dist/lib/frontmatter.js +171 -0
- package/dist/lib/fs-safe.js +140 -0
- package/dist/lib/ids.js +51 -0
- package/dist/lib/index-builder.js +216 -0
- package/dist/lib/input.js +45 -0
- package/dist/lib/lint.js +340 -0
- package/dist/lib/log.js +44 -0
- package/dist/lib/note-new.js +121 -0
- package/dist/lib/notes.js +95 -0
- package/dist/lib/paths.js +59 -0
- package/dist/lib/proposals.js +229 -0
- package/dist/lib/query.js +163 -0
- package/dist/lib/schemas.js +51 -0
- package/dist/lib/skills.js +60 -0
- package/dist/lib/sync.js +760 -0
- package/dist/lib/time.js +21 -0
- package/dist/lib/views.js +139 -0
- package/dist/lib/warnings.js +9 -0
- package/dist/lib/workspace.js +183 -0
- package/package.json +45 -4
- package/spec/schemas/brain.schema.json +200 -0
- package/spec/schemas/note.schema.json +1102 -0
- package/spec/schemas/proposal.schema.json +559 -0
- package/templates/README.md +44 -0
- package/templates/brain/AGENTS.md +55 -0
- package/templates/brain/CLAUDE.md +1 -0
- package/templates/brain/_proposals/.gitkeep +0 -0
- package/templates/brain/archive/goals/.gitkeep +0 -0
- package/templates/brain/archive/projects/.gitkeep +0 -0
- package/templates/brain/areas/.gitkeep +0 -0
- package/templates/brain/brain.json +35 -0
- package/templates/brain/gitignore +6 -0
- package/templates/brain/goals/.gitkeep +0 -0
- package/templates/brain/index.md +3 -0
- package/templates/brain/knowledge/concepts/.gitkeep +0 -0
- package/templates/brain/knowledge/decisions/.gitkeep +0 -0
- package/templates/brain/knowledge/meetings/.gitkeep +0 -0
- package/templates/brain/knowledge/orgs/.gitkeep +0 -0
- package/templates/brain/knowledge/people/.gitkeep +0 -0
- package/templates/brain/knowledge/questions/.gitkeep +0 -0
- package/templates/brain/memories/.gitkeep +0 -0
- package/templates/brain/projects/.gitkeep +0 -0
- package/templates/brain/sources/notion/.gitkeep +0 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { resolveBrainDirectory } from "./brain-path.js";
|
|
4
|
+
import { BrainError } from "./errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the brain (spec/cli.md §4) and read its `brain.json`.
|
|
7
|
+
* Full JSON Schema validation of `brain.json` arrives with the validator in CX-05;
|
|
8
|
+
* until then only presence, JSON syntax and `schema_version` are checked.
|
|
9
|
+
*/
|
|
10
|
+
export async function openBrain(options = {}) {
|
|
11
|
+
const root = await resolveBrainDirectory(options);
|
|
12
|
+
const configPath = join(root, "brain.json");
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = await readFile(configPath, "utf8");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new BrainError("BRAIN_NOT_FOUND", `Not a brain: ${root} has no brain.json`);
|
|
19
|
+
}
|
|
20
|
+
let config;
|
|
21
|
+
try {
|
|
22
|
+
config = JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
26
|
+
throw new BrainError("BRAIN_CONFIG_INVALID", `brain.json is not valid JSON: ${detail}`);
|
|
27
|
+
}
|
|
28
|
+
if (typeof config !== "object" || config === null || Array.isArray(config)) {
|
|
29
|
+
throw new BrainError("BRAIN_CONFIG_INVALID", "brain.json must be a JSON object");
|
|
30
|
+
}
|
|
31
|
+
const version = config.schema_version;
|
|
32
|
+
if (version !== 1) {
|
|
33
|
+
throw new BrainError("SCHEMA_VERSION_UNSUPPORTED", `brain.json schema_version is ${JSON.stringify(version)}; this CLI supports 1`);
|
|
34
|
+
}
|
|
35
|
+
return { root, config: config };
|
|
36
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { parseArgs } from "./args.js";
|
|
2
|
+
import { openBrain } from "./brain.js";
|
|
3
|
+
import { BrainError, exitCodeFor } from "./errors.js";
|
|
4
|
+
import { resolveNow } from "./time.js";
|
|
5
|
+
/** Run one command and print its result or error (spec/cli.md §3.3). Returns the exit code. */
|
|
6
|
+
export async function runCommand(command, argv, io, environment = {}) {
|
|
7
|
+
const separator = argv.indexOf("--");
|
|
8
|
+
const json = (separator === -1 ? argv : argv.slice(0, separator)).includes("--json");
|
|
9
|
+
let brainRoot = null;
|
|
10
|
+
try {
|
|
11
|
+
const args = parseArgs(argv, command.options);
|
|
12
|
+
if (command.maxPositionals !== undefined && args.positionals.length > command.maxPositionals) {
|
|
13
|
+
throw new BrainError("USAGE", `Unexpected argument: ${args.positionals[command.maxPositionals]}`);
|
|
14
|
+
}
|
|
15
|
+
if (args.options.help === true) {
|
|
16
|
+
io.stdout.write(`Usage: ${command.usage}\n\n${command.summary}\n`);
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
const now = resolveNow(args.options.now);
|
|
20
|
+
let brain = null;
|
|
21
|
+
if (command.needsBrain) {
|
|
22
|
+
brain = await openBrain({
|
|
23
|
+
brainFlag: args.options.brain,
|
|
24
|
+
env: environment.env,
|
|
25
|
+
homeDirectory: environment.homeDirectory,
|
|
26
|
+
});
|
|
27
|
+
brainRoot = brain.root;
|
|
28
|
+
}
|
|
29
|
+
const result = await command.run({ args, brain, now, environment });
|
|
30
|
+
const warnings = result.warnings ?? [];
|
|
31
|
+
if (json) {
|
|
32
|
+
writeJson(io, { ok: true, command: command.name, brain: result.resolvedBrain ?? brainRoot, data: result.data, warnings });
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
io.stdout.write(result.human ?? `${JSON.stringify(result.data, null, 2)}\n`);
|
|
36
|
+
for (const warning of warnings)
|
|
37
|
+
io.stderr.write(`warning: ${warning.message}\n`);
|
|
38
|
+
}
|
|
39
|
+
return result.exitCode ?? 0;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const known = error instanceof BrainError;
|
|
43
|
+
const code = known ? error.code : "INTERNAL";
|
|
44
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
45
|
+
if (json) {
|
|
46
|
+
const body = { code, message };
|
|
47
|
+
if (known && error.details !== undefined)
|
|
48
|
+
body.details = error.details;
|
|
49
|
+
writeJson(io, { ok: false, command: command.name, brain: brainRoot, error: body, warnings: [] });
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
io.stderr.write(`error: ${message}\n`);
|
|
53
|
+
}
|
|
54
|
+
if (!known && error instanceof Error && error.stack !== undefined)
|
|
55
|
+
io.stderr.write(`${error.stack}\n`);
|
|
56
|
+
return exitCodeFor(code);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function writeJson(io, value) {
|
|
60
|
+
io.stdout.write(`${JSON.stringify(value)}\n`);
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Error codes and exit codes from spec/cli.md §3.4–3.5. */
|
|
2
|
+
export const EXIT_CODES = {
|
|
3
|
+
USAGE: 2,
|
|
4
|
+
BRAIN_NOT_CONFIGURED: 3,
|
|
5
|
+
BRAIN_NOT_FOUND: 3,
|
|
6
|
+
BRAIN_IS_PRODUCT_REPO: 3,
|
|
7
|
+
BRAIN_CONFIG_INVALID: 3,
|
|
8
|
+
SCHEMA_VERSION_UNSUPPORTED: 3,
|
|
9
|
+
INPUT_UNREADABLE: 4,
|
|
10
|
+
INPUT_INVALID: 4,
|
|
11
|
+
UNKNOWN_ID: 4,
|
|
12
|
+
ID_IN_USE: 4,
|
|
13
|
+
BEFORE_MISMATCH: 4,
|
|
14
|
+
TARGET_NOT_EMPTY: 5,
|
|
15
|
+
ALREADY_A_BRAIN: 5,
|
|
16
|
+
OUTSIDE_ROOT: 5,
|
|
17
|
+
WRITE_CONFLICT: 5,
|
|
18
|
+
INTERNAL: 10,
|
|
19
|
+
};
|
|
20
|
+
/** An expected failure with a contract error code; anything else is INTERNAL. */
|
|
21
|
+
export class BrainError extends Error {
|
|
22
|
+
code;
|
|
23
|
+
details;
|
|
24
|
+
constructor(code, message, details) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "BrainError";
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.details = details;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function exitCodeFor(code) {
|
|
32
|
+
return EXIT_CODES[code];
|
|
33
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { Document, isMap, isScalar, isSeq, parseDocument, visit } from "yaml";
|
|
3
|
+
const parsedDocuments = new WeakMap();
|
|
4
|
+
/** Serialization options of spec/cli.md §3.6.5. */
|
|
5
|
+
const TO_STRING = { lineWidth: 0, flowCollectionPadding: false };
|
|
6
|
+
const KNOWLEDGE_HEAD = ["id", "type", "title", "summary", "lifecycle"];
|
|
7
|
+
const KNOWLEDGE_TAIL = [
|
|
8
|
+
"aliases", "tags", "area", "projects", "sources", "relations", "facts", "timeline",
|
|
9
|
+
"created", "updated", "notion", "last_synced", "notion_fields", "notion_published",
|
|
10
|
+
];
|
|
11
|
+
/** Each type's own fields, in the order of its table in spec/schema.md §4. */
|
|
12
|
+
const TYPE_FIELDS = {
|
|
13
|
+
goal: ["why", "area"],
|
|
14
|
+
project: ["area", "goal", "client", "people"],
|
|
15
|
+
person: ["org"],
|
|
16
|
+
meeting: ["date", "attendees", "source", "decisions", "questions", "tasks"],
|
|
17
|
+
decision: ["date", "assessment", "decided_in", "deciders", "valid_from", "valid_until"],
|
|
18
|
+
question: ["kind", "raised_in", "answer"],
|
|
19
|
+
source: ["source_key", "kind", "file_name", "converter", "content_hash", "captured", "origin_edited"],
|
|
20
|
+
view: ["view_kind", "project", "generated_from", "generated_at"],
|
|
21
|
+
memory: ["scope", "areas", "projects", "stacks", "confidence", "origin"],
|
|
22
|
+
};
|
|
23
|
+
/** Proposal key order: the spec/schema.md §11 example, then its table. */
|
|
24
|
+
const PROPOSAL_ORDER = [
|
|
25
|
+
"id", "type", "title", "created", "updated", "kind", "target", "field", "before", "after", "from",
|
|
26
|
+
"content", "note", "supersedes", "merge_into", "why", "source", "status", "decision", "closed_reason",
|
|
27
|
+
"notion", "notion_published",
|
|
28
|
+
];
|
|
29
|
+
/** Canonical rank of each key for a type (spec/cli.md §3.6.5); a key listed twice keeps its first position. */
|
|
30
|
+
function keyOrder(type) {
|
|
31
|
+
const keys = type === "proposal"
|
|
32
|
+
? PROPOSAL_ORDER
|
|
33
|
+
: [...KNOWLEDGE_HEAD, ...(TYPE_FIELDS[String(type)] ?? []), ...KNOWLEDGE_TAIL];
|
|
34
|
+
const ranks = new Map();
|
|
35
|
+
for (const key of keys)
|
|
36
|
+
if (!ranks.has(key))
|
|
37
|
+
ranks.set(key, ranks.size);
|
|
38
|
+
return ranks;
|
|
39
|
+
}
|
|
40
|
+
/** Parse a Markdown file with a YAML 1.2 core-schema frontmatter mapping. */
|
|
41
|
+
export function parseFrontmatter(source) {
|
|
42
|
+
const opening = /^---(?:\r\n|\n)/.exec(source);
|
|
43
|
+
if (opening === null) {
|
|
44
|
+
throw new Error("Markdown file must start with a YAML frontmatter delimiter");
|
|
45
|
+
}
|
|
46
|
+
const afterOpening = source.slice(opening[0].length);
|
|
47
|
+
const closing = /^---(?:\r\n|\n|$)/m.exec(afterOpening);
|
|
48
|
+
if (closing === null) {
|
|
49
|
+
throw new Error("YAML frontmatter is missing its closing delimiter");
|
|
50
|
+
}
|
|
51
|
+
const yamlSource = afterOpening.slice(0, closing.index);
|
|
52
|
+
const yamlDocument = parseDocument(yamlSource, {
|
|
53
|
+
schema: "core",
|
|
54
|
+
prettyErrors: true,
|
|
55
|
+
uniqueKeys: true,
|
|
56
|
+
});
|
|
57
|
+
if (yamlDocument.errors.length > 0) {
|
|
58
|
+
throw new Error(`Invalid YAML frontmatter: ${yamlDocument.errors[0].message}`);
|
|
59
|
+
}
|
|
60
|
+
if (yamlDocument.contents !== null && !isMap(yamlDocument.contents)) {
|
|
61
|
+
throw new Error("YAML frontmatter must contain a top-level mapping");
|
|
62
|
+
}
|
|
63
|
+
const parsed = yamlDocument.contents === null ? {} : yamlDocument.toJS();
|
|
64
|
+
if (!isRecord(parsed)) {
|
|
65
|
+
throw new Error("YAML frontmatter must contain a top-level mapping");
|
|
66
|
+
}
|
|
67
|
+
const body = afterOpening.slice(closing.index + closing[0].length);
|
|
68
|
+
const document = {
|
|
69
|
+
attributes: parsed,
|
|
70
|
+
body,
|
|
71
|
+
originalSource: source,
|
|
72
|
+
originalAttributes: structuredClone(parsed),
|
|
73
|
+
originalBody: body,
|
|
74
|
+
};
|
|
75
|
+
parsedDocuments.set(document, yamlDocument);
|
|
76
|
+
return document;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Serialize an edited file, preserving the original bytes when unchanged. Existing keys keep
|
|
80
|
+
* their place and unknown keys are kept; a new key goes where the canonical order puts it among
|
|
81
|
+
* the keys present (spec/cli.md §3.6.5).
|
|
82
|
+
*/
|
|
83
|
+
export function serializeFrontmatter(document) {
|
|
84
|
+
if (document.originalSource !== undefined &&
|
|
85
|
+
document.originalAttributes !== undefined &&
|
|
86
|
+
document.originalBody !== undefined &&
|
|
87
|
+
isDeepStrictEqual(document.attributes, document.originalAttributes) &&
|
|
88
|
+
document.body === document.originalBody) {
|
|
89
|
+
return document.originalSource;
|
|
90
|
+
}
|
|
91
|
+
const yaml = serializeAttributes(document);
|
|
92
|
+
return `---\n${yaml}---\n${document.body}`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Serialize a new file (spec/cli.md §3.6.5): keys in canonical order (unknown keys last, in
|
|
96
|
+
* input order), one blank line before the body (none for a source), nothing after the closing
|
|
97
|
+
* `---` when the body is empty.
|
|
98
|
+
*/
|
|
99
|
+
export function serializeNewFile(attributes, body) {
|
|
100
|
+
const ranks = keyOrder(attributes.type);
|
|
101
|
+
const rank = (key) => ranks.get(key) ?? Number.MAX_SAFE_INTEGER;
|
|
102
|
+
const ordered = Object.fromEntries(Object.entries(attributes).sort(([a], [b]) => rank(a) - rank(b)));
|
|
103
|
+
const yamlDocument = new Document(ordered, { schema: "core" });
|
|
104
|
+
applyCollectionStyle(yamlDocument.contents);
|
|
105
|
+
const yaml = yamlDocument.toString(TO_STRING);
|
|
106
|
+
if (body === "")
|
|
107
|
+
return `---\n${yaml}---\n`;
|
|
108
|
+
return `---\n${yaml}---\n${attributes.type === "source" ? "" : "\n"}${body}`;
|
|
109
|
+
}
|
|
110
|
+
function serializeAttributes(document) {
|
|
111
|
+
if (Object.keys(document.attributes).length === 0)
|
|
112
|
+
return "";
|
|
113
|
+
const original = parsedDocuments.get(document);
|
|
114
|
+
const yamlDocument = original?.clone() ?? new Document({}, { schema: "core" });
|
|
115
|
+
if (yamlDocument.contents === null)
|
|
116
|
+
yamlDocument.contents = yamlDocument.createNode({});
|
|
117
|
+
const map = yamlDocument.contents;
|
|
118
|
+
if (!isMap(map)) {
|
|
119
|
+
throw new Error("YAML frontmatter must contain a top-level mapping");
|
|
120
|
+
}
|
|
121
|
+
const current = yamlDocument.toJS();
|
|
122
|
+
for (const key of Object.keys(current)) {
|
|
123
|
+
if (!Object.hasOwn(document.attributes, key))
|
|
124
|
+
yamlDocument.delete(key);
|
|
125
|
+
}
|
|
126
|
+
const ranks = keyOrder(document.attributes.type);
|
|
127
|
+
for (const [key, value] of Object.entries(document.attributes)) {
|
|
128
|
+
if (Object.hasOwn(current, key) && isDeepStrictEqual(current[key], value))
|
|
129
|
+
continue;
|
|
130
|
+
const node = yamlDocument.createNode(value);
|
|
131
|
+
applyCollectionStyle(node);
|
|
132
|
+
if (Object.hasOwn(current, key)) {
|
|
133
|
+
yamlDocument.set(key, node);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
// Insert before the first present key that the canonical order puts after this one.
|
|
137
|
+
const pair = yamlDocument.createPair(key, node);
|
|
138
|
+
const rank = ranks.get(key);
|
|
139
|
+
const index = rank === undefined
|
|
140
|
+
? -1
|
|
141
|
+
: map.items.findIndex((item) => {
|
|
142
|
+
const other = ranks.get(String(isScalar(item.key) ? item.key.value : item.key));
|
|
143
|
+
return other !== undefined && other > rank;
|
|
144
|
+
});
|
|
145
|
+
if (index === -1)
|
|
146
|
+
map.items.push(pair);
|
|
147
|
+
else
|
|
148
|
+
map.items.splice(index, 0, pair);
|
|
149
|
+
}
|
|
150
|
+
return yamlDocument.toString(TO_STRING);
|
|
151
|
+
}
|
|
152
|
+
/** Lists of scalars in flow style (`[a, b]`, `[]`); mappings and lists of mappings in block style. */
|
|
153
|
+
function applyCollectionStyle(node) {
|
|
154
|
+
if (isSeq(node))
|
|
155
|
+
node.flow = node.items.every((item) => isScalar(item));
|
|
156
|
+
if (isMap(node))
|
|
157
|
+
node.flow = false;
|
|
158
|
+
if (!isSeq(node) && !isMap(node))
|
|
159
|
+
return;
|
|
160
|
+
visit(node, {
|
|
161
|
+
Map(_, map) {
|
|
162
|
+
map.flow = false;
|
|
163
|
+
},
|
|
164
|
+
Seq(_, seq) {
|
|
165
|
+
seq.flow = seq.items.every((item) => isScalar(item));
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function isRecord(value) {
|
|
170
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
171
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { link, lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { BrainError } from "./errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* Resolve a write target inside a root: the target and its real parent must be contained,
|
|
7
|
+
* the parent must exist, and an existing target must not be a symbolic link.
|
|
8
|
+
* Every refusal is an OUTSIDE_ROOT BrainError (spec/cli.md §3.6.4).
|
|
9
|
+
*/
|
|
10
|
+
export async function resolveWritableTarget(rootDirectory, targetPath) {
|
|
11
|
+
const root = await realpath(resolve(rootDirectory));
|
|
12
|
+
const target = isAbsolute(targetPath) ? resolve(targetPath) : resolve(root, targetPath);
|
|
13
|
+
assertContained(root, target);
|
|
14
|
+
const parent = await realpath(dirname(target));
|
|
15
|
+
assertContained(root, parent);
|
|
16
|
+
try {
|
|
17
|
+
const targetMetadata = await lstat(target);
|
|
18
|
+
if (targetMetadata.isSymbolicLink()) {
|
|
19
|
+
throw new BrainError("OUTSIDE_ROOT", `Refusing to replace a symbolic link: ${target}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (!isNodeError(error) || error.code !== "ENOENT") {
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { root, target, parent };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Atomically replace a file inside a brain directory.
|
|
31
|
+
*
|
|
32
|
+
* The parent directory must already exist. Existing symlink targets are rejected,
|
|
33
|
+
* and the real parent path is checked to prevent writes through escaping symlinks.
|
|
34
|
+
*/
|
|
35
|
+
export async function atomicWriteFile(brainDirectory, targetPath, data) {
|
|
36
|
+
const { root: brainRoot, target, parent } = await resolveWritableTarget(brainDirectory, targetPath);
|
|
37
|
+
const temporary = resolve(parent, `.${basename(target)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
38
|
+
assertContained(brainRoot, temporary);
|
|
39
|
+
let handle;
|
|
40
|
+
try {
|
|
41
|
+
handle = await open(temporary, "wx", 0o600);
|
|
42
|
+
await handle.writeFile(data);
|
|
43
|
+
await handle.sync();
|
|
44
|
+
await handle.close();
|
|
45
|
+
handle = undefined;
|
|
46
|
+
await rename(temporary, target);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
await handle?.close().catch(() => undefined);
|
|
50
|
+
await unlink(temporary).catch(() => undefined);
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Atomically create a file, refusing to replace anything that appeared after the caller scanned
|
|
56
|
+
* the directory. Linking the completed temporary file is exclusive: unlike rename, it fails with
|
|
57
|
+
* EEXIST rather than overwriting a concurrent writer's file.
|
|
58
|
+
*/
|
|
59
|
+
export async function atomicCreateFile(brainDirectory, targetPath, data, linkFile = link) {
|
|
60
|
+
const { root: brainRoot, target, parent } = await resolveWritableTarget(brainDirectory, targetPath);
|
|
61
|
+
const temporary = resolve(parent, `.${basename(target)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
62
|
+
assertContained(brainRoot, temporary);
|
|
63
|
+
let handle;
|
|
64
|
+
try {
|
|
65
|
+
handle = await open(temporary, "wx", 0o600);
|
|
66
|
+
await handle.writeFile(data);
|
|
67
|
+
await handle.sync();
|
|
68
|
+
await handle.close();
|
|
69
|
+
handle = undefined;
|
|
70
|
+
try {
|
|
71
|
+
await linkFile(temporary, target);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (!isNodeError(error) || !["ENOTSUP", "EPERM", "ENOSYS", "EXDEV"].includes(error.code ?? "")) {
|
|
75
|
+
if (isNodeError(error) && error.code === "EEXIST")
|
|
76
|
+
throw new BrainError("WRITE_CONFLICT", `A file appeared while creating ${targetPath}`);
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
const direct = await open(target, "wx", 0o600).catch((openError) => {
|
|
80
|
+
if (isNodeError(openError) && openError.code === "EEXIST")
|
|
81
|
+
throw new BrainError("WRITE_CONFLICT", `A file appeared while creating ${targetPath}`);
|
|
82
|
+
throw openError;
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
await direct.writeFile(data);
|
|
86
|
+
await direct.sync();
|
|
87
|
+
}
|
|
88
|
+
catch (writeError) {
|
|
89
|
+
await unlink(target).catch(() => undefined);
|
|
90
|
+
throw writeError;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
await direct.close().catch(() => undefined);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
await unlink(temporary).catch(() => undefined);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
await handle?.close().catch(() => undefined);
|
|
100
|
+
await unlink(temporary).catch(() => undefined);
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Create a directory (and parents) inside a root, refusing anything that resolves outside it.
|
|
106
|
+
* The nearest existing ancestor is checked with realpath so a symlinked folder cannot escape.
|
|
107
|
+
*/
|
|
108
|
+
export async function ensureDirectory(rootDirectory, targetPath) {
|
|
109
|
+
const root = await realpath(resolve(rootDirectory));
|
|
110
|
+
const target = isAbsolute(targetPath) ? resolve(targetPath) : resolve(root, targetPath);
|
|
111
|
+
assertContained(root, target);
|
|
112
|
+
let existing = target;
|
|
113
|
+
while (true) {
|
|
114
|
+
try {
|
|
115
|
+
assertContained(root, await realpath(existing));
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (!isNodeError(error) || error.code !== "ENOENT")
|
|
120
|
+
throw error;
|
|
121
|
+
existing = dirname(existing);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
await mkdir(target, { recursive: true });
|
|
125
|
+
}
|
|
126
|
+
/** Throw unless target is the brain root or one of its descendants. */
|
|
127
|
+
export function assertPathInsideBrain(brainRoot, target) {
|
|
128
|
+
assertContained(resolve(brainRoot), resolve(target));
|
|
129
|
+
}
|
|
130
|
+
function assertContained(root, target) {
|
|
131
|
+
const pathFromRoot = relative(root, target);
|
|
132
|
+
if (pathFromRoot === "")
|
|
133
|
+
return;
|
|
134
|
+
if (pathFromRoot === ".." || pathFromRoot.startsWith(`..${sep}`) || isAbsolute(pathFromRoot)) {
|
|
135
|
+
throw new BrainError("OUTSIDE_ROOT", `Refusing to write outside the brain directory: ${target}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function isNodeError(error) {
|
|
139
|
+
return error instanceof Error && "code" in error;
|
|
140
|
+
}
|
package/dist/lib/ids.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { randomBytes as nodeRandomBytes } from "node:crypto";
|
|
2
|
+
export const ID_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
3
|
+
export const ID_PREFIXES = [
|
|
4
|
+
"area",
|
|
5
|
+
"gol",
|
|
6
|
+
"prj",
|
|
7
|
+
"per",
|
|
8
|
+
"org",
|
|
9
|
+
"mtg",
|
|
10
|
+
"dec",
|
|
11
|
+
"que",
|
|
12
|
+
"con",
|
|
13
|
+
"src",
|
|
14
|
+
"mem",
|
|
15
|
+
"prp",
|
|
16
|
+
"vw",
|
|
17
|
+
];
|
|
18
|
+
const ID_LENGTH = 8;
|
|
19
|
+
/** Generate a stable-format note ID, retrying when the candidate already exists. */
|
|
20
|
+
export function generateId(prefix, existingIds, randomBytes = nodeRandomBytes) {
|
|
21
|
+
while (true) {
|
|
22
|
+
const bytes = randomBytes(ID_LENGTH);
|
|
23
|
+
if (bytes.length !== ID_LENGTH) {
|
|
24
|
+
throw new Error(`ID random source returned ${bytes.length} bytes; expected ${ID_LENGTH}`);
|
|
25
|
+
}
|
|
26
|
+
let suffix = "";
|
|
27
|
+
for (const byte of bytes) {
|
|
28
|
+
suffix += ID_ALPHABET[byte & 31];
|
|
29
|
+
}
|
|
30
|
+
const candidate = `${prefix}-${suffix}`;
|
|
31
|
+
if (!existingIds.has(candidate)) {
|
|
32
|
+
return candidate;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** ID prefix of each note type (spec/schema.md §1). */
|
|
37
|
+
export const TYPE_PREFIX = {
|
|
38
|
+
area: "area",
|
|
39
|
+
goal: "gol",
|
|
40
|
+
project: "prj",
|
|
41
|
+
person: "per",
|
|
42
|
+
org: "org",
|
|
43
|
+
meeting: "mtg",
|
|
44
|
+
decision: "dec",
|
|
45
|
+
question: "que",
|
|
46
|
+
concept: "con",
|
|
47
|
+
source: "src",
|
|
48
|
+
memory: "mem",
|
|
49
|
+
proposal: "prp",
|
|
50
|
+
view: "vw",
|
|
51
|
+
};
|