@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,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
|
+
}
|
package/dist/commands/project.js
CHANGED
|
@@ -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
|
|
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,
|
|
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 (
|
|
167
|
+
if (accessToken) {
|
|
177
168
|
try {
|
|
178
|
-
cloudProject = await getProject(baseUrl,
|
|
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 ??
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
:
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
+
}
|
package/dist/commands/toolkit.js
CHANGED
|
@@ -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
|
|
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")
|
package/dist/lib/etna/manager.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/dist/lib/etna/profile.js
CHANGED
|
@@ -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(
|
|
23
|
-
|
|
24
|
-
if (!recorded || !cloud)
|
|
22
|
+
export function toolkitCompatibility(mounted, cloud) {
|
|
23
|
+
if (!mounted || !cloud)
|
|
25
24
|
return { compatible: true, blocksWrites: false, reason: null };
|
|
26
|
-
|
|
27
|
-
|
|
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
|
}
|
package/dist/lib/guards.js
CHANGED
|
@@ -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;
|