@nurix/nustack 0.19.1 → 0.19.3

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,47 @@
1
+ import { listActivity } from "../lib/hosting/logs.js";
2
+ import { printJsonSuccess } from "../lib/json_output.js";
3
+ import { emitError, emitFail, resolveTarget } from "./deploy.js";
4
+ function renderEvent(event) {
5
+ const who = event.actorLabel ?? event.actorType ?? "—";
6
+ const what = event.entityLabel ? `${event.eventType} · ${event.entityLabel}` : event.eventType;
7
+ return `${event.createdAt} ${event.channel.padEnd(7)} ${who} ${what}`;
8
+ }
9
+ export async function runProjectActivity(options, globals) {
10
+ const command = "project activity";
11
+ const json = options.json === true;
12
+ const limit = options.limit === undefined ? undefined : Number(options.limit);
13
+ if (limit !== undefined && !(Number.isInteger(limit) && limit > 0)) {
14
+ emitFail(command, json, "INVALID_ARGUMENT", "--limit takes a positive whole number.");
15
+ return;
16
+ }
17
+ const target = await resolveTarget(command, options, globals);
18
+ if (!target)
19
+ return;
20
+ try {
21
+ const page = await listActivity(target, { cursor: options.cursor, limit, action: options.action });
22
+ if (json) {
23
+ printJsonSuccess(command, { projectId: target.projectId, items: page.items, nextCursor: page.nextCursor });
24
+ return;
25
+ }
26
+ if (page.items.length === 0)
27
+ console.error("No activity on this page.");
28
+ for (const event of page.items)
29
+ console.log(renderEvent(event));
30
+ if (page.nextCursor)
31
+ console.error(`more: nustack project activity --cursor ${page.nextCursor}`);
32
+ }
33
+ catch (error) {
34
+ emitError(command, json, error);
35
+ }
36
+ }
37
+ export function registerActivityCommand(projectCommand, getGlobalOptions) {
38
+ projectCommand
39
+ .command("activity")
40
+ .description("Who did what to the Project, on which channel — newest first, one page at a time")
41
+ .option("--project <id>", "the Project to read (defaults to the bound repository)")
42
+ .option("--limit <n>", "rows per page (default 50, at most 200)")
43
+ .option("--action <action>", "one action, or a prefix ending in * (hosting.*)")
44
+ .option("--cursor <cursor>", "the page after the one that printed this cursor")
45
+ .option("--json", "emit the versioned machine envelope")
46
+ .action(async (options) => runProjectActivity(options, getGlobalOptions()));
47
+ }
@@ -0,0 +1,106 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { ensureAuthWithRecovery, lastAuthFailure } from "../lib/auth_recovery.js";
3
+ import { assertSafeApiPath } from "../lib/guards.js";
4
+ import { authorizedFetch, resolveNuStackUrl } from "../lib/nustack_client.js";
5
+ function collect(value, previous = []) {
6
+ return [...previous, value];
7
+ }
8
+ function typedValue(raw) {
9
+ if (raw === "true")
10
+ return true;
11
+ if (raw === "false")
12
+ return false;
13
+ if (raw === "null")
14
+ return null;
15
+ if (/^-?\d+(\.\d+)?$/.test(raw))
16
+ return Number(raw);
17
+ return raw;
18
+ }
19
+ function splitPair(raw, flag) {
20
+ const at = raw.indexOf("=");
21
+ if (at <= 0)
22
+ throw new Error(`${flag} takes key=value (got ${raw})`);
23
+ return [raw.slice(0, at), raw.slice(at + 1)];
24
+ }
25
+ async function readStdin() {
26
+ const chunks = [];
27
+ for await (const chunk of process.stdin)
28
+ chunks.push(chunk);
29
+ return Buffer.concat(chunks).toString("utf8");
30
+ }
31
+ export async function runApi(path, options, globals) {
32
+ const baseUrl = resolveNuStackUrl(globals);
33
+ let url;
34
+ let body;
35
+ const headers = new Headers();
36
+ try {
37
+ url = assertSafeApiPath(baseUrl, path);
38
+ const fields = {};
39
+ for (const pair of options.field ?? []) {
40
+ const [key, value] = splitPair(pair, "-f");
41
+ fields[key] = value;
42
+ }
43
+ for (const pair of options.rawField ?? []) {
44
+ const [key, value] = splitPair(pair, "-F");
45
+ fields[key] = typedValue(value);
46
+ }
47
+ if (options.input !== undefined) {
48
+ if (Object.keys(fields).length > 0)
49
+ throw new Error("--input and -f/-F are two ways to send a body — use one.");
50
+ body = options.input === "-" ? await readStdin() : await readFile(options.input, "utf8");
51
+ }
52
+ else if (Object.keys(fields).length > 0) {
53
+ body = JSON.stringify(fields);
54
+ }
55
+ for (const raw of options.header ?? []) {
56
+ const at = raw.indexOf(":");
57
+ if (at <= 0)
58
+ throw new Error(`-H takes 'Name: value' (got ${raw})`);
59
+ headers.set(raw.slice(0, at).trim(), raw.slice(at + 1).trim());
60
+ }
61
+ }
62
+ catch (error) {
63
+ console.error(error instanceof Error ? error.message : String(error));
64
+ process.exitCode = 1;
65
+ return;
66
+ }
67
+ if (body !== undefined && !headers.has("Content-Type"))
68
+ headers.set("Content-Type", "application/json");
69
+ const auth = await ensureAuthWithRecovery(baseUrl, true);
70
+ if (!auth) {
71
+ console.error(lastAuthFailure().message);
72
+ process.exitCode = 1;
73
+ return;
74
+ }
75
+ headers.set("Authorization", `Bearer ${auth.accessToken}`);
76
+ const method = (options.method ?? (body === undefined ? "GET" : "POST")).toUpperCase();
77
+ try {
78
+ const response = await authorizedFetch(url, { method, headers, body, signal: AbortSignal.timeout(30_000) });
79
+ if (options.include) {
80
+ console.error(`HTTP ${response.status} ${response.statusText}`);
81
+ response.headers.forEach((value, name) => console.error(`${name}: ${value}`));
82
+ console.error("");
83
+ }
84
+ const text = await response.text();
85
+ if (text)
86
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
87
+ if (!response.ok)
88
+ process.exitCode = 1;
89
+ }
90
+ catch (error) {
91
+ console.error(error instanceof Error ? error.message : String(error));
92
+ process.exitCode = 1;
93
+ }
94
+ }
95
+ export function registerApiCommand(program, getGlobalOptions) {
96
+ program
97
+ .command("api <path>")
98
+ .description("Call a NuStack route with this CLI's credential and print the body verbatim (like `gh api`)")
99
+ .option("-X, --method <method>", "the HTTP method (GET, or POST when a body is given)")
100
+ .option("-f, --field <key=value>", "a string field of the JSON body (repeatable)", collect)
101
+ .option("-F, --raw-field <key=value>", "a typed field — numbers, true, false, null (repeatable)", collect)
102
+ .option("--input <file>", "send a file as the body, or - for stdin")
103
+ .option("-H, --header <header>", "an extra request header, 'Name: value' (repeatable)", collect)
104
+ .option("-i, --include", "print the status line and response headers on stderr")
105
+ .action(async (path, options) => runApi(path, options, getGlobalOptions()));
106
+ }
@@ -45,7 +45,7 @@ async function printBoundStatus(root) {
45
45
  p.log.success(`This repository is bound to Project ${binding.projectId} on branch ${binding.boundBranch}.`);
46
46
  p.log.info(`Binding ${binding.id} · ${binding.status} · applied Project sequence ${binding.lastAppliedProjectSequence}` +
47
47
  (pending.length > 0 ? ` · ${pending.length} pending inbound` : ""));
48
- p.outro("Up to date locally — `nustack project status` for the cloud view, `nustack project sync now` to reconcile.");
48
+ p.outro("Up to date locally — `nustack status` for the cloud view, `nustack project sync now` to reconcile.");
49
49
  return true;
50
50
  }
