@first-tree-ai/context-tree 0.1.7-alpha.202609010710 → 0.1.7
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 +35 -38
- package/dist/cli/index.mjs +923 -647
- package/package.json +7 -28
- package/scripts/postinstall.mjs +53 -0
- package/skills/context-tree-connect/SKILL.md +9 -7
- package/skills/context-tree-create/SKILL.md +10 -10
- package/skills/context-tree-publish/SKILL.md +4 -8
- package/skills/context-tree-read/SKILL.md +32 -12
- package/skills/context-tree-setup/SKILL.md +7 -12
- package/skills/context-tree-write/SKILL.md +196 -17
- package/.agents/plugins/marketplace.json +0 -19
- package/.claude-plugin/marketplace.json +0 -21
- package/.claude-plugin/plugin.json +0 -16
- package/.codex-plugin/plugin.json +0 -36
- package/dist/cli/index.d.mts +0 -1
- package/dist/index.d.mts +0 -77
- package/dist/index.mjs +0 -1449
- package/dist/schemas-C_7izpsa.d.mts +0 -390
- package/dist/schemas-DKHE1sWt.mjs +0 -289
- package/dist/schemas.d.mts +0 -2
- package/dist/schemas.mjs +0 -2
- package/docs/specification.md +0 -160
- package/examples/basic/NODE.md +0 -15
- package/examples/basic/members/NODE.md +0 -8
- package/examples/basic/members/example-agent/NODE.md +0 -7
- package/examples/basic/members/example-agent/memory.md +0 -9
- package/examples/basic/systems/NODE.md +0 -10
- package/examples/basic/systems/runtime.md +0 -15
- package/hooks/hooks.json +0 -26
- package/hooks/session-start.mjs +0 -55
- package/policy/context-tree-policy.md +0 -156
- package/skills/context-tree-connect/scripts/context-tree.mjs +0 -41
- package/skills/context-tree-create/scripts/context-tree.mjs +0 -41
- package/skills/context-tree-publish/scripts/context-tree.mjs +0 -41
- package/skills/context-tree-read/scripts/context-tree.mjs +0 -41
- package/skills/context-tree-setup/scripts/context-tree.mjs +0 -41
- package/skills/context-tree-write/scripts/context-tree.mjs +0 -41
package/dist/index.mjs
DELETED
|
@@ -1,1449 +0,0 @@
|
|
|
1
|
-
import { A as treeNameSchema, C as credentialFreeRepositoryUrlSchema, F as isRecord, O as parseContextTreeRootNode, P as parseMarkdownFrontmatter, T as githubRepositoryIdentitySchema, b as contextTreeStateSchema, f as contextTreeConnectionSchema, i as VALIDATION_CODES, t as CLI_ERROR_CODES } from "./schemas-DKHE1sWt.mjs";
|
|
2
|
-
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { homedir, tmpdir } from "node:os";
|
|
4
|
-
import { basename, dirname, isAbsolute, join, parse, posix, relative, resolve, sep } from "node:path";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
import { spawnSync } from "node:child_process";
|
|
7
|
-
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
-
//#region src/core/internal/errors.ts
|
|
10
|
-
/**
|
|
11
|
-
* A failure the CLI reports with a specific machine-readable code. Anything
|
|
12
|
-
* thrown as a plain Error is reported as CONTEXT_TREE_FAILED instead.
|
|
13
|
-
*/
|
|
14
|
-
var ContextTreeError = class extends Error {
|
|
15
|
-
code;
|
|
16
|
-
constructor(code, message) {
|
|
17
|
-
super(message);
|
|
18
|
-
this.name = "ContextTreeError";
|
|
19
|
-
this.code = code;
|
|
20
|
-
}
|
|
21
|
-
};
|
|
22
|
-
//#endregion
|
|
23
|
-
//#region src/core/internal/git.ts
|
|
24
|
-
function defaultRunner(command, args) {
|
|
25
|
-
const result = spawnSync(command, args, {
|
|
26
|
-
encoding: "utf8",
|
|
27
|
-
stdio: [
|
|
28
|
-
"ignore",
|
|
29
|
-
"pipe",
|
|
30
|
-
"pipe"
|
|
31
|
-
]
|
|
32
|
-
});
|
|
33
|
-
return {
|
|
34
|
-
status: result.status,
|
|
35
|
-
stderr: typeof result.stderr === "string" ? result.stderr : "",
|
|
36
|
-
stdout: typeof result.stdout === "string" ? result.stdout : ""
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
/** A failed Git or `gh` operation. Messages never include the argv. */
|
|
40
|
-
var CommandError = class extends Error {
|
|
41
|
-
command;
|
|
42
|
-
status;
|
|
43
|
-
stderr;
|
|
44
|
-
constructor(command, status, stderr, message) {
|
|
45
|
-
const detail = sanitizeCommandOutput(stderr).trim();
|
|
46
|
-
super(detail.length > 0 ? `${message}: ${detail}` : message);
|
|
47
|
-
this.name = "CommandError";
|
|
48
|
-
this.command = command;
|
|
49
|
-
this.status = status;
|
|
50
|
-
this.stderr = detail;
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
/** Remove credentials and common access-token shapes before surfacing subprocess output. */
|
|
54
|
-
function sanitizeCommandOutput(value) {
|
|
55
|
-
return value.replace(/((?:https?|ssh):\/\/)[^\s/@]+@/giu, "$1<redacted>@").replace(/\b(?:gh[opsu]_[A-Za-z\d_]{20,}|github_pat_[A-Za-z\d_]{20,})\b/gu, "<redacted>").replace(/(authorization\s*:\s*(?:bearer|token)\s+)[^\s]+/giu, "$1<redacted>");
|
|
56
|
-
}
|
|
57
|
-
function trimOutput(value) {
|
|
58
|
-
return value.trim();
|
|
59
|
-
}
|
|
60
|
-
function execute(runner, command, args, message) {
|
|
61
|
-
const result = runner(command, args);
|
|
62
|
-
if (result.status !== 0) throw new CommandError(command, result.status, result.stderr, message);
|
|
63
|
-
return trimOutput(result.stdout);
|
|
64
|
-
}
|
|
65
|
-
/** Run a Git command that is not scoped by `-C`, such as `git init <path>`. */
|
|
66
|
-
function gitCommand(args, options = {}) {
|
|
67
|
-
return execute(options.runner ?? defaultRunner, "git", args, options.message ?? "A Git operation failed.");
|
|
68
|
-
}
|
|
69
|
-
/** Run `git -C <root> <args>` and return trimmed stdout, throwing on failure. */
|
|
70
|
-
function git(root, args, options = {}) {
|
|
71
|
-
return execute(options.runner ?? defaultRunner, "git", [
|
|
72
|
-
"-C",
|
|
73
|
-
root,
|
|
74
|
-
...args
|
|
75
|
-
], options.message ?? "A Git operation failed.");
|
|
76
|
-
}
|
|
77
|
-
/** Run `git -C <root> <args>` and return trimmed stdout, or undefined on failure. */
|
|
78
|
-
function optionalGit(root, args, runner = defaultRunner) {
|
|
79
|
-
const result = runner("git", [
|
|
80
|
-
"-C",
|
|
81
|
-
root,
|
|
82
|
-
...args
|
|
83
|
-
]);
|
|
84
|
-
if (result.status !== 0) return void 0;
|
|
85
|
-
return trimOutput(result.stdout);
|
|
86
|
-
}
|
|
87
|
-
/** Run `gh <args>` and return trimmed stdout, throwing on failure. */
|
|
88
|
-
function gh(args, options = {}) {
|
|
89
|
-
return execute(options.runner ?? defaultRunner, "gh", args, options.message ?? "A GitHub CLI operation failed.");
|
|
90
|
-
}
|
|
91
|
-
//#endregion
|
|
92
|
-
//#region src/core/internal/github-repository.ts
|
|
93
|
-
const ORIGIN_MESSAGE = "Context Tree origin must identify a credential-free GitHub OWNER/REPO repository.";
|
|
94
|
-
function canonicalGitHubRepositoryUrl(repository) {
|
|
95
|
-
githubRepositoryIdentitySchema.parse(repository);
|
|
96
|
-
return `https://github.com/${repository}.git`;
|
|
97
|
-
}
|
|
98
|
-
/** Derive OWNER/REPO from a github.com origin, rejecting anything else. */
|
|
99
|
-
function gitHubRepositoryFromOriginUrl(origin) {
|
|
100
|
-
if (!credentialFreeRepositoryUrlSchema.safeParse(origin).success) throw new Error(ORIGIN_MESSAGE);
|
|
101
|
-
let owner;
|
|
102
|
-
let name;
|
|
103
|
-
const scp = /^(?:git@)?github\.com:([^/]+)\/(.+)$/iu.exec(origin);
|
|
104
|
-
if (scp !== null) [, owner, name] = scp;
|
|
105
|
-
else {
|
|
106
|
-
const url = URL.parse(origin);
|
|
107
|
-
if (url === null || url.hostname.toLowerCase() !== "github.com") throw new Error(ORIGIN_MESSAGE);
|
|
108
|
-
[owner, name] = url.pathname.replace(/^\/+|\/+$/gu, "").split("/");
|
|
109
|
-
}
|
|
110
|
-
const repository = `${owner ?? ""}/${(name ?? "").replace(/\.git$/iu, "")}`;
|
|
111
|
-
if (!githubRepositoryIdentitySchema.safeParse(repository).success) throw new Error(ORIGIN_MESSAGE);
|
|
112
|
-
return repository;
|
|
113
|
-
}
|
|
114
|
-
//#endregion
|
|
115
|
-
//#region src/core/path.ts
|
|
116
|
-
function isPathInside(root, target) {
|
|
117
|
-
const path = relative(root, target);
|
|
118
|
-
return path === "" || !path.startsWith("..") && !isAbsolute(path);
|
|
119
|
-
}
|
|
120
|
-
function resolveTreeRoot(path) {
|
|
121
|
-
const absolute = resolve(path);
|
|
122
|
-
const entry = lstatSync(absolute);
|
|
123
|
-
if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`Context Tree root must be a real directory: ${path}`);
|
|
124
|
-
return realpathSync(absolute);
|
|
125
|
-
}
|
|
126
|
-
/** Resolve a directory while rejecting symlinks in user-controlled path components. */
|
|
127
|
-
function realDirectoryWithoutSymlinks(path, label) {
|
|
128
|
-
const absolute = resolve(path);
|
|
129
|
-
const parsed = parse(absolute);
|
|
130
|
-
const parts = absolute.slice(parsed.root.length).split(sep).filter(Boolean);
|
|
131
|
-
let current = parsed.root;
|
|
132
|
-
for (const [index, part] of parts.entries()) {
|
|
133
|
-
current = resolve(current, part);
|
|
134
|
-
if (lstatSync(current).isSymbolicLink()) if (index === 0) current = realpathSync(current);
|
|
135
|
-
else throw new Error(`${label} must contain no symlink component.`);
|
|
136
|
-
}
|
|
137
|
-
if (!lstatSync(current).isDirectory()) throw new Error(`${label} must be a directory.`);
|
|
138
|
-
return realpathSync(current);
|
|
139
|
-
}
|
|
140
|
-
function toPosixPath(path) {
|
|
141
|
-
return path.replace(/\\/gu, "/");
|
|
142
|
-
}
|
|
143
|
-
//#endregion
|
|
144
|
-
//#region src/core/internal/project.ts
|
|
145
|
-
/**
|
|
146
|
-
* Projects are identified solely by their canonical local root. A Git
|
|
147
|
-
* repository without an origin, a non-Git directory, a Git worktree, and a
|
|
148
|
-
* separate clone are all independent checkouts with their own canonical root.
|
|
149
|
-
*/
|
|
150
|
-
function canonicalProjectRoot(path, runner) {
|
|
151
|
-
const directory = realDirectoryWithoutSymlinks(path, "Project path");
|
|
152
|
-
const toplevel = optionalGit(directory, ["rev-parse", "--show-toplevel"], runner);
|
|
153
|
-
if (toplevel === void 0 || toplevel.length === 0) return directory;
|
|
154
|
-
return realpathSync(toplevel);
|
|
155
|
-
}
|
|
156
|
-
//#endregion
|
|
157
|
-
//#region src/core/internal/filesystem.ts
|
|
158
|
-
function readUtf8File(path) {
|
|
159
|
-
return new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path));
|
|
160
|
-
}
|
|
161
|
-
//#endregion
|
|
162
|
-
//#region src/core/internal/content-class.ts
|
|
163
|
-
const GENERATED_DIRECTORY_NAMES = new Set([
|
|
164
|
-
"node_modules",
|
|
165
|
-
"__pycache__",
|
|
166
|
-
"dist",
|
|
167
|
-
"build",
|
|
168
|
-
".next",
|
|
169
|
-
".turbo"
|
|
170
|
-
]);
|
|
171
|
-
const REPO_INFRA_MARKDOWN_FILES = new Set(["AGENTS.md", "CLAUDE.md"]);
|
|
172
|
-
const MANAGED_SYMLINK_PATHS = new Set(["WHITEPAPER.md"]);
|
|
173
|
-
function toTreeRelativePosixPath(treeRoot, targetPath) {
|
|
174
|
-
return relative(treeRoot, targetPath).replace(/\\/gu, "/");
|
|
175
|
-
}
|
|
176
|
-
function classifyContextContent(relativePath) {
|
|
177
|
-
const parts = relativePath.replace(/\\/gu, "/").replace(/^\.\//u, "").split("/").filter((part) => part.length > 0);
|
|
178
|
-
if (parts.some((part) => part.startsWith(".") || GENERATED_DIRECTORY_NAMES.has(part)) || parts[0] === "scripts" || REPO_INFRA_MARKDOWN_FILES.has(parts.at(-1) ?? "")) return "repo-infra";
|
|
179
|
-
if (parts[0] === "members") return "member";
|
|
180
|
-
return "normal";
|
|
181
|
-
}
|
|
182
|
-
function emptyContentClassCounts() {
|
|
183
|
-
return {
|
|
184
|
-
normal: 0,
|
|
185
|
-
member: 0,
|
|
186
|
-
"repo-infra": 0
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
function readDirectoryEntries(path) {
|
|
190
|
-
try {
|
|
191
|
-
return readdirSync(path, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
|
|
192
|
-
} catch {
|
|
193
|
-
return [];
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
function canonicalTarget$1(realTreeRoot, path) {
|
|
197
|
-
try {
|
|
198
|
-
const realTarget = realpathSync(path);
|
|
199
|
-
if (!isPathInside(realTreeRoot, realTarget)) return { kind: "escaped" };
|
|
200
|
-
return {
|
|
201
|
-
kind: "resolved",
|
|
202
|
-
relativePath: toTreeRelativePosixPath(realTreeRoot, realTarget)
|
|
203
|
-
};
|
|
204
|
-
} catch {
|
|
205
|
-
return { kind: "unresolved" };
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
function inspectMarkdownSymlink(realTreeRoot, absolutePath, contentClass) {
|
|
209
|
-
let targetStat;
|
|
210
|
-
try {
|
|
211
|
-
targetStat = statSync(absolutePath);
|
|
212
|
-
} catch {
|
|
213
|
-
return { kind: "unresolved" };
|
|
214
|
-
}
|
|
215
|
-
const target = canonicalTarget$1(realTreeRoot, absolutePath);
|
|
216
|
-
if (target.kind !== "resolved") return target;
|
|
217
|
-
if (!targetStat.isFile()) return { kind: "unsupported" };
|
|
218
|
-
if (classifyContextContent(target.relativePath) !== contentClass) return {
|
|
219
|
-
kind: "content-class-mismatch",
|
|
220
|
-
canonicalRelativePath: target.relativePath
|
|
221
|
-
};
|
|
222
|
-
return { kind: "regular" };
|
|
223
|
-
}
|
|
224
|
-
function collectContextMarkdownContent(treeRoot) {
|
|
225
|
-
const directories = [];
|
|
226
|
-
const directorySymlinks = [];
|
|
227
|
-
const files = [];
|
|
228
|
-
const realTreeRoot = realpathSync(treeRoot);
|
|
229
|
-
function walk(directoryPath) {
|
|
230
|
-
for (const entry of readDirectoryEntries(directoryPath)) {
|
|
231
|
-
const absolutePath = join(directoryPath, entry.name);
|
|
232
|
-
const relativePath = toTreeRelativePosixPath(treeRoot, absolutePath);
|
|
233
|
-
const contentClass = classifyContextContent(relativePath);
|
|
234
|
-
if (entry.isDirectory()) {
|
|
235
|
-
if (contentClass !== "repo-infra") {
|
|
236
|
-
directories.push(relativePath);
|
|
237
|
-
walk(absolutePath);
|
|
238
|
-
}
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
const symbolicLink = entry.isSymbolicLink();
|
|
242
|
-
if (symbolicLink) try {
|
|
243
|
-
if (statSync(absolutePath).isDirectory()) {
|
|
244
|
-
if (contentClass !== "repo-infra" || entry.name.endsWith(".md")) {
|
|
245
|
-
const target = canonicalTarget$1(realTreeRoot, absolutePath);
|
|
246
|
-
directorySymlinks.push({
|
|
247
|
-
escaped: target.kind === "escaped",
|
|
248
|
-
relativePath
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
} catch {
|
|
254
|
-
if (MANAGED_SYMLINK_PATHS.has(relativePath)) continue;
|
|
255
|
-
if (entry.name.endsWith(".md")) files.push({
|
|
256
|
-
absolutePath,
|
|
257
|
-
contentClass,
|
|
258
|
-
inspection: { kind: "unresolved" },
|
|
259
|
-
relativePath
|
|
260
|
-
});
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
if (!entry.isFile() && !symbolicLink || !entry.name.endsWith(".md")) continue;
|
|
264
|
-
if (symbolicLink && MANAGED_SYMLINK_PATHS.has(relativePath)) continue;
|
|
265
|
-
files.push({
|
|
266
|
-
absolutePath,
|
|
267
|
-
contentClass,
|
|
268
|
-
inspection: symbolicLink ? inspectMarkdownSymlink(realTreeRoot, absolutePath, contentClass) : { kind: "regular" },
|
|
269
|
-
relativePath
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
walk(treeRoot);
|
|
274
|
-
return {
|
|
275
|
-
directories,
|
|
276
|
-
directorySymlinks,
|
|
277
|
-
files
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
//#endregion
|
|
281
|
-
//#region src/core/internal/context-document.ts
|
|
282
|
-
function readContextDocument(path) {
|
|
283
|
-
try {
|
|
284
|
-
return parseMarkdownFrontmatter(readUtf8File(path));
|
|
285
|
-
} catch (error) {
|
|
286
|
-
return {
|
|
287
|
-
body: "",
|
|
288
|
-
data: null,
|
|
289
|
-
error: error instanceof Error ? error.message : String(error),
|
|
290
|
-
frontmatter: "invalid"
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
function readNonEmptyStringField(data, key) {
|
|
295
|
-
if (!(key in data)) return {
|
|
296
|
-
present: false,
|
|
297
|
-
valid: false
|
|
298
|
-
};
|
|
299
|
-
const value = data[key];
|
|
300
|
-
if (typeof value !== "string" || value.trim().length === 0) return {
|
|
301
|
-
present: true,
|
|
302
|
-
valid: false
|
|
303
|
-
};
|
|
304
|
-
return {
|
|
305
|
-
present: true,
|
|
306
|
-
valid: true,
|
|
307
|
-
value: value.trim()
|
|
308
|
-
};
|
|
309
|
-
}
|
|
310
|
-
function readNonEmptyStringArrayField(data, key) {
|
|
311
|
-
if (!(key in data)) return {
|
|
312
|
-
present: false,
|
|
313
|
-
valid: false
|
|
314
|
-
};
|
|
315
|
-
const value = data[key];
|
|
316
|
-
if (!Array.isArray(value) || value.length === 0) return {
|
|
317
|
-
present: true,
|
|
318
|
-
valid: false
|
|
319
|
-
};
|
|
320
|
-
const items = [];
|
|
321
|
-
for (const item of value) {
|
|
322
|
-
if (typeof item !== "string" || item.trim().length === 0) return {
|
|
323
|
-
present: true,
|
|
324
|
-
valid: false
|
|
325
|
-
};
|
|
326
|
-
items.push(item.trim());
|
|
327
|
-
}
|
|
328
|
-
return {
|
|
329
|
-
present: true,
|
|
330
|
-
valid: true,
|
|
331
|
-
value: items
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
function readNodeDocument(path) {
|
|
335
|
-
const document = readContextDocument(path);
|
|
336
|
-
if (document.frontmatter !== "valid") return null;
|
|
337
|
-
const title = readNonEmptyStringField(document.data, "title");
|
|
338
|
-
const description = readNonEmptyStringField(document.data, "description");
|
|
339
|
-
if (!title.valid || description.present && !description.valid) return null;
|
|
340
|
-
return {
|
|
341
|
-
body: document.body,
|
|
342
|
-
frontmatter: document.data,
|
|
343
|
-
title: title.value,
|
|
344
|
-
...description.valid ? { description: description.value } : {}
|
|
345
|
-
};
|
|
346
|
-
}
|
|
347
|
-
//#endregion
|
|
348
|
-
//#region src/core/internal/context-links.ts
|
|
349
|
-
function stripQueryAndFragment(target) {
|
|
350
|
-
const indexes = [target.indexOf("?"), target.indexOf("#")].filter((index) => index >= 0);
|
|
351
|
-
const end = indexes.length === 0 ? target.length : Math.min(...indexes);
|
|
352
|
-
return target.slice(0, end);
|
|
353
|
-
}
|
|
354
|
-
function decodeTarget(target) {
|
|
355
|
-
try {
|
|
356
|
-
return decodeURIComponent(target);
|
|
357
|
-
} catch {
|
|
358
|
-
return target;
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
function isWindowsAbsoluteTarget(target) {
|
|
362
|
-
return /^[a-z]:[\\/]/iu.test(target) || /^\\/u.test(target);
|
|
363
|
-
}
|
|
364
|
-
function isTreeLocalTarget(target) {
|
|
365
|
-
const trimmed = target.trim();
|
|
366
|
-
if (isWindowsAbsoluteTarget(decodeTarget(stripQueryAndFragment(trimmed)))) return true;
|
|
367
|
-
return trimmed.length > 0 && !trimmed.startsWith("#") && !trimmed.startsWith("//") && !/^[a-z][a-z\d+.-]*:/iu.test(trimmed);
|
|
368
|
-
}
|
|
369
|
-
function targetExists(path, softLink) {
|
|
370
|
-
try {
|
|
371
|
-
const stat = statSync(path);
|
|
372
|
-
if (stat.isFile()) return !softLink || path.endsWith(".md");
|
|
373
|
-
return stat.isDirectory() && (!softLink || existsSync(resolve(path, "NODE.md")));
|
|
374
|
-
} catch {
|
|
375
|
-
return false;
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
function resolveLocalTreeTarget(options) {
|
|
379
|
-
if (!isTreeLocalTarget(options.target)) return null;
|
|
380
|
-
const decodedTarget = decodeTarget(stripQueryAndFragment(options.target.trim()));
|
|
381
|
-
const withoutSuffix = decodedTarget.replace(/\\/gu, "/");
|
|
382
|
-
if (withoutSuffix.length === 0) return null;
|
|
383
|
-
if (isWindowsAbsoluteTarget(decodedTarget)) return "escaped-missing";
|
|
384
|
-
const sourceDirectory = posix.dirname(options.sourcePath);
|
|
385
|
-
const relativePath = posix.normalize(options.softLink || withoutSuffix.startsWith("/") ? withoutSuffix.replace(/^\/+/, "") : posix.join(sourceDirectory, withoutSuffix));
|
|
386
|
-
const absoluteRoot = resolve(options.treeRoot);
|
|
387
|
-
const absoluteTarget = resolve(absoluteRoot, relativePath);
|
|
388
|
-
if (!isPathInside(absoluteRoot, absoluteTarget)) return "escaped-missing";
|
|
389
|
-
if (!targetExists(absoluteTarget, options.softLink)) return "missing";
|
|
390
|
-
try {
|
|
391
|
-
if (!isPathInside(realpathSync(absoluteRoot), realpathSync(absoluteTarget))) return "escaped-existing";
|
|
392
|
-
} catch {
|
|
393
|
-
return "missing";
|
|
394
|
-
}
|
|
395
|
-
return "valid";
|
|
396
|
-
}
|
|
397
|
-
function readMarkdownLinkTargets(markdown) {
|
|
398
|
-
const root = fromMarkdown(markdown);
|
|
399
|
-
const targets = [];
|
|
400
|
-
function visit(node) {
|
|
401
|
-
if (!isRecord(node)) return;
|
|
402
|
-
if ((node.type === "link" || node.type === "image" || node.type === "definition") && typeof node.url === "string") targets.push(node.url);
|
|
403
|
-
if (Array.isArray(node.children)) for (const child of node.children) visit(child);
|
|
404
|
-
}
|
|
405
|
-
visit(root);
|
|
406
|
-
return targets;
|
|
407
|
-
}
|
|
408
|
-
//#endregion
|
|
409
|
-
//#region src/core/internal/validate-nodes.ts
|
|
410
|
-
function addFinding(findings, code, path, message, target) {
|
|
411
|
-
findings.push({
|
|
412
|
-
code,
|
|
413
|
-
message,
|
|
414
|
-
path,
|
|
415
|
-
...target === void 0 ? {} : { target }
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
function validateRequiredNodeMetadata(document, path, findings) {
|
|
419
|
-
if (document.frontmatter === "missing") {
|
|
420
|
-
addFinding(findings, VALIDATION_CODES.frontmatterMissing, path, "missing frontmatter");
|
|
421
|
-
return;
|
|
422
|
-
}
|
|
423
|
-
if (document.frontmatter === "invalid") {
|
|
424
|
-
addFinding(findings, VALIDATION_CODES.frontmatterParse, path, `frontmatter could not be parsed: ${document.error}`);
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
const title = readNonEmptyStringField(document.data, "title");
|
|
428
|
-
if (!title.present) addFinding(findings, VALIDATION_CODES.titleMissing, path, "missing 'title' field in frontmatter");
|
|
429
|
-
else if (!title.valid) addFinding(findings, VALIDATION_CODES.titleInvalid, path, "'title' must be a non-empty string");
|
|
430
|
-
const description = readNonEmptyStringField(document.data, "description");
|
|
431
|
-
if (description.present && !description.valid) addFinding(findings, VALIDATION_CODES.descriptionInvalid, path, "'description' must be a non-empty string when present");
|
|
432
|
-
}
|
|
433
|
-
function validateRootOnlyFields(document, path, findings) {
|
|
434
|
-
if (path === "NODE.md" || document.frontmatter !== "valid") return;
|
|
435
|
-
const fields = ["schemaVersion"].filter((field) => field in document.data);
|
|
436
|
-
if (fields.length > 0) addFinding(findings, VALIDATION_CODES.rootOnlyFields, path, `root-only frontmatter field${fields.length === 1 ? "" : "s"} must appear only in root NODE.md: ${fields.join(", ")}`);
|
|
437
|
-
}
|
|
438
|
-
function readSoftLinks(document, path, findings) {
|
|
439
|
-
if (document.frontmatter !== "valid") return [];
|
|
440
|
-
const softLinks = readNonEmptyStringArrayField(document.data, "soft_links");
|
|
441
|
-
if (!softLinks.present) return [];
|
|
442
|
-
if (!softLinks.valid) {
|
|
443
|
-
addFinding(findings, VALIDATION_CODES.softLinksInvalid, path, "'soft_links' must be a non-empty string array when present");
|
|
444
|
-
return [];
|
|
445
|
-
}
|
|
446
|
-
return softLinks.value;
|
|
447
|
-
}
|
|
448
|
-
function validateSoftLinks(options) {
|
|
449
|
-
for (const target of readSoftLinks(options.document, options.path, options.findings)) {
|
|
450
|
-
const resolved = resolveLocalTreeTarget({
|
|
451
|
-
sourcePath: options.path,
|
|
452
|
-
target,
|
|
453
|
-
treeRoot: options.treeRoot,
|
|
454
|
-
softLink: true
|
|
455
|
-
});
|
|
456
|
-
if (resolved === null || resolved === "missing" || resolved === "escaped-missing") addFinding(options.findings, VALIDATION_CODES.softLinkBroken, options.path, "broken soft_links target", target);
|
|
457
|
-
if (resolved === null) continue;
|
|
458
|
-
if (resolved === "escaped-existing" || resolved === "escaped-missing") addFinding(options.findings, VALIDATION_CODES.softLinkPathEscape, options.path, "soft_links target resolves outside the Context Tree root", target);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
function validateMarkdownLinks(document, path, treeRoot, findings) {
|
|
462
|
-
for (const target of readMarkdownLinkTargets(document.body)) {
|
|
463
|
-
const resolved = resolveLocalTreeTarget({
|
|
464
|
-
sourcePath: path,
|
|
465
|
-
target,
|
|
466
|
-
treeRoot,
|
|
467
|
-
softLink: false
|
|
468
|
-
});
|
|
469
|
-
if (resolved === null) continue;
|
|
470
|
-
if (resolved === "escaped-existing" || resolved === "escaped-missing") addFinding(findings, VALIDATION_CODES.markdownPathEscape, path, "Markdown link resolves outside the Context Tree root", target);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
function collectNodeValidationFindings(treeRoot) {
|
|
474
|
-
const findings = [];
|
|
475
|
-
const scannedByContentClass = emptyContentClassCounts();
|
|
476
|
-
const content = collectContextMarkdownContent(treeRoot);
|
|
477
|
-
for (const directory of content.directories) {
|
|
478
|
-
const nodePath = `${directory}/NODE.md`;
|
|
479
|
-
let hasRegularNode = false;
|
|
480
|
-
try {
|
|
481
|
-
const entry = lstatSync(join(treeRoot, nodePath));
|
|
482
|
-
hasRegularNode = entry.isFile() && !entry.isSymbolicLink();
|
|
483
|
-
} catch {}
|
|
484
|
-
if (!hasRegularNode) addFinding(findings, VALIDATION_CODES.directoryNodeMissing, directory, "Context Tree directory is missing NODE.md");
|
|
485
|
-
}
|
|
486
|
-
for (const directory of content.directorySymlinks) addFinding(findings, directory.escaped ? VALIDATION_CODES.directorySymlinkPathEscape : VALIDATION_CODES.directorySymlinkUnsupported, directory.relativePath, directory.escaped ? "directory symlink resolves outside the Context Tree root" : "Context Tree domain directories must not be symlinks");
|
|
487
|
-
for (const file of content.files) {
|
|
488
|
-
scannedByContentClass[file.contentClass] += 1;
|
|
489
|
-
if (file.inspection.kind === "unresolved") {
|
|
490
|
-
addFinding(findings, VALIDATION_CODES.markdownFileSymlinkBroken, file.relativePath, "Markdown file symlink target cannot be resolved");
|
|
491
|
-
continue;
|
|
492
|
-
}
|
|
493
|
-
if (file.inspection.kind === "escaped") {
|
|
494
|
-
addFinding(findings, VALIDATION_CODES.markdownFilePathEscape, file.relativePath, "Markdown file resolves outside the Context Tree root");
|
|
495
|
-
continue;
|
|
496
|
-
}
|
|
497
|
-
if (file.inspection.kind === "unsupported") {
|
|
498
|
-
addFinding(findings, VALIDATION_CODES.markdownFileSymlinkUnsupported, file.relativePath, "Markdown file symlink must resolve to a regular file");
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
501
|
-
if (file.inspection.kind === "content-class-mismatch") {
|
|
502
|
-
const canonicalContentClass = classifyContextContent(file.inspection.canonicalRelativePath);
|
|
503
|
-
addFinding(findings, VALIDATION_CODES.markdownFileContentClassMismatch, file.relativePath, `Markdown file symlink crosses content-class boundary from ${file.contentClass} to ${canonicalContentClass}`, file.inspection.canonicalRelativePath);
|
|
504
|
-
continue;
|
|
505
|
-
}
|
|
506
|
-
if (file.contentClass === "repo-infra") continue;
|
|
507
|
-
const document = readContextDocument(file.absolutePath);
|
|
508
|
-
validateRootOnlyFields(document, file.relativePath, findings);
|
|
509
|
-
if (file.relativePath !== "NODE.md" || document.frontmatter === "valid") validateRequiredNodeMetadata(document, file.relativePath, findings);
|
|
510
|
-
validateSoftLinks({
|
|
511
|
-
document,
|
|
512
|
-
findings,
|
|
513
|
-
path: file.relativePath,
|
|
514
|
-
treeRoot
|
|
515
|
-
});
|
|
516
|
-
validateMarkdownLinks(document, file.relativePath, treeRoot, findings);
|
|
517
|
-
}
|
|
518
|
-
return {
|
|
519
|
-
findings,
|
|
520
|
-
scannedByContentClass
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
//#endregion
|
|
524
|
-
//#region src/core/verify.ts
|
|
525
|
-
function rootNodeFindings(root) {
|
|
526
|
-
const path = join(root, "NODE.md");
|
|
527
|
-
if (!existsSync(path)) return [{
|
|
528
|
-
code: VALIDATION_CODES.rootMissing,
|
|
529
|
-
message: "root NODE.md is missing",
|
|
530
|
-
path: "NODE.md"
|
|
531
|
-
}];
|
|
532
|
-
try {
|
|
533
|
-
const entry = lstatSync(path);
|
|
534
|
-
if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("Root NODE.md must be a regular file and must not be a symlink.");
|
|
535
|
-
parseContextTreeRootNode(readUtf8File(path));
|
|
536
|
-
return [];
|
|
537
|
-
} catch (error) {
|
|
538
|
-
return [{
|
|
539
|
-
code: VALIDATION_CODES.rootNodeInvalid,
|
|
540
|
-
message: error instanceof Error ? error.message : String(error),
|
|
541
|
-
path: "NODE.md"
|
|
542
|
-
}];
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
function deduplicate(findings) {
|
|
546
|
-
const seen = /* @__PURE__ */ new Set();
|
|
547
|
-
return findings.filter((finding) => {
|
|
548
|
-
const key = `${finding.code}\0${finding.path}\0${finding.target ?? ""}`;
|
|
549
|
-
if (seen.has(key)) return false;
|
|
550
|
-
seen.add(key);
|
|
551
|
-
return true;
|
|
552
|
-
});
|
|
553
|
-
}
|
|
554
|
-
function verifyTree(treePath) {
|
|
555
|
-
const root = resolveTreeRoot(treePath);
|
|
556
|
-
const nodeResult = collectNodeValidationFindings(root);
|
|
557
|
-
const findings = deduplicate([...rootNodeFindings(root), ...nodeResult.findings]);
|
|
558
|
-
return {
|
|
559
|
-
findings,
|
|
560
|
-
ok: findings.length === 0,
|
|
561
|
-
root,
|
|
562
|
-
scannedByContentClass: nodeResult.scannedByContentClass,
|
|
563
|
-
schemaVersion: 1
|
|
564
|
-
};
|
|
565
|
-
}
|
|
566
|
-
//#endregion
|
|
567
|
-
//#region src/core/internal/tree-state.ts
|
|
568
|
-
/**
|
|
569
|
-
* The one tree-state resolver shared by create, connect, sync, publish, and
|
|
570
|
-
* writes. It validates a checkout exactly once and reports the discriminated
|
|
571
|
-
* tree state: a local-only tree, or a published tree with its GitHub
|
|
572
|
-
* OWNER/REPO identity. Resolution never backfills or mutates stored state.
|
|
573
|
-
*/
|
|
574
|
-
/** Require a real directory with no symlink component that is an exact Git root. */
|
|
575
|
-
function exactGitRoot(treePath, runner) {
|
|
576
|
-
const root = realDirectoryWithoutSymlinks(treePath, "Context Tree path");
|
|
577
|
-
const toplevel = optionalGit(root, ["rev-parse", "--show-toplevel"], runner);
|
|
578
|
-
if (toplevel === void 0 || toplevel.length === 0) throw new Error("Context Tree path must be a Git repository.");
|
|
579
|
-
if (realpathSync(toplevel) !== root) throw new Error("Context Tree path must be the real Git root.");
|
|
580
|
-
return root;
|
|
581
|
-
}
|
|
582
|
-
/** Parse the root NODE.md, refusing symlinked or irregular files. */
|
|
583
|
-
function parseRootNode(root) {
|
|
584
|
-
const path = join(root, "NODE.md");
|
|
585
|
-
const entry = lstatSync(path);
|
|
586
|
-
if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("Context Tree root NODE.md must be a regular file.");
|
|
587
|
-
return parseContextTreeRootNode(readUtf8File(path));
|
|
588
|
-
}
|
|
589
|
-
/**
|
|
590
|
-
* Validate a clean checkout without inferring state from mutable Git remotes.
|
|
591
|
-
* Uncommitted changes and invalid content each get their own code so callers
|
|
592
|
-
* can tell "commit your edits" apart from "this path is gone".
|
|
593
|
-
*/
|
|
594
|
-
function validateTreeCheckout(treePath, runner) {
|
|
595
|
-
const root = exactGitRoot(treePath, runner);
|
|
596
|
-
if (git(root, [
|
|
597
|
-
"status",
|
|
598
|
-
"--porcelain",
|
|
599
|
-
"--untracked-files=all"
|
|
600
|
-
], {
|
|
601
|
-
message: "Failed to inspect Context Tree cleanliness.",
|
|
602
|
-
runner
|
|
603
|
-
}).trim().length !== 0) throw new ContextTreeError(CLI_ERROR_CODES.dirtyTree, `The Context Tree at ${root} has uncommitted changes; commit or discard them.`);
|
|
604
|
-
if (!verifyTree(root).ok) throw new ContextTreeError(CLI_ERROR_CODES.invalidTree, `The Context Tree at ${root} is invalid; run context-tree verify --tree-path ${root}.`);
|
|
605
|
-
return root;
|
|
606
|
-
}
|
|
607
|
-
/** Validate a stored state without reclassifying it from mutable Git remotes. */
|
|
608
|
-
function validateStoredTreeState(state, runner) {
|
|
609
|
-
const path = validateTreeCheckout(state.path, runner);
|
|
610
|
-
return state.kind === "local" ? {
|
|
611
|
-
kind: "local",
|
|
612
|
-
path
|
|
613
|
-
} : {
|
|
614
|
-
kind: "github",
|
|
615
|
-
path,
|
|
616
|
-
repository: state.repository
|
|
617
|
-
};
|
|
618
|
-
}
|
|
619
|
-
//#endregion
|
|
620
|
-
//#region src/core/connections.ts
|
|
621
|
-
const connectionsFileSchema = z.object({
|
|
622
|
-
connections: z.array(contextTreeConnectionSchema),
|
|
623
|
-
schemaVersion: z.literal(1)
|
|
624
|
-
}).strict();
|
|
625
|
-
const DUPLICATE_MESSAGE = "Duplicate Context Tree connection records exist for this project.";
|
|
626
|
-
const NO_CONNECTION_MESSAGE = "No Context Tree connection exists for this project; run context-tree create or connect.";
|
|
627
|
-
function realHome() {
|
|
628
|
-
try {
|
|
629
|
-
return realpathSync(homedir());
|
|
630
|
-
} catch {
|
|
631
|
-
return homedir();
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
/** Create a managed application directory below the home directory, failing closed on symlinks. */
|
|
635
|
-
function ensureManagedDirectory(...segments) {
|
|
636
|
-
let current = realHome();
|
|
637
|
-
for (const segment of segments) {
|
|
638
|
-
current = join(current, segment);
|
|
639
|
-
const entry = lstatSync(current, { throwIfNoEntry: false });
|
|
640
|
-
if (entry === void 0) {
|
|
641
|
-
mkdirSync(current, { mode: 448 });
|
|
642
|
-
continue;
|
|
643
|
-
}
|
|
644
|
-
if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`Context Tree managed directory must be a real directory: ${current}`);
|
|
645
|
-
}
|
|
646
|
-
return current;
|
|
647
|
-
}
|
|
648
|
-
/** The managed namespace without creating it; listing must not create an absent directory. */
|
|
649
|
-
function managedTreesPath() {
|
|
650
|
-
return join(realHome(), ".context-tree", "trees");
|
|
651
|
-
}
|
|
652
|
-
function connectionsPath() {
|
|
653
|
-
return join(realHome(), ".context-tree", "connections.json");
|
|
654
|
-
}
|
|
655
|
-
function managedTreesRoot() {
|
|
656
|
-
return ensureManagedDirectory(".context-tree", "trees");
|
|
657
|
-
}
|
|
658
|
-
function loadConnections(required) {
|
|
659
|
-
const path = connectionsPath();
|
|
660
|
-
if (!existsSync(path)) {
|
|
661
|
-
if (required) throw new ContextTreeError(CLI_ERROR_CODES.noConnection, NO_CONNECTION_MESSAGE);
|
|
662
|
-
return {
|
|
663
|
-
connections: [],
|
|
664
|
-
schemaVersion: 1
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
try {
|
|
668
|
-
const entry = lstatSync(path);
|
|
669
|
-
if (entry.isSymbolicLink() || !entry.isFile()) throw new Error("not a regular file");
|
|
670
|
-
return connectionsFileSchema.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
671
|
-
} catch {
|
|
672
|
-
throw new ContextTreeError(CLI_ERROR_CODES.corruptConnection, "Context Tree connections are corrupt; remove connections.json and run context-tree connect again.");
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
function saveConnections(value) {
|
|
676
|
-
const directory = ensureManagedDirectory(".context-tree");
|
|
677
|
-
const path = join(directory, "connections.json");
|
|
678
|
-
const temporary = join(directory, `.connections-${process.pid}-${Date.now()}.tmp`);
|
|
679
|
-
writeFileSync(temporary, `${JSON.stringify(connectionsFileSchema.parse(value), null, 2)}\n`, {
|
|
680
|
-
encoding: "utf8",
|
|
681
|
-
flag: "wx",
|
|
682
|
-
mode: 384
|
|
683
|
-
});
|
|
684
|
-
try {
|
|
685
|
-
renameSync(temporary, path);
|
|
686
|
-
chmodSync(path, 384);
|
|
687
|
-
} finally {
|
|
688
|
-
rmSync(temporary, { force: true });
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
/** Exactly one record may exist per project; more than one is corruption. */
|
|
692
|
-
function singleConnection(stored, canonical) {
|
|
693
|
-
const matches = stored.connections.filter((connection) => connection.projectPath === canonical);
|
|
694
|
-
if (matches.length > 1) throw new ContextTreeError(CLI_ERROR_CODES.corruptConnection, DUPLICATE_MESSAGE);
|
|
695
|
-
return matches[0];
|
|
696
|
-
}
|
|
697
|
-
function isManagedName(value) {
|
|
698
|
-
return treeNameSchema.safeParse(value).success && value === value.toLowerCase();
|
|
699
|
-
}
|
|
700
|
-
function managedName(value) {
|
|
701
|
-
if (!isManagedName(value)) throw new Error(`Managed Context Tree names must be safe lowercase path segments: ${value}`);
|
|
702
|
-
return value;
|
|
703
|
-
}
|
|
704
|
-
/** List valid, clean managed trees, excluding unsafe or invalid candidates without failing the listing. */
|
|
705
|
-
function listManagedTrees(runner) {
|
|
706
|
-
const root = managedTreesPath();
|
|
707
|
-
if (!existsSync(root)) return {
|
|
708
|
-
schemaVersion: 1,
|
|
709
|
-
trees: []
|
|
710
|
-
};
|
|
711
|
-
const rootEntry = lstatSync(root);
|
|
712
|
-
if (rootEntry.isSymbolicLink() || !rootEntry.isDirectory()) throw new Error("Context Tree managed directory must be a real directory.");
|
|
713
|
-
const trees = [];
|
|
714
|
-
for (const candidate of readdirSync(root, { withFileTypes: true })) {
|
|
715
|
-
if (!candidate.isDirectory() || !isManagedName(candidate.name)) continue;
|
|
716
|
-
try {
|
|
717
|
-
trees.push({
|
|
718
|
-
name: candidate.name,
|
|
719
|
-
tree: classifyCheckout(join(root, candidate.name), runner)
|
|
720
|
-
});
|
|
721
|
-
} catch {}
|
|
722
|
-
}
|
|
723
|
-
trees.sort((left, right) => left.name.localeCompare(right.name));
|
|
724
|
-
return {
|
|
725
|
-
schemaVersion: 1,
|
|
726
|
-
trees
|
|
727
|
-
};
|
|
728
|
-
}
|
|
729
|
-
function findConnectionRecord(projectPath, runner) {
|
|
730
|
-
return singleConnection(loadConnections(false), canonicalProjectRoot(projectPath, runner));
|
|
731
|
-
}
|
|
732
|
-
function validateManagedTreeState(tree, runner) {
|
|
733
|
-
const validated = validateStoredTreeState(tree, runner);
|
|
734
|
-
if (dirname(validated.path) === managedTreesPath() && !isManagedName(basename(validated.path))) throw new Error(`Managed Context Tree names must be safe lowercase path segments: ${basename(validated.path)}`);
|
|
735
|
-
return validated;
|
|
736
|
-
}
|
|
737
|
-
function resolveConnectionRecord(projectPath, runner) {
|
|
738
|
-
const canonical = canonicalProjectRoot(projectPath, runner);
|
|
739
|
-
const connection = singleConnection(loadConnections(true), canonical);
|
|
740
|
-
if (connection === void 0) throw new ContextTreeError(CLI_ERROR_CODES.noConnection, NO_CONNECTION_MESSAGE);
|
|
741
|
-
try {
|
|
742
|
-
return {
|
|
743
|
-
projectPath: connection.projectPath,
|
|
744
|
-
tree: validateManagedTreeState(connection.tree, runner)
|
|
745
|
-
};
|
|
746
|
-
} catch (error) {
|
|
747
|
-
if (error instanceof ContextTreeError) throw error;
|
|
748
|
-
const detail = error instanceof Error ? error.message : "unknown failure";
|
|
749
|
-
throw new ContextTreeError(CLI_ERROR_CODES.staleConnection, `The connected Context Tree is no longer usable at ${connection.tree.path}; run context-tree connect to point this project at its current location. ${detail}`);
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
function resolveConnection(projectPath, runner) {
|
|
753
|
-
return {
|
|
754
|
-
schemaVersion: 1,
|
|
755
|
-
tree: resolveConnectionRecord(projectPath, runner).tree
|
|
756
|
-
};
|
|
757
|
-
}
|
|
758
|
-
function upsertConnection(connection, runner) {
|
|
759
|
-
const canonical = canonicalProjectRoot(connection.projectPath, runner);
|
|
760
|
-
const record = {
|
|
761
|
-
projectPath: canonical,
|
|
762
|
-
tree: validateManagedTreeState(contextTreeStateSchema.parse(connection.tree), runner)
|
|
763
|
-
};
|
|
764
|
-
const stored = loadConnections(false);
|
|
765
|
-
const previous = singleConnection(stored, canonical);
|
|
766
|
-
if (previous !== void 0 && JSON.stringify(previous.tree) === JSON.stringify(record.tree)) return {
|
|
767
|
-
schemaVersion: 1,
|
|
768
|
-
tree: record.tree
|
|
769
|
-
};
|
|
770
|
-
saveConnections({
|
|
771
|
-
connections: [...stored.connections.filter((candidate) => candidate.projectPath !== canonical), record],
|
|
772
|
-
schemaVersion: 1
|
|
773
|
-
});
|
|
774
|
-
return {
|
|
775
|
-
schemaVersion: 1,
|
|
776
|
-
tree: record.tree
|
|
777
|
-
};
|
|
778
|
-
}
|
|
779
|
-
function updateConnectionTree(projectPath, tree, runner) {
|
|
780
|
-
const canonical = canonicalProjectRoot(projectPath, runner);
|
|
781
|
-
const stored = loadConnections(true);
|
|
782
|
-
if (singleConnection(stored, canonical) === void 0) throw new ContextTreeError(CLI_ERROR_CODES.noConnection, NO_CONNECTION_MESSAGE);
|
|
783
|
-
const validatedTree = validateManagedTreeState(contextTreeStateSchema.parse(tree), runner);
|
|
784
|
-
saveConnections({
|
|
785
|
-
connections: stored.connections.map((connection) => connection.projectPath === canonical ? {
|
|
786
|
-
...connection,
|
|
787
|
-
tree: validatedTree
|
|
788
|
-
} : connection),
|
|
789
|
-
schemaVersion: 1
|
|
790
|
-
});
|
|
791
|
-
}
|
|
792
|
-
/**
|
|
793
|
-
* Validate an exact checkout and classify it from a safe origin: no origin
|
|
794
|
-
* is local state, a credential-free GitHub origin is GitHub state, and any
|
|
795
|
-
* other origin is rejected as unsafe or unsupported.
|
|
796
|
-
*/
|
|
797
|
-
function classifyCheckout(path, runner) {
|
|
798
|
-
const root = validateTreeCheckout(path, runner);
|
|
799
|
-
const origin = optionalGit(root, [
|
|
800
|
-
"remote",
|
|
801
|
-
"get-url",
|
|
802
|
-
"origin"
|
|
803
|
-
], runner);
|
|
804
|
-
if (origin === void 0) return {
|
|
805
|
-
kind: "local",
|
|
806
|
-
path: root
|
|
807
|
-
};
|
|
808
|
-
return {
|
|
809
|
-
kind: "github",
|
|
810
|
-
path: root,
|
|
811
|
-
repository: gitHubRepositoryFromOriginUrl(origin)
|
|
812
|
-
};
|
|
813
|
-
}
|
|
814
|
-
function sameRepository(left, right) {
|
|
815
|
-
return left.toLowerCase() === right.toLowerCase();
|
|
816
|
-
}
|
|
817
|
-
/** An existing managed directory that must be a real directory, not a symlinked alias. */
|
|
818
|
-
function realManagedDirectory(name, destination) {
|
|
819
|
-
const entry = lstatSync(destination);
|
|
820
|
-
if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`Managed Context Tree name ${name} is occupied by an unsafe destination.`);
|
|
821
|
-
}
|
|
822
|
-
/**
|
|
823
|
-
* Connect by exact managed name or GitHub OWNER/REPO, or attach an exact,
|
|
824
|
-
* clean, fully valid Git checkout at an explicit disk path in place.
|
|
825
|
-
*/
|
|
826
|
-
function connectProject(options, runner) {
|
|
827
|
-
if ("treePath" in options) {
|
|
828
|
-
const tree = classifyCheckout(options.treePath, runner);
|
|
829
|
-
return upsertConnection({
|
|
830
|
-
projectPath: options.projectPath,
|
|
831
|
-
tree
|
|
832
|
-
}, runner);
|
|
833
|
-
}
|
|
834
|
-
const treesRoot = managedTreesRoot();
|
|
835
|
-
if (!options.target.includes("/")) {
|
|
836
|
-
const name = managedName(options.target);
|
|
837
|
-
const destination = join(treesRoot, name);
|
|
838
|
-
if (!existsSync(destination)) throw new Error(`No managed Context Tree named ${name} exists.`);
|
|
839
|
-
realManagedDirectory(name, destination);
|
|
840
|
-
const tree = classifyCheckout(destination, runner);
|
|
841
|
-
return upsertConnection({
|
|
842
|
-
projectPath: options.projectPath,
|
|
843
|
-
tree
|
|
844
|
-
}, runner);
|
|
845
|
-
}
|
|
846
|
-
const repository = githubRepositoryIdentitySchema.parse(options.target);
|
|
847
|
-
const repositoryName = repository.split("/")[1];
|
|
848
|
-
if (repositoryName === void 0) throw new Error("Repository must be OWNER/REPO.");
|
|
849
|
-
const name = managedName(repositoryName.toLowerCase());
|
|
850
|
-
const destination = join(treesRoot, name);
|
|
851
|
-
if (existsSync(destination)) {
|
|
852
|
-
realManagedDirectory(name, destination);
|
|
853
|
-
const tree = classifyCheckout(destination, runner);
|
|
854
|
-
if (tree.kind !== "github" || !sameRepository(tree.repository, repository)) throw new Error(`Managed Context Tree name ${name} is already used by a different tree.`);
|
|
855
|
-
return upsertConnection({
|
|
856
|
-
projectPath: options.projectPath,
|
|
857
|
-
tree
|
|
858
|
-
}, runner);
|
|
859
|
-
}
|
|
860
|
-
mkdirSync(destination, { mode: 448 });
|
|
861
|
-
try {
|
|
862
|
-
git(treesRoot, [
|
|
863
|
-
"clone",
|
|
864
|
-
"--quiet",
|
|
865
|
-
"--origin",
|
|
866
|
-
"origin",
|
|
867
|
-
"--",
|
|
868
|
-
canonicalGitHubRepositoryUrl(repository),
|
|
869
|
-
destination
|
|
870
|
-
], {
|
|
871
|
-
message: "Cloning the Context Tree repository failed.",
|
|
872
|
-
runner
|
|
873
|
-
});
|
|
874
|
-
const tree = classifyCheckout(destination, runner);
|
|
875
|
-
if (tree.kind !== "github" || !sameRepository(tree.repository, repository)) throw new Error("The cloned Context Tree origin does not match the requested repository.");
|
|
876
|
-
return upsertConnection({
|
|
877
|
-
projectPath: options.projectPath,
|
|
878
|
-
tree
|
|
879
|
-
}, runner);
|
|
880
|
-
} catch (error) {
|
|
881
|
-
rmSync(destination, {
|
|
882
|
-
force: true,
|
|
883
|
-
recursive: true
|
|
884
|
-
});
|
|
885
|
-
throw error;
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
//#endregion
|
|
889
|
-
//#region src/core/internal/packaged-resource.ts
|
|
890
|
-
const PACKAGE_NAME = "@first-tree-ai/context-tree";
|
|
891
|
-
function isPackageRoot(path) {
|
|
892
|
-
const manifestPath = join(path, "package.json");
|
|
893
|
-
if (!existsSync(manifestPath)) return false;
|
|
894
|
-
try {
|
|
895
|
-
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
896
|
-
return isRecord(manifest) && manifest.name === PACKAGE_NAME;
|
|
897
|
-
} catch {
|
|
898
|
-
return false;
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
function resolvePackagedResource(...segments) {
|
|
902
|
-
let candidate = dirname(fileURLToPath(import.meta.url));
|
|
903
|
-
const filesystemRoot = parse(candidate).root;
|
|
904
|
-
while (true) {
|
|
905
|
-
if (isPackageRoot(candidate)) {
|
|
906
|
-
const resource = resolve(candidate, ...segments);
|
|
907
|
-
if (existsSync(resource)) return resource;
|
|
908
|
-
throw new Error(`Packaged resource is missing: ${segments.join("/")}`);
|
|
909
|
-
}
|
|
910
|
-
if (candidate === filesystemRoot) break;
|
|
911
|
-
candidate = dirname(candidate);
|
|
912
|
-
}
|
|
913
|
-
throw new Error(`Package root is missing while resolving: ${segments.join("/")}`);
|
|
914
|
-
}
|
|
915
|
-
function readPackageManifest() {
|
|
916
|
-
const parsed = JSON.parse(readFileSync(resolvePackagedResource("package.json"), "utf8"));
|
|
917
|
-
if (!isRecord(parsed)) throw new Error("Package metadata is invalid.");
|
|
918
|
-
return parsed;
|
|
919
|
-
}
|
|
920
|
-
function readPackageVersion() {
|
|
921
|
-
const manifest = readPackageManifest();
|
|
922
|
-
if (typeof manifest.version !== "string") throw new Error("Package version is missing or invalid.");
|
|
923
|
-
return manifest.version;
|
|
924
|
-
}
|
|
925
|
-
//#endregion
|
|
926
|
-
//#region src/core/scaffold.ts
|
|
927
|
-
function template(name, values) {
|
|
928
|
-
let result = readFileSync(resolvePackagedResource("templates", name), "utf8");
|
|
929
|
-
for (const [key, value] of Object.entries(values)) result = result.replaceAll(`{{${key}}}`, value);
|
|
930
|
-
return result;
|
|
931
|
-
}
|
|
932
|
-
/** Templated regular files, written before the CLAUDE.md -> AGENTS.md symlink. */
|
|
933
|
-
const TEMPLATED_FILES = [
|
|
934
|
-
["NODE.md", "root-node.md"],
|
|
935
|
-
["AGENTS.md", "AGENTS.md"],
|
|
936
|
-
[".github/workflows/validate-context-tree.yml", "validate-context-tree.yml"]
|
|
937
|
-
];
|
|
938
|
-
const SCAFFOLD_FILES = [
|
|
939
|
-
"NODE.md",
|
|
940
|
-
"AGENTS.md",
|
|
941
|
-
"CLAUDE.md",
|
|
942
|
-
".github/workflows/validate-context-tree.yml"
|
|
943
|
-
];
|
|
944
|
-
function initializeGitRepository(root, runner) {
|
|
945
|
-
gitCommand([
|
|
946
|
-
"init",
|
|
947
|
-
"--quiet",
|
|
948
|
-
"--",
|
|
949
|
-
root
|
|
950
|
-
], {
|
|
951
|
-
message: "Failed to initialize Git repository.",
|
|
952
|
-
runner
|
|
953
|
-
});
|
|
954
|
-
return git(root, [
|
|
955
|
-
"symbolic-ref",
|
|
956
|
-
"--short",
|
|
957
|
-
"HEAD"
|
|
958
|
-
], {
|
|
959
|
-
message: "Failed to resolve the initial Git branch during repository initialization.",
|
|
960
|
-
runner
|
|
961
|
-
});
|
|
962
|
-
}
|
|
963
|
-
function commitScaffold(root, runner) {
|
|
964
|
-
for (const file of SCAFFOLD_FILES) git(root, [
|
|
965
|
-
"add",
|
|
966
|
-
"--",
|
|
967
|
-
file
|
|
968
|
-
], {
|
|
969
|
-
message: "Failed to stage the scaffold files.",
|
|
970
|
-
runner
|
|
971
|
-
});
|
|
972
|
-
git(root, [
|
|
973
|
-
"-c",
|
|
974
|
-
"user.name=Context Tree",
|
|
975
|
-
"-c",
|
|
976
|
-
"user.email=context-tree@localhost",
|
|
977
|
-
"-c",
|
|
978
|
-
"commit.gpgsign=false",
|
|
979
|
-
"commit",
|
|
980
|
-
"--quiet",
|
|
981
|
-
"-m",
|
|
982
|
-
"Initialize Context Tree"
|
|
983
|
-
], {
|
|
984
|
-
message: "Failed to commit the scaffold.",
|
|
985
|
-
runner
|
|
986
|
-
});
|
|
987
|
-
return git(root, ["rev-parse", "HEAD"], {
|
|
988
|
-
message: "Failed to resolve the scaffold commit.",
|
|
989
|
-
runner
|
|
990
|
-
});
|
|
991
|
-
}
|
|
992
|
-
function scaffoldTree(options) {
|
|
993
|
-
const name = treeNameSchema.parse(options.name);
|
|
994
|
-
const root = resolve(options.path);
|
|
995
|
-
const destination = lstatSync(root, { throwIfNoEntry: false });
|
|
996
|
-
if (destination !== void 0) {
|
|
997
|
-
if (destination.isSymbolicLink() || !destination.isDirectory()) throw new Error(`Refusing to scaffold into a symlink or non-directory destination: ${root}`);
|
|
998
|
-
if (readdirSync(root).length > 0) throw new Error(`Refusing to scaffold into a non-empty directory: ${root}`);
|
|
999
|
-
}
|
|
1000
|
-
const initialBranch = initializeGitRepository(root, options.runner);
|
|
1001
|
-
const values = {
|
|
1002
|
-
branchJson: JSON.stringify(initialBranch),
|
|
1003
|
-
packageVersion: readPackageVersion(),
|
|
1004
|
-
title: name,
|
|
1005
|
-
titleJson: JSON.stringify(name)
|
|
1006
|
-
};
|
|
1007
|
-
for (const [relativePath, source] of TEMPLATED_FILES) {
|
|
1008
|
-
const path = join(root, relativePath);
|
|
1009
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
1010
|
-
writeFileSync(path, template(source, values), {
|
|
1011
|
-
encoding: "utf8",
|
|
1012
|
-
flag: "wx",
|
|
1013
|
-
mode: 420
|
|
1014
|
-
});
|
|
1015
|
-
}
|
|
1016
|
-
symlinkSync("AGENTS.md", join(root, "CLAUDE.md"), "file");
|
|
1017
|
-
if (!verifyTree(root).ok) throw new Error("Refusing to commit an invalid Context Tree scaffold.");
|
|
1018
|
-
return {
|
|
1019
|
-
branch: initialBranch,
|
|
1020
|
-
commit: commitScaffold(root, options.runner),
|
|
1021
|
-
root
|
|
1022
|
-
};
|
|
1023
|
-
}
|
|
1024
|
-
//#endregion
|
|
1025
|
-
//#region src/core/create.ts
|
|
1026
|
-
function projectName(canonicalRoot) {
|
|
1027
|
-
const normalized = basename(canonicalRoot).toLowerCase().replace(/[^a-z\d._-]+/gu, "-").replace(/-{2,}/gu, "-").replace(/^[-.]+/u, "").replace(/[-.]+$/u, "").slice(0, 40);
|
|
1028
|
-
return /^[a-z\d]/u.test(normalized) ? normalized : "project";
|
|
1029
|
-
}
|
|
1030
|
-
function existingCreateResult(destination, runner) {
|
|
1031
|
-
return {
|
|
1032
|
-
branch: git(destination, [
|
|
1033
|
-
"symbolic-ref",
|
|
1034
|
-
"--short",
|
|
1035
|
-
"HEAD"
|
|
1036
|
-
], {
|
|
1037
|
-
message: "Failed to resolve the managed tree branch.",
|
|
1038
|
-
runner
|
|
1039
|
-
}),
|
|
1040
|
-
commitSha: git(destination, ["rev-parse", "HEAD"], {
|
|
1041
|
-
message: "Failed to resolve the managed tree commit.",
|
|
1042
|
-
runner
|
|
1043
|
-
}),
|
|
1044
|
-
created: false,
|
|
1045
|
-
schemaVersion: 1,
|
|
1046
|
-
title: parseRootNode(destination).frontmatter.title,
|
|
1047
|
-
treePath: destination
|
|
1048
|
-
};
|
|
1049
|
-
}
|
|
1050
|
-
/** Create and connect the project's uniquely named managed local Context Tree. */
|
|
1051
|
-
function createProject(projectPath, runner) {
|
|
1052
|
-
const canonical = canonicalProjectRoot(projectPath, runner);
|
|
1053
|
-
const name = treeNameSchema.parse(`${projectName(canonical)}-context-tree`);
|
|
1054
|
-
const destination = join(managedTreesRoot(), name);
|
|
1055
|
-
const current = findConnectionRecord(canonical, runner);
|
|
1056
|
-
if (current !== void 0 && current.tree.path !== destination) throw new Error(`This project is already connected to a Context Tree at ${current.tree.path}; run context-tree connect ${name} to switch.`);
|
|
1057
|
-
if (existsSync(destination)) {
|
|
1058
|
-
const entry = lstatSync(destination);
|
|
1059
|
-
if (entry.isSymbolicLink() || !entry.isDirectory() || current === void 0) throw new Error(`Managed Context Tree name ${name} is occupied; run context-tree connect ${name}.`);
|
|
1060
|
-
return existingCreateResult(destination, runner);
|
|
1061
|
-
}
|
|
1062
|
-
mkdirSync(destination, { mode: 448 });
|
|
1063
|
-
try {
|
|
1064
|
-
const scaffold = scaffoldTree({
|
|
1065
|
-
name,
|
|
1066
|
-
path: destination,
|
|
1067
|
-
runner
|
|
1068
|
-
});
|
|
1069
|
-
upsertConnection({
|
|
1070
|
-
projectPath: canonical,
|
|
1071
|
-
tree: {
|
|
1072
|
-
kind: "local",
|
|
1073
|
-
path: scaffold.root
|
|
1074
|
-
}
|
|
1075
|
-
}, runner);
|
|
1076
|
-
return {
|
|
1077
|
-
branch: scaffold.branch,
|
|
1078
|
-
commitSha: scaffold.commit,
|
|
1079
|
-
created: true,
|
|
1080
|
-
schemaVersion: 1,
|
|
1081
|
-
title: name,
|
|
1082
|
-
treePath: scaffold.root
|
|
1083
|
-
};
|
|
1084
|
-
} catch (error) {
|
|
1085
|
-
rmSync(destination, {
|
|
1086
|
-
force: true,
|
|
1087
|
-
recursive: true
|
|
1088
|
-
});
|
|
1089
|
-
throw error;
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
//#endregion
|
|
1093
|
-
//#region src/core/policy.ts
|
|
1094
|
-
function readContextTreePolicy() {
|
|
1095
|
-
return {
|
|
1096
|
-
content: readFileSync(resolvePackagedResource("policy", "context-tree-policy.md"), "utf8"),
|
|
1097
|
-
schemaVersion: 1
|
|
1098
|
-
};
|
|
1099
|
-
}
|
|
1100
|
-
//#endregion
|
|
1101
|
-
//#region src/core/publish.ts
|
|
1102
|
-
function authenticatedAccount(runner) {
|
|
1103
|
-
let login;
|
|
1104
|
-
try {
|
|
1105
|
-
login = gh([
|
|
1106
|
-
"api",
|
|
1107
|
-
"user",
|
|
1108
|
-
"--jq",
|
|
1109
|
-
".login"
|
|
1110
|
-
], {
|
|
1111
|
-
message: "GitHub account lookup failed.",
|
|
1112
|
-
runner
|
|
1113
|
-
});
|
|
1114
|
-
} catch (error) {
|
|
1115
|
-
if (error instanceof CommandError && /gh auth login|not logged|authentication failed|http 401|bad credentials/iu.test(error.stderr)) throw new ContextTreeError(CLI_ERROR_CODES.githubAuth, "GitHub authentication failed; run gh auth login before publishing.");
|
|
1116
|
-
throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub account lookup failed; publication did not start and must not be retried automatically.");
|
|
1117
|
-
}
|
|
1118
|
-
if (login.trim().length === 0) throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub account lookup returned no repository owner.");
|
|
1119
|
-
return login.trim();
|
|
1120
|
-
}
|
|
1121
|
-
function classifyCreationFailure(stderr) {
|
|
1122
|
-
if (/already exists/iu.test(stderr)) return new ContextTreeError(CLI_ERROR_CODES.repositoryExists, "A GitHub repository with this name already exists; choose an explicit OWNER/REPO override.");
|
|
1123
|
-
return new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub repository creation has an uncertain or partial result; do not retry automatically.");
|
|
1124
|
-
}
|
|
1125
|
-
/**
|
|
1126
|
-
* Publish a clean, valid local tree as a private GitHub repository. The
|
|
1127
|
-
* default repository name derives from the authenticated account and managed
|
|
1128
|
-
* tree name; OWNER/REPO is accepted only as an explicit override. The initial
|
|
1129
|
-
* publication is one gh repo create operation, and the stored connection is
|
|
1130
|
-
* updated atomically to the published tree state.
|
|
1131
|
-
*/
|
|
1132
|
-
function publishProject(projectPath, options = {}, runner) {
|
|
1133
|
-
const connection = resolveConnectionRecord(projectPath, runner);
|
|
1134
|
-
const root = connection.tree.path;
|
|
1135
|
-
if (connection.tree.kind === "github") throw new ContextTreeError(CLI_ERROR_CODES.failed, `The Context Tree is already published as ${connection.tree.repository}; writes publish new commits automatically.`);
|
|
1136
|
-
if (optionalGit(root, [
|
|
1137
|
-
"remote",
|
|
1138
|
-
"get-url",
|
|
1139
|
-
"origin"
|
|
1140
|
-
], runner) !== void 0) throw new ContextTreeError(CLI_ERROR_CODES.failed, "A local Context Tree must not already have an origin before publication.");
|
|
1141
|
-
const branch = git(root, [
|
|
1142
|
-
"symbolic-ref",
|
|
1143
|
-
"--short",
|
|
1144
|
-
"HEAD"
|
|
1145
|
-
], {
|
|
1146
|
-
message: "Failed to resolve the checked-out branch.",
|
|
1147
|
-
runner
|
|
1148
|
-
});
|
|
1149
|
-
const sha = git(root, ["rev-parse", "HEAD"], {
|
|
1150
|
-
message: "Failed to resolve the Context Tree commit.",
|
|
1151
|
-
runner
|
|
1152
|
-
});
|
|
1153
|
-
const repository = options.repository === void 0 ? `${authenticatedAccount(runner)}/${basename(root)}` : githubRepositoryIdentitySchema.parse(options.repository);
|
|
1154
|
-
const url = canonicalGitHubRepositoryUrl(repository);
|
|
1155
|
-
try {
|
|
1156
|
-
gh([
|
|
1157
|
-
"repo",
|
|
1158
|
-
"create",
|
|
1159
|
-
repository,
|
|
1160
|
-
"--private",
|
|
1161
|
-
"--source",
|
|
1162
|
-
root,
|
|
1163
|
-
"--remote",
|
|
1164
|
-
"origin",
|
|
1165
|
-
"--push"
|
|
1166
|
-
], {
|
|
1167
|
-
message: "GitHub repository creation failed.",
|
|
1168
|
-
runner
|
|
1169
|
-
});
|
|
1170
|
-
} catch (error) {
|
|
1171
|
-
if (error instanceof CommandError) throw classifyCreationFailure(error.stderr);
|
|
1172
|
-
throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "GitHub publication ended with an uncertain result.");
|
|
1173
|
-
}
|
|
1174
|
-
try {
|
|
1175
|
-
updateConnectionTree(connection.projectPath, {
|
|
1176
|
-
kind: "github",
|
|
1177
|
-
path: root,
|
|
1178
|
-
repository
|
|
1179
|
-
}, runner);
|
|
1180
|
-
} catch {
|
|
1181
|
-
throw new ContextTreeError(CLI_ERROR_CODES.publishIncomplete, "The private repository was created, but updating the local connection failed.");
|
|
1182
|
-
}
|
|
1183
|
-
return {
|
|
1184
|
-
branch,
|
|
1185
|
-
repository,
|
|
1186
|
-
schemaVersion: 1,
|
|
1187
|
-
sha,
|
|
1188
|
-
url
|
|
1189
|
-
};
|
|
1190
|
-
}
|
|
1191
|
-
//#endregion
|
|
1192
|
-
//#region src/core/read.ts
|
|
1193
|
-
function normalizeTreeTarget(value) {
|
|
1194
|
-
if (!value || value === ".") return "";
|
|
1195
|
-
const normalized = posix.normalize(toPosixPath(value).replace(/^\.\//u, ""));
|
|
1196
|
-
if (normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/")) throw new Error(`Read target is outside the Context Tree: ${value}`);
|
|
1197
|
-
return normalized.replace(/\/$/u, "");
|
|
1198
|
-
}
|
|
1199
|
-
function canonicalTarget(root, path) {
|
|
1200
|
-
const requested = normalizeTreeTarget(path);
|
|
1201
|
-
const semanticPath = requested === "NODE.md" ? "" : requested.endsWith("/NODE.md") ? dirname(requested) : requested;
|
|
1202
|
-
if (classifyContextContent(semanticPath) === "repo-infra") throw new Error(`Read target is repository infrastructure: ${requested || "."}`);
|
|
1203
|
-
const absolutePath = resolve(root, semanticPath);
|
|
1204
|
-
if (!isPathInside(root, absolutePath)) throw new Error("Read target escapes the Context Tree root.");
|
|
1205
|
-
const entry = lstatSync(absolutePath);
|
|
1206
|
-
if (entry.isSymbolicLink() || !entry.isDirectory() && !entry.isFile()) throw new Error(`Read target must be a real file or directory: ${requested || "."}`);
|
|
1207
|
-
if (realpathSync(absolutePath) !== absolutePath) throw new Error(`Read target must not traverse a symlink: ${requested || "."}`);
|
|
1208
|
-
const relativePath = toPosixPath(relative(root, absolutePath));
|
|
1209
|
-
if (entry.isFile() && !absolutePath.endsWith(".md")) throw new Error(`Read target must be a Markdown file or indexed directory: ${requested || "."}`);
|
|
1210
|
-
return {
|
|
1211
|
-
absolutePath,
|
|
1212
|
-
kind: entry.isDirectory() ? "directory" : "file",
|
|
1213
|
-
relativePath
|
|
1214
|
-
};
|
|
1215
|
-
}
|
|
1216
|
-
function readNode(path, relativePath, kind) {
|
|
1217
|
-
const documentPath = kind === "directory" ? join(path, "NODE.md") : path;
|
|
1218
|
-
const entry = lstatSync(documentPath);
|
|
1219
|
-
if (entry.isSymbolicLink() || !entry.isFile()) throw new Error(`Context Tree document must be a regular file: ${relativePath || "NODE.md"}`);
|
|
1220
|
-
const document = readNodeDocument(documentPath);
|
|
1221
|
-
if (document === null) throw new Error(`Context Tree document has invalid or missing metadata: ${relativePath || "."}`);
|
|
1222
|
-
return {
|
|
1223
|
-
body: document.body,
|
|
1224
|
-
contentClass: classifyContextContent(relativePath),
|
|
1225
|
-
frontmatter: document.frontmatter,
|
|
1226
|
-
kind,
|
|
1227
|
-
path: relativePath || "."
|
|
1228
|
-
};
|
|
1229
|
-
}
|
|
1230
|
-
function childSummary(root, parentPath, name) {
|
|
1231
|
-
const absolutePath = join(parentPath, name);
|
|
1232
|
-
const relativePath = toPosixPath(relative(root, absolutePath));
|
|
1233
|
-
const contentClass = classifyContextContent(relativePath);
|
|
1234
|
-
if (contentClass === "repo-infra") return null;
|
|
1235
|
-
const entry = lstatSync(absolutePath);
|
|
1236
|
-
if (entry.isSymbolicLink()) return null;
|
|
1237
|
-
const kind = entry.isDirectory() ? "directory" : entry.isFile() && name.endsWith(".md") && name !== "NODE.md" ? "file" : null;
|
|
1238
|
-
if (kind === null) return null;
|
|
1239
|
-
const document = readNodeDocument(kind === "directory" ? join(absolutePath, "NODE.md") : absolutePath);
|
|
1240
|
-
if (document === null) throw new Error(`Context Tree child has invalid or missing metadata: ${relativePath}`);
|
|
1241
|
-
return {
|
|
1242
|
-
contentClass,
|
|
1243
|
-
...document.description === void 0 ? {} : { description: document.description },
|
|
1244
|
-
kind,
|
|
1245
|
-
path: relativePath,
|
|
1246
|
-
title: document.title
|
|
1247
|
-
};
|
|
1248
|
-
}
|
|
1249
|
-
function readTree(treePath, path) {
|
|
1250
|
-
const root = resolveTreeRoot(treePath);
|
|
1251
|
-
const target = canonicalTarget(root, path);
|
|
1252
|
-
const node = readNode(target.absolutePath, target.relativePath, target.kind);
|
|
1253
|
-
return {
|
|
1254
|
-
children: target.kind === "file" ? [] : readdirSync(target.absolutePath).map((name) => childSummary(root, target.absolutePath, name)).filter((child) => child !== null).sort((left, right) => left.path.localeCompare(right.path)),
|
|
1255
|
-
node,
|
|
1256
|
-
root,
|
|
1257
|
-
schemaVersion: 1,
|
|
1258
|
-
target: target.relativePath || "."
|
|
1259
|
-
};
|
|
1260
|
-
}
|
|
1261
|
-
//#endregion
|
|
1262
|
-
//#region src/core/sync.ts
|
|
1263
|
-
/**
|
|
1264
|
-
* Local trees report their checked-out state without network access. GitHub
|
|
1265
|
-
* trees fast-forward the exact checked-out branch once, then revalidate.
|
|
1266
|
-
*/
|
|
1267
|
-
function syncProject(projectPath, runner) {
|
|
1268
|
-
const connection = resolveConnectionRecord(projectPath, runner);
|
|
1269
|
-
const root = connection.tree.path;
|
|
1270
|
-
const branch = git(root, [
|
|
1271
|
-
"symbolic-ref",
|
|
1272
|
-
"--short",
|
|
1273
|
-
"HEAD"
|
|
1274
|
-
], {
|
|
1275
|
-
message: "Failed to resolve the checked-out branch.",
|
|
1276
|
-
runner
|
|
1277
|
-
});
|
|
1278
|
-
if (connection.tree.kind === "github") {
|
|
1279
|
-
git(root, [
|
|
1280
|
-
"pull",
|
|
1281
|
-
"--ff-only",
|
|
1282
|
-
"origin",
|
|
1283
|
-
branch
|
|
1284
|
-
], {
|
|
1285
|
-
message: "Fast-forwarding the Context Tree failed.",
|
|
1286
|
-
runner
|
|
1287
|
-
});
|
|
1288
|
-
validateStoredTreeState(connection.tree, runner);
|
|
1289
|
-
}
|
|
1290
|
-
return {
|
|
1291
|
-
branch,
|
|
1292
|
-
schemaVersion: 1,
|
|
1293
|
-
sha: git(root, ["rev-parse", "HEAD"], {
|
|
1294
|
-
message: "Failed to resolve the Context Tree commit.",
|
|
1295
|
-
runner
|
|
1296
|
-
}),
|
|
1297
|
-
tree: connection.tree
|
|
1298
|
-
};
|
|
1299
|
-
}
|
|
1300
|
-
//#endregion
|
|
1301
|
-
//#region src/core/write.ts
|
|
1302
|
-
const TASK_BRANCH_PREFIX = "context-tree/write/";
|
|
1303
|
-
/** Synchronize first, then create an isolated task worktree at the exact HEAD. */
|
|
1304
|
-
function prepareContextWrite(projectPath, runner) {
|
|
1305
|
-
const synchronized = syncProject(projectPath, runner);
|
|
1306
|
-
const root = synchronized.tree.path;
|
|
1307
|
-
const destination = mkdtempSync(join(tmpdir(), "context-tree-write-"));
|
|
1308
|
-
const taskBranch = `${TASK_BRANCH_PREFIX}${basename(destination)}`;
|
|
1309
|
-
try {
|
|
1310
|
-
git(root, [
|
|
1311
|
-
"worktree",
|
|
1312
|
-
"add",
|
|
1313
|
-
"--quiet",
|
|
1314
|
-
"-b",
|
|
1315
|
-
taskBranch,
|
|
1316
|
-
destination,
|
|
1317
|
-
synchronized.sha
|
|
1318
|
-
], {
|
|
1319
|
-
message: "Creating the isolated write worktree failed.",
|
|
1320
|
-
runner
|
|
1321
|
-
});
|
|
1322
|
-
return {
|
|
1323
|
-
schemaVersion: 1,
|
|
1324
|
-
worktreePath: realDirectoryWithoutSymlinks(destination, "Write worktree")
|
|
1325
|
-
};
|
|
1326
|
-
} catch (error) {
|
|
1327
|
-
rmSync(destination, {
|
|
1328
|
-
force: true,
|
|
1329
|
-
recursive: true
|
|
1330
|
-
});
|
|
1331
|
-
throw error;
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
/** Commit every pending change, then fast-forward locally or push once. */
|
|
1335
|
-
function finishContextWrite(options, runner) {
|
|
1336
|
-
const connection = resolveConnectionRecord(options.projectPath, runner);
|
|
1337
|
-
const root = connection.tree.path;
|
|
1338
|
-
const { taskBranch, worktreePath } = validatePreparedWorktree(root, options.worktreePath, runner);
|
|
1339
|
-
const branch = git(root, [
|
|
1340
|
-
"symbolic-ref",
|
|
1341
|
-
"--short",
|
|
1342
|
-
"HEAD"
|
|
1343
|
-
], {
|
|
1344
|
-
message: "Failed to resolve the connected checkout branch.",
|
|
1345
|
-
runner
|
|
1346
|
-
});
|
|
1347
|
-
if (git(worktreePath, [
|
|
1348
|
-
"status",
|
|
1349
|
-
"--porcelain",
|
|
1350
|
-
"--untracked-files=all"
|
|
1351
|
-
], {
|
|
1352
|
-
message: "Failed to inspect the prepared worktree.",
|
|
1353
|
-
runner
|
|
1354
|
-
}).length === 0) throw new Error("The prepared worktree has no pending changes.");
|
|
1355
|
-
if (!verifyTree(worktreePath).ok) throw new ContextTreeError(CLI_ERROR_CODES.invalidTree, `Refusing to commit an invalid Context Tree; run context-tree verify --tree-path ${worktreePath}.`);
|
|
1356
|
-
git(worktreePath, ["add", "--all"], {
|
|
1357
|
-
message: "Staging the Context Tree changes failed.",
|
|
1358
|
-
runner
|
|
1359
|
-
});
|
|
1360
|
-
git(worktreePath, [
|
|
1361
|
-
"-c",
|
|
1362
|
-
"commit.gpgsign=false",
|
|
1363
|
-
"commit",
|
|
1364
|
-
"--quiet",
|
|
1365
|
-
"-m",
|
|
1366
|
-
options.message
|
|
1367
|
-
], {
|
|
1368
|
-
message: "Committing the Context Tree changes failed.",
|
|
1369
|
-
runner
|
|
1370
|
-
});
|
|
1371
|
-
const sha = git(worktreePath, ["rev-parse", "HEAD"], {
|
|
1372
|
-
message: "Failed to resolve the write commit.",
|
|
1373
|
-
runner
|
|
1374
|
-
});
|
|
1375
|
-
try {
|
|
1376
|
-
if (connection.tree.kind === "local") git(root, [
|
|
1377
|
-
"merge",
|
|
1378
|
-
"--ff-only",
|
|
1379
|
-
taskBranch
|
|
1380
|
-
], {
|
|
1381
|
-
message: "Fast-forwarding the local Context Tree failed.",
|
|
1382
|
-
runner
|
|
1383
|
-
});
|
|
1384
|
-
else git(worktreePath, [
|
|
1385
|
-
"push",
|
|
1386
|
-
"origin",
|
|
1387
|
-
`HEAD:refs/heads/${branch}`
|
|
1388
|
-
], {
|
|
1389
|
-
message: "Publishing the Context Tree write failed.",
|
|
1390
|
-
runner
|
|
1391
|
-
});
|
|
1392
|
-
} catch (error) {
|
|
1393
|
-
if (isNonFastForward(error)) throw new ContextTreeError(CLI_ERROR_CODES.writeOutdated, `The Context Tree advanced; the prepared worktree is preserved at ${worktreePath}.`);
|
|
1394
|
-
throw error;
|
|
1395
|
-
}
|
|
1396
|
-
removeWorktree(root, worktreePath, taskBranch, runner);
|
|
1397
|
-
return {
|
|
1398
|
-
branch,
|
|
1399
|
-
schemaVersion: 1,
|
|
1400
|
-
sha
|
|
1401
|
-
};
|
|
1402
|
-
}
|
|
1403
|
-
function gitCommonDirectory(root, runner) {
|
|
1404
|
-
const value = git(root, ["rev-parse", "--git-common-dir"], {
|
|
1405
|
-
message: "Failed to resolve the Git common directory.",
|
|
1406
|
-
runner
|
|
1407
|
-
});
|
|
1408
|
-
return realDirectoryWithoutSymlinks(isAbsolute(value) ? value : resolve(root, value), "Git common directory");
|
|
1409
|
-
}
|
|
1410
|
-
function validatePreparedWorktree(root, suppliedPath, runner) {
|
|
1411
|
-
const worktreePath = realDirectoryWithoutSymlinks(suppliedPath, "Prepared worktree");
|
|
1412
|
-
if (gitCommonDirectory(worktreePath, runner) !== gitCommonDirectory(root, runner)) throw new Error("The prepared worktree does not belong to the connected Context Tree.");
|
|
1413
|
-
const taskBranch = git(worktreePath, [
|
|
1414
|
-
"symbolic-ref",
|
|
1415
|
-
"--short",
|
|
1416
|
-
"HEAD"
|
|
1417
|
-
], {
|
|
1418
|
-
message: "Failed to resolve the worktree branch.",
|
|
1419
|
-
runner
|
|
1420
|
-
});
|
|
1421
|
-
if (!taskBranch.startsWith(TASK_BRANCH_PREFIX)) throw new Error("The prepared worktree is not on a reserved Context Tree write branch.");
|
|
1422
|
-
return {
|
|
1423
|
-
taskBranch,
|
|
1424
|
-
worktreePath
|
|
1425
|
-
};
|
|
1426
|
-
}
|
|
1427
|
-
function isNonFastForward(error) {
|
|
1428
|
-
return error instanceof CommandError && /non-fast-forward|fetch first|tip of your current branch is behind|not possible to fast-forward|diverg/i.test(error.stderr);
|
|
1429
|
-
}
|
|
1430
|
-
function removeWorktree(root, worktreePath, taskBranch, runner) {
|
|
1431
|
-
git(root, [
|
|
1432
|
-
"worktree",
|
|
1433
|
-
"remove",
|
|
1434
|
-
worktreePath
|
|
1435
|
-
], {
|
|
1436
|
-
message: "Removing the write worktree failed.",
|
|
1437
|
-
runner
|
|
1438
|
-
});
|
|
1439
|
-
git(root, [
|
|
1440
|
-
"branch",
|
|
1441
|
-
"-D",
|
|
1442
|
-
taskBranch
|
|
1443
|
-
], {
|
|
1444
|
-
message: "Deleting the write branch failed.",
|
|
1445
|
-
runner
|
|
1446
|
-
});
|
|
1447
|
-
}
|
|
1448
|
-
//#endregion
|
|
1449
|
-
export { connectProject, createProject, finishContextWrite, listManagedTrees, prepareContextWrite, publishProject, readContextTreePolicy, readTree, resolveConnection, syncProject, verifyTree };
|