@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.
Files changed (63) hide show
  1. package/dist/commands/check.js +73 -0
  2. package/dist/commands/deploy.js +2 -1
  3. package/dist/commands/doctor.js +2 -1
  4. package/dist/commands/harness.js +127 -48
  5. package/dist/commands/init.js +103 -3
  6. package/dist/commands/login.js +9 -2
  7. package/dist/commands/mcp.js +7 -0
  8. package/dist/commands/project-sync.js +2 -1
  9. package/dist/commands/records.js +10 -0
  10. package/dist/commands/review.js +3 -2
  11. package/dist/commands/sessions.js +4 -1
  12. package/dist/commands/sync.js +2 -1
  13. package/dist/index.js +4 -0
  14. package/dist/lib/agent/claudeHooks.js +4 -1
  15. package/dist/lib/auth_grant.js +15 -3
  16. package/dist/lib/auth_recovery.js +10 -3
  17. package/dist/lib/etna/manager.js +24 -6
  18. package/dist/lib/etna/profile.js +6 -0
  19. package/dist/lib/etna/reconcile.js +6 -0
  20. package/dist/lib/harness/codes.js +24 -0
  21. package/dist/lib/harness/hookCheck.js +325 -0
  22. package/dist/lib/harness/hookEntry.js +27 -0
  23. package/dist/lib/harness/legacyMount.js +231 -0
  24. package/dist/lib/harness/mount.js +199 -131
  25. package/dist/lib/harness/storeWaivers.js +43 -0
  26. package/dist/lib/harness/vendor.js +29 -0
  27. package/dist/lib/harness/versionsClient.js +54 -0
  28. package/dist/lib/local_store/database.js +18 -0
  29. package/dist/lib/nustack_client.js +35 -2
  30. package/dist/lib/onboarding/attach_existing_git.js +14 -2
  31. package/dist/lib/onboarding/binding.js +46 -2
  32. package/dist/lib/onboarding/commit_trailer_hook.js +66 -0
  33. package/dist/lib/onboarding/manifest_writer.js +19 -3
  34. package/dist/lib/onboarding/session_hook.js +76 -0
  35. package/dist/lib/origin_guard.js +43 -0
  36. package/dist/lib/project_contract.js +104 -0
  37. package/dist/lib/records/door.js +26 -19
  38. package/dist/lib/records/drain.js +10 -4
  39. package/dist/lib/records/refusalLog.js +30 -1
  40. package/dist/lib/session-engine/supervisor.js +6 -18
  41. package/dist/lib/sessions/client.js +0 -12
  42. package/dist/program.js +14 -3
  43. package/dist/vendor/harness/assemble/runtimes.js +32 -0
  44. package/dist/vendor/harness/assemble/settingsMerge.js +136 -0
  45. package/dist/vendor/harness/digest/index.js +38 -0
  46. package/dist/vendor/harness/lib/findings.js +32 -0
  47. package/dist/vendor/harness/lib/frontmatter.js +79 -0
  48. package/dist/vendor/harness/lib/paths.js +31 -0
  49. package/dist/vendor/harness/mount/cache.js +65 -0
  50. package/dist/vendor/harness/mount/exclude.js +77 -0
  51. package/dist/vendor/harness/mount/files.js +38 -0
  52. package/dist/vendor/harness/mount/index.js +352 -0
  53. package/dist/vendor/harness/rules/changeRules.js +162 -0
  54. package/dist/vendor/harness/rules/context.js +38 -0
  55. package/dist/vendor/harness/rules/docsRoot.js +34 -0
  56. package/dist/vendor/harness/rules/index.js +109 -0
  57. package/dist/vendor/harness/rules/node.js +144 -0
  58. package/dist/vendor/harness/rules/outputGate.js +28 -0
  59. package/dist/vendor/harness/rules/ownership.js +178 -0
  60. package/dist/vendor/harness/rules/packageTable.js +113 -0
  61. package/dist/vendor/harness/rules/plans.js +96 -0
  62. package/dist/vendor/harness/rules/sessionWalls.js +31 -0
  63. package/package.json +5 -5
