@nurix/nustack 0.10.0-dev.8 → 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
package/dist/index.js CHANGED
@@ -9,21 +9,22 @@ import { runSync } from "./commands/sync.js";
9
9
  import { runDescribe } from "./commands/describe.js";
10
10
  import { runValidate } from "./commands/validate.js";
11
11
  import { runGraph } from "./commands/graph.js";
12
+ import { runLogscope } from "./commands/logscope.js";
12
13
  import { runDoctor } from "./commands/doctor.js";
14
+ import { runTokenCreate, runTokenList, runTokenRevoke } from "./commands/token.js";
13
15
  import { runOpen } from "./commands/open.js";
14
16
  import { runBootstrap } from "./commands/bootstrap.js";
15
17
  import { runLogout } from "./commands/logout.js";
16
18
  import { runTelemetry } from "./commands/telemetry.js";
17
19
  import { runSearch, runSearchServices } from "./commands/search.js";
18
20
  import { runDeviceList, runDeviceRevoke, runDeviceStatus } from "./commands/device.js";
19
- import { registerWorkspaceSyncCommands } from "./commands/workspace-sync.js";
20
- import { registerWorkspaceCommands } from "./commands/workspace.js";
21
+ import { registerProjectSyncCommands } from "./commands/project-sync.js";
22
+ import { registerProjectCommands } from "./commands/project.js";
21
23
  import { runOrganizationList, runOrganizationCreate, runOrganizationUse } from "./commands/organization.js";
22
24
  import { runDocsList, runDocsShow, runDocsStatus } from "./commands/docs.js";
23
25
  import { runHandoffList, runHandoffShow } from "./commands/handoff.js";
24
26
  import { runToolkitStatus, runToolkitReconcile } from "./commands/toolkit.js";
25
- import { runLaneServe } from "./commands/lane.js";
26
- import { runSessionsRecord } from "./commands/sessions.js";
27
+ import { runSessionsList, runSessionsPrune, runSessionsRecord, runSessionsServe, } from "./commands/sessions.js";
27
28
  import { registerReviewCommands } from "./commands/review.js";
28
29
  import { runAgentContext, runAgentHookInstall, runAgentHookRemove } from "./commands/agent.js";
29
30
  import { printJsonError } from "./lib/json_output.js";
@@ -52,8 +53,8 @@ program
52
53
  .action(async (options) => runLogout(program.opts(), { forgetDevice: options.forgetDevice }));
53
54
  program
54
55
  .command("init")
55
- .description("Onboard this folder: sign in, then bind it to a Workspace (the onboarding router)")
56
- .option("--organization <id>", "the Organization to create or select the Workspace in")
56
+ .description("Onboard this folder: sign in, then bind it to a Project (the onboarding router)")
57
+ .option("--organization <id>", "the Organization to create or select the Project in")
57
58
  .option("--yes", "pre-confirm the populated-folder onboarding guard")
58
59
  .action(async (options) => runInit({ ...program.opts(), ...options }));
59
60
  program
@@ -69,8 +70,8 @@ program
69
70
  .action(async (options) => runList(options, program.opts()));
70
71
  program
71
72
  .command("sync")
72
- .description("Project this Workspace's Service credentials into the managed .env block (not Documents or Git)")
73
- .option("--workspace <id>", "the Workspace to act as (defaults to nustack.service.json / NUSTACK_WORKSPACE_ID)")
73
+ .description("Project this Project's Service credentials into the managed .env block (not Documents or Git)")
74
+ .option("--project <id>", "the Project to act as (defaults to nustack.service.json / NUSTACK_WORKSPACE_ID)")
74
75
  .option("--json", "emit the versioned machine envelope instead of interactive output")
75
76
  .action(async (options) => runSync({ ...program.opts(), ...options }));
76
77
  program
@@ -95,10 +96,29 @@ program
95
96
  .allowUnknownOption()
96
97
  .allowExcessArguments()
97
98
  .action(async (_fn, _args, options) => runGraph(process.argv, options));
