@nurix/nustack 0.19.0 → 0.19.2
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/activity.js +47 -0
- package/dist/commands/api.js +106 -0
- package/dist/commands/bootstrap.js +1 -1
- package/dist/commands/deploy.js +372 -0
- package/dist/commands/doctor.js +2 -1
- package/dist/commands/harness.js +90 -0
- package/dist/commands/init.js +1 -1
- package/dist/commands/mcp.js +103 -0
- package/dist/commands/project.js +28 -51
- package/dist/commands/records.js +150 -0
- package/dist/commands/toolkit.js +2 -2
- package/dist/commands/whoami.js +79 -0
- package/dist/index.js +37 -0
- package/dist/lib/etna/manager.js +12 -1
- package/dist/lib/etna/profile.js +2 -0
- package/dist/lib/etna/reconcile.js +5 -11
- package/dist/lib/guards.js +12 -0
- package/dist/lib/harness/mount.js +155 -0
- package/dist/lib/hosting/client.js +71 -0
- package/dist/lib/hosting/logs.js +76 -0
- package/dist/lib/interactive.js +3 -0
- package/dist/lib/local_store/migrations.js +31 -0
- package/dist/lib/mcp/clients.js +130 -0
- package/dist/lib/mcp/relay.js +106 -0
- package/dist/lib/nustack_client.js +2 -2
- package/dist/lib/records/door.js +223 -0
- package/dist/lib/session-engine/supervisor.js +18 -0
- package/dist/lib/sessions/client.js +12 -0
- package/package.json +4 -3
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
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";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
export const MOUNT_STATE_PATH = ".claude/state/mount.json";
|
|
8
|
+
const IGNORE_START = "# etna:mount:start";
|
|
9
|
+
const IGNORE_END = "# etna:mount:end";
|
|
10
|
+
const ETNA_MOUNT_SPECIFIER = "@nurix/etna/mount";
|
|
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 {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function readMountState(root) {
|
|
32
|
+
return parseMountState(await readIfPresent(path.join(root, MOUNT_STATE_PATH)));
|
|
33
|
+
}
|
|
34
|
+
export function readMountStateSync(root) {
|
|
35
|
+
try {
|
|
36
|
+
return parseMountState(readFileSync(path.join(root, MOUNT_STATE_PATH)));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function parseMountState(raw) {
|
|
43
|
+
if (!raw)
|
|
44
|
+
return null;
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(raw.toString("utf8"));
|
|
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 } : {}) };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function fetchBytes(fetchImpl, url) {
|
|
56
|
+
const response = await fetchImpl(url);
|
|
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(/^\/+/, "")}`;
|
|
63
|
+
}
|
|
64
|
+
async function writeAtomic(target, bytes) {
|
|
65
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
66
|
+
const staging = `${target}.mount-${process.pid}`;
|
|
67
|
+
await writeFile(staging, bytes);
|
|
68
|
+
await rename(staging, target);
|
|
69
|
+
}
|
|
70
|
+
function ignoredNamesOf(files) {
|
|
71
|
+
const names = new Set([".claude/state/"]);
|
|
72
|
+
for (const file of files) {
|
|
73
|
+
const parts = file.path.split("/");
|
|
74
|
+
if (parts[0] === ".agents")
|
|
75
|
+
names.add(".agents/skills/");
|
|
76
|
+
else if (parts[0] === ".codex")
|
|
77
|
+
names.add(parts.length > 2 ? `.codex/${parts[1]}/` : `.codex/${parts[1]}`);
|
|
78
|
+
else if (parts.length > 3)
|
|
79
|
+
names.add(`${parts.slice(0, 3).join("/")}/`);
|
|
80
|
+
else
|
|
81
|
+
names.add(file.path);
|
|
82
|
+
}
|
|
83
|
+
return [...names].sort();
|
|
84
|
+
}
|
|
85
|
+
async function writeExcludeBlock(root, files) {
|
|
86
|
+
let excludePath;
|
|
87
|
+
try {
|
|
88
|
+
excludePath = execFileSync("git", ["rev-parse", "--git-path", "info/exclude"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const absolute = path.isAbsolute(excludePath) ? excludePath : path.join(root, excludePath);
|
|
94
|
+
const held = (await readIfPresent(absolute))?.toString("utf8") ?? "";
|
|
95
|
+
const start = held.indexOf(IGNORE_START);
|
|
96
|
+
const end = held.indexOf(IGNORE_END);
|
|
97
|
+
const outside = start >= 0 && end > start ? held.slice(0, start) + held.slice(end + IGNORE_END.length).replace(/^\n/, "") : held;
|
|
98
|
+
const block = [IGNORE_START, ...ignoredNamesOf(files), IGNORE_END].join("\n");
|
|
99
|
+
await writeAtomic(absolute, `${outside.replace(/\n*$/, outside ? "\n" : "")}${block}\n`);
|
|
100
|
+
}
|
|
101
|
+
export async function mountWithAdapter(root, manifestUrl, options = {}) {
|
|
102
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
103
|
+
const cacheDir = options.cacheDir ?? defaultCacheDir();
|
|
104
|
+
const manifestBytes = await fetchBytes(fetchImpl, manifestUrl);
|
|
105
|
+
const digest = sha256Of(manifestBytes);
|
|
106
|
+
const manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
107
|
+
if (!manifest || !Array.isArray(manifest.files))
|
|
108
|
+
throw new Error(`the manifest at ${manifestUrl} lists no files`);
|
|
109
|
+
const version = String(manifest.version ?? "");
|
|
110
|
+
const prior = await readMountState(root);
|
|
111
|
+
const mounted = manifest.files.filter(isMountedPath);
|
|
112
|
+
let written = 0;
|
|
113
|
+
let kept = 0;
|
|
114
|
+
for (const file of mounted) {
|
|
115
|
+
if (file.path.split("/").includes("..") || path.isAbsolute(file.path))
|
|
116
|
+
throw new Error(`the manifest names an unsafe path: ${file.path}`);
|
|
117
|
+
const target = path.join(root, file.path);
|
|
118
|
+
const held = await readIfPresent(target);
|
|
119
|
+
if (held && file.sha256 && sha256Of(held) === file.sha256) {
|
|
120
|
+
kept += 1;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const cached = file.sha256 ? await readIfPresent(path.join(cacheDir, "objects", file.sha256)) : null;
|
|
124
|
+
let bytes = cached && sha256Of(cached) === file.sha256 ? cached : null;
|
|
125
|
+
if (!bytes) {
|
|
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;
|
|
134
|
+
}
|
|
135
|
+
await writeExcludeBlock(root, mounted);
|
|
136
|
+
await mkdir(path.join(root, ".claude", "state"), { recursive: true });
|
|
137
|
+
if (!(await readIfPresent(path.join(root, ".claude", "state", ".gitignore"))))
|
|
138
|
+
await writeFile(path.join(root, ".claude", "state", ".gitignore"), "*\n");
|
|
139
|
+
const state = { version, digest, mountedAt: new Date().toISOString(), manifestUrl };
|
|
140
|
+
if (!prior || prior.digest !== digest || prior.version !== version || written > 0)
|
|
141
|
+
await writeAtomic(path.join(root, MOUNT_STATE_PATH), `${JSON.stringify(state, null, 2)}\n`);
|
|
142
|
+
return { version, digest, written, kept, via: "adapter" };
|
|
143
|
+
}
|
|
144
|
+
export async function mountHarness(root, manifestUrl, options = {}) {
|
|
145
|
+
if (options.useEtna !== false) {
|
|
146
|
+
const specifier = ETNA_MOUNT_SPECIFIER;
|
|
147
|
+
const etna = (await import(specifier).catch(() => null));
|
|
148
|
+
if (etna && typeof etna.mount === "function") {
|
|
149
|
+
await etna.mount(root, manifestUrl);
|
|
150
|
+
const state = await readMountState(root);
|
|
151
|
+
return { version: state?.version ?? "", digest: state?.digest ?? "", written: 0, kept: 0, via: "etna" };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return mountWithAdapter(root, manifestUrl, options);
|
|
155
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { assertSafeOpaqueId } from "../guards.js";
|
|
2
|
+
import { authorizedFetch, errorMessage } from "../nustack_client.js";
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
4
|
+
const DEFAULT_POLL_INTERVAL_MS = 4_000;
|
|
5
|
+
export class HostingRequestError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
status;
|
|
8
|
+
constructor(code, message, status) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.name = "HostingRequestError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function callProjectRoute(target, suffix, label, method = "GET") {
|
|
16
|
+
const safeId = encodeURIComponent(assertSafeOpaqueId("The Project id", target.projectId));
|
|
17
|
+
const response = await authorizedFetch(`${target.baseUrl}/api/v2/projects/${safeId}${suffix}`, {
|
|
18
|
+
method,
|
|
19
|
+
headers: { Authorization: `Bearer ${target.accessToken}` },
|
|
20
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
21
|
+
});
|
|
22
|
+
const payload = (await response.json().catch(() => null));
|
|
23
|
+
if (!response.ok || payload?.data === undefined) {
|
|
24
|
+
throw new HostingRequestError(payload?.error?.code ?? "REQUEST_FAILED", errorMessage(payload, response.status, label), response.status);
|
|
25
|
+
}
|
|
26
|
+
return { data: payload.data, meta: payload.meta ?? {} };
|
|
27
|
+
}
|
|
28
|
+
async function call(target, suffix, label, method = "GET") {
|
|
29
|
+
return (await callProjectRoute(target, suffix, label, method)).data;
|
|
30
|
+
}
|
|
31
|
+
export function getHostingStatus(target) {
|
|
32
|
+
return call(target, "/hosting", "hosting status failed");
|
|
33
|
+
}
|
|
34
|
+
export function startDeploy(target) {
|
|
35
|
+
return call(target, "/hosting/deploys", "deploy failed", "POST");
|
|
36
|
+
}
|
|
37
|
+
export function rollbackHosting(target) {
|
|
38
|
+
return call(target, "/hosting/rollback", "rollback failed", "POST");
|
|
39
|
+
}
|
|
40
|
+
export function setKeepWarm(target, isOn) {
|
|
41
|
+
return call(target, "/hosting/pin", "keep-warm failed", isOn ? "POST" : "DELETE");
|
|
42
|
+
}
|
|
43
|
+
export function listVersions(target) {
|
|
44
|
+
return call(target, "/versions", "version list failed");
|
|
45
|
+
}
|
|
46
|
+
export function pollIntervalMs(fallback = DEFAULT_POLL_INTERVAL_MS) {
|
|
47
|
+
const raw = Number.parseInt(process.env.NUSTACK_POLL_INTERVAL_MS ?? "", 10);
|
|
48
|
+
return Number.isInteger(raw) && raw > 0 ? Math.max(raw, 50) : fallback;
|
|
49
|
+
}
|
|
50
|
+
export async function watchDeploy(target, version, options) {
|
|
51
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
52
|
+
const interval = pollIntervalMs();
|
|
53
|
+
let lastState = null;
|
|
54
|
+
let last = null;
|
|
55
|
+
for (;;) {
|
|
56
|
+
last = await getHostingStatus(target);
|
|
57
|
+
if (last.deployState !== null && last.deployState !== lastState) {
|
|
58
|
+
lastState = last.deployState;
|
|
59
|
+
options.onState?.(last.deployState);
|
|
60
|
+
}
|
|
61
|
+
if (last.deployState === null && last.lastOutcome?.version === version) {
|
|
62
|
+
return { kind: "settled", outcome: last.lastOutcome, status: last };
|
|
63
|
+
}
|
|
64
|
+
if (last.deployState === null && last.lastOutcome && last.lastOutcome.version > version) {
|
|
65
|
+
return { kind: "superseded", status: last, laterVersion: last.lastOutcome.version };
|
|
66
|
+
}
|
|
67
|
+
if (Date.now() + interval > deadline)
|
|
68
|
+
return { kind: "timeout", status: last };
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { callProjectRoute, pollIntervalMs } from "./client.js";
|
|
2
|
+
const FOLLOW_INTERVAL_MS = 2_000;
|
|
3
|
+
export async function getBuildLog(target, version) {
|
|
4
|
+
return (await callProjectRoute(target, `/hosting/builds/${version}`, "build log read failed")).data;
|
|
5
|
+
}
|
|
6
|
+
export async function getRuntimeLogs(target, cursor) {
|
|
7
|
+
const query = new URLSearchParams();
|
|
8
|
+
if (cursor.after !== undefined)
|
|
9
|
+
query.set("after", String(cursor.after));
|
|
10
|
+
if (cursor.instance)
|
|
11
|
+
query.set("instance", cursor.instance);
|
|
12
|
+
if (cursor.limit !== undefined)
|
|
13
|
+
query.set("limit", String(cursor.limit));
|
|
14
|
+
const suffix = query.size > 0 ? `/hosting/logs?${query.toString()}` : "/hosting/logs";
|
|
15
|
+
return (await callProjectRoute(target, suffix, "log read failed")).data;
|
|
16
|
+
}
|
|
17
|
+
export async function listActivity(target, page) {
|
|
18
|
+
const query = new URLSearchParams();
|
|
19
|
+
if (page.cursor)
|
|
20
|
+
query.set("cursor", page.cursor);
|
|
21
|
+
if (page.limit !== undefined)
|
|
22
|
+
query.set("limit", String(page.limit));
|
|
23
|
+
if (page.action)
|
|
24
|
+
query.set("action", page.action);
|
|
25
|
+
const suffix = query.size > 0 ? `/events?${query.toString()}` : "/events";
|
|
26
|
+
const answer = await callProjectRoute(target, suffix, "activity read failed");
|
|
27
|
+
return { items: answer.data.items, nextCursor: answer.meta.nextCursor ?? null };
|
|
28
|
+
}
|
|
29
|
+
export function orderedSteps(log) {
|
|
30
|
+
const failing = log.steps.filter((step) => step.state === "failed");
|
|
31
|
+
return [...log.steps.filter((step) => step.state !== "failed"), ...failing];
|
|
32
|
+
}
|
|
33
|
+
export function renderBuildLog(log) {
|
|
34
|
+
const blocks = orderedSteps(log).map((step) => {
|
|
35
|
+
const head = `── ${step.clause} · ${step.state}`;
|
|
36
|
+
return step.tail ? `${head}\n${step.tail}` : head;
|
|
37
|
+
});
|
|
38
|
+
return [`v${log.version} · ${log.ok ? "graded green" : "graded red"}`, ...blocks].join("\n");
|
|
39
|
+
}
|
|
40
|
+
export function renderRuntimeLine(line) {
|
|
41
|
+
return `${line.at} ${line.stream.padEnd(6)} ${line.text}`;
|
|
42
|
+
}
|
|
43
|
+
export function stateNotice(page) {
|
|
44
|
+
if (page.state === "stopped")
|
|
45
|
+
return "the app is asleep — its recent lines went with the instance that printed them";
|
|
46
|
+
if (page.state === "starting")
|
|
47
|
+
return "the app is starting — a version switch is in flight";
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
export async function followRuntimeLogs(target, first, sink, limit) {
|
|
51
|
+
const interval = pollIntervalMs(FOLLOW_INTERVAL_MS);
|
|
52
|
+
let instance = first.instance;
|
|
53
|
+
let after = first.state === "running" ? first.latestSeq : undefined;
|
|
54
|
+
let lastNotice = stateNotice(first);
|
|
55
|
+
for (;;) {
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
57
|
+
const page = await getRuntimeLogs(target, { after, instance, limit });
|
|
58
|
+
const notice = stateNotice(page);
|
|
59
|
+
if (notice) {
|
|
60
|
+
if (notice !== lastNotice)
|
|
61
|
+
sink.notice(notice);
|
|
62
|
+
lastNotice = notice;
|
|
63
|
+
after = undefined;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
lastNotice = null;
|
|
67
|
+
if (instance !== null && page.instance !== instance)
|
|
68
|
+
sink.notice("— instance restarted —");
|
|
69
|
+
else if (page.isTruncated)
|
|
70
|
+
sink.notice("— lines were dropped before they could be read —");
|
|
71
|
+
for (const line of page.lines)
|
|
72
|
+
sink.line(renderRuntimeLine(line));
|
|
73
|
+
instance = page.instance;
|
|
74
|
+
after = page.latestSeq;
|
|
75
|
+
}
|
|
76
|
+
}
|
package/dist/lib/interactive.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import * as p from "@clack/prompts";
|
|
2
|
+
export const NON_INTERACTIVE_VARIABLE = "NUSTACK_NON_INTERACTIVE";
|
|
2
3
|
export function isInteractive() {
|
|
4
|
+
if (process.env[NON_INTERACTIVE_VARIABLE] === "1")
|
|
5
|
+
return false;
|
|
3
6
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
4
7
|
}
|
|
5
8
|
export async function confirmOrFallback(message, fallback) {
|
|
@@ -205,6 +205,36 @@ const MIGRATION_V7 = {
|
|
|
205
205
|
`CREATE INDEX local_bindings_project_idx ON local_bindings (project_id)`,
|
|
206
206
|
],
|
|
207
207
|
};
|
|
208
|
+
const MIGRATION_V8 = {
|
|
209
|
+
version: 8,
|
|
210
|
+
statements: [
|
|
211
|
+
`CREATE TABLE record_outbox (
|
|
212
|
+
id TEXT PRIMARY KEY,
|
|
213
|
+
project_id TEXT NOT NULL,
|
|
214
|
+
kind TEXT NOT NULL CHECK (kind IN ('backlog','backlog.remove','bookkeeping','gate_event')),
|
|
215
|
+
idempotency_key TEXT NOT NULL,
|
|
216
|
+
door_url TEXT NOT NULL,
|
|
217
|
+
row_json TEXT NOT NULL CHECK (json_valid(row_json)),
|
|
218
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
219
|
+
next_retry_at_ms INTEGER,
|
|
220
|
+
last_error TEXT,
|
|
221
|
+
created_at_ms INTEGER NOT NULL,
|
|
222
|
+
updated_at_ms INTEGER NOT NULL
|
|
223
|
+
)`,
|
|
224
|
+
`CREATE UNIQUE INDEX record_outbox_idempotency_key_unique ON record_outbox (idempotency_key)`,
|
|
225
|
+
`CREATE TABLE record_store (
|
|
226
|
+
id TEXT PRIMARY KEY,
|
|
227
|
+
project_id TEXT,
|
|
228
|
+
repository_root TEXT NOT NULL,
|
|
229
|
+
kind TEXT NOT NULL CHECK (kind IN ('backlog','backlog.remove','bookkeeping','gate_event')),
|
|
230
|
+
idempotency_key TEXT NOT NULL,
|
|
231
|
+
row_json TEXT NOT NULL CHECK (json_valid(row_json)),
|
|
232
|
+
created_at_ms INTEGER NOT NULL,
|
|
233
|
+
updated_at_ms INTEGER NOT NULL
|
|
234
|
+
)`,
|
|
235
|
+
`CREATE UNIQUE INDEX record_store_idempotency_key_unique ON record_store (idempotency_key)`,
|
|
236
|
+
],
|
|
237
|
+
};
|
|
208
238
|
export const MIGRATIONS = [
|
|
209
239
|
MIGRATION_V1,
|
|
210
240
|
MIGRATION_V2,
|
|
@@ -213,6 +243,7 @@ export const MIGRATIONS = [
|
|
|
213
243
|
MIGRATION_V5,
|
|
214
244
|
MIGRATION_V6,
|
|
215
245
|
MIGRATION_V7,
|
|
246
|
+
MIGRATION_V8,
|
|
216
247
|
];
|
|
217
248
|
export const LATEST_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
|
218
249
|
export class LocalSchemaTooNewError extends Error {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export const MCP_CLIENTS = ["claude", "cursor", "codex", "vscode"];
|
|
6
|
+
export const SERVER_NAME = "nustack";
|
|
7
|
+
const COMMAND = "nustack";
|
|
8
|
+
const ARGS = ["mcp", "serve"];
|
|
9
|
+
export function targetFor(client, scope, cwd, home = os.homedir()) {
|
|
10
|
+
const plain = { command: COMMAND, args: ARGS };
|
|
11
|
+
switch (client) {
|
|
12
|
+
case "claude":
|
|
13
|
+
return { format: "json", file: scope === "user" ? path.join(home, ".claude.json") : path.join(cwd, ".mcp.json"), table: "mcpServers", entry: plain };
|
|
14
|
+
case "cursor":
|
|
15
|
+
return { format: "json", file: path.join(scope === "user" ? home : cwd, ".cursor", "mcp.json"), table: "mcpServers", entry: plain };
|
|
16
|
+
case "codex":
|
|
17
|
+
return scope === "user" ? { format: "toml", file: path.join(home, ".codex", "config.toml") } : null;
|
|
18
|
+
case "vscode":
|
|
19
|
+
return scope === "project" ? { format: "json", file: path.join(cwd, ".vscode", "mcp.json"), table: "servers", entry: { type: "stdio", ...plain } } : null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function detectClients(home = os.homedir()) {
|
|
23
|
+
const found = [];
|
|
24
|
+
if (existsSync(path.join(home, ".claude.json")) || existsSync(path.join(home, ".claude")))
|
|
25
|
+
found.push("claude");
|
|
26
|
+
if (existsSync(path.join(home, ".cursor")))
|
|
27
|
+
found.push("cursor");
|
|
28
|
+
if (existsSync(path.join(home, ".codex")))
|
|
29
|
+
found.push("codex");
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
function detectIndent(raw) {
|
|
33
|
+
const indented = /\n(\x20+)"/.exec(raw);
|
|
34
|
+
return indented?.[1] ? indented[1].length : 2;
|
|
35
|
+
}
|
|
36
|
+
function isManaged(value) {
|
|
37
|
+
if (!value || typeof value !== "object")
|
|
38
|
+
return false;
|
|
39
|
+
const command = value.command;
|
|
40
|
+
return typeof command === "string" && (command === COMMAND || command.endsWith(`/${COMMAND}`));
|
|
41
|
+
}
|
|
42
|
+
export class ClientConfigError extends Error {
|
|
43
|
+
}
|
|
44
|
+
function editJson(target, raw, install) {
|
|
45
|
+
let document = {};
|
|
46
|
+
if (raw !== null && raw.trim()) {
|
|
47
|
+
try {
|
|
48
|
+
document = JSON.parse(raw);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new ClientConfigError(`${target.file} is not plain JSON (comments or a syntax error); edit it by hand.`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const indent = raw ? detectIndent(raw) : 2;
|
|
55
|
+
const table = (document[target.table] && typeof document[target.table] === "object" ? { ...document[target.table] } : null) ?? null;
|
|
56
|
+
const current = table?.[SERVER_NAME];
|
|
57
|
+
if (current !== undefined && !isManaged(current)) {
|
|
58
|
+
throw new ClientConfigError(`${target.file} already has a "${SERVER_NAME}" server that this CLI did not write; remove it or rename it first.`);
|
|
59
|
+
}
|
|
60
|
+
const render = (value) => `${JSON.stringify(value, null, indent)}\n`;
|
|
61
|
+
if (install) {
|
|
62
|
+
if (current !== undefined && JSON.stringify(current) === JSON.stringify(target.entry))
|
|
63
|
+
return { action: "unchanged", next: raw };
|
|
64
|
+
const next = { ...document, [target.table]: { ...(table ?? {}), [SERVER_NAME]: target.entry } };
|
|
65
|
+
return { action: current === undefined ? "added" : "updated", next: render(next) };
|
|
66
|
+
}
|
|
67
|
+
if (current === undefined || !table)
|
|
68
|
+
return { action: "absent", next: raw };
|
|
69
|
+
delete table[SERVER_NAME];
|
|
70
|
+
const next = { ...document };
|
|
71
|
+
if (Object.keys(table).length > 0)
|
|
72
|
+
next[target.table] = table;
|
|
73
|
+
else
|
|
74
|
+
delete next[target.table];
|
|
75
|
+
return { action: "removed", next: Object.keys(next).length === 0 ? null : render(next) };
|
|
76
|
+
}
|
|
77
|
+
const TOML_HEADER = `[mcp_servers.${SERVER_NAME}]`;
|
|
78
|
+
const TOML_BLOCK = `${TOML_HEADER}\ncommand = "${COMMAND}"\nargs = [${ARGS.map((arg) => `"${arg}"`).join(", ")}]\n`;
|
|
79
|
+
function tomlBlock(lines) {
|
|
80
|
+
const start = lines.findIndex((line) => line.trim() === TOML_HEADER);
|
|
81
|
+
if (start === -1)
|
|
82
|
+
return null;
|
|
83
|
+
let end = start + 1;
|
|
84
|
+
while (end < lines.length && !/^\s*\[/.test(lines[end]))
|
|
85
|
+
end += 1;
|
|
86
|
+
return { start, end };
|
|
87
|
+
}
|
|
88
|
+
function editToml(raw, install) {
|
|
89
|
+
const text = raw ?? "";
|
|
90
|
+
const lines = text.split("\n");
|
|
91
|
+
const block = tomlBlock(lines);
|
|
92
|
+
if (install) {
|
|
93
|
+
if (block) {
|
|
94
|
+
const existing = `${lines.slice(block.start, block.end).join("\n").trimEnd()}\n`;
|
|
95
|
+
if (existing === TOML_BLOCK)
|
|
96
|
+
return { action: "unchanged", next: raw };
|
|
97
|
+
const rest = lines.slice(block.end).join("\n");
|
|
98
|
+
const head = lines.slice(0, block.start).join("\n");
|
|
99
|
+
return { action: "updated", next: `${head}${head ? "\n" : ""}${TOML_BLOCK}${rest ? `\n${rest}` : ""}` };
|
|
100
|
+
}
|
|
101
|
+
if (!text)
|
|
102
|
+
return { action: "added", next: TOML_BLOCK };
|
|
103
|
+
return { action: "added", next: `${text}${text.endsWith("\n") ? "" : "\n"}\n${TOML_BLOCK}` };
|
|
104
|
+
}
|
|
105
|
+
if (!block)
|
|
106
|
+
return { action: "absent", next: raw };
|
|
107
|
+
let start = block.start;
|
|
108
|
+
if (start > 0 && lines[start - 1] === "")
|
|
109
|
+
start -= 1;
|
|
110
|
+
const kept = [...lines.slice(0, start), ...lines.slice(block.end)];
|
|
111
|
+
const next = kept.join("\n").replace(/\n*$/, "\n");
|
|
112
|
+
return { action: "removed", next: next.trim() ? next : null };
|
|
113
|
+
}
|
|
114
|
+
export async function planEdit(target, install) {
|
|
115
|
+
const before = existsSync(target.file) ? await readFile(target.file, "utf8") : null;
|
|
116
|
+
const outcome = target.format === "json" ? editJson(target, before, install) : editToml(before, install);
|
|
117
|
+
return { ...outcome, before };
|
|
118
|
+
}
|
|
119
|
+
export async function applyEdit(target, outcome) {
|
|
120
|
+
if (outcome.action === "unchanged" || outcome.action === "absent")
|
|
121
|
+
return;
|
|
122
|
+
if (outcome.next === null) {
|
|
123
|
+
await rm(target.file, { force: true });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
await mkdir(path.dirname(target.file), { recursive: true });
|
|
127
|
+
const temporary = `${target.file}.nustack-${process.pid}`;
|
|
128
|
+
await writeFile(temporary, outcome.next, "utf8");
|
|
129
|
+
await rename(temporary, target.file);
|
|
130
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
export const RELAY_UNAUTHORIZED = -32001;
|
|
3
|
+
const INTERNAL_ERROR = -32603;
|
|
4
|
+
const PARSE_ERROR = -32700;
|
|
5
|
+
export function doorUrlFor(origin, override) {
|
|
6
|
+
if (override?.trim())
|
|
7
|
+
return override.trim();
|
|
8
|
+
const url = new URL(origin);
|
|
9
|
+
if (url.hostname === "apps.nustack.ai")
|
|
10
|
+
return "https://mcp.nustack.ai/nustack";
|
|
11
|
+
return `${url.origin}/nustack`;
|
|
12
|
+
}
|
|
13
|
+
function rpcError(id, code, message) {
|
|
14
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
15
|
+
}
|
|
16
|
+
export function rewriteToolList(tools, defaultProjectId) {
|
|
17
|
+
return tools.map((tool) => {
|
|
18
|
+
const property = tool.inputSchema?.properties?.projectId;
|
|
19
|
+
if (!property || !tool.inputSchema)
|
|
20
|
+
return tool;
|
|
21
|
+
const note = defaultProjectId ? ` Defaults to ${defaultProjectId}, the Project this folder is bound to.` : "";
|
|
22
|
+
const rewritten = {
|
|
23
|
+
...tool,
|
|
24
|
+
inputSchema: {
|
|
25
|
+
...tool.inputSchema,
|
|
26
|
+
properties: { ...tool.inputSchema.properties, projectId: { ...property, description: `${property.description ?? ""}${note}`.trim() } },
|
|
27
|
+
required: (tool.inputSchema.required ?? []).filter((name) => name !== "projectId"),
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
if (rewritten.inputSchema.required.length === 0)
|
|
31
|
+
delete rewritten.inputSchema.required;
|
|
32
|
+
return rewritten;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export async function runRelay(options) {
|
|
36
|
+
let projectVerbs = null;
|
|
37
|
+
const forward = async (frame) => {
|
|
38
|
+
const bearer = await options.bearer();
|
|
39
|
+
if (!bearer)
|
|
40
|
+
return { status: 401, body: null };
|
|
41
|
+
const response = await options.fetch(options.doorUrl, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${bearer}` },
|
|
44
|
+
body: JSON.stringify(frame),
|
|
45
|
+
});
|
|
46
|
+
const text = await response.text();
|
|
47
|
+
return { status: response.status, body: text ? JSON.parse(text) : null };
|
|
48
|
+
};
|
|
49
|
+
const remember = (tools) => {
|
|
50
|
+
projectVerbs = new Set(tools.filter((tool) => tool.inputSchema?.properties?.projectId).map((tool) => tool.name));
|
|
51
|
+
};
|
|
52
|
+
const knownProjectVerbs = async () => {
|
|
53
|
+
if (projectVerbs)
|
|
54
|
+
return projectVerbs;
|
|
55
|
+
const listed = await forward({ jsonrpc: "2.0", id: "relay-tools", method: "tools/list" });
|
|
56
|
+
remember((listed.body?.result?.tools ?? []));
|
|
57
|
+
return projectVerbs ?? new Set();
|
|
58
|
+
};
|
|
59
|
+
const answer = async (line) => {
|
|
60
|
+
let frame;
|
|
61
|
+
try {
|
|
62
|
+
frame = JSON.parse(line);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return rpcError(null, PARSE_ERROR, "Body is not valid JSON");
|
|
66
|
+
}
|
|
67
|
+
const id = frame.id ?? null;
|
|
68
|
+
const isNotification = frame.id === undefined || frame.id === null;
|
|
69
|
+
try {
|
|
70
|
+
if (frame.method === "tools/call" && options.defaultProjectId) {
|
|
71
|
+
const args = (frame.params?.["arguments"] ?? {});
|
|
72
|
+
const name = frame.params?.["name"];
|
|
73
|
+
if (!("projectId" in args) && typeof name === "string" && (await knownProjectVerbs()).has(name)) {
|
|
74
|
+
frame = { ...frame, params: { ...frame.params, arguments: { ...args, projectId: options.defaultProjectId } } };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const forwarded = await forward(frame);
|
|
78
|
+
if (forwarded.status === 401) {
|
|
79
|
+
return isNotification ? null : rpcError(id, RELAY_UNAUTHORIZED, "NuStack refused this CLI's credential — run `nustack login` in a terminal, then retry.");
|
|
80
|
+
}
|
|
81
|
+
if (isNotification || !forwarded.body)
|
|
82
|
+
return null;
|
|
83
|
+
if (frame.method === "tools/list") {
|
|
84
|
+
const result = forwarded.body.result;
|
|
85
|
+
if (result?.tools) {
|
|
86
|
+
remember(result.tools);
|
|
87
|
+
return { ...forwarded.body, result: { ...result, tools: rewriteToolList(result.tools, options.defaultProjectId) } };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return forwarded.body;
|
|
91
|
+
}
|
|
92
|
+
catch (cause) {
|
|
93
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
94
|
+
options.log(`[nustack mcp] ${frame.method ?? "frame"} failed: ${message}`);
|
|
95
|
+
return isNotification ? null : rpcError(id, INTERNAL_ERROR, `NuStack could not be reached: ${message}`);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const lines = createInterface({ input: options.input, crlfDelay: Number.POSITIVE_INFINITY });
|
|
99
|
+
for await (const line of lines) {
|
|
100
|
+
if (!line.trim())
|
|
101
|
+
continue;
|
|
102
|
+
const reply = await answer(line);
|
|
103
|
+
if (reply)
|
|
104
|
+
options.output.write(`${JSON.stringify(reply)}\n`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -4,7 +4,7 @@ import { DEVICE_HEADER, envToken, knownDeviceId, refreshAccessTokenAfterRefusal,
|
|
|
4
4
|
import { getOrCreateDeviceId } from "./device_identity.js";
|
|
5
5
|
const DEFAULT_NUSTACK_URL = "https://apps.nustack.ai";
|
|
6
6
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
7
|
-
async function authorizedFetch(input, init = {}) {
|
|
7
|
+
export async function authorizedFetch(input, init = {}) {
|
|
8
8
|
const headers = new Headers(init.headers);
|
|
9
9
|
headers.set(DEVICE_HEADER, knownDeviceId() ?? (await getOrCreateDeviceId()));
|
|
10
10
|
headers.set("User-Agent", userAgent());
|
|
@@ -24,7 +24,7 @@ function isStreamBody(body) {
|
|
|
24
24
|
return body != null && typeof body !== "string" && !(body instanceof ArrayBuffer) && !ArrayBuffer.isView(body) && !(body instanceof URLSearchParams);
|
|
25
25
|
}
|
|
26
26
|
const KEYS_PLANE = "the credential plane (/api/v1/keys)";
|
|
27
|
-
function errorMessage(payload, status, label, plane) {
|
|
27
|
+
export function errorMessage(payload, status, label, plane) {
|
|
28
28
|
if (plane && status === 404 && typeof payload?.error === "string") {
|
|
29
29
|
return `${label}: this NuStack deployment does not serve ${plane} — the server half of this command is not deployed, so it cannot succeed against this host.`;
|
|
30
30
|
}
|