@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.
@@ -0,0 +1,103 @@
1
+ import { ensureAuthWithRecovery } from "../lib/auth_recovery.js";
2
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
3
+ import { authorizedFetch, resolveNuStackUrl } from "../lib/nustack_client.js";
4
+ import { resolveProjectId } from "../lib/project_context.js";
5
+ import { doorUrlFor, runRelay } from "../lib/mcp/relay.js";
6
+ import { applyEdit, ClientConfigError, detectClients, MCP_CLIENTS, planEdit, targetFor } from "../lib/mcp/clients.js";
7
+ async function runServe(options, globals) {
8
+ const baseUrl = resolveNuStackUrl(globals);
9
+ const flag = options.project?.trim();
10
+ const bound = flag ? null : await resolveProjectId(process.cwd(), {});
11
+ const defaultProjectId = flag ?? (bound?.ok ? bound.projectId : null);
12
+ const doorUrl = doorUrlFor(baseUrl, process.env.NUSTACK_MCP_URL);
13
+ process.stderr.write(`nustack mcp: relaying to ${doorUrl}${defaultProjectId ? `, default project ${defaultProjectId}` : ""}\n`);
14
+ await runRelay({
15
+ doorUrl,
16
+ bearer: async () => (await ensureAuthWithRecovery(baseUrl, true))?.accessToken ?? null,
17
+ defaultProjectId,
18
+ fetch: (url, init) => authorizedFetch(url, { ...init, signal: AbortSignal.timeout(60_000) }),
19
+ input: process.stdin,
20
+ output: process.stdout,
21
+ log: (line) => process.stderr.write(`${line}\n`),
22
+ });
23
+ }
24
+ function chosenClients(raw) {
25
+ if (!raw) {
26
+ const found = detectClients();
27
+ return found.length > 0 ? found : ["claude"];
28
+ }
29
+ const names = raw.split(",").map((name) => name.trim()).filter(Boolean);
30
+ const unknown = names.filter((name) => !MCP_CLIENTS.includes(name));
31
+ if (unknown.length > 0)
32
+ return `Unknown client ${unknown.join(", ")}; choose from ${MCP_CLIENTS.join(", ")}.`;
33
+ return names;
34
+ }
35
+ async function runEdit(command, options) {
36
+ const json = options.json === true;
37
+ const fail = (code, message) => {
38
+ if (json)
39
+ printJsonError(command, { code, message });
40
+ else {
41
+ console.error(message);
42
+ process.exitCode = 1;
43
+ }
44
+ };
45
+ const scope = (options.scope ?? "user");
46
+ if (scope !== "user" && scope !== "project")
47
+ return fail("INVALID_ARGUMENT", "--scope takes user or project.");
48
+ const clients = chosenClients(options.client);
49
+ if (typeof clients === "string")
50
+ return fail("INVALID_ARGUMENT", clients);
51
+ const install = command === "mcp install";
52
+ const rows = [];
53
+ for (const client of clients) {
54
+ const target = targetFor(client, scope, process.cwd());
55
+ if (!target) {
56
+ rows.push({ client, scope, path: null, action: "skipped", reason: `${client} has no ${scope}-scope MCP configuration` });
57
+ continue;
58
+ }
59
+ try {
60
+ const planned = await planEdit(target, install);
61
+ if (!options.dryRun)
62
+ await applyEdit(target, planned);
63
+ rows.push({ client, scope, path: target.file, action: planned.action });
64
+ }
65
+ catch (error) {
66
+ if (!(error instanceof ClientConfigError))
67
+ throw error;
68
+ rows.push({ client, scope, path: target.file, action: "skipped", reason: error.message });
69
+ }
70
+ }
71
+ if (json) {
72
+ printJsonSuccess(command, { dryRun: options.dryRun === true, changes: rows });
73
+ return;
74
+ }
75
+ for (const row of rows) {
76
+ const verb = options.dryRun && (row.action === "added" || row.action === "updated" || row.action === "removed") ? `would be ${row.action}` : row.action;
77
+ console.log(`${row.client.padEnd(7)} ${verb.padEnd(16)} ${row.path ?? ""}${row.reason ? ` (${row.reason})` : ""}`);
78
+ }
79
+ if (install && !options.dryRun && rows.some((row) => row.action === "added" || row.action === "updated")) {
80
+ console.error("Restart the agent to pick up the nustack MCP server.");
81
+ }
82
+ }
83
+ export function registerMcpCommands(program, getGlobalOptions) {
84
+ const mcp = program.command("mcp").description("NuStack as an MCP server for coding agents");
85
+ mcp.command("serve")
86
+ .description("Relay an agent's MCP stdio to NuStack with this CLI's credential (launched by the agent, not by you)")
87
+ .option("--project <id>", "the Project a Project tool acts on when the agent names none (default: this folder's)")
88
+ .action(async (options) => runServe(options, getGlobalOptions()));
89
+ mcp.command("install")
90
+ .description("Register `nustack mcp serve` with Claude Code, Cursor, Codex or VS Code")
91
+ .option("--client <names>", `comma-separated: ${MCP_CLIENTS.join(", ")} (default: the clients found on this machine)`)
92
+ .option("--scope <scope>", "user (your home folder) or project (this folder's files)", "user")
93
+ .option("--dry-run", "print each file and the change, write nothing")
94
+ .option("--json", "emit the versioned machine envelope")
95
+ .action(async (options) => runEdit("mcp install", options));
96
+ mcp.command("uninstall")
97
+ .description("Remove the entry `nustack mcp install` wrote, leaving everything else as it was")
98
+ .option("--client <names>", `comma-separated: ${MCP_CLIENTS.join(", ")} (default: the clients found on this machine)`)
99
+ .option("--scope <scope>", "user or project", "user")
100
+ .option("--dry-run", "print each file and the change, write nothing")
101
+ .option("--json", "emit the versioned machine envelope")
102
+ .action(async (options) => runEdit("mcp uninstall", options));
103
+ }
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import * as p from "@clack/prompts";
4
4
  import { createProjectViaAsk, getProject, listProjects, listOrganizations, publishProject, resolveNuStackUrl, unpublishProject, } from "../lib/nustack_client.js";