99
+ program
100
+ .command("logscope [fn] [args...]")
101
+ .description("Log tracking: ask this machine's log corpus a question, or `index` / `corpora` / `allow` / `prune`")
102
+ .option("--root <dir>", "the project this command is about (default: the working tree's root)")
103
+ .option("--corpus <ref>", "a corpus id, or a path belonging to one")
104
+ .option("--budget <n>", "token ceiling for the answer; 0 means unbounded")
105
+ .option("--limit <n>", "rows requested — beats a default budget, never an explicit one")
106
+ .option("--retain <days>", "retention for `index`; 0 keeps the corpus until it is forgotten")
107
+ .option("--examples", "keep up to three exemplar log lines per template (off by default)")
108
+ .option("--no-redact", "store exemplars unredacted (loud, recorded on the corpus)")
109
+ .option("--since <ts>", "window start for container sources")
110
+ .option("--until <ts>", "window end for container sources")
111
+ .option("--revision <sha>", "the source revision these logs were written by")
112
+ .option("--note <text>", "a note recorded with an `allow` grant")
113
+ .option("--dry-run", "for `prune`: report what would be dropped and delete nothing")
114
+ .option("--json", "emit the versioned machine envelope with the answer as data")
115
+ .allowUnknownOption()
116
+ .allowExcessArguments()
117
+ .action(async (_fn, _args, options) => runLogscope(process.argv, options));
98
118
  program
99
119
  .command("doctor")
100
120
  .description("Run the full local diagnostics matrix (identity, repository, manifests, toolkit, telemetry, credentials)")
101
- .option("--workspace <id>", "the Workspace to diagnose (defaults to the bound repository)")
121
+ .option("--project <id>", "the Project to diagnose (defaults to the bound repository)")
102
122
  .option("--json", "emit the versioned machine envelope (secrets and absolute paths redacted)")
103
123
  .option("--local-verbose", "include Device-local absolute paths in the output")
104
124
  .action(async (options) => runDoctor({ ...program.opts(), ...options }));
@@ -108,6 +128,24 @@ program
108
128
  .option("--env <name>", "pick a specific endpoint environment (default: dev)")
109
129
  .option("--json", "print the resolved URL as the machine envelope instead of launching")
110
130
  .action(async (service, options) => runOpen(service, options, program.opts()));
131
+ const token = program.command("token").description("Mint, list and revoke your personal access tokens (the NUSTACK_TOKEN a pipeline or agent uses)");
132
+ token
133
+ .command("create")
134
+ .description("Mint a personal access token; the secret is printed once, on stdout alone")
135
+ .requiredOption("--label <label>", "a name for the token, unique among your live ones")
136
+ .option("--expires <days>", "days until the token expires (1–365); no expiry when omitted")
137
+ .option("--json", "emit the versioned machine envelope")
138
+ .action(async (options) => runTokenCreate(options, program.opts()));
139
+ token
140
+ .command("list")
141
+ .description("List your personal access tokens (never a secret)")
142
+ .option("--json", "emit the versioned machine envelope")
143
+ .action(async (options) => runTokenList(options, program.opts()));
144
+ token
145
+ .command("revoke <token-id>")
146
+ .description("Revoke a personal access token; it stops working on the next request")
147
+ .option("--json", "emit the versioned machine envelope")
148
+ .action(async (tokenId, options) => runTokenRevoke(tokenId, options, program.opts()));
111
149
  const device = program.command("device").description("Inspect your signed-in devices and end this machine's access");
112
150
  device
113
151
  .command("list")
@@ -144,9 +182,9 @@ program
144
182
  const toLimit = (value) => Number.parseInt(value, 10);
145
183
  const search = program
146
184
  .command("search [keywords...]")
147
- .description("Search the NuStack Directory for workspaces by keyword")
185
+ .description("Search the NuStack Directory for projects by keyword")
148
186
  .option("--json", "emit the raw results array as JSON")
149
- .option("--category <cat>", "filter workspaces by category")
187
+ .option("--category <cat>", "filter projects by category")
150
188
  .option("--limit <n>", "max results (default 20)", toLimit)
151
189
  .action(async (keywords, options) => runSearch(keywords, options, program.opts()));
152
190
  search
@@ -156,8 +194,8 @@ search
156
194
  .option("--type <type>", "filter services by type")
157
195
  .option("--limit <n>", "max results (default 20)", toLimit)
158
196
  .action(async (keywords, options) => runSearchServices(keywords, { ...search.opts(), ...options }, program.opts()));
159
- search.addHelpText("after", '\n`services` is a reserved subcommand. To search workspaces for the literal word "services", use the\n' +
160
- "explicit separator: `nustack search -- services` (everything after `--` is an workspaces query).");
197
+ search.addHelpText("after", '\n`services` is a reserved subcommand. To search projects for the literal word "services", use the\n' +
198
+ "explicit separator: `nustack search -- services` (everything after `--` is an projects query).");
161
199
  const organization = program.command("organization").description("Work with your NuStack Organizations");