@@ -0,0 +1,29 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ const VENDORED = {
4
+ digest: "digest/index.js",
5
+ mount: "mount/index.js",
6
+ rules: "rules/index.js",
7
+ rulesNode: "rules/node.js",
8
+ };
9
+ const WORKSPACE = {
10
+ digest: "@nurix/harness/digest",
11
+ mount: "@nurix/harness/mount",
12
+ rules: "@nurix/harness/rules",
13
+ rulesNode: "@nurix/harness/rules/node",
14
+ };
15
+ const VENDOR_ROOT = new URL("../../vendor/harness/", import.meta.url);
16
+ export function isSourceRun(moduleUrl = import.meta.url) {
17
+ const file = fileURLToPath(moduleUrl);
18
+ return file.split(path.sep).join("/").includes("/packages/cli/src/");
19
+ }
20
+ const loaded = new Map();
21
+ export function loadHarness(name) {
22
+ let module = loaded.get(name);
23
+ if (!module) {
24
+ const specifier = isSourceRun() ? WORKSPACE[name] : new URL(VENDORED[name], VENDOR_ROOT).href;
25
+ module = import(specifier);
26
+ loaded.set(name, module);
27
+ }
28
+ return module;
29
+ }
@@ -0,0 +1,54 @@
1
+ import { authorizedFetch } from "../nustack_client.js";
2
+ import { assertSafeOpaqueId } from "../guards.js";
3
+ const REQUEST_TIMEOUT_MS = 15_000;
4
+ export class VersionsRouteError extends Error {
5
+ status;
6
+ code;
7
+ constructor(status, code, message) {
8
+ super(message);
9
+ this.name = "VersionsRouteError";
10
+ this.status = status;
11
+ this.code = code;
12
+ }
13
+ }
14
+ function versionsPath(projectId, ...segments) {
15
+ const safe = assertSafeOpaqueId("The Project id", projectId);
16
+ return `/api/v2/projects/${encodeURIComponent(safe)}/harness-versions${segments.map((segment) => `/${encodeURIComponent(segment)}`).join("")}`;
17
+ }
18
+ async function request(baseUrl, accessToken, routePath) {
19
+ try {
20
+ return await authorizedFetch(`${baseUrl}${routePath}`, {
21
+ headers: { Authorization: `Bearer ${accessToken}` },
22
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
23
+ });
24
+ }
25
+ catch (error) {
26
+ if (typeof error.code === "string" && error.code === "ORIGIN_MISMATCH")
27
+ throw error;
28
+ throw new VersionsRouteError(0, null, `${routePath} did not answer: ${error instanceof Error ? error.message : String(error)}`);
29
+ }
30
+ }
31
+ async function enveloped(response, label) {
32
+ const body = (await response.json().catch(() => null));
33
+ if (!response.ok || body?.data === undefined) {
34
+ const code = typeof body?.error?.code === "string" ? body.error.code : null;
35
+ const message = typeof body?.error?.message === "string" ? body.error.message : `HTTP ${response.status}`;
36
+ throw new VersionsRouteError(response.status, code, `${label}: ${message}`);
37
+ }
38
+ return body.data;
39
+ }
40
+ export async function resolveProjectVersion(baseUrl, accessToken, projectId, ask) {
41
+ const query = "digest" in ask ? `?digest=${encodeURIComponent(ask.digest)}` : "?track=latest";
42
+ const response = await request(baseUrl, accessToken, `${versionsPath(projectId, "resolve")}${query}`);
43
+ return enveloped(response, "harness version resolve failed");
44
+ }
45
+ export async function fetchVersionManifest(baseUrl, accessToken, projectId, semver) {
46
+ const response = await request(baseUrl, accessToken, versionsPath(projectId, semver, "manifest"));
47
+ return enveloped(response, `harness version ${semver} manifest read failed`);
48
+ }
49
+ export async function fetchVersionObject(baseUrl, accessToken, projectId, semver, entry) {
50
+ const response = await request(baseUrl, accessToken, versionsPath(projectId, semver, "files", ...entry.path.split("/")));
51
+ if (!response.ok)
52
+ await enveloped(response, `harness file ${entry.path} read failed`);
53
+ return new Uint8Array(await response.arrayBuffer());
54
+ }
@@ -54,6 +54,24 @@ export async function openLocalStore(options = {}) {
54
54
  },
55
55
  };
56
56
  }