5
- import { ensureAccessToken } from "../lib/auth_grant.js";
6
5
  import { ensureAuthWithRecovery, lastAuthFailure } from "../lib/auth_recovery.js";
7
6
  import { resolveProjectId, resolveOrganizationId } from "../lib/project_context.js";
8
7
  import { readActiveOrganizationContext } from "./organization.js";
@@ -16,6 +15,7 @@ import { resolveRepository } from "../lib/git-sync/core/repository.js";
16
15
  import { readProjectManifests } from "../lib/onboarding/manifest_writer.js";
17
16
  import { hasManifest, scaffoldManifest } from "../lib/manifest.js";
18
17
  import { toolkitCompatibility } from "../lib/etna/reconcile.js";
18
+ import { readMountStateSync } from "../lib/harness/mount.js";
19
19
  import { attachExistingGit } from "../lib/onboarding/attach_existing_git.js";
20
20
  import { webFirstAttach } from "../lib/onboarding/web_first_attach.js";
21
21
  import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
@@ -153,29 +153,20 @@ export async function runProjectAttach(options, globals) {
153
153
  store.close();
154
154
  }
155
155
  }
156
- export async function runProjectStatus(options, globals) {
157
- const json = options.json === true;
158
- const cwd = process.cwd();
159
- const project = await resolveProjectId(cwd, { project: options.project });
160
- if (!project.ok) {
161
- emitFail("project status", json, "PROJECT_CONTEXT_MISSING", project.message);
162
- return;
163
- }
164
- const baseUrl = resolveNuStackUrl(globals);
165
- const auth = await ensureAccessToken(baseUrl).catch(() => null);
156
+ export async function readProjectOverview(projectId, cwd, baseUrl, accessToken) {
166
157
  const store = await openLocalStore();
167
158
  let binding = null;
168
159
  try {
169
- binding = findBindingForProject(store, project.projectId, cwd);
160
+ binding = findBindingForProject(store, projectId, cwd);
170
161
  }
171
162
  finally {
172
163
  store.close();
173
164
  }
174
165
  let cloudProject = null;
175
166
  let cloud = null;
176
- if (auth) {
167
+ if (accessToken) {
177
168
  try {
178
- cloudProject = await getProject(baseUrl, auth.accessToken, project.projectId);
169
+ cloudProject = await getProject(baseUrl, accessToken, projectId);
179
170
  if (cloudProject.toolkit && cloudProject.toolkit.version && cloudProject.toolkit.rendererVersion && cloudProject.toolkit.placementMapVersion) {
180
171
  cloud = {
181
172
  name: cloudProject.toolkit.name,
@@ -183,6 +174,7 @@ export async function runProjectStatus(options, globals) {
183
174
  rendererVersion: cloudProject.toolkit.rendererVersion,
184
175
  placementMapVersion: cloudProject.toolkit.placementMapVersion,
185
176
  catalogVersionId: null,
177
+ harnessVersion: cloudProject.harness?.version ?? null,
186
178
  };
187
179
  }
188
180
  }
@@ -190,37 +182,28 @@ export async function runProjectStatus(options, globals) {
190
182
  cloudProject = null;
191
183
  }
192
184
  }
193
- const compatibility = toolkitCompatibility(binding ?? { etnaProfileJson: null }, cloud);
194
- if (json) {
195
- printJsonSuccess("project status", {
196
- projectId: project.projectId,
197
- cloudReachable: cloudProject !== null,
198
- cloud: cloudProject
199
- ? { label: cloudProject.label, status: cloudProject.status, publicationState: cloudProject.publicationState, initStatus: cloudProject.initStatus }
200
- : null,
201
- binding: binding
202
- ? {
203
- id: binding.id,
204
- boundBranch: binding.boundBranch,
205
- lastProcessedCommitId: binding.lastProcessedCommitId,
206
- lastAppliedProjectSequence: binding.lastAppliedProjectSequence,
207
- status: binding.status,
208
- }
209
- : null,
210
- toolkit: { compatible: compatibility.compatible, blocksWrites: compatibility.blocksWrites, reason: compatibility.reason },
211
- });
212
- return;
213
- }
214
- console.log(`Project ${project.projectId}`);
215
- console.log(` cloud: ${cloudProject ? `${cloudProject.label} — ${cloudProject.status} / ${cloudProject.publicationState}` : "(unreachable — local-only view)"}`);
216
- if (binding) {
217
- console.log(` binding: ${binding.id} on ${binding.boundBranch} (${binding.status})`);
218
- console.log(` cursor: processed=${binding.lastProcessedCommitId?.slice(0, 12) ?? "—"} applied@${binding.lastAppliedProjectSequence}`);
219
- }
220
- else {
221
- console.log(" binding: (this folder is not bound on this device)");
222
- }
223
- console.log(` toolkit: ${compatibility.blocksWrites ? `BLOCKED — ${compatibility.reason}` : "clear (projection and sync writes allowed)"}`);
185
+ const compatibility = toolkitCompatibility(readMountStateSync(binding?.repositoryRoot ?? cwd), cloud);
186
+ return { cloudProject, binding, compatibility };
187
+ }
188
+ export function projectOverviewJson(projectId, overview) {
189
+ const { cloudProject, binding, compatibility } = overview;
190
+ return {
191
+ projectId,
192
+ cloudReachable: cloudProject !== null,
193
+ cloud: cloudProject
194
+ ? { label: cloudProject.label, status: cloudProject.status, publicationState: cloudProject.publicationState, initStatus: cloudProject.initStatus }
195
+ : null,
196
+ binding: binding
197
+ ? {
198
+ id: binding.id,
199
+ boundBranch: binding.boundBranch,
200
+ lastProcessedCommitId: binding.lastProcessedCommitId,
201
+ lastAppliedProjectSequence: binding.lastAppliedProjectSequence,
202
+ status: binding.status,
203
+ }
204
+ : null,
205
+ toolkit: { compatible: compatibility.compatible, blocksWrites: compatibility.blocksWrites, reason: compatibility.reason },
206
+ };
224
207
  }
225
208
  export async function runProjectOpen(options, globals) {
226
209
  const json = options.json === true;
@@ -452,12 +435,6 @@ export function registerProjectCommands(projectCommand, getGlobalOptions) {
452
435
  .option("--yes", "confirm populated-folder onboarding and rebind")
453
436
  .option("--json", "emit the versioned machine envelope")
454
437
  .action(async (options) => runProjectAttach(options, getGlobalOptions()));
455
- projectCommand
456
- .command("status")
457
- .description("Show the cloud Project summary merged with the local binding (degraded offline)")
458
- .option("--project <id>", "the Project to act as (defaults to the bound repository)")
459
- .option("--json", "emit the versioned machine envelope")
460
- .action(async (options) => runProjectStatus(options, getGlobalOptions()));
461
438
  projectCommand
462
439
  .command("open")
463
440
  .description("Open the Project in the browser (--json prints the URL instead of launching)")
@@ -0,0 +1,150 @@
1
+ import { existsSync } from "node:fs";
2
+ import { openLocalStore } from "../lib/local_store/database.js";
3
+ import { stateDbPath } from "../lib/local_store/paths.js";
4
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
5
+ import { RECORDS_BATCH_CAP, drainOutbox, drainStore, emitRecords, isRecordKind, listOutbox, listStored, resolveDoor, } from "../lib/records/door.js";
6
+ async function readStdin() {
7
+ if (process.stdin.isTTY)
8
+ return "";
9
+ const chunks = [];
10
+ for await (const chunk of process.stdin)
11
+ chunks.push(chunk);
12
+ return Buffer.concat(chunks).toString("utf8");
13
+ }
14
+ function parseRows(raw) {
15
+ let parsed;
16
+ try {
17
+ parsed = JSON.parse(raw);
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ const list = Array.isArray(parsed) ? parsed : [parsed];
23
+ const rows = list.filter((row) => !!row && typeof row === "object" && typeof row.anchor === "string" && row.anchor.trim() !== "");
24
+ return rows.length === list.length ? rows : null;
25
+ }
26
+ function emitLine(outcome) {
27
+ if (outcome.rung === 1)
28
+ return `records emit: landed ${outcome.landed} on NuStack (${outcome.projectId})\n`;
29
+ if (outcome.rung === 2)
30
+ return `records emit: the door refused (${outcome.error}); ${outcome.keys.length} row(s) wait in the outbox\n`;
31
+ return `records emit: ${outcome.reason}; ${outcome.keys.length} row(s) kept in the local store\n`;
32
+ }
33
+ export async function runRecordsEmit(options, globals) {
34
+ const json = options.json === true;
35
+ const refuse = (message) => {
36
+ if (json)
37
+ printJsonError("records emit", { code: "VALIDATION_FAILED", message });
38
+ else {
39
+ process.stderr.write(`records emit: ${message}\n`);
40
+ process.exitCode = 1;
41
+ }
42
+ };
43
+ if (!isRecordKind(options.kind))
44
+ return refuse("--kind must be backlog, backlog.remove, bookkeeping or gate_event");
45
+ const rows = parseRows(options.row ?? (await readStdin()));
46
+ if (!rows || rows.length === 0)
47
+ return refuse("the rows must be JSON — one object or an array — each with an anchor");
48
+ const root = options.root ?? process.cwd();
49
+ const resolution = await resolveDoor(root, globals, options.project);
50
+ const store = await openLocalStore();
51
+ try {
52
+ const outcomes = [];
53
+ for (let start = 0; start < rows.length; start += RECORDS_BATCH_CAP) {
54
+ outcomes.push(await emitRecords(store, resolution, options.kind, rows.slice(start, start + RECORDS_BATCH_CAP)));
55
+ }
56
+ if (outcomes.every((outcome) => outcome.rung === 1))
57
+ await drainOutbox(store, globals, { dueOnly: true });
58
+ const last = outcomes[outcomes.length - 1];
59
+ const keys = outcomes.flatMap((outcome) => outcome.keys);
60
+ if (json)
61
+ printJsonSuccess("records emit", { ...last, keys });
62
+ else
63
+ process.stdout.write(emitLine({ ...last, keys }));
64
+ }
65
+ finally {
66
+ store.close();
67
+ }
68
+ }
69
+ export async function runRecordsSync(options, globals) {
70
+ const store = await openLocalStore();
71
+ try {
72
+ const outbox = await drainOutbox(store, globals);
73
+ const stored = await drainStore(store, globals);
74
+ const result = {
75
+ sent: outbox.sent + stored.sent,
76
+ waiting: listOutbox(store).length,
77
+ stored: listStored(store).length,
78
+ lastError: outbox.lastError ?? stored.lastError,
79
+ };
80
+ if (options.json)
81
+ printJsonSuccess("records sync", result);
82
+ else
83
+ process.stdout.write(`records sync: sent ${result.sent}; ${result.waiting} wait in the outbox, ${result.stored} kept locally\n`);
84
+ }
85
+ finally {
86
+ store.close();
87
+ }
88
+ }
89
+ export async function runRecordsList(options) {
90
+ const store = await openLocalStore();
91
+ try {
92
+ const outbox = listOutbox(store, options.project);
93
+ const stored = listStored(store, options.project ? { projectId: options.project } : {});
94
+ const newest = [...outbox].sort((a, b) => b.createdAtMs - a.createdAtMs).find((row) => row.lastError !== null);
95
+ const data = {
96
+ outboxCount: outbox.length,
97
+ storeCount: stored.length,
98
+ lastError: newest?.lastError ?? null,
99
+ outbox,
100
+ store: stored,
101
+ };
102
+ if (options.json)
103
+ printJsonSuccess("records list", data);
104
+ else {
105
+ process.stdout.write(`records list: ${data.outboxCount} in the outbox, ${data.storeCount} kept locally\n`);
106
+ if (data.lastError)
107
+ process.stdout.write(` last error: ${data.lastError}\n`);
108
+ }
109
+ }
110
+ finally {
111
+ store.close();
112
+ }
113
+ }
114
+ export async function drainBeforeCommand(globals) {
115
+ if (!existsSync(stateDbPath()))
116
+ return;
117
+ try {
118
+ const store = await openLocalStore();
119
+ try {
120
+ await drainOutbox(store, globals, { dueOnly: true, limit: 20 });
121
+ }
122
+ finally {
123
+ store.close();
124
+ }
125
+ }
126
+ catch {
127
+ }
128
+ }
129
+ export function registerRecordsCommands(recordsCommand, getGlobalOptions) {
130
+ recordsCommand
131
+ .command("emit")
132
+ .description("Land records on NuStack, else park them in the outbox, else keep them locally")
133
+ .option("--kind <kind>", "backlog, backlog.remove, bookkeeping or gate_event")
134
+ .option("--row <json>", "the row, or an array of rows, as JSON (default: read from stdin)")
135
+ .option("--root <dir>", "the folder the records are about (default: the current directory)")
136
+ .option("--project <id>", "the Project, when the folder carries no binding marker")
137
+ .option("--json", "emit the versioned machine envelope")
138
+ .action(async (options) => runRecordsEmit(options, getGlobalOptions()));
139
+ recordsCommand
140
+ .command("sync")
141
+ .description("Send every outbox row, and stored rows whose folder now has a door")
142
+ .option("--json", "emit the versioned machine envelope")
143
+ .action(async (options) => runRecordsSync(options, getGlobalOptions()));
144
+ recordsCommand
145
+ .command("list")
146
+ .description("Show the rows that have not reached NuStack: the outbox and the local store")
147
+ .option("--project <id>", "only this Project's rows")
148
+ .option("--json", "emit the versioned machine envelope")
149
+ .action(async (options) => runRecordsList(options));
150
+ }
@@ -9,6 +9,7 @@ import { resolveRepository } from "../lib/git-sync/core/repository.js";
9
9
  import { readProjectManifests, diagnoseManifests } from "../lib/onboarding/manifest_writer.js";
10
10
  import { fetchToolkitProfile, ToolkitProfileUnavailableError } from "../lib/etna/profile.js";
11
11
  import { buildReconcilePlan, applyReconcile, toolkitCompatibility } from "../lib/etna/reconcile.js";
12
+ import { readMountStateSync } from "../lib/harness/mount.js";
12
13
  import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
13
14
  function emitFail(command, json, code, message) {
14
15
  if (json)
@@ -59,8 +60,7 @@ export async function runToolkitStatus(options, globals) {
59
60
  }
60
61
  const manifests = await readProjectManifests(ctx.root);
61
62
  const diagnosis = diagnoseManifests({ name: cloud.name, version: cloud.version }, manifests.projectManifest?.toolkit ?? null, manifests.serviceToolkit);
62
- const binding = await withStore((store) => getBindingByRepositoryRoot(store, ctx.root));
63
- const compatibility = toolkitCompatibility(binding ?? { etnaProfileJson: null }, cloud);
63
+ const compatibility = toolkitCompatibility(readMountStateSync(ctx.root), cloud);
64
64
  if (json) {
65
65
  printJsonSuccess("toolkit status", {
66
66
  projectId: ctx.projectId,
@@ -0,0 +1,79 @@
1
+ import { envToken, knownDeviceId, PERSONAL_TOKEN_PREFIX } from "../lib/auth_grant.js";
2
+ import { ensureAuthWithRecovery, lastAuthFailure } from "../lib/auth_recovery.js";
3
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
4
+ import { authorizedFetch, errorMessage, resolveNuStackUrl } from "../lib/nustack_client.js";
5
+ import { readActiveOrganizationContext } from "./organization.js";
6
+ function emitFail(json, code, message) {
7
+ if (json)
8
+ printJsonError("whoami", { code, message });
9
+ else {
10
+ console.error(message);
11
+ process.exitCode = 1;
12
+ }
13
+ }
14
+ function localSource() {
15
+ const fromEnv = envToken();
16
+ if (fromEnv?.kind === "ok") {
17
+ const id = fromEnv.token.slice(PERSONAL_TOKEN_PREFIX.length).split("_")[0] ?? "";
18
+ return { kind: "env_token", label: `NUSTACK_TOKEN (${PERSONAL_TOKEN_PREFIX}${id.slice(0, 8)}…)` };
19
+ }
20
+ const device = knownDeviceId();
21
+ return { kind: "device", label: device ? `device sign-in (device ${device.slice(0, 8)}…)` : "device sign-in" };
22
+ }
23
+ export async function runWhoami(options, globals) {
24
+ const json = options.json === true;
25
+ const baseUrl = resolveNuStackUrl(globals);
26
+ const auth = await ensureAuthWithRecovery(baseUrl, json);
27
+ if (!auth) {
28
+ const failure = lastAuthFailure();
29
+ emitFail(json, failure.code, failure.message);
30
+ return;
31
+ }
32
+ let answer;
33
+ try {
34
+ const response = await authorizedFetch(`${baseUrl}/api/v2/auth/session`, {
35
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
36
+ signal: AbortSignal.timeout(10_000),
37
+ });
38
+ const payload = (await response.json().catch(() => null));
39
+ if (response.status === 401) {
40
+ emitFail(json, "SIGNED_OUT", "NuStack does not recognise this credential — run `nustack login`, or mint a new token with `nustack token create`.");
41
+ return;
42
+ }
43
+ if (!response.ok || !payload?.data) {
44
+ emitFail(json, payload?.error?.code ?? "REQUEST_FAILED", errorMessage(payload, response.status, "whoami failed"));
45
+ return;
46
+ }
47
+ answer = payload.data;
48
+ }
49
+ catch (error) {
50
+ emitFail(json, "REQUEST_FAILED", error instanceof Error ? error.message : String(error));
51
+ return;
52
+ }
53
+ const source = localSource();
54
+ const localOrganization = await readActiveOrganizationContext().catch(() => null);
55
+ const identity = answer.user ? answer.user.email : answer.principal.label;
56
+ if (json) {
57
+ printJsonSuccess("whoami", { ...answer, source: source.kind, localOrganizationId: localOrganization, baseUrl });
58
+ return;
59
+ }
60
+ console.log(identity);
61
+ console.error(` via ${source.label}${answer.via ? ` · the server saw ${answer.via}` : ""}`);
62
+ if (answer.user === null)
63
+ console.error(` principal ${answer.principal.id} · role ${answer.role}`);
64
+ const organization = answer.organizationId ?? "none";
65
+ if (localOrganization && answer.organizationId && localOrganization !== answer.organizationId) {
66
+ console.error(` org ${organization} on the server — this machine's active Organization differs: ${localOrganization}`);
67
+ }
68
+ else {
69
+ console.error(` org ${organization}${localOrganization ? " (active here too)" : ""}`);
70
+ }
71
+ console.error(` base URL ${baseUrl}`);
72
+ }
73
+ export function registerWhoamiCommand(program, getGlobalOptions) {
74
+ program
75
+ .command("whoami")
76
+ .description("Who NuStack says this credential is, how it arrived, and the Organization it resolves")
77
+ .option("--json", "emit the versioned machine envelope")
78
+ .action(async (options) => runWhoami(options, getGlobalOptions()));
79
+ }
package/dist/index.js CHANGED
@@ -20,12 +20,20 @@ import { runSearch, runSearchServices } from "./commands/search.js";
20
20
  import { runDeviceList, runDeviceRevoke, runDeviceStatus } from "./commands/device.js";
21
21
  import { registerProjectSyncCommands } from "./commands/project-sync.js";
22
22
  import { registerProjectCommands } from "./commands/project.js";
23
+ import { registerDeployCommands, registerLogsCommand, registerProjectSetCommand } from "./commands/deploy.js";
24
+ import { registerActivityCommand } from "./commands/activity.js";
25
+ import { registerWhoamiCommand } from "./commands/whoami.js";
26
+ import { registerApiCommand } from "./commands/api.js";
27
+ import { registerMcpCommands } from "./commands/mcp.js";
28
+ import { NON_INTERACTIVE_VARIABLE } from "./lib/interactive.js";
23
29
  import { runOrganizationList, runOrganizationCreate, runOrganizationUse } from "./commands/organization.js";
24
30
  import { runDocsList, runDocsShow, runDocsStatus } from "./commands/docs.js";
25
31
  import { runHandoffList, runHandoffShow } from "./commands/handoff.js";
26
32
  import { runToolkitStatus, runToolkitReconcile } from "./commands/toolkit.js";
33
+ import { runHarnessMount, runHarnessStatus } from "./commands/harness.js";
27
34
  import { runSessionsList, runSessionsPrune, runSessionsRecord, runSessionsServe, } from "./commands/sessions.js";
28
35
  import { registerReviewCommands } from "./commands/review.js";
36
+ import { drainBeforeCommand, registerRecordsCommands } from "./commands/records.js";
29
37
  import { runAgentContext, runAgentHookInstall, runAgentHookRemove } from "./commands/agent.js";
30
38
  import { printJsonError } from "./lib/json_output.js";
31
39
  import { setCliVersion } from "./lib/auth_grant.js";
@@ -37,6 +45,7 @@ program
37
45
  .description("Bootstrap NuStack services into your repo via the NuStack discovery plane.")
38
46
  .version(version)
39
47
  .option("--nustack-url <url>", "NuStack base URL (defaults to $NUSTACK_URL, then the hosted instance)")
48
+ .option("--non-interactive", `never prompt — take the default or refuse with a coded error (also $${NON_INTERACTIVE_VARIABLE}=1)`)
40
49
  .action(async () => runBootstrap(program.opts()));
41
50
  program.addHelpText("after", "\nRun `nustack` with no command to bootstrap: it checks your sign-in, runs login if needed, then init.");
42
51
  program
@@ -122,6 +131,11 @@ program
122
131
  .option("--json", "emit the versioned machine envelope (secrets and absolute paths redacted)")
123
132
  .option("--local-verbose", "include Device-local absolute paths in the output")
124
133
  .action(async (options) => runDoctor({ ...program.opts(), ...options }));
134
+ registerDeployCommands(program, () => program.opts());
135
+ registerLogsCommand(program, () => program.opts());
136
+ registerWhoamiCommand(program, () => program.opts());
137
+ registerApiCommand(program, () => program.opts());
138
+ registerMcpCommands(program, () => program.opts());
125
139
  program
126
140
  .command("open <service>")
127
141
  .description("Open the service's dev endpoint (or repository) in the browser")
@@ -214,8 +228,17 @@ organization
214
228
  .action(async (target, options) => runOrganizationUse(target, options, program.opts()));
215
229
  const project = program.command("project").description("Work with NuStack Projects");
216
230
  registerProjectCommands(project, () => program.opts());
231
+ registerProjectSetCommand(project, () => program.opts());
232
+ registerActivityCommand(project, () => program.opts());
217
233
  registerProjectSyncCommands(project, () => program.opts());
218
234
  registerReviewCommands(program.command("review").description("Read and mirror NuStack Review items"), () => program.opts());
235
+ registerRecordsCommands(program.command("records").description("Emit, send and list harness records"), () => program.opts());
236
+ program.hook("preAction", async (_program, action) => {
237
+ if (program.opts().nonInteractive)
238
+ process.env[NON_INTERACTIVE_VARIABLE] = "1";
239
+ if (action.parent?.name() !== "records")
240
+ await drainBeforeCommand(program.opts());
241
+ });
219
242
  const docs = program.command("docs").description("Work with a Project's Documents");
220
243
  docs
221
244
  .command("list")
@@ -275,6 +298,20 @@ sessions
275
298
  .option("--force", "also reclaim an unmerged or dirty session — never one whose engine is running")
276
299
  .option("--json", "emit the versioned machine envelope")
277
300
  .action(async (options) => runSessionsPrune(options));
301
+ const harness = program.command("harness").description("Mount the Project's harness and compare it with the cloud pin");
302
+ harness
303
+ .command("mount [path]")
304
+ .description("Mount the resolved harness version into a checkout (default: the current repository)")
305
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
306
+ .option("--manifest <url>", "mount this manifest instead of the one the Project resolves")
307
+ .option("--json", "emit the versioned machine envelope")
308
+ .action(async (target, options) => runHarnessMount(target, options, program.opts()));
309
+ harness
310
+ .command("status")
311
+ .description("Print the mounted harness against the cloud pin; exits 1 when they differ")
312
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
313
+ .option("--json", "emit the versioned machine envelope")
314
+ .action(async (options) => runHarnessStatus(options, program.opts()));
278
315
  const toolkit = program.command("toolkit").description("Inspect and reconcile the Project's toolkit pin");
279
316
  toolkit
280
317
  .command("status")
@@ -2,6 +2,7 @@ import { detectPackageManager, installPackage } from "../package_manager.js";
2
2
  import { assertSafePackageName } from "../guards.js";
3
3
  import { updateBindingEtnaProfile } from "../local_store/bindings.js";
4
4
  import { serializeProfileForBinding } from "./profile.js";
5
+ import { mountHarness } from "../harness/mount.js";
5
6
  export function toolkitPackageName(profile) {
6
7
  if (!profile.name.startsWith("@"))
7
8
  return null;
@@ -15,7 +16,17 @@ export function toolkitPackageName(profile) {
15
16
  export async function installToolkit(store, bindingId, profile, cwd, deps = {}) {
16
17
  const packageName = toolkitPackageName(profile);
17
18
  let result;
18
- if (!packageName) {
19
+ const manifestUrl = deps.manifestUrl ?? profile.manifestUrl ?? null;
20
+ if (manifestUrl) {
21
+ try {
22
+ await (deps.mount ?? mountHarness)(cwd, manifestUrl);
23
+ result = { ok: true, installed: true, skipped: false, reason: "mounted" };
24
+ }
25
+ catch (error) {
26
+ result = { ok: false, installed: false, skipped: false, reason: error instanceof Error ? error.message : String(error) };
27
+ }
28
+ }
29
+ else if (!packageName) {
19
30
  result = { ok: true, installed: false, skipped: true, reason: "device_level_toolkit" };
20
31
  }
21
32
  else {
@@ -19,6 +19,8 @@ export async function fetchToolkitProfile(baseUrl, accessToken, projectId) {
19
19
  rendererVersion: typeof toolkit.rendererVersion === "string" ? toolkit.rendererVersion : "",
20
20
  placementMapVersion: typeof toolkit.placementMapVersion === "string" ? toolkit.placementMapVersion : "",
21
21
  catalogVersionId: null,
22
+ harnessVersion: project.harness?.version ?? null,
23
+ manifestUrl: project.harness?.manifestUrl ?? null,
22
24
  };
23
25
  }
24
26
  export function toolkitProfileFromManifest(manifest) {
@@ -19,18 +19,12 @@ export function parseRecordedToolkit(binding) {
19
19
  return null;
20
20
  }
21
21
  }
22
- export function toolkitCompatibility(binding, cloud) {
23
- const recorded = parseRecordedToolkit(binding);
24
- if (!recorded || !cloud)
22
+ export function toolkitCompatibility(mounted, cloud) {
23
+ if (!mounted || !cloud)
25
24
  return { compatible: true, blocksWrites: false, reason: null };
26
- if (recorded.name !== cloud.name) {
27
- return { compatible: false, blocksWrites: true, reason: `toolkit name diverges (local ${recorded.name} vs cloud ${cloud.name})` };
28
- }
29
- if (recorded.version !== cloud.version) {
30
- return { compatible: false, blocksWrites: true, reason: `toolkit version diverges (local ${recorded.version} vs cloud ${cloud.version})` };
31
- }
32
- if (cloud.rendererVersion && recorded.rendererVersion && recorded.rendererVersion !== cloud.rendererVersion) {
33
- return { compatible: false, blocksWrites: true, reason: `renderer version diverges (local ${recorded.rendererVersion} vs cloud ${cloud.rendererVersion})` };
25
+ const pinned = cloud.harnessVersion ?? cloud.version;
26
+ if (mounted.version !== pinned) {
27
+ return { compatible: false, blocksWrites: true, reason: `mounted harness ${mounted.version} differs from the cloud pin ${pinned}` };
34
28
  }
35
29
  return { compatible: true, blocksWrites: false, reason: null };
36
30
  }
@@ -90,6 +90,18 @@ export function assertSafeOpaqueId(label, raw) {
90
90
  }
91
91
  return raw;
92
92
  }
93
+ export function assertSafeApiPath(origin, raw) {
94
+ assertNoControlChars("The API path", raw);
95
+ if (!raw.startsWith("/") || raw.startsWith("//") || /^\/*[a-z][a-z0-9+.-]*:/i.test(raw) || raw.includes("\\")) {
96
+ throw new Error(`The API path must be a path on the NuStack origin, starting with a single "/" (got ${raw})`);
97
+ }
98
+ const base = new URL(origin);
99
+ const joined = new URL(raw, base);
100
+ if (joined.protocol !== base.protocol || joined.host !== base.host) {
101
+ throw new Error(`The API path leaves the NuStack origin (${raw})`);
102
+ }
103
+ return joined;
104
+ }
93
105
  export function assertSafeBrowserUrl(raw) {
94
106
  assertNoControlChars("The approval URL", raw);
95
107
  let url;