@nurix/nustack 0.10.0-dev.9 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +1 -0
  2. package/dist/commands/bootstrap.js +4 -4
  3. package/dist/commands/docs.js +13 -13
  4. package/dist/commands/doctor.js +51 -17
  5. package/dist/commands/handoff.js +8 -8
  6. package/dist/commands/init.js +43 -43
  7. package/dist/commands/login.js +19 -6
  8. package/dist/commands/logout.js +14 -6
  9. package/dist/commands/logscope.js +55 -0
  10. package/dist/commands/{workspace-sync.js → project-sync.js} +5 -5
  11. package/dist/commands/{workspace.js → project.js} +133 -133
  12. package/dist/commands/review.js +38 -15
  13. package/dist/commands/search.js +7 -7
  14. package/dist/commands/sessions.js +156 -5
  15. package/dist/commands/sync.js +10 -10
  16. package/dist/commands/token.js +102 -0
  17. package/dist/commands/toolkit.js +19 -19
  18. package/dist/index.js +88 -36
  19. package/dist/lib/auth_grant.js +32 -0
  20. package/dist/lib/auth_recovery.js +3 -1
  21. package/dist/lib/credential_store.js +106 -0
  22. package/dist/lib/device_identity.js +6 -6
  23. package/dist/lib/device_keychain.js +16 -37
  24. package/dist/lib/etna/profile.js +9 -9
  25. package/dist/lib/etna/reconcile.js +11 -11
  26. package/dist/lib/git-sync/core/engine.js +4 -4
  27. package/dist/lib/git-sync/inbound/apply_commit.js +3 -3
  28. package/dist/lib/git-sync/inbound/approval.js +1 -1
  29. package/dist/lib/git-sync/inbound/download.js +8 -8
  30. package/dist/lib/git-sync/inbound/event_sync.js +13 -13
  31. package/dist/lib/git-sync/inbound/notification.js +1 -1
  32. package/dist/lib/git-sync/outbound/extraction.js +4 -4
  33. package/dist/lib/git-sync/outbound/history_decision.js +2 -2
  34. package/dist/lib/git-sync/outbound/publisher.js +2 -2
  35. package/dist/lib/git-sync/outbound/queue.js +3 -3
  36. package/dist/lib/git-sync/outbound/upload_client.js +4 -4
  37. package/dist/lib/git-sync/whitelist/index.js +1 -1
  38. package/dist/lib/git-sync/whitelist/types.js +1 -1
  39. package/dist/lib/git-sync/whitelist/validation.js +5 -5
  40. package/dist/lib/libsecret_store.js +47 -0
  41. package/dist/lib/local_store/accepted_uploads.js +5 -5
  42. package/dist/lib/local_store/bindings.js +11 -11
  43. package/dist/lib/local_store/index.js +1 -1
  44. package/dist/lib/local_store/migrations.js +13 -0
  45. package/dist/lib/local_store/project_events_cache.js +54 -0
  46. package/dist/lib/logs/answer.js +99 -0
  47. package/dist/lib/logs/library.js +19 -0
  48. package/dist/lib/logs/lifecycle.js +159 -0
  49. package/dist/lib/nustack_client.js +88 -46
  50. package/dist/lib/onboarding/attach_existing_git.js +7 -7
  51. package/dist/lib/onboarding/binding.js +11 -11
  52. package/dist/lib/onboarding/folder_state.js +10 -10
  53. package/dist/lib/onboarding/handoff_apply.js +2 -2
  54. package/dist/lib/onboarding/init_fresh_folder.js +18 -18
  55. package/dist/lib/onboarding/manifest_writer.js +31 -31
  56. package/dist/lib/onboarding/web_first_attach.js +12 -12
  57. package/dist/lib/{workspace_context.js → project_context.js} +24 -24
  58. package/dist/lib/review/client.js +1 -1
  59. package/dist/lib/{lanes → session-engine}/claudeAdapter.js +3 -3
  60. package/dist/lib/{lanes → session-engine}/frames.js +36 -27
  61. package/dist/lib/session-engine/prune.js +233 -0
  62. package/dist/lib/session-engine/registry.js +83 -0
  63. package/dist/lib/session-engine/supervisor.js +321 -0
  64. package/dist/lib/session-engine/worktree.js +89 -0
  65. package/dist/lib/session_telemetry.js +2 -2
  66. package/dist/lib/sessions/client.js +8 -8
  67. package/dist/lib/sessions/outsideSessions.js +6 -6
  68. package/dist/lib/telemetry/config_renderer.js +3 -3
  69. package/dist/lib/telemetry/health_report.js +1 -1
  70. package/package.json +5 -4
  71. package/dist/commands/lane.js +0 -12
  72. package/dist/lib/lanes/supervisor.js +0 -247
  73. package/dist/lib/lanes/worktree.js +0 -33
  74. package/dist/lib/local_store/workspace_events_cache.js +0 -54