162
200
  organization
163
201
  .command("list")
@@ -174,66 +212,80 @@ organization
174
212
  .description("Set the active Organization context for this device")
175
213
  .option("--json", "emit the versioned machine envelope")
176
214
  .action(async (target, options) => runOrganizationUse(target, options, program.opts()));
177
- const workspace = program.command("workspace").description("Work with NuStack Workspaces");
178
- registerWorkspaceCommands(workspace, () => program.opts());
179
- registerWorkspaceSyncCommands(workspace, () => program.opts());
215
+ const project = program.command("project").description("Work with NuStack Projects");
216
+ registerProjectCommands(project, () => program.opts());
217
+ registerProjectSyncCommands(project, () => program.opts());
180
218
  registerReviewCommands(program.command("review").description("Read and mirror NuStack Review items"), () => program.opts());
181
- const docs = program.command("docs").description("Work with a Workspace's Documents");
219
+ const docs = program.command("docs").description("Work with a Project's Documents");
182
220
  docs
183
221
  .command("list")
184
- .description("List the Workspace's Documents")
185
- .option("--workspace <id>", "the Workspace to act as (defaults to the bound repository)")
222
+ .description("List the Project's Documents")
223
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
186
224
  .option("--json", "emit the versioned machine envelope")
187
225
  .action(async (options) => runDocsList(options, program.opts()));
188
226
  docs
189
227
  .command("show <slug-or-id>")
190
228
  .description("Show a Document: a bounded inline body or its content URL")
191
- .option("--workspace <id>", "the Workspace to act as (defaults to the bound repository)")
229
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
192
230
  .option("--json", "emit the versioned machine envelope")
193
231
  .action(async (target, options) => runDocsShow(target, options, program.opts()));
194
232
  docs
195
233
  .command("status")
196
234
  .description("Report per-path local/cloud Document convergence for the bound repository")
197
- .option("--workspace <id>", "the Workspace to act as (defaults to the bound repository)")
235
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
198
236
  .option("--json", "emit the versioned machine envelope")
199
237
  .action(async (options) => runDocsStatus(options, program.opts()));
200
- const handoff = program.command("handoff").description("Work with a Workspace's Handoffs");
238
+ const handoff = program.command("handoff").description("Work with a Project's Handoffs");
201
239
  handoff
202
240
  .command("list")
203
- .description("List the Workspace's Handoffs")
204
- .option("--workspace <id>", "the Workspace to act as (defaults to the bound repository)")
241
+ .description("List the Project's Handoffs")
242
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
205
243
  .option("--json", "emit the versioned machine envelope")
206
244
  .action(async (options) => runHandoffList(options, program.opts()));
207
245
  handoff
208
246
  .command("show <id>")
209
247
  .description("Show a Handoff (add --targets for the binding-scoped per-target manifest)")
210
- .option("--workspace <id>", "the Workspace to act as (defaults to the bound repository)")
248
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
211
249
  .option("--targets", "list each target's path, operation, and base semantics")
212
250
  .option("--json", "emit the versioned machine envelope")
213
251
  .action(async (id, options) => runHandoffShow(id, options, program.opts()));
214
- const lane = program.command("lane").description("The desktop build room's lane engine");
215
- lane
252
+ const sessions = program
253
+ .command("sessions")
254
+ .description("Sessions on this machine — the engine the desktop drives, and the record of what ran");
255
+ sessions
216
256
  .command("serve")
217
- .description("Run the long-lived lane supervisor: JSONL frames on stdin/stdout (used by the NuStack desktop)")
218
- .action(async () => runLaneServe(version));
219
- const sessions = program.command("sessions").description("The session record over this machine's agent work");
257
+ .description("Run the long-lived session supervisor: JSONL frames on stdin/stdout (used by the NuStack desktop)")
258
+ .action(async () => runSessionsServe(version));
220
259
  sessions
221
260
  .command("record")
222
261
  .description("Record the sessions one desktop scan found in a bound folder (used by the NuStack desktop)")
223
262
  .option("--scan <json>", "the scan payload as JSON, or @<path> to read it from a file")
224
263
  .option("--json", "emit the versioned machine envelope")
225
264
  .action(async (options) => runSessionsRecord(options, program.opts()));
