@nurix/nustack 0.23.11 → 0.23.13
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/dist/commands/check.js +73 -0
- package/dist/commands/deploy.js +2 -1
- package/dist/commands/doctor.js +2 -1
- package/dist/commands/harness.js +127 -48
- package/dist/commands/init.js +103 -3
- package/dist/commands/login.js +9 -2
- package/dist/commands/mcp.js +7 -0
- package/dist/commands/project-sync.js +2 -1
- package/dist/commands/records.js +10 -0
- package/dist/commands/review.js +3 -2
- package/dist/commands/sessions.js +4 -1
- package/dist/commands/sync.js +2 -1
- package/dist/index.js +4 -0
- package/dist/lib/agent/claudeHooks.js +4 -1
- package/dist/lib/auth_grant.js +15 -3
- package/dist/lib/auth_recovery.js +10 -3
- package/dist/lib/etna/manager.js +24 -6
- package/dist/lib/etna/profile.js +6 -0
- package/dist/lib/etna/reconcile.js +6 -0
- package/dist/lib/harness/codes.js +24 -0
- package/dist/lib/harness/hookCheck.js +325 -0
- package/dist/lib/harness/hookEntry.js +27 -0
- package/dist/lib/harness/legacyMount.js +231 -0
- package/dist/lib/harness/mount.js +199 -131
- package/dist/lib/harness/storeWaivers.js +43 -0
- package/dist/lib/harness/vendor.js +29 -0
- package/dist/lib/harness/versionsClient.js +54 -0
- package/dist/lib/local_store/database.js +18 -0
- package/dist/lib/nustack_client.js +35 -2
- package/dist/lib/onboarding/attach_existing_git.js +14 -2
- package/dist/lib/onboarding/binding.js +46 -2
- package/dist/lib/onboarding/commit_trailer_hook.js +66 -0
- package/dist/lib/onboarding/manifest_writer.js +19 -3
- package/dist/lib/onboarding/session_hook.js +76 -0
- package/dist/lib/origin_guard.js +43 -0
- package/dist/lib/project_contract.js +104 -0
- package/dist/lib/records/door.js +26 -19
- package/dist/lib/records/drain.js +10 -4
- package/dist/lib/records/refusalLog.js +30 -1
- package/dist/lib/session-engine/supervisor.js +6 -18
- package/dist/lib/sessions/client.js +0 -12
- package/dist/program.js +14 -3
- package/dist/vendor/harness/assemble/runtimes.js +32 -0
- package/dist/vendor/harness/assemble/settingsMerge.js +136 -0
- package/dist/vendor/harness/digest/index.js +38 -0
- package/dist/vendor/harness/lib/findings.js +32 -0
- package/dist/vendor/harness/lib/frontmatter.js +79 -0
- package/dist/vendor/harness/lib/paths.js +31 -0
- package/dist/vendor/harness/mount/cache.js +65 -0
- package/dist/vendor/harness/mount/exclude.js +77 -0
- package/dist/vendor/harness/mount/files.js +38 -0
- package/dist/vendor/harness/mount/index.js +352 -0
- package/dist/vendor/harness/rules/changeRules.js +162 -0
- package/dist/vendor/harness/rules/context.js +38 -0
- package/dist/vendor/harness/rules/docsRoot.js +34 -0
- package/dist/vendor/harness/rules/index.js +109 -0
- package/dist/vendor/harness/rules/node.js +144 -0
- package/dist/vendor/harness/rules/outputGate.js +28 -0
- package/dist/vendor/harness/rules/ownership.js +178 -0
- package/dist/vendor/harness/rules/packageTable.js +113 -0
- package/dist/vendor/harness/rules/plans.js +96 -0
- package/dist/vendor/harness/rules/sessionWalls.js +31 -0
- package/package.json +5 -5
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, rename, rm, rmdir, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { HarnessError, HARNESS_CODES } from "./codes.js";
|
|
7
|
+
export const LEGACY_MOUNT_RECORD_PATH = ".claude/state/legacy-mount.json";
|
|
8
|
+
export const LEGACY_EXCLUDE_START = "# etna:mount:start";
|
|
9
|
+
export const LEGACY_EXCLUDE_END = "# etna:mount:end";
|
|
10
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
12
|
+
const FETCH_CONCURRENCY = 8;
|
|
13
|
+
const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
14
|
+
export function isMountedPath(file) {
|
|
15
|
+
if (file.policy)
|
|
16
|
+
return file.policy === "mount";
|
|
17
|
+
const contract = ["CLAUDE.md", "AGENTS.md", ".claude/harness.json", ".claude/settings.json", ".claude/hooks/mount.mjs", ".codex/hooks.json"];
|
|
18
|
+
if (contract.includes(file.path))
|
|
19
|
+
return false;
|
|
20
|
+
return file.path.startsWith(".claude/") || file.path.startsWith(".agents/") || file.path.startsWith(".codex/");
|
|
21
|
+
}
|
|
22
|
+
function isSafePath(file) {
|
|
23
|
+
return file !== "" && !path.isAbsolute(file) && !file.split(/[\\/]/).includes("..") && !file.split("/").includes(".git") && !file.startsWith(".claude/state/");
|
|
24
|
+
}
|
|
25
|
+
async function readIfPresent(file) {
|
|
26
|
+
try {
|
|
27
|
+
return await readFile(file);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function writeAtomic(target, bytes) {
|
|
34
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
35
|
+
const staging = `${target}.mount-${process.pid}`;
|
|
36
|
+
await writeFile(staging, bytes);
|
|
37
|
+
await rename(staging, target);
|
|
38
|
+
}
|
|
39
|
+
async function fetchBytes(url) {
|
|
40
|
+
const response = await globalThis.fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
41
|
+
if (!response.ok)
|
|
42
|
+
throw new Error(`${url} answered ${response.status}`);
|
|
43
|
+
return Buffer.from(await response.arrayBuffer());
|
|
44
|
+
}
|
|
45
|
+
function objectUrlOf(manifestUrl, key) {
|
|
46
|
+
return `${new URL(manifestUrl).origin}/${key.replace(/^\/+/, "")}`;
|
|
47
|
+
}
|
|
48
|
+
function excludePathOf(root) {
|
|
49
|
+
try {
|
|
50
|
+
const relative = execFileSync("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
51
|
+
return path.isAbsolute(relative) ? relative : path.join(root, relative);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function withoutLegacyBlock(held) {
|
|
58
|
+
const start = held.indexOf(LEGACY_EXCLUDE_START);
|
|
59
|
+
const end = held.indexOf(LEGACY_EXCLUDE_END);
|
|
60
|
+
if (start < 0 || end <= start)
|
|
61
|
+
return held;
|
|
62
|
+
return held.slice(0, start) + held.slice(end + LEGACY_EXCLUDE_END.length).replace(/^\n/, "");
|
|
63
|
+
}
|
|
64
|
+
function ignoredNamesOf(files) {
|
|
65
|
+
const names = new Set([".claude/state/"]);
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
const parts = file.path.split("/");
|
|
68
|
+
if (parts[0] === ".agents")
|
|
69
|
+
names.add(".agents/skills/");
|
|
70
|
+
else if (parts[0] === ".codex")
|
|
71
|
+
names.add(parts.length > 2 ? `.codex/${parts[1]}/` : `.codex/${parts[1]}`);
|
|
72
|
+
else if (parts.length > 3)
|
|
73
|
+
names.add(`${parts.slice(0, 3).join("/")}/`);
|
|
74
|
+
else
|
|
75
|
+
names.add(file.path);
|
|
76
|
+
}
|
|
77
|
+
return [...names].sort();
|
|
78
|
+
}
|
|
79
|
+
async function writeLegacyExcludeBlock(root, files) {
|
|
80
|
+
const excludePath = excludePathOf(root);
|
|
81
|
+
if (!excludePath)
|
|
82
|
+
return;
|
|
83
|
+
const outside = withoutLegacyBlock((await readIfPresent(excludePath))?.toString("utf8") ?? "");
|
|
84
|
+
const block = [LEGACY_EXCLUDE_START, ...ignoredNamesOf(files), LEGACY_EXCLUDE_END].join("\n");
|
|
85
|
+
await writeAtomic(excludePath, `${outside.replace(/\n*$/, outside ? "\n" : "")}${block}\n`);
|
|
86
|
+
}
|
|
87
|
+
export async function removeLegacyExcludeBlock(root) {
|
|
88
|
+
const excludePath = excludePathOf(root);
|
|
89
|
+
if (!excludePath)
|
|
90
|
+
return;
|
|
91
|
+
const held = (await readIfPresent(excludePath))?.toString("utf8");
|
|
92
|
+
if (held === undefined || !held.includes(LEGACY_EXCLUDE_START))
|
|
93
|
+
return;
|
|
94
|
+
await writeAtomic(excludePath, withoutLegacyBlock(held));
|
|
95
|
+
}
|
|
96
|
+
function parseLegacyRecord(raw) {
|
|
97
|
+
if (raw === null)
|
|
98
|
+
return null;
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(raw.toString());
|
|
101
|
+
if (typeof parsed.version !== "string" || typeof parsed.digest !== "string")
|
|
102
|
+
return null;
|
|
103
|
+
return {
|
|
104
|
+
version: parsed.version,
|
|
105
|
+
digest: parsed.digest,
|
|
106
|
+
mountedAt: typeof parsed.mountedAt === "string" ? parsed.mountedAt : "",
|
|
107
|
+
source: "legacy",
|
|
108
|
+
origin: typeof parsed.origin === "string" ? parsed.origin : null,
|
|
109
|
+
legacyFiles: Array.isArray(parsed.legacyFiles)
|
|
110
|
+
? parsed.legacyFiles.filter((file) => typeof file?.path === "string" && typeof file?.sha256 === "string")
|
|
111
|
+
: [],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export async function readLegacyMountRecord(root) {
|
|
119
|
+
return parseLegacyRecord(await readIfPresent(path.join(root, LEGACY_MOUNT_RECORD_PATH)));
|
|
120
|
+
}
|
|
121
|
+
export function readLegacyMountRecordSync(root) {
|
|
122
|
+
const file = path.join(root, LEGACY_MOUNT_RECORD_PATH);
|
|
123
|
+
if (!existsSync(file))
|
|
124
|
+
return null;
|
|
125
|
+
try {
|
|
126
|
+
return parseLegacyRecord(readFileSync(file));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export async function mountLegacyManifest(root, manifestUrl, origin) {
|
|
133
|
+
const manifestBytes = await fetchBytes(manifestUrl);
|
|
134
|
+
let manifest;
|
|
135
|
+
try {
|
|
136
|
+
manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
throw new HarnessError(HARNESS_CODES.HARNESS_MOUNT_FAILED, `the manifest at ${manifestUrl} is not JSON`);
|
|
140
|
+
}
|
|
141
|
+
if (!manifest || !Array.isArray(manifest.files))
|
|
142
|
+
throw new HarnessError(HARNESS_CODES.HARNESS_MOUNT_FAILED, `the manifest at ${manifestUrl} lists no files`);
|
|
143
|
+
const mounted = manifest.files.filter(isMountedPath);
|
|
144
|
+
for (const file of mounted) {
|
|
145
|
+
if (typeof file.sha256 !== "string" || !SHA256.test(file.sha256)) {
|
|
146
|
+
throw new HarnessError(HARNESS_CODES.HARNESS_DIGEST_MISMATCH, `the manifest entry ${String(file.path)} carries no verifiable sha256; nothing was written`);
|
|
147
|
+
}
|
|
148
|
+
if (typeof file.path !== "string" || !isSafePath(file.path) || typeof file.key !== "string") {
|
|
149
|
+
throw new HarnessError(HARNESS_CODES.HARNESS_MOUNT_FAILED, `the manifest names an unsafe path: ${String(file.path)}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const toWrite = [];
|
|
153
|
+
const kept = [];
|
|
154
|
+
const needed = [];
|
|
155
|
+
for (const file of mounted) {
|
|
156
|
+
const held = await readIfPresent(path.join(root, file.path));
|
|
157
|
+
if (held && sha256Of(held) === file.sha256)
|
|
158
|
+
kept.push(file.path);
|
|
159
|
+
else
|
|
160
|
+
needed.push(file);
|
|
161
|
+
}
|
|
162
|
+
const fetched = new Map();
|
|
163
|
+
let next = 0;
|
|
164
|
+
const worker = async () => {
|
|
165
|
+
while (next < needed.length) {
|
|
166
|
+
const file = needed[next++];
|
|
167
|
+
const bytes = await fetchBytes(objectUrlOf(manifestUrl, file.key));
|
|
168
|
+
if (sha256Of(bytes) !== file.sha256) {
|
|
169
|
+
throw new HarnessError(HARNESS_CODES.HARNESS_DIGEST_MISMATCH, `the object for ${file.path} does not match its sha256; nothing was written`);
|
|
170
|
+
}
|
|
171
|
+
fetched.set(file, bytes);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
await Promise.all(Array.from({ length: Math.min(FETCH_CONCURRENCY, needed.length) }, worker));
|
|
175
|
+
for (const file of needed)
|
|
176
|
+
toWrite.push({ file, bytes: fetched.get(file) });
|
|
177
|
+
for (const { file, bytes } of toWrite)
|
|
178
|
+
await writeAtomic(path.join(root, file.path), bytes);
|
|
179
|
+
await writeLegacyExcludeBlock(root, mounted);
|
|
180
|
+
const stateDir = path.join(root, ".claude", "state");
|
|
181
|
+
await mkdir(stateDir, { recursive: true });
|
|
182
|
+
if (!(await readIfPresent(path.join(stateDir, ".gitignore"))))
|
|
183
|
+
await writeFile(path.join(stateDir, ".gitignore"), "*\n");
|
|
184
|
+
const record = {
|
|
185
|
+
version: String(manifest.version ?? ""),
|
|
186
|
+
digest: sha256Of(manifestBytes),
|
|
187
|
+
mountedAt: new Date().toISOString(),
|
|
188
|
+
source: "legacy",
|
|
189
|
+
origin,
|
|
190
|
+
legacyFiles: mounted.map((file) => ({ path: file.path, sha256: file.sha256 })),
|
|
191
|
+
};
|
|
192
|
+
await writeAtomic(path.join(root, LEGACY_MOUNT_RECORD_PATH), `${JSON.stringify(record, null, 2)}\n`);
|
|
193
|
+
return { version: record.version, digest: record.digest, written: toWrite.map((item) => item.file.path), kept };
|
|
194
|
+
}
|
|
195
|
+
export async function handOverLegacyMount(root, keptByVersion) {
|
|
196
|
+
const record = await readLegacyMountRecord(root);
|
|
197
|
+
if (!record)
|
|
198
|
+
return null;
|
|
199
|
+
const removed = [];
|
|
200
|
+
const editedKept = [];
|
|
201
|
+
for (const file of record.legacyFiles) {
|
|
202
|
+
if (keptByVersion.has(file.path) || !isSafePath(file.path))
|
|
203
|
+
continue;
|
|
204
|
+
const target = path.join(root, file.path);
|
|
205
|
+
const held = await readIfPresent(target);
|
|
206
|
+
if (!held)
|
|
207
|
+
continue;
|
|
208
|
+
if (sha256Of(held) !== file.sha256) {
|
|
209
|
+
editedKept.push(file.path);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
await rm(target, { force: true });
|
|
213
|
+
await removeEmptyParents(root, path.dirname(file.path));
|
|
214
|
+
removed.push(file.path);
|
|
215
|
+
}
|
|
216
|
+
await removeLegacyExcludeBlock(root);
|
|
217
|
+
await rm(path.join(root, LEGACY_MOUNT_RECORD_PATH), { force: true });
|
|
218
|
+
return { removed, editedKept };
|
|
219
|
+
}
|
|
220
|
+
async function removeEmptyParents(root, relativeDir) {
|
|
221
|
+
let dir = relativeDir;
|
|
222
|
+
while (dir && dir !== "." && dir !== "/") {
|
|
223
|
+
try {
|
|
224
|
+
await rmdir(path.join(root, dir));
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
dir = path.dirname(dir);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
@@ -1,155 +1,223 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
5
|
-
import { homedir } from "node:os";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
2
|
import path from "node:path";
|
|
3
|
+
import { ensureAccessToken } from "../auth_grant.js";
|
|
4
|
+
import { getProject, resolveNuStackOrigin } from "../nustack_client.js";
|
|
5
|
+
import { isOriginMismatch } from "../origin_guard.js";
|
|
6
|
+
import { resolveProjectId } from "../project_context.js";
|
|
7
|
+
import { readProjectContract } from "../project_contract.js";
|
|
8
|
+
import { hasEtnaMountHook } from "../onboarding/session_hook.js";
|
|
9
|
+
import { HarnessError, HARNESS_CODES } from "./codes.js";
|
|
10
|
+
import { handOverLegacyMount, mountLegacyManifest, readLegacyMountRecord, readLegacyMountRecordSync } from "./legacyMount.js";
|
|
11
|
+
import { loadHarness } from "./vendor.js";
|
|
12
|
+
import { fetchVersionManifest, fetchVersionObject, resolveProjectVersion, VersionsRouteError } from "./versionsClient.js";
|
|
7
13
|
export const MOUNT_STATE_PATH = ".claude/state/mount.json";
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
12
|
-
export function defaultCacheDir(env = process.env) {
|
|
13
|
-
return env.ETNA_CACHE_DIR?.trim() || path.join(homedir(), ".etna", "cache");
|
|
14
|
-
}
|
|
15
|
-
export function isMountedPath(file) {
|
|
16
|
-
if (file.policy)
|
|
17
|
-
return file.policy === "mount";
|
|
18
|
-
const contract = ["CLAUDE.md", "AGENTS.md", ".claude/harness.json", ".claude/settings.json", ".claude/hooks/mount.mjs", ".codex/hooks.json"];
|
|
19
|
-
if (contract.includes(file.path))
|
|
20
|
-
return false;
|
|
21
|
-
return file.path.startsWith(".claude/") || file.path.startsWith(".agents/") || file.path.startsWith(".codex/");
|
|
22
|
-
}
|
|
23
|
-
async function readIfPresent(file) {
|
|
24
|
-
try {
|
|
25
|
-
return await readFile(file);
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
14
|
+
const DIGEST_REFUSALS = new Set(["mount.digest_mismatch", "mount.expected_digest_mismatch", "mount.object_mismatch", "mount.cache_corrupt", "mount.bad_sha256"]);
|
|
15
|
+
function stateOf(record) {
|
|
16
|
+
if (!record || typeof record.version !== "string" || typeof record.digest !== "string")
|
|
28
17
|
return null;
|
|
29
|
-
|
|
18
|
+
const source = record.source === "network" || record.source === "cache" || record.source === "legacy" ? record.source : null;
|
|
19
|
+
return {
|
|
20
|
+
version: record.version,
|
|
21
|
+
digest: record.digest,
|
|
22
|
+
mountedAt: typeof record.mountedAt === "string" ? record.mountedAt : "",
|
|
23
|
+
source,
|
|
24
|
+
origin: typeof record.origin === "string" ? record.origin : null,
|
|
25
|
+
};
|
|
30
26
|
}
|
|
31
27
|
export async function readMountState(root) {
|
|
32
|
-
|
|
28
|
+
const { readMountRecord } = await loadHarness("mount");
|
|
29
|
+
return stateOf(readMountRecord(root)) ?? stateOf((await readLegacyMountRecord(root)));
|
|
33
30
|
}
|
|
34
31
|
export function readMountStateSync(root) {
|
|
32
|
+
const file = path.join(root, MOUNT_STATE_PATH);
|
|
33
|
+
let record = null;
|
|
34
|
+
if (existsSync(file)) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
37
|
+
record = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
record = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return stateOf(record) ?? stateOf(readLegacyMountRecordSync(root));
|
|
44
|
+
}
|
|
45
|
+
function coded(code, message) {
|
|
46
|
+
return Object.assign(new Error(message), { code });
|
|
47
|
+
}
|
|
48
|
+
export async function readSummary(root, projectId, globals) {
|
|
49
|
+
let baseUrl;
|
|
35
50
|
try {
|
|
36
|
-
|
|
51
|
+
baseUrl = resolveNuStackOrigin(globals, root).url;
|
|
37
52
|
}
|
|
38
|
-
catch {
|
|
39
|
-
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (isOriginMismatch(error))
|
|
55
|
+
return { kind: "offline", reason: error.message, guardRefusal: error, baseUrl: null };
|
|
56
|
+
throw error;
|
|
40
57
|
}
|
|
41
|
-
|
|
42
|
-
function parseMountState(raw) {
|
|
43
|
-
if (!raw)
|
|
44
|
-
return null;
|
|
58
|
+
let token;
|
|
45
59
|
try {
|
|
46
|
-
|
|
47
|
-
if (typeof parsed.version !== "string" || typeof parsed.digest !== "string")
|
|
48
|
-
return null;
|
|
49
|
-
return { version: parsed.version, digest: parsed.digest, mountedAt: typeof parsed.mountedAt === "string" ? parsed.mountedAt : "", ...(typeof parsed.manifestUrl === "string" ? { manifestUrl: parsed.manifestUrl } : {}) };
|
|
60
|
+
token = await ensureAccessToken(baseUrl);
|
|
50
61
|
}
|
|
51
|
-
catch {
|
|
52
|
-
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (isOriginMismatch(error))
|
|
64
|
+
return { kind: "offline", reason: error.message, guardRefusal: error, baseUrl };
|
|
65
|
+
return { kind: "offline", reason: "this machine's sign-in could not be read", guardRefusal: null, baseUrl };
|
|
66
|
+
}
|
|
67
|
+
if (!token)
|
|
68
|
+
return { kind: "offline", reason: "this machine is not signed in", guardRefusal: null, baseUrl };
|
|
69
|
+
try {
|
|
70
|
+
const summary = await getProject(baseUrl, token.accessToken, projectId);
|
|
71
|
+
return { kind: "summary", summary, baseUrl, accessToken: token.accessToken };
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (isOriginMismatch(error))
|
|
75
|
+
return { kind: "offline", reason: error.message, guardRefusal: error, baseUrl };
|
|
76
|
+
const code = error.code;
|
|
77
|
+
if (typeof code === "string" && code !== "UNEXPECTED" && code !== "SIGNED_OUT")
|
|
78
|
+
throw error;
|
|
79
|
+
return { kind: "offline", reason: `the Project summary could not be read (${error instanceof Error ? error.message : String(error)})`, guardRefusal: null, baseUrl };
|
|
53
80
|
}
|
|
54
81
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!response.ok)
|
|
58
|
-
throw new Error(`${url} answered ${response.status}`);
|
|
59
|
-
return Buffer.from(await response.arrayBuffer());
|
|
60
|
-
}
|
|
61
|
-
function objectUrlOf(manifestUrl, key) {
|
|
62
|
-
return `${new URL(manifestUrl).origin}/${key.replace(/^\/+/, "")}`;
|
|
82
|
+
export function isProjectVersionSummary(summary) {
|
|
83
|
+
return Boolean(summary.harness?.digest || summary.harness?.versionManifestPath);
|
|
63
84
|
}
|
|
64
|
-
async function
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
85
|
+
export async function projectOf(root, flag, contractProjectId) {
|
|
86
|
+
if (flag?.trim())
|
|
87
|
+
return flag.trim();
|
|
88
|
+
if (contractProjectId)
|
|
89
|
+
return contractProjectId;
|
|
90
|
+
const resolved = await resolveProjectId(root, {});
|
|
91
|
+
if (!resolved.ok)
|
|
92
|
+
throw coded("PROJECT_CONTEXT_MISSING", resolved.message);
|
|
93
|
+
return resolved.projectId;
|
|
69
94
|
}
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
95
|
+
function outcomeOf(projectId, result, lines, offlineCode) {
|
|
96
|
+
if (result.status === "mounted" || result.status === "current") {
|
|
97
|
+
const kept = result.findings.filter((finding) => finding.code === "mount.kept_edited" || finding.code === "mount.contract_edited");
|
|
98
|
+
return {
|
|
99
|
+
projectId,
|
|
100
|
+
status: result.status,
|
|
101
|
+
source: result.source ?? "network",
|
|
102
|
+
version: result.semver,
|
|
103
|
+
digest: result.digest,
|
|
104
|
+
runtime: result.runtime,
|
|
105
|
+
written: result.written,
|
|
106
|
+
kept: result.kept,
|
|
107
|
+
removed: result.removed,
|
|
108
|
+
lines: [...lines, result.line, ...kept.map((finding) => finding.message)],
|
|
109
|
+
};
|
|
82
110
|
}
|
|
83
|
-
|
|
111
|
+
const finding = result.findings[0];
|
|
112
|
+
if (result.status === "refused" && finding && DIGEST_REFUSALS.has(finding.code))
|
|
113
|
+
throw new HarnessError(HARNESS_CODES.HARNESS_DIGEST_MISMATCH, result.line);
|
|
114
|
+
if (result.status === "unavailable" && (!finding || finding.code === "mount.no_route"))
|
|
115
|
+
throw new HarnessError(offlineCode, result.line);
|
|
116
|
+
throw new HarnessError(HARNESS_CODES.HARNESS_MOUNT_FAILED, result.line);
|
|
84
117
|
}
|
|
85
|
-
async function
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
118
|
+
async function recordedFiles(root, runtime) {
|
|
119
|
+
const { readMountRecord } = await loadHarness("mount");
|
|
120
|
+
return new Set((readMountRecord(root)?.runtimes?.[runtime]?.files ?? []).map((file) => file.path));
|
|
121
|
+
}
|
|
122
|
+
async function handOver(root, runtime, outcome) {
|
|
123
|
+
const handed = await handOverLegacyMount(root, await recordedFiles(root, runtime));
|
|
124
|
+
if (!handed)
|
|
125
|
+
return outcome;
|
|
126
|
+
const lines = [...outcome.lines, ...handed.editedKept.map((file) => `kept ${file}: the legacy harness wrote it and it has been edited since`)];
|
|
127
|
+
return { ...outcome, removed: [...outcome.removed, ...handed.removed], lines };
|
|
128
|
+
}
|
|
129
|
+
export async function mountProjectHarness(root, options) {
|
|
130
|
+
const log = options.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
131
|
+
const report = (outcome) => {
|
|
132
|
+
for (const line of outcome.lines)
|
|
133
|
+
log(line);
|
|
134
|
+
return outcome;
|
|
135
|
+
};
|
|
136
|
+
const runtime = options.runtime ?? "claude-code";
|
|
137
|
+
if (await hasEtnaMountHook(root)) {
|
|
138
|
+
throw new HarnessError(HARNESS_CODES.ETNA_MOUNT_PRESENT, "This checkout's .claude/settings.json runs etna's mount hook (.claude/hooks/mount.mjs), and two mounters on one checkout erase each other's record. Remove etna's SessionStart entry, then run `nustack init`.");
|
|
89
139
|
}
|
|
90
|
-
|
|
91
|
-
|
|
140
|
+
const contract = await readProjectContract(root);
|
|
141
|
+
const projectId = await projectOf(root, options.projectId, contract.projectId);
|
|
142
|
+
const lines = [];
|
|
143
|
+
let harness = contract.harness;
|
|
144
|
+
if (!harness) {
|
|
145
|
+
harness = { track: "latest" };
|
|
146
|
+
if (contract.contractPresent)
|
|
147
|
+
lines.push("This contract names no harness version, so the newest is mounted; run `nustack init` to record one.");
|
|
92
148
|
}
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
bytes = await fetchBytes(fetchImpl, objectUrlOf(manifestUrl, file.key));
|
|
127
|
-
if (file.sha256 && sha256Of(bytes) !== file.sha256)
|
|
128
|
-
throw new Error(`the object for ${file.path} does not match its sha256`);
|
|
129
|
-
if (file.sha256)
|
|
130
|
-
await writeAtomic(path.join(cacheDir, "objects", file.sha256), bytes);
|
|
131
|
-
}
|
|
132
|
-
await writeAtomic(target, bytes);
|
|
133
|
-
written += 1;
|
|
149
|
+
const pinned = "digest" in harness ? harness : null;
|
|
150
|
+
const { mountVersion, defaultHarnessCacheDir } = await loadHarness("mount");
|
|
151
|
+
const common = { root, cacheDir: defaultHarnessCacheDir(), runtime, projectId, writeContract: options.writeContract };
|
|
152
|
+
const noFetch = async () => {
|
|
153
|
+
throw new Error("a cached mount fetches nothing");
|
|
154
|
+
};
|
|
155
|
+
const fromCache = async (reason, guardRefusal, origin) => {
|
|
156
|
+
const result = await mountVersion({ ...common, manifest: null, fetchObject: noFetch, expectedDigest: pinned?.digest ?? null, recordExtras: origin ? { origin } : {} });
|
|
157
|
+
if (guardRefusal && result.status !== "mounted" && result.status !== "current")
|
|
158
|
+
throw new HarnessError(HARNESS_CODES.ORIGIN_MISMATCH, guardRefusal.message);
|
|
159
|
+
const notice = guardRefusal ? `harness taken from the cache, the origin was refused: ${guardRefusal.message}` : `harness taken from the cache: ${reason}`;
|
|
160
|
+
return handOver(root, runtime, outcomeOf(projectId, result, [...lines, notice], HARNESS_CODES.HARNESS_OFFLINE_NO_CACHE));
|
|
161
|
+
};
|
|
162
|
+
const read = await readSummary(root, projectId, options.globals ?? {});
|
|
163
|
+
if (read.kind === "offline")
|
|
164
|
+
return report(await fromCache(read.reason, read.guardRefusal, read.baseUrl));
|
|
165
|
+
if (!isProjectVersionSummary(read.summary)) {
|
|
166
|
+
const manifestUrl = read.summary.harness?.manifestUrl ?? null;
|
|
167
|
+
if (!manifestUrl)
|
|
168
|
+
throw new HarnessError(HARNESS_CODES.HARNESS_UNAVAILABLE, `Project ${projectId} resolves no harness version to mount.`);
|
|
169
|
+
const legacy = await mountLegacyManifest(root, manifestUrl, read.baseUrl);
|
|
170
|
+
return report({
|
|
171
|
+
projectId,
|
|
172
|
+
status: legacy.written.length > 0 ? "mounted" : "current",
|
|
173
|
+
source: "legacy",
|
|
174
|
+
version: legacy.version,
|
|
175
|
+
digest: legacy.digest,
|
|
176
|
+
runtime,
|
|
177
|
+
written: legacy.written,
|
|
178
|
+
kept: legacy.kept,
|
|
179
|
+
removed: [],
|
|
180
|
+
lines: [...lines, `harness mounted: ${legacy.version} (legacy) from ${manifestUrl}`],
|
|
181
|
+
});
|
|
134
182
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
183
|
+
let resolved;
|
|
184
|
+
try {
|
|
185
|
+
resolved = await resolveProjectVersion(read.baseUrl, read.accessToken, projectId, pinned ? { digest: pinned.digest } : { track: "latest" });
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (isOriginMismatch(error))
|
|
189
|
+
return report(await fromCache(error.message, error, read.baseUrl));
|
|
190
|
+
if (error instanceof VersionsRouteError && error.status === 404) {
|
|
191
|
+
if (error.code === "PROJECT_NOT_FOUND")
|
|
192
|
+
throw coded("PROJECT_NOT_FOUND", error.message);
|
|
193
|
+
if (error.code === "VALIDATION_FAILED" && pinned) {
|
|
194
|
+
const result = await mountVersion({ ...common, manifest: null, fetchObject: noFetch, expectedDigest: pinned.digest, recordExtras: { origin: read.baseUrl } });
|
|
195
|
+
if (result.status !== "mounted" && result.status !== "current") {
|
|
196
|
+
throw new HarnessError(HARNESS_CODES.HARNESS_DIGEST_MISMATCH, `Project ${projectId} serves no version with the pinned digest ${pinned.digest}, and none is cached.`);
|
|
197
|
+
}
|
|
198
|
+
return report(await handOver(root, runtime, outcomeOf(projectId, result, lines, HARNESS_CODES.HARNESS_DIGEST_MISMATCH)));
|
|
199
|
+
}
|
|
200
|
+
throw new HarnessError(HARNESS_CODES.HARNESS_UNAVAILABLE, `Project ${projectId} serves no harness version yet: ${error.message}`);
|
|
152
201
|
}
|
|
202
|
+
if (error instanceof VersionsRouteError && (error.status === 0 || error.status >= 500))
|
|
203
|
+
return report(await fromCache(error.message, null, read.baseUrl));
|
|
204
|
+
throw new HarnessError(HARNESS_CODES.HARNESS_MOUNT_FAILED, error instanceof Error ? error.message : String(error));
|
|
205
|
+
}
|
|
206
|
+
let manifest;
|
|
207
|
+
try {
|
|
208
|
+
manifest = await fetchVersionManifest(read.baseUrl, read.accessToken, projectId, resolved.semver);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
if (error instanceof VersionsRouteError && (error.status === 0 || error.status >= 500))
|
|
212
|
+
return report(await fromCache(error.message, null, read.baseUrl));
|
|
213
|
+
throw new HarnessError(HARNESS_CODES.HARNESS_MOUNT_FAILED, error instanceof Error ? error.message : String(error));
|
|
153
214
|
}
|
|
154
|
-
|
|
215
|
+
const result = await mountVersion({
|
|
216
|
+
...common,
|
|
217
|
+
manifest,
|
|
218
|
+
fetchObject: (entry) => fetchVersionObject(read.baseUrl, read.accessToken, projectId, resolved.semver, entry),
|
|
219
|
+
expectedDigest: pinned?.digest ?? resolved.digest,
|
|
220
|
+
recordExtras: { origin: read.baseUrl },
|
|
221
|
+
});
|
|
222
|
+
return report(await handOver(root, runtime, outcomeOf(projectId, result, lines, HARNESS_CODES.HARNESS_OFFLINE_NO_CACHE)));
|
|
155
223
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { openLocalStoreReadOnly } from "../local_store/database.js";
|
|
2
|
+
const RECORDS_STORE_VERSION = 9;
|
|
3
|
+
const NEVER_EXPIRES = "9999-12-31";
|
|
4
|
+
const DISCHARGED_VERDICTS = new Set(["updated", "owes_nothing"]);
|
|
5
|
+
export async function readStoreDocumentationWaivers(projectId, dbPath) {
|
|
6
|
+
if (!projectId)
|
|
7
|
+
return [];
|
|
8
|
+
const store = await openLocalStoreReadOnly(dbPath ? { dbPath } : {}).catch(() => null);
|
|
9
|
+
if (!store)
|
|
10
|
+
return [];
|
|
11
|
+
try {
|
|
12
|
+
if (store.version < RECORDS_STORE_VERSION)
|
|
13
|
+
return [];
|
|
14
|
+
const rows = store.db
|
|
15
|
+
.prepare("SELECT payload_json FROM records WHERE kind = 'obligation' AND project_id = ?")
|
|
16
|
+
.all(projectId);
|
|
17
|
+
const subjects = new Set();
|
|
18
|
+
for (const row of rows) {
|
|
19
|
+
let payload;
|
|
20
|
+
try {
|
|
21
|
+
payload = JSON.parse(row.payload_json);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (payload.type !== "documentation" || payload.state !== "discharged" || typeof payload.subject !== "string")
|
|
27
|
+
continue;
|
|
28
|
+
if (typeof payload.outcome?.verdict !== "string" || !DISCHARGED_VERDICTS.has(payload.outcome.verdict))
|
|
29
|
+
continue;
|
|
30
|
+
subjects.add(payload.subject);
|
|
31
|
+
}
|
|
32
|
+
return [...subjects].sort().map((subject) => ({
|
|
33
|
+
kind: "waived",
|
|
34
|
+
body: `bookkeeping \`${subject}\` — its documentation obligation is discharged in the local store expires=${NEVER_EXPIRES}`,
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
store.close();
|
|
42
|
+
}
|
|
43
|
+
}
|