@@ -8,16 +8,30 @@ const EXIT_FOR = {
8
8
  none: 1,
9
9
  unreachable: 2,
10
10
  };
11
+ const latestAnswered = (items) => items.find((item) => item.answer?.verdict === "signed" || item.answer?.verdict === "refused");
11
12
  export function decisionStateOf(items) {
12
- const latest = items.find((item) => item.answer?.verdict === "signed" || item.answer?.verdict === "refused");
13
+ const latest = latestAnswered(items);
13
14
  if (!latest)
14
15
  return "none";
15
16
  return latest.answer?.verdict === "signed" ? "signed" : "refused";
16
17
  }
17
- function checkLine(state, commit) {
18
+ export function waivedKeysOf(items) {
19
+ const latest = latestAnswered(items);
20
+ if (latest?.answer?.verdict !== "signed")
21
+ return [];
22
+ const waivers = latest.answer.waivers;
23
+ if (!Array.isArray(waivers))
24
+ return [];
25
+ return waivers
26
+ .map((waiver) => waiver?.criterionKey)
27
+ .filter((key) => typeof key === "string" && key.length > 0);
28
+ }
29
+ function checkLine(state, commit, waived) {
18
30
  switch (state) {
19
31
  case "signed":
20
- return `review check: ${commit} is signed off\n`;
32
+ return waived.length
33
+ ? `review check: ${commit} is signed off — ${waived.length} ${waived.length === 1 ? "criterion" : "criteria"} waived: ${waived.join(", ")}\n`
34
+ : `review check: ${commit} is signed off\n`;
21
35
  case "refused":
22
36
  return `review check: ${commit} was refused\n`;
23
37
  case "none":
@@ -34,11 +48,11 @@ export async function runReviewCheck(options, globals) {
34
48
  process.exitCode = 2;
35
49
  return;
36
50
  }
37
- const report = (state) => {
51
+ const report = (state, waived = []) => {
38
52
  if (json)
39
- printJsonSuccess("review check", { commit, state, exitCode: EXIT_FOR[state] });
53
+ printJsonSuccess("review check", { commit, state, exitCode: EXIT_FOR[state], waived });
40
54
  else
41
- process.stdout.write(checkLine(state, commit));
55
+ process.stdout.write(checkLine(state, commit, waived));
42
56
  process.exitCode = EXIT_FOR[state];
43
57
  };
44
58
  const baseUrl = resolveNuStackUrl(globals);
@@ -47,7 +61,7 @@ export async function runReviewCheck(options, globals) {
47
61
  return report("unreachable");
48
62
  try {
49
63
  const items = await makeReviewClient(baseUrl, deviceAuth.accessToken).readDecisions(commit, options.repository?.trim() || null);
50
- report(decisionStateOf(items));
64
+ report(decisionStateOf(items), waivedKeysOf(items));
51
65
  }
52
66
  catch (error) {
53
67
  if (error instanceof ReviewUnreachableError)
@@ -77,7 +91,16 @@ function readSubject(raw) {
77
91
  type: "tool_permission",
78
92
  toolName,
79
93
  displayName,
80
- toolInput: row.toolInput ?? null,
94
+ ...(typeof row.target === "string" || row.target === null ? { target: row.target } : {}),
95
+ ...(typeof row.inputBytes === "number" && Number.isFinite(row.inputBytes)
96
+ ? { inputBytes: row.inputBytes }
97
+ : {}),
98
+ ...(Array.isArray(row.inputFields) && row.inputFields.every((field) => typeof field === "string")
99
+ ? { inputFields: row.inputFields }
100
+ : {}),
101
+ ...(typeof row.redactionVersion === "number" && Number.isFinite(row.redactionVersion)
102
+ ? { redactionVersion: row.redactionVersion }
103
+ : {}),
81
104
  ...(typeof row.description === "string" ? { description: row.description } : {}),
82
105
  };
83
106
  }
@@ -117,13 +140,13 @@ export async function runReviewMirror(options, globals) {
117
140
  const answered = await client.answerReviewItem(id, verdict);
118
141
  return mirrorReport(json, "answered", answered.id);
119
142
  }
120
- const workspaceId = options.workspace?.trim() ?? "";
121
- if (!workspaceId)
122
- return mirrorReport(json, "skipped", null, "--workspace is required to open a mirrored prompt");
143
+ const projectId = options.project?.trim() ?? "";
144
+ if (!projectId)
145
+ return mirrorReport(json, "skipped", null, "--project is required to open a mirrored prompt");
123
146
  const subject = readSubject(options.subject);
124
147
  if (!subject)
125
148
  return mirrorReport(json, "skipped", null, "--subject must be a tool-permission JSON object");
126
- const created = await client.createReviewItem({ workspaceId, sessionId, subject });
149
+ const created = await client.createReviewItem({ projectId, sessionId, subject });
127
150
  return mirrorReport(json, "open", created.id);
128
151
  }
129
152
  catch (error) {
@@ -140,9 +163,9 @@ export function registerReviewCommands(reviewCommand, getGlobalOptions) {
140
163
  .action(async (options) => runReviewCheck(options, getGlobalOptions()));
141
164
  reviewCommand
142
165
  .command("mirror")
143
- .description("Reflect a lane's permission prompt, or its answer, into the Review pane")
144
- .option("--session <laneId>", "the lane's session id")
145
- .option("--workspace <id>", "the Workspace the lane's folder is bound to (required with --state open)")
166
+ .description("Reflect a session's permission prompt, or its answer, into the Review pane")
167
+ .option("--session <sessionId>", "the session's id")
168
+ .option("--project <id>", "the Project the session's folder is bound to (required with --state open)")
146
169
  .option("--state <state>", "open or answered")
147
170
  .option("--subject <json>", "the tool-permission subject, as JSON (required with --state open)")
148
171
  .option("--verdict <verdict>", "allow or deny (required with --state answered)")
@@ -1,4 +1,4 @@
1
- import { resolveNuStackUrl, searchWorkspaces, searchCatalogue, } from "../lib/nustack_client.js";
1
+ import { resolveNuStackUrl, searchProjects, searchCatalogue, } from "../lib/nustack_client.js";
2
2
  export async function runSearch(keywords, options, globals) {
3
3
  const query = keywords.join(" ").trim();
4
4
  if (!query) {
@@ -12,20 +12,20 @@ export async function runSearch(keywords, options, globals) {
12
12
  return;
13
13
  }
14
14
  const baseUrl = resolveNuStackUrl(globals);
15
- const results = await searchWorkspaces(baseUrl, query, { category: options.category, limit: options.limit });
15
+ const results = await searchProjects(baseUrl, query, { category: options.category, limit: options.limit });
16
16
  if (options.json) {
17
17
  console.log(JSON.stringify(results, null, 2));
18
18
  return;
19
19
  }
20
20
  if (results.length === 0) {
21
- console.log(`No workspaces matched "${query}". Looking for a platform capability? Try \`nustack search services ${query}\`.`);
21
+ console.log(`No projects matched "${query}". Looking for a platform capability? Try \`nustack search services ${query}\`.`);
22
22
  return;
23
23
  }
24
24
  console.log(`${results.length} result${results.length === 1 ? "" : "s"} for "${query}"`);
25
- for (const workspace of results) {
26
- const category = workspace.categories[0] ?? "—";
27
- console.log(`${workspace.displayName.padEnd(28)} ${category.padEnd(16)} ${workspace.tagline ?? ""}`);
28
- const link = workspace.links[0];
25
+ for (const project of results) {
26
+ const category = project.categories[0] ?? "—";
27
+ console.log(`${project.displayName.padEnd(28)} ${category.padEnd(16)} ${project.tagline ?? ""}`);
28
+ const link = project.links[0];
29
29
  if (link)
30
30
  console.log(` ${link.url}`);
31
31
  }
@@ -1,9 +1,25 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import * as p from "@clack/prompts";
2
4
  import { ensureAccessToken } from "../lib/auth_grant.js";
5
+ import { isInteractive } from "../lib/interactive.js";
3
6
  import { resolveNuStackUrl } from "../lib/nustack_client.js";
4
- import { makeSessionsClient } from "../lib/sessions/client.js";
7
+ import { makeSessionsClient, sessionsClientFromEnv } from "../lib/sessions/client.js";
5
8
  import { recordOutsideSessions, } from "../lib/sessions/outsideSessions.js";
6
- import { printJsonSuccess } from "../lib/json_output.js";
9
+ import { inspectSession, readOrphanedWorktrees, reclaimSession, } from "../lib/session-engine/prune.js";
10
+ import { readSessionRegistry, sessionRegistryPath } from "../lib/session-engine/registry.js";
11
+ import { SessionSupervisor } from "../lib/session-engine/supervisor.js";
12
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
13
+ export async function runSessionsServe(engineVersion) {
14
+ const supervisor = new SessionSupervisor({
15
+ input: process.stdin,
16
+ output: process.stdout,
17
+ engineVersion,
18
+ sessions: sessionsClientFromEnv(process.env),
19
+ });
20
+ await supervisor.run();
21
+ process.exit(0);
22
+ }
7
23
  async function readScanArgument(raw) {
8
24
  const body = raw.startsWith("@") ? await readFile(raw.slice(1), "utf8") : raw;
9
25
  return JSON.parse(body);
@@ -25,7 +41,7 @@ function readTranscripts(value) {
25
41
  sessionKey,
26
42
  provider,
27
43
  modifiedMs,
28
- workspaceId: typeof row.workspaceId === "string" && row.workspaceId ? row.workspaceId : null,
44
+ projectId: typeof row.projectId === "string" && row.projectId ? row.projectId : null,
29
45
  vendorSessionId: typeof row.id === "string" && row.id ? row.id : null,
30
46
  ...(typeof row.sizeBytes === "number" ? { sizeBytes: row.sizeBytes } : {}),
31
47
  });
@@ -46,6 +62,10 @@ export async function runSessionsRecord(options, globals) {
46
62
  if (!options.scan)
47
63
  return report("sessions record", EMPTY, json, "no scan payload");
48
64
  const payload = (await readScanArgument(options.scan).catch(() => null));
65
+ if (payload && "laneSessionIds" in payload) {
66
+ process.stderr.write("sessions record: this NuStack desktop is older than this CLI: it sent laneSessionIds, which is now engineSessionIds. Update both.\n");
67
+ process.exit(1);
68
+ }
49
69
  const transcripts = readTranscripts(payload?.sessions);
50
70
  if (transcripts.length === 0)
51
71
  return report("sessions record", EMPTY, json, "nothing to record");
@@ -54,9 +74,140 @@ export async function runSessionsRecord(options, globals) {
54
74
  return report("sessions record", EMPTY, json, "signed out");
55
75
  const summary = await recordOutsideSessions(makeSessionsClient(baseUrl, deviceAuth.accessToken), {
56
76
  transcripts,
57
- laneSessionIds: Array.isArray(payload?.laneSessionIds)
58
- ? payload.laneSessionIds.filter((id) => typeof id === "string")
77
+ engineSessionIds: Array.isArray(payload?.engineSessionIds)
78
+ ? payload.engineSessionIds.filter((id) => typeof id === "string")
59
79
  : [],
60
80
  });
61
81
  report("sessions record", summary, json);
62
82
  }
83
+ async function readSessions() {
84
+ const { records, dropped } = await readSessionRegistry();
85
+ const orphans = await readOrphanedWorktrees();
86
+ const sessions = [];
87
+ for (const record of [...records, ...orphans.records]) {
88
+ sessions.push({ record, facts: await inspectSession(record) });
89
+ }
90
+ return { sessions, unreadable: dropped, undecipherableOrphans: orphans.undecipherable };
91
+ }
92
+ function statusWord(entry) {
93
+ if (entry.facts.orphaned)
94
+ return "orphaned";
95
+ if (entry.facts.stale)
96
+ return "stale";
97
+ return entry.record.status;
98
+ }
99
+ function formatSessionRow(entry) {
100
+ const checkout = path.basename(entry.record.projectPath);
101
+ return (`${entry.record.sessionId.padEnd(38)} ${checkout.padEnd(22)} ${(entry.record.branch ?? "—").padEnd(31)} ` +
102
+ `${statusWord(entry).padEnd(10)} ${entry.facts.reason}`);
103
+ }
104
+ function toJsonView(entry) {
105
+ const { record, facts } = entry;
106
+ return {
107
+ sessionId: record.sessionId,
108
+ projectPath: record.projectPath,
109
+ worktreePath: record.worktreePath,
110
+ branch: record.branch,
111
+ status: record.status,
112
+ nustack: record.nustack,
113
+ createdAt: record.createdAt,
114
+ closedAt: record.closedAt ?? null,
115
+ permissionMode: record.permissionMode ?? null,
116
+ baseBranch: record.baseBranch ?? null,
117
+ isolated: facts.isolated,
118
+ checkoutPresent: facts.checkoutPresent,
119
+ worktreePresent: facts.worktreePresent,
120
+ branchExists: facts.branchExists,
121
+ reading: facts.reading,
122
+ dirty: facts.dirty,
123
+ stale: facts.stale,
124
+ orphaned: facts.orphaned,
125
+ reclaimable: facts.reclaimable,
126
+ reason: facts.reason,
127
+ };
128
+ }
129
+ export async function runSessionsList(options) {
130
+ const json = options.json === true;
131
+ const { sessions, unreadable, undecipherableOrphans } = await readSessions();
132
+ if (json) {
133
+ printJsonSuccess("sessions list", {
134
+ registryPath: sessionRegistryPath(),
135
+ unreadable,
136
+ undecipherableOrphans,
137
+ sessions: sessions.map(toJsonView),
138
+ });
139
+ return;
140
+ }
141
+ if (sessions.length === 0 && unreadable === 0 && undecipherableOrphans.length === 0) {
142
+ console.log("No engine-opened sessions on this machine.");
143
+ return;
144
+ }
145
+ if (sessions.length > 0) {
146
+ console.log(`${"SESSION".padEnd(38)} ${"CHECKOUT".padEnd(22)} ${"BRANCH".padEnd(31)} ${"STATUS".padEnd(10)} HOLDS`);
147
+ for (const entry of sessions)
148
+ console.log(formatSessionRow(entry));
149
+ }
150
+ if (unreadable > 0)
151
+ console.log(`${unreadable} row(s) in the registry could not be read and were skipped.`);
152
+ for (const orphan of undecipherableOrphans) {
153
+ console.log(`1 worktree under the pre-rename engine home names no checkout and must be removed by hand: ${orphan}.`);
154
+ }
155
+ }
156
+ function previewLine(entry) {
157
+ const verb = entry.facts.reclaimable ? "reclaim" : "refuse";
158
+ return ` ${verb} ${entry.record.sessionId} ${entry.record.branch ?? "—"} — ${entry.facts.reason}`;
159
+ }
160
+ export async function runSessionsPrune(options) {
161
+ const json = options.json === true;
162
+ const force = options.force === true;
163
+ const { sessions } = await readSessions();
164
+ const scoped = options.session ? sessions.filter((entry) => entry.record.sessionId === options.session) : sessions;
165
+ if (options.session && scoped.length === 0) {
166
+ const message = `No session ${options.session} in this machine's registry — pass one from \`nustack sessions list\`.`;
167
+ if (json)
168
+ return printJsonError("sessions prune", { code: "SESSION_NOT_FOUND", message });
169
+ p.log.error(message);
170
+ process.exitCode = 1;
171
+ return;
172
+ }
173
+ const candidates = scoped.filter((entry) => force || entry.facts.reclaimable);
174
+ if (options.yes !== true) {
175
+ const plan = candidates.map((entry) => ({
176
+ sessionId: entry.record.sessionId,
177
+ branch: entry.record.branch,
178
+ worktreePath: entry.record.worktreePath,
179
+ orphaned: entry.facts.orphaned,
180
+ reclaimable: entry.facts.reclaimable,
181
+ reason: entry.facts.reason,
182
+ }));
183
+ if (json)
184
+ return printJsonSuccess("sessions prune", { applied: false, plan, note: "preview only — pass --yes to apply" });
185
+ if (plan.length === 0) {
186
+ console.log("Nothing to reclaim.");
187
+ return;
188
+ }
189
+ console.log(`Would reclaim ${plan.length} session(s):`);
190
+ for (const entry of candidates)
191
+ console.log(previewLine(entry));
192
+ if (!isInteractive()) {
193
+ console.log("Preview only — re-run with --yes to apply.");
194
+ return;
195
+ }
196
+ const answer = await p.confirm({ message: "Remove these worktrees and delete their branches?" });
197
+ if (p.isCancel(answer) || !answer) {
198
+ p.outro("Cancelled — nothing reclaimed.");
199
+ return;
200
+ }
201
+ }
202
+ const reclaimed = [];
203
+ const refused = [];
204
+ for (const entry of candidates) {
205
+ const outcome = await reclaimSession(entry.record, { force });
206
+ (outcome.reason === null ? reclaimed : refused).push(outcome);
207
+ }
208
+ if (json)
209
+ return printJsonSuccess("sessions prune", { applied: true, reclaimed, refused });
210
+ console.log(`Reclaimed ${reclaimed.length} session(s); refused ${refused.length}.`);
211
+ for (const outcome of refused)
212
+ console.log(` refused ${outcome.sessionId} — ${outcome.reason ?? "unknown"}`);
213
+ }
@@ -1,9 +1,9 @@
1
1
  import path from "node:path";
2
2
  import * as p from "@clack/prompts";
3
- import { exchangeServiceCredential, fetchServiceCredential, listWorkspaceIntegrations, resolveNuStackUrl, } from "../lib/nustack_client.js";
3
+ import { exchangeServiceCredential, fetchServiceCredential, listProjectIntegrations, resolveNuStackUrl, } from "../lib/nustack_client.js";
4
4
  import { readConsumesEdges } from "../lib/manifest.js";
5
5
  import { ensureAccessToken, signedOutCopy } from "../lib/auth_grant.js";
6
- import { resolveWorkspaceId } from "../lib/workspace_context.js";
6
+ import { resolveProjectId } from "../lib/project_context.js";
7
7
  import { readManagedValues, writeManagedValues } from "../lib/env_file.js";
8
8
  import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
9
9
  const COMMAND = "sync";
@@ -18,23 +18,23 @@ export async function runSync(options) {
18
18
  if (!deviceAuth)
19
19
  return failSync(json, "SIGNED_OUT", signedOut(), "The managed block was not touched.");
20
20
  const accessToken = deviceAuth.accessToken;
21
- const workspace = await resolveWorkspaceId(cwd, { workspace: options.workspace });
22
- if (!workspace.ok)
23
- return failSync(json, "WORKSPACE_UNRESOLVED", workspace.message, "The managed block was not touched.");
21
+ const project = await resolveProjectId(cwd, { project: options.project });
22
+ if (!project.ok)
23
+ return failSync(json, "PROJECT_UNRESOLVED", project.message, "The managed block was not touched.");
24
24
  const spinner = json ? null : p.spinner();
25
25
  spinner?.start("Exchanging declared services");
26
26
  let activeServices;
27
27
  let projections;
28
28
  try {
29
29
  for (const edge of await readConsumesEdges(cwd)) {
30
- await exchangeServiceCredential(baseUrl, accessToken, { workspaceId: workspace.workspaceId, service: edge.service });
30
+ await exchangeServiceCredential(baseUrl, accessToken, { projectId: project.projectId, service: edge.service });
31
31
  }
32
32
  spinner?.message("Enumerating active integrations");
33
- const integrations = await listWorkspaceIntegrations(baseUrl, accessToken, workspace.workspaceId);
33
+ const integrations = await listProjectIntegrations(baseUrl, accessToken, project.projectId);
34
34
  activeServices = integrations.filter((i) => i.status === "active").map((i) => i.serviceSlug);
35
35
  projections = [];
36
36
  for (const service of activeServices) {
37
- projections.push(await fetchServiceCredential(baseUrl, accessToken, { workspaceId: workspace.workspaceId, service }));
37
+ projections.push(await fetchServiceCredential(baseUrl, accessToken, { projectId: project.projectId, service }));
38
38
  }
39
39
  }
40
40
  catch (error) {
@@ -59,7 +59,7 @@ export async function runSync(options) {
59
59
  await writeManagedValues(path.join(cwd, ".env.example"), placeholders);
60
60
  if (json) {
61
61
  printJsonSuccess(COMMAND, {
62
- workspaceId: workspace.workspaceId,
62
+ projectId: project.projectId,
63
63
  integrations: activeServices.map((service) => ({ service, status: "active" })),
64
64
  envKeysWritten: Object.keys(nextValues),
65
65
  removedKeys,
@@ -69,7 +69,7 @@ export async function runSync(options) {
69
69
  p.log.success(`Managed block rewritten: ${projections.length} service credential(s).`);
70
70
  if (removedKeys.length > 0)
71
71
  p.log.info(`Removed stale entries: ${removedKeys.join(", ")}`);
72
- p.outro("In sync with NuStack — Service credentials only (Documents & Git are `nustack workspace sync`).");
72
+ p.outro("In sync with NuStack — Service credentials only (Documents & Git are `nustack project sync`).");
73
73
  }
74
74
  function failSync(json, code, message, humanTail) {
75
75
  if (json) {
@@ -0,0 +1,102 @@
1
+ import { createPersonalToken, listPersonalTokens, PersonalTokenRequestError, resolveNuStackUrl, revokePersonalToken } from "../lib/nustack_client.js";
2
+ import { ensureAuthWithRecovery, lastAuthFailure } from "../lib/auth_recovery.js";
3
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
4
+ const MAX_EXPIRY_DAYS = 365;
5
+ async function requireAuth(command, json, globals) {
6
+ const baseUrl = resolveNuStackUrl(globals);
7
+ const auth = await ensureAuthWithRecovery(baseUrl, json);
8
+ if (!auth) {
9
+ const failure = lastAuthFailure();
10
+ if (json)
11
+ printJsonError(command, { code: failure.code, message: failure.message });
12
+ else {
13
+ console.error(failure.message);
14
+ process.exitCode = 1;
15
+ }
16
+ return null;
17
+ }
18
+ return { baseUrl, accessToken: auth.accessToken };
19
+ }
20
+ function refuse(command, json, error) {
21
+ const code = error instanceof PersonalTokenRequestError ? error.code : "REQUEST_FAILED";
22
+ const message = error instanceof Error ? error.message : String(error);
23
+ if (json)
24
+ printJsonError(command, { code, message });
25
+ else {
26
+ console.error(message);
27
+ process.exitCode = 1;
28
+ }
29
+ }
30
+ export async function runTokenCreate(options, globals) {
31
+ const json = options.json === true;
32
+ const label = options.label?.trim() ?? "";
33
+ if (!label)
34
+ return refuse("token create", json, new PersonalTokenRequestError("VALIDATION_FAILED", "A label is required: `nustack token create --label <name>`."));
35
+ let expiresInDays;
36
+ if (options.expires !== undefined) {
37
+ const parsed = Number(options.expires);
38
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_EXPIRY_DAYS) {
39
+ return refuse("token create", json, new PersonalTokenRequestError("VALIDATION_FAILED", `--expires is a whole number of days, 1–${MAX_EXPIRY_DAYS}.`));
40
+ }
41
+ expiresInDays = parsed;
42
+ }
43
+ const auth = await requireAuth("token create", json, globals);
44
+ if (!auth)
45
+ return;
46
+ try {
47
+ const minted = await createPersonalToken(auth.baseUrl, auth.accessToken, { label, ...(expiresInDays ? { expiresInDays } : {}) });
48
+ if (json) {
49
+ printJsonSuccess("token create", minted);
50
+ return;
51
+ }
52
+ console.error(`Minted \`${minted.token.label}\` (${minted.token.tokenPrefix}…)${minted.token.expiresAt ? `, expires ${minted.token.expiresAt}` : ""}.`);
53
+ console.error(`This is the only time the secret is shown. Store it as ${"NUSTACK_TOKEN"} in your pipeline; revoke it with \`nustack token revoke ${minted.token.id}\`.`);
54
+ console.log(minted.secret);
55
+ }
56
+ catch (error) {
57
+ refuse("token create", json, error);
58
+ }
59
+ }
60
+ export async function runTokenList(options, globals) {
61
+ const json = options.json === true;
62
+ const auth = await requireAuth("token list", json, globals);
63
+ if (!auth)
64
+ return;
65
+ try {
66
+ const tokens = await listPersonalTokens(auth.baseUrl, auth.accessToken);
67
+ if (json) {
68
+ printJsonSuccess("token list", { tokens });
69
+ return;
70
+ }
71
+ if (tokens.length === 0) {
72
+ console.log("No personal access tokens. Mint one with `nustack token create --label <name>`.");
73
+ return;
74
+ }
75
+ for (const token of tokens) {
76
+ const state = token.revokedAt ? `revoked ${token.revokedAt}` : token.expiresAt ? `expires ${token.expiresAt}` : "no expiry";
77
+ console.log(`${token.id.padEnd(38)} ${`${token.tokenPrefix}…`.padEnd(18)} ${token.label.padEnd(24)} ${state.padEnd(30)} last used ${token.lastUsedAt ?? "never"}`);
78
+ }
79
+ }
80
+ catch (error) {
81
+ refuse("token list", json, error);
82
+ }
83
+ }
84
+ export async function runTokenRevoke(tokenId, options, globals) {
85
+ const json = options.json === true;
86
+ if (!tokenId?.trim())
87
+ return refuse("token revoke", json, new PersonalTokenRequestError("VALIDATION_FAILED", "A token id is required: `nustack token revoke <id>` (see `nustack token list`)."));
88
+ const auth = await requireAuth("token revoke", json, globals);
89
+ if (!auth)
90
+ return;
91
+ try {
92
+ const revoked = await revokePersonalToken(auth.baseUrl, auth.accessToken, tokenId.trim());
93
+ if (json) {
94
+ printJsonSuccess("token revoke", revoked);
95
+ return;
96
+ }
97
+ console.log(`Revoked ${revoked.id} at ${revoked.revokedAt}.`);
98
+ }
99
+ catch (error) {
100
+ refuse("token revoke", json, error);
101
+ }
102
+ }
@@ -1,12 +1,12 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { resolveNuStackUrl } from "../lib/nustack_client.js";
3
3
  import { ensureAuthWithRecovery, lastAuthFailure } from "../lib/auth_recovery.js";
4
- import { resolveWorkspaceId } from "../lib/workspace_context.js";
4
+ import { resolveProjectId } from "../lib/project_context.js";
5
5
  import { isInteractive } from "../lib/interactive.js";
6
6
  import { openLocalStore } from "../lib/local_store/database.js";
7
7
  import { getBindingByRepositoryRoot } from "../lib/local_store/bindings.js";
8
8
  import { resolveRepository } from "../lib/git-sync/core/repository.js";
9
- import { readWorkspaceManifests, diagnoseManifests } from "../lib/onboarding/manifest_writer.js";
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
12
  import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
@@ -28,9 +28,9 @@ async function resolveContext(command, json, options, globals) {
28
28
  return null;
29
29
  }
30
30
  const baseUrl = resolveNuStackUrl(globals);
31
- const workspace = await resolveWorkspaceId(root, options);
32
- if (!workspace.ok) {
33
- emitFail(command, json, "WORKSPACE_CONTEXT_MISSING", workspace.message);
31
+ const project = await resolveProjectId(root, options);
32
+ if (!project.ok) {
33
+ emitFail(command, json, "PROJECT_CONTEXT_MISSING", project.message);
34
34
  return null;
35
35
  }
36
36
  const deviceAuth = await ensureAuthWithRecovery(baseUrl, json);
@@ -39,7 +39,7 @@ async function resolveContext(command, json, options, globals) {
39
39
  emitFail(command, json, failure.code, failure.message);
40
40
  return null;
41
41
  }
42
- return { baseUrl, accessToken: deviceAuth.accessToken, workspaceId: workspace.workspaceId, root };
42
+ return { baseUrl, accessToken: deviceAuth.accessToken, projectId: project.projectId, root };
43
43
  }
44
44
  export async function runToolkitStatus(options, globals) {
45
45
  const json = options.json === true;
@@ -48,7 +48,7 @@ export async function runToolkitStatus(options, globals) {
48
48
  return;
49
49
  let cloud;
50
50
  try {
51
- cloud = await fetchToolkitProfile(ctx.baseUrl, ctx.accessToken, ctx.workspaceId);
51
+ cloud = await fetchToolkitProfile(ctx.baseUrl, ctx.accessToken, ctx.projectId);
52
52
  }
53
53
  catch (error) {
54
54
  if (error instanceof ToolkitProfileUnavailableError) {
@@ -57,24 +57,24 @@ export async function runToolkitStatus(options, globals) {
57
57
  }
58
58
  throw error;
59
59
  }
60
- const manifests = await readWorkspaceManifests(ctx.root);
61
- const diagnosis = diagnoseManifests({ name: cloud.name, version: cloud.version }, manifests.workspaceManifest?.toolkit ?? null, manifests.serviceToolkit);
60
+ const manifests = await readProjectManifests(ctx.root);
61
+ const diagnosis = diagnoseManifests({ name: cloud.name, version: cloud.version }, manifests.projectManifest?.toolkit ?? null, manifests.serviceToolkit);
62
62
  const binding = await withStore((store) => getBindingByRepositoryRoot(store, ctx.root));
63
63
  const compatibility = toolkitCompatibility(binding ?? { etnaProfileJson: null }, cloud);
64
64
  if (json) {
65
65
  printJsonSuccess("toolkit status", {
66
- workspaceId: ctx.workspaceId,
66
+ projectId: ctx.projectId,
67
67
  cloud,
68
- workspaceJson: manifests.workspaceManifest?.toolkit ?? null,
68
+ projectJson: manifests.projectManifest?.toolkit ?? null,
69
69
  serviceJson: manifests.serviceToolkit,
70
70
  diagnosis,
71
71
  compatibility,
72
72
  });
73
73
  return;
74
74
  }
75
- console.log(`Toolkit for Workspace ${ctx.workspaceId}`);
75
+ console.log(`Toolkit for Project ${ctx.projectId}`);
76
76
  console.log(` cloud pin: ${cloud.name}@${cloud.version} (renderer ${cloud.rendererVersion}, placement ${cloud.placementMapVersion})`);
77
- console.log(` .nustack/app.json: ${fmtToolkit(manifests.workspaceManifest?.toolkit)}`);
77
+ console.log(` .nustack/app.json: ${fmtToolkit(manifests.projectManifest?.toolkit)}`);
78
78
  console.log(` nustack.service.json:${fmtToolkit(manifests.serviceToolkit)}`);
79
79
  console.log(` diagnosis: ${diagnosis}`);
80
80
  console.log(` write gate: ${compatibility.blocksWrites ? `BLOCKED — ${compatibility.reason}` : "clear (projection and sync writes allowed)"}`);
@@ -89,7 +89,7 @@ export async function runToolkitReconcile(options, globals) {
89
89
  return;
90
90
  let cloud;
91
91
  try {
92
- cloud = await fetchToolkitProfile(ctx.baseUrl, ctx.accessToken, ctx.workspaceId);
92
+ cloud = await fetchToolkitProfile(ctx.baseUrl, ctx.accessToken, ctx.projectId);
93
93
  }
94
94
  catch (error) {
95
95
  if (error instanceof ToolkitProfileUnavailableError) {
@@ -99,12 +99,12 @@ export async function runToolkitReconcile(options, globals) {
99
99
  throw error;
100
100
  }
101
101
  const binding = await withStore((store) => getBindingByRepositoryRoot(store, ctx.root));
102
- const manifests = await readWorkspaceManifests(ctx.root);
103
- const identity = manifests.workspaceManifest
104
- ? { workspaceId: manifests.workspaceManifest.workspaceId, organizationId: manifests.workspaceManifest.organizationId }
105
- : manifests.serviceWorkspaceBinding;
102
+ const manifests = await readProjectManifests(ctx.root);
103
+ const identity = manifests.projectManifest
104
+ ? { projectId: manifests.projectManifest.workspaceId, organizationId: manifests.projectManifest.organizationId }
105
+ : manifests.serviceProjectBinding;
106
106
  if (!identity) {
107
- emitFail("toolkit reconcile", json, "WORKSPACE_CONTEXT_MISSING", "No `.nustack/app.json` binding to reconcile — attach the repository first.");
107
+ emitFail("toolkit reconcile", json, "PROJECT_CONTEXT_MISSING", "No `.nustack/app.json` binding to reconcile — attach the repository first.");
108
108
  return;
109
109
  }
110
110
  const plan = await buildReconcilePlan(ctx.root, binding ?? { etnaProfileJson: null }, cloud);