226
- const toolkit = program.command("toolkit").description("Inspect and reconcile the Workspace's toolkit pin");
265
+ sessions
266
+ .command("list")
267
+ .description("List the sessions this machine's engine opened, and what each one's worktree and branch still hold")
268
+ .option("--json", "emit the versioned machine envelope")
269
+ .action(async (options) => runSessionsList(options));
270
+ sessions
271
+ .command("prune")
272
+ .description("Reclaim the worktree and branch of a session that has ended (preview unless --yes)")
273
+ .option("--session <id>", "reclaim one session instead of every reclaimable one")
274
+ .option("--yes", "apply the preview without prompting")
275
+ .option("--force", "also reclaim an unmerged or dirty session — never one whose engine is running")
276
+ .option("--json", "emit the versioned machine envelope")
277
+ .action(async (options) => runSessionsPrune(options));
278
+ const toolkit = program.command("toolkit").description("Inspect and reconcile the Project's toolkit pin");
227
279
  toolkit
228
280
  .command("status")
229
281
  .description("Compare the cloud pin against .nustack/workspace.json and nustack.service.json (F-026)")
230
- .option("--workspace <id>", "the Workspace to act as (defaults to the bound repository)")
282
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
231
283
  .option("--json", "emit the versioned machine envelope")
232
284
  .action(async (options) => runToolkitStatus(options, program.opts()));
233
285
  toolkit
234
286
  .command("reconcile")
235
287
  .description("Preview and apply the deterministic toolkit repair (no User content is touched)")
236
- .option("--workspace <id>", "the Workspace to act as (defaults to the bound repository)")
288
+ .option("--project <id>", "the Project to act as (defaults to the bound repository)")
237
289
  .option("--yes", "apply the deterministic preview without prompting")
238
290
  .option("--json", "emit the versioned machine envelope")
239
291
  .action(async (options) => runToolkitReconcile(options, program.opts()));
@@ -253,14 +305,14 @@ agentHook
253
305
  .action(async () => runAgentHookRemove());