57
+ export async function openLocalStoreReadOnly(options = {}) {
58
+ const dbFile = options.dbPath ?? stateDbPath();
59
+ if (!existsSync(dbFile))
60
+ return null;
61
+ const db = await withSuppressedSqliteWarning(async () => {
62
+ const { DatabaseSync } = await loadSqlite();
63
+ return new DatabaseSync(dbFile, { readOnly: true });
64
+ });
65
+ db.exec("PRAGMA busy_timeout = 2000");
66
+ const row = db.prepare("PRAGMA user_version").get();
67
+ return {
68
+ db,
69
+ version: Number(row?.user_version ?? 0),
70
+ close() {
71
+ db.close();
72
+ },
73
+ };
74
+ }
57
75
  export async function readStoreVersion(dbFile = stateDbPath()) {
58
76
  if (!existsSync(dbFile))
59
77
  return null;
@@ -2,10 +2,16 @@ import { assertSafeNuStackUrl, assertSafeOpaqueId } from "./guards.js";
2
2
  import { createSseParser } from "./sse_framing.js";
3
3
  import { deadCredentialCopy, DEVICE_HEADER, envToken, forgetRefusedToken, knownDeviceId, TokenRefusedError, userAgent } from "./auth_grant.js";
4
4
  import { getOrCreateDeviceId } from "./device_identity.js";
5
- const DEFAULT_NUSTACK_URL = "https://apps.nustack.ai";
5
+ import { readStoredCredential } from "./auth_grant.js";
6
+ import { readProjectContractSync } from "./project_contract.js";
7
+ import { contractOrigins, isContractOrigin, isPlatformDefault, originMismatchError, PLATFORM_DEFAULT_ORIGIN, recordContractOrigin, refusedContractOrigin, } from "./origin_guard.js";
8
+ const DEFAULT_NUSTACK_URL = PLATFORM_DEFAULT_ORIGIN;
9
+ export { contractOrigins };
6
10
  const REQUEST_TIMEOUT_MS = 10_000;
7
11
  export async function authorizedFetch(input, init = {}) {
8
12
  const headers = new Headers(init.headers);
13
+ if (headers.has("Authorization"))
14
+ await assertCredentialOrigin(String(input));
9
15
  headers.set(DEVICE_HEADER, knownDeviceId() ?? (await getOrCreateDeviceId()));
10
16
  headers.set("User-Agent", userAgent());
11
17
  const response = await globalThis.fetch(input, { ...init, headers });
@@ -33,8 +39,35 @@ export function errorMessage(payload, status, label, plane) {
33
39
  const enveloped = typeof payload?.error === "object" ? payload.error?.message : undefined;
34
40
  return enveloped ?? payload?.message ?? `${label}: HTTP ${status}`;
35
41
  }
42
+ export function resolveNuStackOrigin(options, cwd) {
43
+ if (options.nustackUrl)
44
+ return { url: assertSafeNuStackUrl(options.nustackUrl), source: "flag" };
45
+ if (process.env.NUSTACK_URL)
46
+ return { url: assertSafeNuStackUrl(process.env.NUSTACK_URL), source: "env" };
47
+ const origin = readProjectContractSync(cwd).origin;
48
+ if (origin) {
49
+ const url = assertSafeNuStackUrl(origin);
50
+ if (!isPlatformDefault(url)) {
51
+ recordContractOrigin(url);
52
+ if (process.env.NUSTACK_TOKEN?.trim())
53
+ throw originMismatchError(url, null, "pat");
54
+ }
55
+ return { url, source: "contract" };
56
+ }
57
+ return { url: assertSafeNuStackUrl(DEFAULT_NUSTACK_URL), source: "default" };
58
+ }
36
59
  export function resolveNuStackUrl(options) {
37
- return assertSafeNuStackUrl(options.nustackUrl ?? process.env.NUSTACK_URL ?? DEFAULT_NUSTACK_URL);
60
+ return resolveNuStackOrigin(options, process.cwd()).url;
61
+ }
62
+ export async function assertCredentialOrigin(url) {
63
+ if (contractOrigins.size === 0 || !isContractOrigin(url))
64
+ return;
65
+ if (process.env.NUSTACK_TOKEN?.trim())
66
+ throw originMismatchError(url, null, "pat");
67
+ const held = (await readStoredCredential().catch(() => null))?.credential ?? null;
68
+ const refused = refusedContractOrigin(held?.studioBaseUrl ?? null, url);
69
+ if (refused)
70
+ throw originMismatchError(refused, held?.studioBaseUrl ?? null, held ? "device" : "none");
38
71
  }
39
72
  export async function fetchCatalogue(baseUrl) {
40
73
  const response = await authorizedFetch(`${baseUrl}/api/catalog.json`, {
@@ -1,7 +1,7 @@
1
1
  import { readHeadCommit } from "../git-sync/core/repository.js";
2
2
  import { readSymbolicBranch } from "../git-sync/core/repository.js";
3
3
  import { reconcileInboundBinding } from "../git-sync/inbound/index.js";
4
- import { fetchToolkitProfile } from "../etna/profile.js";
4
+ import { fetchToolkitProfile, ToolkitProfileUnavailableError } from "../etna/profile.js";
5
5
  import { classifyFolder, attachRejection } from "./folder_state.js";
6
6
  import { runRepositoryOnboarding, } from "./binding.js";
7
7
  export async function attachExistingGit(ctx, options) {
@@ -24,7 +24,19 @@ export async function attachExistingGit(ctx, options) {
24
24
  if (!boundBranch) {
25
25
  return { ok: false, code: "DETACHED_HEAD", message: "HEAD is detached — check out a branch before attaching." };
26
26
  }
27
- const profile = await fetchToolkitProfile(ctx.baseUrl, ctx.accessToken, options.projectId);
27
+ let profile;
28
+ try {
29
+ profile = await fetchToolkitProfile(ctx.baseUrl, ctx.accessToken, options.projectId);
30
+ }
31
+ catch (error) {
32
+ if (error instanceof ToolkitProfileUnavailableError)
33
+ throw error;
34
+ return {
35
+ ok: false,
36
+ code: "PROJECT_SUMMARY_UNREACHABLE",
37
+ message: `The summary of Project ${options.projectId} could not be read, so this folder was not bound: ${error instanceof Error ? error.message : String(error)}`,
38
+ };
39
+ }
28
40
  const baselineCommitId = readHeadCommit(state.repository);
29
41
  const result = await runRepositoryOnboarding({
30
42
  store: ctx.store,
@@ -6,6 +6,9 @@ import { readHeadCommit } from "../git-sync/core/repository.js";
6
6
  import { writeProjectManifests } from "./manifest_writer.js";
7
7
  import { installToolkit } from "../etna/manager.js";
8
8
  import { serializeProfileForBinding } from "../etna/profile.js";
9
+ import { contractFieldsFromSummary, readProjectContract } from "../project_contract.js";
10
+ import { hasEtnaMountHook, installHarnessMountHook } from "./session_hook.js";
11
+ import { installCommitTrailerHook, trailerHookLine } from "./commit_trailer_hook.js";
9
12
  import { SYNC_PROTOCOL_VERSION } from "../git-sync/whitelist/types.js";
10
13
  export function computeRepositoryFingerprint(repository) {
11
14
  return createHash("sha256").update(repository.commonDir).digest("hex");
@@ -49,7 +52,19 @@ async function maybeRunTelemetry(input, binding) {
49
52
  return;
50
53
  await ensureBindingTelemetry({ baseUrl: input.baseUrl, store: input.store, binding }).catch(() => { });
51
54
  }
55
+ export async function onboardingContractFields(root, baseUrl, profile) {
56
+ const held = await readProjectContract(root);
57
+ const fromSummary = profile.contractFields ?? contractFieldsFromSummary(null);
58
+ return {
59
+ origin: baseUrl,
60
+ door: baseUrl,
61
+ harness: fromSummary.harness,
62
+ branchPolicy: fromSummary.branchPolicy ?? null,
63
+ docsRoot: held.docsRoot ?? "docs",
64
+ };
65
+ }
52
66
  export async function runRepositoryOnboarding(input) {
67
+ const root = input.repository.repositoryRoot;
53
68
  const baselineCommitId = input.baselineCommitId !== undefined ? input.baselineCommitId : readHeadCommit(input.repository);
54
69
  const established = await establishBinding({
55
70
  store: input.store,
@@ -64,8 +79,7 @@ export async function runRepositoryOnboarding(input) {
64
79
  etnaProfileJson: serializeProfileForBinding(input.profile),
65
80
  runTelemetry: input.runTelemetry,
66
81
  });
67
- const install = await installToolkit(input.store, established.binding.id, input.profile, input.repository.repositoryRoot, input.toolkitDeps ?? {});
68
- const projectManifest = await writeProjectManifests(input.repository.repositoryRoot, {
82
+ const projectManifest = await writeProjectManifests(root, {
69
83
  projectId: input.projectId,
70
84
  organizationId: input.organizationId,
71
85
  toolkit: {
@@ -74,7 +88,33 @@ export async function runRepositoryOnboarding(input) {
74
88
  rendererVersion: input.profile.rendererVersion,
75
89
  placementMapVersion: input.profile.placementMapVersion,
76
90
  },
91
+ contract: await onboardingContractFields(root, input.baseUrl, input.profile),
92
+ });
93
+ const notices = [];
94
+ const etnaMounts = await hasEtnaMountHook(root);
95
+ let sessionHook = "skipped_etna_mount";
96
+ if (etnaMounts) {
97
+ notices.push("etna's mount hook is in .claude/settings.json, so NuStack installed no session-start hook and mounted nothing: remove etna's SessionStart entry (.claude/hooks/mount.mjs), then re-run `nustack init`.");
98
+ }
99
+ else {
100
+ sessionHook = await installHarnessMountHook(root);
101
+ if (sessionHook === "skipped_unparsable")
102
+ notices.push(".claude/settings.json does not parse as JSON, so the session-start mount hook was not installed; the file was left untouched.");
103
+ }
104
+ const install = await installToolkit(input.store, established.binding.id, input.profile, root, {
105
+ ...(input.toolkitDeps ?? {}),
106
+ skipMount: etnaMounts,
107
+ writeContract: "when-unmodified",
108
+ projectId: input.projectId,
109
+ globals: { nustackUrl: input.baseUrl },
77
110
  });
111
+ const mount = install.mount ?? { status: "failed", code: "HARNESS_UNAVAILABLE", message: install.reason ?? "no harness to mount" };
112
+ if (mount.status === "failed")
113
+ notices.push(`harness not mounted (${mount.code}): ${mount.message ?? ""} — the next session start retries it.`);
114
+ const trailerHook = await installCommitTrailerHook(root);
115
+ const trailerLine = trailerHookLine(trailerHook);
116
+ if (trailerLine)
117
+ notices.push(trailerLine);
78
118
  return {
79
119
  flow: input.flow,
80
120
  projectId: input.projectId,
@@ -86,6 +126,10 @@ export async function runRepositoryOnboarding(input) {
86
126
  reused: !established.created,
87
127
  install,
88
128
  projectManifest,
129
+ sessionHook,
130
+ mount,
131
+ trailerHook,
132
+ notices,
89
133
  noPush: true,
90
134
  };
91
135
  }
@@ -0,0 +1,66 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ export const TRAILER_HOOK_MARKER = "# nustack:managed commit-msg v1";
6
+ export const CHANGE_TRAILER_KEY = "NuStack-Change";
7
+ export const TRAILER_HOOK_BODY = `#!/bin/sh
8
+ ${TRAILER_HOOK_MARKER}
9
+ # Appends a ${CHANGE_TRAILER_KEY}: <32 lowercase hex> trailer, the change id NuStack's harness records key a
10
+ # commit by. Written by the nustack CLI (nustack init, nustack harness mount); an amend or a rebase keeps the id.
11
+ msg="$1"
12
+ [ -f "$msg" ] || exit 0
13
+ case "$(sed -n '1p' "$msg")" in
14
+ "fixup! "*|"squash! "*) exit 0 ;;
15
+ esac
16
+ grep -qi '^${CHANGE_TRAILER_KEY}:' "$msg" && exit 0
17
+ id=$(od -An -N16 -tx1 /dev/urandom | tr -d ' \\n')
18
+ [ \${#id} -eq 32 ] || exit 0
19
+ git interpret-trailers --in-place --trailer "${CHANGE_TRAILER_KEY}: $id" "$msg"
20
+ `;
21
+ function git(root, args) {
22
+ try {
23
+ return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export function isManagedTrailerHook(text) {
30
+ return text.split("\n").slice(0, 2).some((line) => line.trim() === TRAILER_HOOK_MARKER);
31
+ }
32
+ export async function installCommitTrailerHook(root) {
33
+ const commonDir = git(root, ["rev-parse", "--git-common-dir"]);
34
+ if (!commonDir)
35
+ return "skipped_not_git";
36
+ const hooksPath = git(root, ["config", "--get", "core.hooksPath"]);
37
+ if (hooksPath)
38
+ return "skipped_hooks_path";
39
+ const hooksDir = path.join(path.isAbsolute(commonDir) ? commonDir : path.join(root, commonDir), "hooks");
40
+ const hookFile = path.join(hooksDir, "commit-msg");
41
+ if (existsSync(hookFile)) {
42
+ const held = await readFile(hookFile, "utf8").catch(() => "");
43
+ if (!isManagedTrailerHook(held))
44
+ return "skipped_foreign_hook";
45
+ if (held === TRAILER_HOOK_BODY) {
46
+ await chmod(hookFile, 0o755);
47
+ return "installed";
48
+ }
49
+ }
50
+ await mkdir(hooksDir, { recursive: true });
51
+ await writeFile(hookFile, TRAILER_HOOK_BODY, { encoding: "utf8", mode: 0o755 });
52
+ await chmod(hookFile, 0o755);
53
+ return "installed";
54
+ }
55
+ export function trailerHookLine(value) {
56
+ switch (value) {
57
+ case "skipped_hooks_path":
58
+ return "NuStack-Change trailer hook not installed: core.hooksPath is set, so this repository's own hook manager owns commit-msg.";
59
+ case "skipped_foreign_hook":
60
+ return "NuStack-Change trailer hook not installed: a commit-msg hook NuStack did not write is already there.";
61
+ case "skipped_not_git":
62
+ return "NuStack-Change trailer hook not installed: this folder is not a git repository.";
63
+ default:
64
+ return null;
65
+ }
66
+ }
@@ -5,8 +5,8 @@ import { legacyAppJsonPath, readBindingMarker, projectJsonPath } from "../projec
5
5
  import { manifestPath } from "../manifest.js";
6
6
  const CURRENT_BINDING_PROTOCOL_VERSION = 1;
7
7
  const CURRENT_DOCUMENT_PROTOCOL_VERSION = 1;
8
- function buildProjectManifest(identity) {
9
- return {
8
+ function buildProjectManifest(identity, existing) {
9
+ const owned = {
10
10
  schemaVersion: 2,
11
11
  workspaceId: identity.projectId,
12
12
  organizationId: identity.organizationId,
@@ -19,6 +19,22 @@ function buildProjectManifest(identity) {
19
19
  placementMapVersion: identity.toolkit.placementMapVersion,
20
20
  },
21
21
  };
22
+ const supplied = Object.fromEntries(Object.entries(identity.contract ?? {}).filter(([, value]) => value !== undefined));
23
+ return { ...(existing ?? {}), ...owned, ...supplied };
24
+ }
25
+ async function readExistingContract(root) {
26
+ const filePath = projectJsonPath(root);
27
+ if (!existsSync(filePath))
28
+ return null;
29
+ try {
30
+ const parsed = JSON.parse(await readFile(filePath, "utf8"));
31
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
32
+ return null;
33
+ return parsed.schemaVersion === 2 ? parsed : null;
34
+ }
35
+ catch {
36
+ return null;
37
+ }
22
38
  }
23
39
  async function atomicWrite(target, content) {
24
40
  const prior = existsSync(target) ? await readFile(target, "utf8") : null;
@@ -54,7 +70,7 @@ export async function writeProjectManifests(root, identity, hooks = {}) {
54
70
  toolkit: { name: identity.toolkit.name, version: identity.toolkit.version },
55
71
  };
56
72
  delete nextService.nustackApp;
57
- const projectManifest = buildProjectManifest(identity);
73
+ const projectManifest = buildProjectManifest(identity, await readExistingContract(root));
58
74
  const projectDir = path.dirname(projectJsonPath(root));
59
75
  await mkdir(projectDir, { recursive: true });
60
76
  const projectPrior = await atomicWrite(projectJsonPath(root), JSON.stringify(projectManifest, null, 2) + "\n");
@@ -0,0 +1,76 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ export const SETTINGS_PATH = ".claude/settings.json";
5
+ export const HARNESS_MOUNT_MARKER = "nustack:managed:harness-mount";
6
+ export const HARNESS_MOUNT_COMMAND = `nustack harness mount "$CLAUDE_PROJECT_DIR" >/dev/null || true # ${HARNESS_MOUNT_MARKER}`;
7
+ const ETNA_MOUNT_HOOK = /\.claude\/hooks\/mount\.mjs/;
8
+ const DEFAULT_INDENT = 2;
9
+ function isObject(value) {
10
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11
+ }
12
+ function commandsOf(entry) {
13
+ if (!isObject(entry) || !Array.isArray(entry.hooks))
14
+ return [];
15
+ return entry.hooks.flatMap((hook) => (isObject(hook) && typeof hook.command === "string" ? [hook.command] : []));
16
+ }
17
+ function sessionStartEntries(settings) {
18
+ const hooks = settings.hooks;
19
+ if (!isObject(hooks) || !Array.isArray(hooks.SessionStart))
20
+ return [];
21
+ return hooks.SessionStart;
22
+ }
23
+ function isHarnessMountEntry(entry) {
24
+ return commandsOf(entry).some((command) => command.trimEnd().endsWith(`# ${HARNESS_MOUNT_MARKER}`));
25
+ }
26
+ function managedEntry() {
27
+ return { matcher: "", hooks: [{ type: "command", command: HARNESS_MOUNT_COMMAND }] };
28
+ }
29
+ function detectIndent(raw) {
30
+ const indented = /\n(\x20+)"/.exec(raw);
31
+ return indented?.[1] ? indented[1].length : DEFAULT_INDENT;
32
+ }
33
+ async function readSettings(root) {
34
+ const file = path.join(root, SETTINGS_PATH);
35
+ if (!existsSync(file))
36
+ return { kind: "absent" };
37
+ const raw = await readFile(file, "utf8").catch(() => null);
38
+ if (raw === null)
39
+ return { kind: "unparsable" };
40
+ if (raw.trim() === "")
41
+ return { kind: "parsed", settings: {}, indent: DEFAULT_INDENT };
42
+ try {
43
+ const parsed = JSON.parse(raw);
44
+ return isObject(parsed) ? { kind: "parsed", settings: parsed, indent: detectIndent(raw) } : { kind: "unparsable" };
45
+ }
46
+ catch {
47
+ return { kind: "unparsable" };
48
+ }
49
+ }
50
+ export async function hasEtnaMountHook(root) {
51
+ const read = await readSettings(root);
52
+ if (read.kind !== "parsed")
53
+ return false;
54
+ return sessionStartEntries(read.settings).some((entry) => commandsOf(entry).some((command) => ETNA_MOUNT_HOOK.test(command)));
55
+ }
56
+ export async function installHarnessMountHook(root) {
57
+ const read = await readSettings(root);
58
+ if (read.kind === "unparsable")
59
+ return "skipped_unparsable";
60
+ const settings = read.kind === "parsed" ? read.settings : {};
61
+ const indent = read.kind === "parsed" ? read.indent : DEFAULT_INDENT;
62
+ const entries = sessionStartEntries(settings);
63
+ if (entries.some((entry) => commandsOf(entry).some((command) => ETNA_MOUNT_HOOK.test(command))))
64
+ return "skipped_etna_mount";
65
+ const wanted = managedEntry();
66
+ const index = entries.findIndex(isHarnessMountEntry);
67
+ if (index >= 0 && JSON.stringify(entries[index]) === JSON.stringify(wanted))
68
+ return "present";
69
+ const nextEntries = index >= 0 ? entries.map((entry, at) => (at === index ? wanted : entry)) : [...entries, wanted];
70
+ const hooks = isObject(settings.hooks) ? settings.hooks : {};
71
+ const next = { ...settings, hooks: { ...hooks, SessionStart: nextEntries } };
72
+ const file = path.join(root, SETTINGS_PATH);
73
+ await mkdir(path.dirname(file), { recursive: true });
74
+ await writeFile(file, `${JSON.stringify(next, null, indent)}\n`, "utf8");
75
+ return "installed";
76
+ }
@@ -0,0 +1,43 @@
1
+ import { HarnessError, HARNESS_CODES } from "./harness/codes.js";
2
+ export const PLATFORM_DEFAULT_ORIGIN = "https://apps.nustack.ai";
3
+ export const contractOrigins = new Set();
4
+ export function originOf(url) {
5
+ try {
6
+ return new URL(url).origin;
7
+ }
8
+ catch {
9
+ return url.trim().replace(/\/+$/, "");
10
+ }
11
+ }
12
+ export function isPlatformDefault(url) {
13
+ return originOf(url) === originOf(PLATFORM_DEFAULT_ORIGIN);
14
+ }
15
+ export function recordContractOrigin(url) {
16
+ if (!isPlatformDefault(url))
17
+ contractOrigins.add(originOf(url));
18
+ }
19
+ export function isContractOrigin(url) {
20
+ return contractOrigins.has(originOf(url));
21
+ }
22
+ export function originMismatchError(origin, credentialOrigin, credentialKind) {
23
+ const held = credentialKind === "pat"
24
+ ? "a personal access token (NUSTACK_TOKEN) is never sent to an origin a repository names"
25
+ : credentialKind === "none"
26
+ ? "this machine is not signed in to it, and a sign-in never opens for an origin a repository names"
27
+ : `this machine's sign-in is for ${credentialOrigin ?? "another origin"}`;
28
+ return new HarnessError(HARNESS_CODES.ORIGIN_MISMATCH, `This repository's .nustack/workspace.json names ${origin} as its NuStack origin, but ${held}. ` +
29
+ `To use ${origin}, state it outside the repository: \`nustack login --nustack-url ${origin}\`, or set NUSTACK_URL=${origin}.`);
30
+ }
31
+ export function refusedContractOrigin(credentialOrigin, origin) {
32
+ const candidates = origin === undefined ? [...contractOrigins] : isContractOrigin(origin) ? [originOf(origin)] : [];
33
+ const held = credentialOrigin === null ? null : originOf(credentialOrigin);
34
+ return candidates.find((candidate) => candidate !== held) ?? null;
35
+ }
36
+ export function isOriginMismatch(error) {
37
+ return error instanceof HarnessError && error.code === HARNESS_CODES.ORIGIN_MISMATCH;
38
+ }
39
+ export function nullUnlessOriginRefused(error) {
40
+ if (isOriginMismatch(error))
41
+ throw error;
42
+ return null;
43
+ }
@@ -0,0 +1,104 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ export const CONTRACT_PATH = ".nustack/workspace.json";
5
+ export const LEGACY_HARNESS_MANIFEST_PATH = ".claude/harness.json";
6
+ const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
7
+ function parseObject(text) {
8
+ if (text === null)
9
+ return null;
10
+ try {
11
+ const parsed = JSON.parse(text);
12
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ function contractObject(text) {
19
+ const parsed = parseObject(text);
20
+ return parsed !== null && parsed.schemaVersion === 2 ? parsed : null;
21
+ }
22
+ function nonEmpty(value) {
23
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
24
+ }
25
+ function urlOf(value) {
26
+ const trimmed = nonEmpty(value);
27
+ return trimmed === null ? null : trimmed.replace(/\/+$/, "");
28
+ }
29
+ export function docsRootDeclaration(value) {
30
+ if (typeof value !== "string")
31
+ return null;
32
+ const trimmed = value.trim().replace(/[\\/]+$/, "");
33
+ if (trimmed === "" || /^([A-Za-z]:)?[\\/]/.test(trimmed))
34
+ return null;
35
+ return trimmed.split(/[\\/]/).includes("..") ? null : trimmed;
36
+ }
37
+ export function harnessOf(value) {
38
+ if (typeof value !== "object" || value === null || Array.isArray(value))
39
+ return null;
40
+ const harness = value;
41
+ if (harness.track === "latest")
42
+ return { track: "latest" };
43
+ const pin = nonEmpty(harness.pin);
44
+ const digest = typeof harness.digest === "string" ? harness.digest.trim() : "";
45
+ return pin !== null && DIGEST_PATTERN.test(digest) ? { pin, digest } : null;
46
+ }
47
+ function doorOf(holder) {
48
+ if (holder === null || !Object.hasOwn(holder, "door"))
49
+ return undefined;
50
+ return urlOf(holder.door);
51
+ }
52
+ function legacyMount(legacy) {
53
+ const mount = legacy?.mount;
54
+ return typeof mount === "object" && mount !== null && !Array.isArray(mount) ? mount : null;
55
+ }
56
+ export function parseProjectContract(contractText, legacyText) {
57
+ const contract = contractObject(contractText);
58
+ const legacy = parseObject(legacyText);
59
+ const contractDoor = doorOf(contract);
60
+ const legacyDoor = doorOf(legacyMount(legacy));
61
+ return {
62
+ projectId: contract === null ? null : nonEmpty(contract.workspaceId),
63
+ harness: contract === null ? null : harnessOf(contract.harness),
64
+ door: contractDoor !== undefined ? contractDoor : legacyDoor,
65
+ doorSource: contractDoor !== undefined ? "contract" : legacyDoor !== undefined ? "legacy" : null,
66
+ origin: contract === null ? null : urlOf(contract.origin),
67
+ docsRoot: (contract === null ? null : docsRootDeclaration(contract.docsRoot)) ?? docsRootDeclaration(legacy?.docsRoot),
68
+ branchPolicy: contract?.branchPolicy === "trunk" || contract?.branchPolicy === "branch" ? contract.branchPolicy : null,
69
+ contractPresent: contract !== null,
70
+ legacyPresent: legacyText !== null,
71
+ legacyVersion: nonEmpty(legacy?.version),
72
+ };
73
+ }
74
+ function readTextSync(file) {
75
+ if (!existsSync(file))
76
+ return null;
77
+ try {
78
+ return readFileSync(file, "utf8");
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ async function readText(file) {
85
+ if (!existsSync(file))
86
+ return null;
87
+ return readFile(file, "utf8").catch(() => null);
88
+ }
89
+ export async function readProjectContract(root) {
90
+ return parseProjectContract(await readText(path.join(root, CONTRACT_PATH)), await readText(path.join(root, LEGACY_HARNESS_MANIFEST_PATH)));
91
+ }
92
+ export function readProjectContractSync(root) {
93
+ return parseProjectContract(readTextSync(path.join(root, CONTRACT_PATH)), readTextSync(path.join(root, LEGACY_HARNESS_MANIFEST_PATH)));
94
+ }
95
+ export function contractFieldsFromSummary(summary) {
96
+ if (summary === null)
97
+ return { harness: { track: "latest" }, branchPolicy: null };
98
+ const harness = summary.harness;
99
+ const branchPolicy = summary.branchPolicy === "trunk" || summary.branchPolicy === "branch" ? summary.branchPolicy : null;
100
+ if (harness?.track === "pinned" && typeof harness.digest === "string" && DIGEST_PATTERN.test(harness.digest)) {
101
+ return { harness: { pin: harness.version, digest: harness.digest }, branchPolicy };
102
+ }
103
+ return { harness: { track: "latest" }, branchPolicy };
104
+ }