51
51
  finally {
@@ -0,0 +1,372 @@
1
+ import { ensureAccessToken } from "../lib/auth_grant.js";
2
+ import { ensureAuthWithRecovery, lastAuthFailure } from "../lib/auth_recovery.js";
3
+ import { getHostingStatus, HostingRequestError, listVersions, rollbackHosting, setKeepWarm, startDeploy, watchDeploy, } from "../lib/hosting/client.js";
4
+ import { followRuntimeLogs, getBuildLog, getRuntimeLogs, renderBuildLog, renderRuntimeLine, stateNotice } from "../lib/hosting/logs.js";
5
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
6
+ import { resolveNuStackUrl } from "../lib/nustack_client.js";
7
+ import { resolveProjectId } from "../lib/project_context.js";
8
+ import { projectOverviewJson, readProjectOverview } from "./project.js";
9
+ const DEFAULT_TIMEOUT_MINUTES = 15;
10
+ export function emitFail(command, json, code, message) {
11
+ if (json)
12
+ printJsonError(command, { code, message });
13
+ else {
14
+ console.error(message);
15
+ process.exitCode = 1;
16
+ }
17
+ }
18
+ export function emitError(command, json, error) {
19
+ if (error instanceof HostingRequestError)
20
+ emitFail(command, json, error.code, error.message);
21
+ else
22
+ emitFail(command, json, "REQUEST_FAILED", error instanceof Error ? error.message : String(error));
23
+ }
24
+ export async function resolveTarget(command, options, globals) {
25
+ const json = options.json === true;
26
+ const project = await resolveProjectId(process.cwd(), { project: options.project });
27
+ if (!project.ok) {
28
+ emitFail(command, json, "PROJECT_CONTEXT_MISSING", project.message);
29
+ return null;
30
+ }
31
+ const baseUrl = resolveNuStackUrl(globals);
32
+ const auth = await ensureAuthWithRecovery(baseUrl, json);
33
+ if (!auth) {
34
+ const failure = lastAuthFailure();
35
+ emitFail(command, json, failure.code, failure.message);
36
+ return null;
37
+ }
38
+ return { baseUrl, accessToken: auth.accessToken, projectId: project.projectId };
39
+ }
40
+ export function describeAge(iso, now = Date.now()) {
41
+ const seconds = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
42
+ const steps = [
43
+ [86_400, "day"],
44
+ [3_600, "hour"],
45
+ [60, "minute"],
46
+ ];
47
+ for (const [size, unit] of steps) {
48
+ if (seconds >= size) {
49
+ const count = Math.floor(seconds / size);
50
+ return `${count} ${unit}${count === 1 ? "" : "s"} ago`;
51
+ }
52
+ }
53
+ return "just now";
54
+ }
55
+ function sourceLine(status) {
56
+ const age = status.settledAt ? `, settled ${describeAge(status.settledAt)}` : "";
57
+ return `deploying Studio's tree${age} — not this folder`;
58
+ }
59
+ const RED_TAIL_LINES = 40;
60
+ function explainRed(outcome, failingTail) {
61
+ const lines = [outcome.failedClause ? `v${outcome.version} failed at \`${outcome.failedClause}\`` : `v${outcome.version} failed`];
62
+ if (failingTail) {
63
+ lines.push(failingTail.split("\n").slice(-RED_TAIL_LINES).join("\n"));
64
+ lines.push(`(the whole clause: nustack logs --build ${outcome.version})`);
65
+ }
66
+ else if (outcome.summary)
67
+ lines.push(outcome.summary);
68
+ if (outcome.redrive === "dispatched")
69
+ lines.push("Studio opened a coding round to fix this.");
70
+ return lines.join("\n");
71
+ }
72
+ async function failingTail(target, outcome) {
73
+ try {
74
+ const log = await getBuildLog(target, outcome.version);
75
+ const step = log.steps.find((candidate) => candidate.clause === outcome.failedClause) ?? log.steps.find((candidate) => candidate.state === "failed");
76
+ return step?.tail || null;
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ function parseTimeoutMs(raw) {
83
+ if (raw === undefined)
84
+ return DEFAULT_TIMEOUT_MINUTES * 60_000;
85
+ const minutes = Number(raw);
86
+ return Number.isFinite(minutes) && minutes > 0 ? minutes * 60_000 : null;
87
+ }
88
+ export async function runDeploy(options, globals) {
89
+ const command = "deploy";
90
+ const json = options.json === true;
91
+ const timeoutMs = parseTimeoutMs(options.timeout);
92
+ if (timeoutMs === null) {
93
+ emitFail(command, json, "INVALID_ARGUMENT", `--timeout takes a positive number of minutes (got ${options.timeout}).`);
94
+ return;
95
+ }
96
+ const target = await resolveTarget(command, options, globals);
97
+ if (!target)
98
+ return;
99
+ try {
100
+ console.error(sourceLine(await getHostingStatus(target)));
101
+ const started = await startDeploy(target);
102
+ if (options.wait === false) {
103
+ if (json)
104
+ printJsonSuccess(command, { projectId: target.projectId, version: started.version, state: "building" });
105
+ else
106
+ console.log(String(started.version));
107
+ return;
108
+ }
109
+ const watched = await watchDeploy(target, started.version, {
110
+ timeoutMs,
111
+ onState: (state) => console.error(`${state} v${started.version}`),
112
+ });
113
+ if (watched.kind === "timeout") {
114
+ emitFail(command, json, "DEPLOY_TIMEOUT", `v${started.version} is still running on Studio — \`nustack status\` shows where it is.`);
115
+ return;
116
+ }
117
+ if (watched.kind === "superseded") {
118
+ emitFail(command, json, "DEPLOY_OUTCOME_UNKNOWN", `v${started.version} finished, but v${watched.laterVersion} settled after it and Studio keeps only the latest outcome — \`nustack versions\` shows which is live.`);
119
+ return;
120
+ }
121
+ const { outcome, status } = watched;
122
+ if (!outcome.ok) {
123
+ const explanation = explainRed(outcome, await failingTail(target, outcome));
124
+ if (json)
125
+ printJsonError(command, { code: "DEPLOY_FAILED", message: explanation });
126
+ else
127
+ emitFail(command, false, "DEPLOY_FAILED", explanation);
128
+ return;
129
+ }
130
+ if (json)
131
+ printJsonSuccess(command, { projectId: target.projectId, version: started.version, url: status.url, outcome });
132
+ else
133
+ console.log(status.url ?? `v${started.version} is live`);
134
+ }
135
+ catch (error) {
136
+ emitError(command, json, error);
137
+ }
138
+ }
139
+ function hostingLines(status) {
140
+ const lines = [];
141
+ if (status.live) {
142
+ lines.push(`live v${status.live.version} · ${status.url ?? "—"} · promoted ${describeAge(status.live.promotedAt)} by ${status.live.promotedBy}`);
143
+ }
144
+ else if (status.url) {
145
+ lines.push(`live nothing yet · ${status.url}`);
146
+ }
147
+ if (status.buildingVersion !== null)
148
+ lines.push(`building v${status.buildingVersion} · ${status.deployState ?? "building"}`);
149
+ const last = status.lastOutcome;
150
+ if (last && !last.ok) {
151
+ const clause = last.failedClause ? `failed at \`${last.failedClause}\`` : "failed";
152
+ lines.push(`last v${last.version} · ${clause}${last.redrive === "dispatched" ? " · Studio opened a coding round" : ""}`);
153
+ }
154
+ lines.push(`warm ${status.isPinned ? "on" : "off"}`);
155
+ return lines;
156
+ }
157
+ function overviewLines(projectId, overview) {
158
+ const { cloudProject, binding, compatibility } = overview;
159
+ const name = cloudProject ? `${cloudProject.label} · ${cloudProject.status} / ${cloudProject.publicationState}` : `${projectId} · cloud unreachable — local-only view`;
160
+ const lines = [binding ? `${name} · bound to ${binding.repositoryRoot}` : `${name} · this folder is not bound on this device`];
161
+ if (binding)
162
+ lines.push(`binding ${binding.id} on ${binding.boundBranch} (${binding.status}) · applied@${binding.lastAppliedProjectSequence}`);
163
+ lines.push(`toolkit ${compatibility.blocksWrites ? `BLOCKED — ${compatibility.reason}` : "compatible"}`);
164
+ return lines;
165
+ }
166
+ export async function runStatus(options, globals) {
167
+ const command = "status";
168
+ const json = options.json === true;
169
+ const cwd = process.cwd();
170
+ const project = await resolveProjectId(cwd, { project: options.project });
171
+ if (!project.ok) {
172
+ emitFail(command, json, "PROJECT_CONTEXT_MISSING", project.message);
173
+ return;
174
+ }
175
+ const baseUrl = resolveNuStackUrl(globals);
176
+ const auth = await ensureAccessToken(baseUrl).catch(() => null);
177
+ const overview = await readProjectOverview(project.projectId, cwd, baseUrl, auth?.accessToken ?? null);
178
+ let hosting = null;
179
+ if (auth && overview.cloudProject) {
180
+ hosting = await getHostingStatus({ baseUrl, accessToken: auth.accessToken, projectId: project.projectId }).catch(() => null);
181
+ }
182
+ if (json) {
183
+ printJsonSuccess(command, { ...projectOverviewJson(project.projectId, overview), hosting });
184
+ return;
185
+ }
186
+ const lines = overviewLines(project.projectId, overview);
187
+ if (hosting)
188
+ lines.splice(1, 0, ...hostingLines(hosting));
189
+ console.log(lines.join("\n"));
190
+ }
191
+ export async function runVersions(options, globals) {
192
+ const command = "versions";
193
+ const json = options.json === true;
194
+ const target = await resolveTarget(command, options, globals);
195
+ if (!target)
196
+ return;
197
+ try {
198
+ const [list, status] = await Promise.all([listVersions(target), getHostingStatus(target)]);
199
+ const rollbackTarget = status.live && status.previous ? status.previousVersion : null;
200
+ const rows = list.versions.map((row) => ({
201
+ version: row.version,
202
+ createdAt: row.createdAt,
203
+ seam: row.seam,
204
+ isLive: row.isLive,
205
+ isStale: row.isStale,
206
+ isRollbackTarget: row.version === rollbackTarget,
207
+ }));
208
+ if (json) {
209
+ printJsonSuccess(command, { projectId: target.projectId, versions: rows });
210
+ return;
211
+ }
212
+ if (rows.length === 0) {
213
+ console.log("No versions yet — nothing has been published from Studio.");
214
+ return;
215
+ }
216
+ for (const row of rows) {
217
+ const marks = [row.isLive ? "live" : "", row.isRollbackTarget ? "rollback target" : "", row.isStale ? "stale" : ""].filter(Boolean).join(" · ");
218
+ console.log(`v${String(row.version).padEnd(5)} ${row.createdAt} ${row.seam.padEnd(10)} ${marks}`.trimEnd());
219
+ }
220
+ }
221
+ catch (error) {
222
+ emitError(command, json, error);
223
+ }
224
+ }
225
+ export async function runRollback(options, globals) {
226
+ const command = "rollback";
227
+ const json = options.json === true;
228
+ const target = await resolveTarget(command, options, globals);
229
+ if (!target)
230
+ return;
231
+ try {
232
+ const moved = await rollbackHosting(target);
233
+ const status = await getHostingStatus(target);
234
+ if (json) {
235
+ printJsonSuccess(command, { projectId: target.projectId, liveVersion: moved.liveVersion, previousVersion: moved.previousVersion, url: status.url });
236
+ return;
237
+ }
238
+ console.error(`rolled back to v${moved.liveVersion}`);
239
+ console.log(status.url ?? `v${moved.liveVersion} is live`);
240
+ }
241
+ catch (error) {
242
+ emitError(command, json, error);
243
+ }
244
+ }
245
+ function readPositive(raw) {
246
+ if (raw === undefined)
247
+ return undefined;
248
+ return /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
249
+ }
250
+ export async function runLogs(options, globals) {
251
+ const command = "logs";
252
+ const json = options.json === true;
253
+ const version = typeof options.build === "string" ? readPositive(options.build) : undefined;
254
+ const limit = readPositive(options.limit);
255
+ if (version === null || limit === null) {
256
+ emitFail(command, json, "INVALID_ARGUMENT", "--build's version and --limit take positive whole numbers.");
257
+ return;
258
+ }
259
+ if (options.follow && (options.build || json)) {
260
+ emitFail(command, json, "INVALID_ARGUMENT", "--follow streams runtime lines; it combines with neither --build nor --json.");
261
+ return;
262
+ }
263
+ const target = await resolveTarget(command, options, globals);
264
+ if (!target)
265
+ return;
266
+ try {
267
+ if (options.build) {
268
+ const status = version === undefined ? await getHostingStatus(target) : null;
269
+ const chosen = version ?? status?.buildingVersion ?? status?.lastOutcome?.version ?? status?.liveVersion ?? null;
270
+ if (chosen === null) {
271
+ emitFail(command, json, "INVALID_STATE", "No version has been graded yet — nothing has a build log.");
272
+ return;
273
+ }
274
+ const log = await getBuildLog(target, chosen);
275
+ if (json)
276
+ printJsonSuccess(command, { projectId: target.projectId, ...log });
277
+ else
278
+ console.log(renderBuildLog(log));
279
+ return;
280
+ }
281
+ const page = await getRuntimeLogs(target, { limit });
282
+ if (json) {
283
+ printJsonSuccess(command, { projectId: target.projectId, ...page });
284
+ return;
285
+ }
286
+ const notice = stateNotice(page);
287
+ if (notice)
288
+ console.error(notice);
289
+ for (const line of page.lines)
290
+ console.log(renderRuntimeLine(line));
291
+ if (options.follow) {
292
+ await followRuntimeLogs(target, page, { line: (text) => console.log(text), notice: (text) => console.error(text) }, limit);
293
+ }
294
+ }
295
+ catch (error) {
296
+ emitError(command, json, error);
297
+ }
298
+ }
299
+ const SETTING_KEYS = ["keep-warm"];
300
+ export async function runProjectSet(key, value, options, globals) {
301
+ const command = "project set";
302
+ const json = options.json === true;
303
+ if (!SETTING_KEYS.includes(key)) {
304
+ emitFail(command, json, "INVALID_ARGUMENT", `Unknown setting "${key}" — the settings are: ${SETTING_KEYS.join(", ")}.`);
305
+ return;
306
+ }
307
+ if (value !== "on" && value !== "off") {
308
+ emitFail(command, json, "INVALID_ARGUMENT", `keep-warm takes on or off (got "${value}").`);
309
+ return;
310
+ }
311
+ const target = await resolveTarget(command, options, globals);
312
+ if (!target)
313
+ return;
314
+ try {
315
+ const outcome = await setKeepWarm(target, value === "on");
316
+ if (json) {
317
+ printJsonSuccess(command, { projectId: target.projectId, key, value, ...outcome });
318
+ return;
319
+ }
320
+ console.log(`keep-warm ${value} · ${outcome.slotsUsed} of ${outcome.slots} always-on slot(s) in use`);
321
+ }
322
+ catch (error) {
323
+ emitError(command, json, error);
324
+ }
325
+ }
326
+ export function registerDeployCommands(program, getGlobalOptions) {
327
+ program
328
+ .command("deploy")
329
+ .description("Grade and publish the tree Studio holds for this Project (never this folder), then print the live URL")
330
+ .option("--project <id>", "the Project to deploy (defaults to the bound repository)")
331
+ .option("--no-wait", "exit once the deploy starts, printing its version")
332
+ .option("--timeout <minutes>", `stop watching after this long; the deploy keeps running (default ${DEFAULT_TIMEOUT_MINUTES})`)
333
+ .option("--json", "emit the versioned machine envelope")
334
+ .action(async (options) => runDeploy(options, getGlobalOptions()));
335
+ program
336
+ .command("status")
337
+ .description("The Project on one screen: what is live, what is building, the last outcome, keep-warm, the binding")
338
+ .option("--project <id>", "the Project to show (defaults to the bound repository)")
339
+ .option("--json", "emit the versioned machine envelope")
340
+ .action(async (options) => runStatus(options, getGlobalOptions()));
341
+ program
342
+ .command("versions")
343
+ .description("The Project's versions, marking the live one and the one a rollback restores")
344
+ .option("--project <id>", "the Project to list (defaults to the bound repository)")
345
+ .option("--json", "emit the versioned machine envelope")
346
+ .action(async (options) => runVersions(options, getGlobalOptions()));
347
+ program
348
+ .command("rollback")
349
+ .description("Make the previous version live again — one step back")
350
+ .option("--project <id>", "the Project to roll back (defaults to the bound repository)")
351
+ .option("--json", "emit the versioned machine envelope")
352
+ .action(async (options) => runRollback(options, getGlobalOptions()));
353
+ }
354
+ export function registerLogsCommand(program, getGlobalOptions) {
355
+ program
356
+ .command("logs")
357
+ .description("What the Project's backend printed recently (--follow keeps reading), or with --build a version's build log")
358
+ .option("--project <id>", "the Project to read (defaults to the bound repository)")
359
+ .option("--build [version]", "a graded version's build log instead of the runtime output (defaults to the one building, then the last graded, then the live one)")
360
+ .option("--follow", "keep reading new lines until interrupted")
361
+ .option("--limit <n>", "lines per read (app-hosting keeps the newest 1000)")
362
+ .option("--json", "emit the versioned machine envelope")
363
+ .action(async (options) => runLogs(options, getGlobalOptions()));
364
+ }
365
+ export function registerProjectSetCommand(projectCommand, getGlobalOptions) {
366
+ projectCommand
367
+ .command("set <key> <value>")
368
+ .description(`Change a Project setting (${SETTING_KEYS.join(", ")}); keep-warm takes on or off`)
369
+ .option("--project <id>", "the Project to change (defaults to the bound repository)")
370
+ .option("--json", "emit the versioned machine envelope")
371
+ .action(async (key, value, options) => runProjectSet(key, value, options, getGlobalOptions()));
372
+ }
@@ -255,7 +255,7 @@ async function confirm(message) {
255
255
  function reportSuccess(result) {
256
256
  p.log.success(`Bound to Project ${result.projectId} on branch ${result.boundBranch} (binding ${result.bindingId}).`);
257
257
  p.log.info(`Toolkit ${result.toolkit.name}@${result.toolkit.version}. No Git push, pull, or remote was created.`);
258
- p.outro("Ready — `nustack project status` · `nustack docs list` · `nustack project sync now`.");
258
+ p.outro("Ready — `nustack status` · `nustack docs list` · `nustack project sync now`.");
259
259
  }
260
260
  function reportFailure(failure) {
261
261
  p.log.error(failure.message);
@@ -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
+ }