254
306
  function describeError(error) {
255
307
  const directCode = error?.code;
256
- if (directCode === "WORKSPACE_CONTEXT_MISSING") {
257
- return "no Workspace in context — run inside a bound repository (`.nustack/workspace.json`) or pass `--workspace <id>`.";
308
+ if (directCode === "PROJECT_CONTEXT_MISSING") {
309
+ return "no Project in context — run inside a bound repository (`.nustack/workspace.json`) or pass `--project <id>`.";
258
310
  }
259
311
  if (directCode === "ORGANIZATION_CONTEXT_MISSING") {
260
312
  return "no Organization in context — run `nustack organization use <id>` or pass `--organization <id>`.";
261
313
  }
262
314
  if (directCode === "TOOLKIT_INCOMPATIBLE") {
263
- return "the installed toolkit is incompatible with the Workspace's cloud pin — run `nustack toolkit reconcile`.";
315
+ return "the installed toolkit is incompatible with the Project's cloud pin — run `nustack toolkit reconcile`.";
264
316
  }
265
317
  const cause = error?.cause;
266
318
  const code = cause?.code;
@@ -279,6 +279,28 @@ export async function revokeGrant(input) {
279
279
  return "unreachable";
280
280
  }
281
281
  }
282
+ export const ENV_TOKEN_VARIABLE = "NUSTACK_TOKEN";
283
+ export const PERSONAL_TOKEN_PREFIX = "nst_pat_";
284
+ export function envToken() {
285
+ const raw = process.env[ENV_TOKEN_VARIABLE]?.trim();
286
+ if (!raw)
287
+ return null;
288
+ const halves = raw.startsWith(PERSONAL_TOKEN_PREFIX) ? raw.slice(PERSONAL_TOKEN_PREFIX.length).split("_") : [];
289
+ if (halves.length !== 2 || !halves[0] || !halves[1]) {
290
+ return {
291
+ kind: "malformed",
292
+ message: `${ENV_TOKEN_VARIABLE} is not a NuStack personal access token — expected \`${PERSONAL_TOKEN_PREFIX}<id>_<secret>\`, as \`nustack token create\` prints it.`,
293
+ };
294
+ }
295
+ return { kind: "ok", token: raw };
296
+ }
297
+ export class TokenRefusedError extends Error {
298
+ code = "TOKEN_REFUSED";
299
+ constructor() {
300
+ super(`${ENV_TOKEN_VARIABLE} was refused by NuStack — it is revoked, expired, or was minted for another NuStack. Mint a new one with \`nustack token create\`.`);
301
+ this.name = "TokenRefusedError";
302
+ }
303
+ }
282
304
  async function readStoredGrant() {
283
305
  const deviceId = await getOrCreateDeviceId();
284
306
  resolvedDeviceId = deviceId;
@@ -332,6 +354,14 @@ async function refreshAndPersist(grant, audience, nustackBaseUrl) {
332
354
  return refreshed;
333
355
  }
334
356
  export async function resolveAccessToken(nustackBaseUrl, resource) {
357
+ const fromEnv = envToken();
358
+ if (fromEnv) {
359
+ if (fromEnv.kind === "malformed")
360
+ return { kind: "malformed_token", message: fromEnv.message };
361
+ const deviceId = resolvedDeviceId ?? (await getOrCreateDeviceId());
362
+ resolvedDeviceId = deviceId;
363
+ return { kind: "ok", session: { accessToken: fromEnv.token, userEmail: "", deviceId, authBaseUrl: "" } };
364
+ }
335
365
  const { deviceId, grant, failure } = await readStoredGrant();
336
366
  if (!grant) {
337
367
  if (failure)
@@ -363,6 +393,8 @@ export async function ensureAccessToken(nustackBaseUrl, resource) {
363
393
  return outcome.kind === "ok" ? { accessToken: outcome.session.accessToken, userEmail: outcome.session.userEmail } : null;
364
394
  }
365
395
  export async function refreshAccessTokenAfterRefusal(staleToken, nustackBaseUrl, resource) {
396
+ if (envToken())
397
+ return null;
366
398
  const { deviceId, grant } = await readStoredGrant();
367
399
  if (!grant)
368
400
  return null;
@@ -13,6 +13,8 @@ function describeFailure(outcome) {
13
13
  if (outcome.kind === "needs_reauthorization") {
14
14
  return { code: "REAUTHORIZATION_REQUIRED", message: `This machine isn't approved for ${outcome.resource} yet — run \`nustack login\` to approve it.`, retryable: false };
15
15
  }
16
+ if (outcome.kind === "malformed_token")
17
+ return { code: "TOKEN_MALFORMED", message: outcome.message, retryable: false };
16
18
  return { code: "SIGNED_OUT", message: deadCredentialCopy(outcome.reason), retryable: false };
17
19
  }
18
20
  function reconnectingCopy(outcome) {
@@ -36,7 +38,7 @@ export async function ensureAuthWithRecovery(baseUrl, json) {
36
38
  if (outcome.kind === "ok")
37
39
  return { accessToken: outcome.session.accessToken, userEmail: outcome.session.userEmail };
38
40
  lastFailure = describeFailure(outcome);
39
- if (json || !isInteractive())
41
+ if (json || !isInteractive() || outcome.kind === "malformed_token")
40
42
  return null;
41
43
  p.log.warn(reconnectingCopy(outcome));
42
44
  const didLogin = await performDeviceLogin(baseUrl, process.cwd(), { reauthorize: true });
@@ -0,0 +1,106 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { ensureNuStackLayout, nustackHomeDir } from "./local_store/paths.js";
7
+ import { libsecretStore, isLibsecretAvailable } from "./libsecret_store.js";
8
+ const runFile = promisify(execFile);
9
+ const DEFAULT_SECURITY_BIN = "/usr/bin/security";
10
+ const SERVICE = "nustack-device";
11
+ const OWNER_ONLY_FILE = 0o600;
12
+ export class CredentialStoreUnavailableError extends Error {
13
+ constructor(name) {
14
+ super(`NUSTACK_CREDENTIAL_STORE=${name} names a store this machine does not have — unset it, or use \`file\`.`);
15
+ this.name = "CredentialStoreUnavailableError";
16
+ }
17
+ }
18
+ function securityBin() {
19
+ return process.env.NUSTACK_KEYCHAIN_BIN?.trim() || DEFAULT_SECURITY_BIN;
20
+ }
21
+ export function isKeychainAvailable() {
22
+ const override = process.env.NUSTACK_KEYCHAIN_BIN?.trim();
23
+ if (override)
24
+ return existsSync(override);
25
+ return process.platform === "darwin" && existsSync(DEFAULT_SECURITY_BIN);
26
+ }
27
+ export const keychainStore = {
28
+ name: "keychain",
29
+ async readRaw(account) {
30
+ try {
31
+ const { stdout } = await runFile(securityBin(), ["find-generic-password", "-s", SERVICE, "-a", account, "-w"]);
32
+ return stdout.trim();
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ },
38
+ async writeRaw(account, payload) {
39
+ await runFile(securityBin(), ["add-generic-password", "-U", "-s", SERVICE, "-a", account, "-w", payload]);
40
+ },
41
+ async deleteRaw(account) {
42
+ try {
43
+ await runFile(securityBin(), ["delete-generic-password", "-s", SERVICE, "-a", account]);
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ },
50
+ };
51
+ export function credentialsFilePath() {
52
+ return path.join(nustackHomeDir(), "credentials.json");
53
+ }
54
+ async function readCredentialsFile(account) {
55
+ try {
56
+ const raw = await readFile(credentialsFilePath(), "utf8");
57
+ const parsed = JSON.parse(raw);
58
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
59
+ return null;
60
+ if (parsed.device_id !== account)
61
+ return null;
62
+ return raw.trim();
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ export const fileStore = {
69
+ name: "file",
70
+ readRaw: readCredentialsFile,
71
+ async writeRaw(_account, payload) {
72
+ await ensureNuStackLayout();
73
+ const target = credentialsFilePath();
74
+ const staging = `${target}.${process.pid}.tmp`;
75
+ await writeFile(staging, `${payload}\n`, { encoding: "utf8", mode: OWNER_ONLY_FILE });
76
+ await rename(staging, target);
77
+ },
78
+ async deleteRaw(account) {
79
+ if (!(await readCredentialsFile(account)))
80
+ return false;
81
+ await rm(credentialsFilePath(), { force: true });
82
+ return true;
83
+ },
84
+ };
85
+ export function selectCredentialStore() {
86
+ const requested = (process.env.NUSTACK_CREDENTIAL_STORE ?? "auto").trim().toLowerCase();
87
+ if (requested === "keychain") {
88
+ if (!isKeychainAvailable())
89
+ throw new CredentialStoreUnavailableError(requested);
90
+ return keychainStore;
91
+ }
92
+ if (requested === "libsecret") {
93
+ if (!isLibsecretAvailable())
94
+ throw new CredentialStoreUnavailableError(requested);
95
+ return libsecretStore;
96
+ }
97
+ if (requested === "file")
98
+ return fileStore;
99
+ if (requested !== "auto")
100
+ throw new CredentialStoreUnavailableError(requested);
101
+ if (isKeychainAvailable())
102
+ return keychainStore;
103
+ if (isLibsecretAvailable())
104
+ return libsecretStore;
105
+ return fileStore;
106
+ }
@@ -1,22 +1,22 @@
1
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { readFile, rm } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { randomBytes } from "node:crypto";
4
- import os from "node:os";
5
4
  import path from "node:path";
5
+ import { ensureNuStackLayout, nustackHomeDir } from "./local_store/paths.js";
6
+ import { writeFileSecure } from "./secure_file.js";
6
7
  function deviceIdFilePath() {
7
- return path.join(os.homedir(), ".nustack", "device_id");
8
+ return path.join(nustackHomeDir(), "device_id");
8
9
  }
9
10
  export async function getOrCreateDeviceId() {
10
11
  const file = deviceIdFilePath();
11
- const dir = path.dirname(file);
12
12
  if (existsSync(file)) {
13
13
  const existing = (await readFile(file, "utf8")).trim();
14
14
  if (existing)
15
15
  return existing;
16
16
  }
17
17
  const deviceId = `dev_${randomBytes(16).toString("hex")}`;
18
- await mkdir(dir, { recursive: true });
19
- await writeFile(file, `${deviceId}\n`, "utf8");
18
+ await ensureNuStackLayout();
19
+ await writeFileSecure(file, `${deviceId}\n`);
20
20
  return deviceId;
21
21
  }
22
22
  export async function clearDeviceId() {
@@ -1,18 +1,6 @@
1
- import { execFile } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { promisify } from "node:util";
4
- const runFile = promisify(execFile);
5
- const DEFAULT_SECURITY_BIN = "/usr/bin/security";
6
- const SERVICE = "nustack-device";
1
+ import { selectCredentialStore } from "./credential_store.js";
2
+ export { isKeychainAvailable } from "./credential_store.js";
7
3
  export const KEYCHAIN_SCHEMA_VERSION = 3;
8
- function securityBin() {
9
- return process.env.NUSTACK_KEYCHAIN_BIN?.trim() || DEFAULT_SECURITY_BIN;
10
- }
11
- export function isKeychainAvailable() {
12
- if (process.env.NUSTACK_KEYCHAIN_BIN)
13
- return true;
14
- return process.platform === "darwin" && existsSync(DEFAULT_SECURITY_BIN);
15
- }
16
4
  const REQUIRED_FIELDS = ["refresh_token", "auth_base_url", "issuer", "device_id", "user_email"];
17
5
  export function decodeCredential(raw) {
18
6
  let parsed;
@@ -51,7 +39,7 @@ export function mergeCredential(existing, update) {
51
39
  export async function writeDeviceCredential(input) {
52
40
  const stored = await readRawCredential(input.device_id);
53
41
  const payload = mergeCredential(stored, input);
54
- await runFile(securityBin(), ["add-generic-password", "-U", "-s", SERVICE, "-a", input.device_id, "-w", JSON.stringify(payload)]);
42
+ await selectCredentialStore().writeRaw(input.device_id, JSON.stringify(payload));
55
43
  }
56
44
  export async function readDeviceCredential(deviceId) {
57
45
  const decoded = await readDecoded(deviceId);
@@ -70,32 +58,23 @@ async function readRawCredential(deviceId) {
70
58
  return decoded.reason === "older-schema" || decoded.reason === "future-schema" ? decoded.stored : null;
71
59
  }
72
60
  async function readDecoded(deviceId) {
61
+ const raw = await selectCredentialStore().readRaw(deviceId);
62
+ if (raw === null)
63
+ return null;
64
+ const decoded = decodeCredential(raw);
65
+ if (decoded.ok)
66
+ return decoded;
67
+ let stored = null;
73
68
  try {
74
- const { stdout } = await runFile(securityBin(), ["find-generic-password", "-s", SERVICE, "-a", deviceId, "-w"]);
75
- const decoded = decodeCredential(stdout.trim());
76
- if (decoded.ok)
77
- return decoded;
78
- let stored = null;
79
- try {
80
- const parsed = JSON.parse(stdout.trim());
81
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
82
- stored = parsed;
83
- }
84
- catch {
85
- stored = null;
86
- }
87
- return { ...decoded, stored };
69
+ const parsed = JSON.parse(raw.trim());
70
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
71
+ stored = parsed;
88
72
  }
89
73
  catch {
90
- return null;
74
+ stored = null;
91
75
  }
76
+ return { ...decoded, stored };
92
77
  }
93
78
  export async function deleteDeviceCredential(deviceId) {
94
- try {
95
- await runFile(securityBin(), ["delete-generic-password", "-s", SERVICE, "-a", deviceId]);
96
- return true;
97
- }
98
- catch {
99
- return false;
100
- }
79
+ return selectCredentialStore().deleteRaw(deviceId);
101
80
  }
@@ -1,17 +1,17 @@
1
- import { getWorkspace } from "../nustack_client.js";
1
+ import { getProject } from "../nustack_client.js";
2
2
  export class ToolkitProfileUnavailableError extends Error {
3
- workspaceId;
4
- constructor(workspaceId) {
5
- super("The Workspace summary did not project a Toolkit Profile pin — the owning server plan must expose it on GET /api/v2/workspaces/{workspaceId}.");
3
+ projectId;
4
+ constructor(projectId) {
5
+ super("The Project summary did not project a Toolkit Profile pin — the owning server plan must expose it on GET /api/v2/projects/{projectId}.");
6
6
  this.name = "ToolkitProfileUnavailableError";
7
- this.workspaceId = workspaceId;
7
+ this.projectId = projectId;
8
8
  }
9
9
  }
10
- export async function fetchToolkitProfile(baseUrl, accessToken, workspaceId) {
11
- const workspace = await getWorkspace(baseUrl, accessToken, workspaceId);
12
- const toolkit = workspace.toolkit;
10
+ export async function fetchToolkitProfile(baseUrl, accessToken, projectId) {
11
+ const project = await getProject(baseUrl, accessToken, projectId);
12
+ const toolkit = project.toolkit;
13
13
  if (!toolkit || typeof toolkit.name !== "string" || typeof toolkit.version !== "string") {
14
- throw new ToolkitProfileUnavailableError(workspaceId);
14
+ throw new ToolkitProfileUnavailableError(projectId);
15
15
  }
16
16
  return {
17
17
  name: toolkit.name,
@@ -1,4 +1,4 @@
1
- import { writeWorkspaceManifests, readWorkspaceManifests, diagnoseManifests, } from "../onboarding/manifest_writer.js";
1
+ import { writeProjectManifests, readProjectManifests, diagnoseManifests, } from "../onboarding/manifest_writer.js";
2
2
  import { serializeProfileForBinding } from "./profile.js";
3
3
  import { updateBindingEtnaProfile } from "../local_store/bindings.js";
4
4
  export function parseRecordedToolkit(binding) {
@@ -35,16 +35,16 @@ export function toolkitCompatibility(binding, cloud) {
35
35
  return { compatible: true, blocksWrites: false, reason: null };
36
36
  }
37
37
  export async function buildReconcilePlan(root, binding, cloud) {
38
- const manifests = await readWorkspaceManifests(root);
38
+ const manifests = await readProjectManifests(root);
39
39
  const cloudId = { name: cloud.name, version: cloud.version };
40
- const diagnosis = diagnoseManifests(cloudId, manifests.workspaceManifest?.toolkit ?? null, manifests.serviceToolkit);
40
+ const diagnosis = diagnoseManifests(cloudId, manifests.projectManifest?.toolkit ?? null, manifests.serviceToolkit);
41
41
  const changes = [];
42
- const workspace = manifests.workspaceManifest?.toolkit;
43
- if (workspace) {
44
- pushIf(changes, ".nustack/workspace.json", "toolkit.name", workspace.name, cloud.name);
45
- pushIf(changes, ".nustack/workspace.json", "toolkit.version", workspace.version, cloud.version);
46
- pushIf(changes, ".nustack/workspace.json", "toolkit.rendererVersion", workspace.rendererVersion, cloud.rendererVersion);
47
- pushIf(changes, ".nustack/workspace.json", "toolkit.placementMapVersion", workspace.placementMapVersion, cloud.placementMapVersion);
42
+ const project = manifests.projectManifest?.toolkit;
43
+ if (project) {
44
+ pushIf(changes, ".nustack/workspace.json", "toolkit.name", project.name, cloud.name);
45
+ pushIf(changes, ".nustack/workspace.json", "toolkit.version", project.version, cloud.version);
46
+ pushIf(changes, ".nustack/workspace.json", "toolkit.rendererVersion", project.rendererVersion, cloud.rendererVersion);
47
+ pushIf(changes, ".nustack/workspace.json", "toolkit.placementMapVersion", project.placementMapVersion, cloud.placementMapVersion);
48
48
  }
49
49
  const svc = manifests.serviceToolkit;
50
50
  if (svc) {
@@ -63,8 +63,8 @@ function pushIf(changes, file, field, from, to) {
63
63
  changes.push({ file, field, from, to });
64
64
  }
65
65
  export async function applyReconcile(store, bindingId, root, identity, cloud) {
66
- await writeWorkspaceManifests(root, {
67
- workspaceId: identity.workspaceId,
66
+ await writeProjectManifests(root, {
67
+ projectId: identity.projectId,
68
68
  organizationId: identity.organizationId,
69
69
  toolkit: {
70
70
  name: cloud.name,
@@ -10,7 +10,7 @@ const DEFAULT_HEARTBEAT_MS = 30_000;
10
10
  export function observeBinding(binding) {
11
11
  const base = {
12
12
  bindingId: binding.id,
13
- workspaceId: binding.workspaceId,
13
+ projectId: binding.projectId,
14
14
  boundBranch: binding.boundBranch,
15
15
  status: binding.status,
16
16
  resolved: false,
@@ -18,7 +18,7 @@ export function observeBinding(binding) {
18
18
  operationState: "unknown",
19
19
  headCommitId: null,
20
20
  lastProcessedCommitId: binding.lastProcessedCommitId,
21
- lastAppliedWorkspaceSequence: binding.lastAppliedWorkspaceSequence,
21
+ lastAppliedProjectSequence: binding.lastAppliedProjectSequence,
22
22
  pendingCommitCount: 0,
23
23
  };
24
24
  let repo;
@@ -209,12 +209,12 @@ export function createSyncEngine(options) {
209
209
  safeEmit({
210
210
  event: "binding_status",
211
211
  bindingId: observation.bindingId,
212
- workspaceId: observation.workspaceId,
212
+ projectId: observation.projectId,
213
213
  boundBranch: observation.boundBranch,
214
214
  status: observation.status,
215
215
  headCommitId: observation.headCommitId,
216
216
  lastProcessedCommitId: observation.lastProcessedCommitId,
217
- lastAppliedWorkspaceSequence: observation.lastAppliedWorkspaceSequence,
217
+ lastAppliedProjectSequence: observation.lastAppliedProjectSequence,
218
218
  pendingCommitCount: observation.pendingCommitCount,
219
219
  });
220
220
  }