@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
package/README.md CHANGED
@@ -74,4 +74,5 @@ nustack telemetry [status|enable|disable|reinstall]
74
74
 
75
75
  - **`--json` almost everywhere** — most commands emit the `nustackJsonVersion: 1` machine envelope with `--json`; errors are typed (`{ error: { code, message } }`), never a bare HTTP status. Four surfaces break the pattern by design — `describe`, `search`, `search services`, and the `workspace sync` family emit a bare array/object instead, because the desktop and the agents pin those exact shapes.
76
76
  - **Base URL** — `--nustack-url` flag → `NUSTACK_URL` env → the hosted default (`https://apps.nustack.ai`).
77
+ - **Credential** — `NUSTACK_TOKEN` (a personal access token from `nustack token create`, for pipelines and agents; wins over every store, never refreshes, never opens a browser) → the OS store (macOS Keychain, libsecret on Linux) → `~/.nustack/credentials.json` (`0600`) where no OS store exists. `NUSTACK_CREDENTIAL_STORE=auto|keychain|libsecret|file` overrides the probe.
77
78
  - **Organization context** — `nustack organization use` persists the selection locally; commands send it as a header the server re-proves against your memberships on every request.
@@ -22,7 +22,7 @@ export async function runBootstrap(options) {
22
22
  return;
23
23
  await ensureDeviceTelemetry({ baseUrl, log: (message) => p.log.info(message) });
24
24
  const state = classifyFolder(cwd);
25
- const bound = state.class === "git_worktree" && state.repository && state.git?.markerWorkspaceId
25
+ const bound = state.class === "git_worktree" && state.repository && state.git?.markerProjectId
26
26
  ? await printBoundStatus(state.repository.repositoryRoot)
27
27
  : false;
28
28
  if (bound)
@@ -42,10 +42,10 @@ async function printBoundStatus(root) {
42
42
  "decision_required",
43
43
  "approved",
44
44
  ]);
45
- p.log.success(`This repository is bound to Workspace ${binding.workspaceId} on branch ${binding.boundBranch}.`);
46
- p.log.info(`Binding ${binding.id} · ${binding.status} · applied Workspace sequence ${binding.lastAppliedWorkspaceSequence}` +
45
+ p.log.success(`This repository is bound to Project ${binding.projectId} on branch ${binding.boundBranch}.`);
46
+ p.log.info(`Binding ${binding.id} · ${binding.status} · applied Project sequence ${binding.lastAppliedProjectSequence}` +
47
47
  (pending.length > 0 ? ` · ${pending.length} pending inbound` : ""));
48
- p.outro("Up to date locally — `nustack workspace status` for the cloud view, `nustack workspace sync now` to reconcile.");
48
+ p.outro("Up to date locally — `nustack project status` for the cloud view, `nustack project sync now` to reconcile.");
49
49
  return true;
50
50
  }
51
51
  finally {
@@ -1,13 +1,13 @@
1
1
  import { getDocument, listDocuments, resolveNuStackUrl, } from "../lib/nustack_client.js";
2
2
  import { ensureAuthWithRecovery, lastAuthFailure } from "../lib/auth_recovery.js";
3
- import { resolveWorkspaceId } from "../lib/workspace_context.js";
3
+ import { resolveProjectId } from "../lib/project_context.js";
4
4
  import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
5
5
  const INLINE_BODY_LIMIT = 16 * 1024;
6
6
  async function resolveContext(command, json, options, globals) {
7
7
  const baseUrl = resolveNuStackUrl(globals);
8
- const workspace = await resolveWorkspaceId(process.cwd(), options);
9
- if (!workspace.ok) {
10
- emitFail(command, json, "WORKSPACE_CONTEXT_MISSING", workspace.message);
8
+ const project = await resolveProjectId(process.cwd(), options);
9
+ if (!project.ok) {
10
+ emitFail(command, json, "PROJECT_CONTEXT_MISSING", project.message);
11
11
  return null;
12
12
  }
13
13
  const deviceAuth = await ensureAuthWithRecovery(baseUrl, json);
@@ -16,7 +16,7 @@ async function resolveContext(command, json, options, globals) {
16
16
  emitFail(command, json, failure.code, failure.message);
17
17
  return null;
18
18
  }
19
- return { baseUrl, accessToken: deviceAuth.accessToken, workspaceId: workspace.workspaceId };
19
+ return { baseUrl, accessToken: deviceAuth.accessToken, projectId: project.projectId };
20
20
  }
21
21
  function emitFail(command, json, code, message) {
22
22
  if (json)
@@ -31,13 +31,13 @@ export async function runDocsList(options, globals) {
31
31
  const ctx = await resolveContext("docs list", json, options, globals);
32
32
  if (!ctx)
33
33
  return;
34
- const documents = await listDocuments(ctx.baseUrl, ctx.accessToken, ctx.workspaceId);
34
+ const documents = await listDocuments(ctx.baseUrl, ctx.accessToken, ctx.projectId);
35
35
  if (json) {
36
- printJsonSuccess("docs list", { workspaceId: ctx.workspaceId, documents });
36
+ printJsonSuccess("docs list", { projectId: ctx.projectId, documents });
37
37
  return;
38
38
  }
39
39
  if (documents.length === 0) {
40
- console.log("This Workspace has no Documents yet.");
40
+ console.log("This Project has no Documents yet.");
41
41
  return;
42
42
  }
43
43
  for (const doc of documents) {
@@ -50,10 +50,10 @@ export async function runDocsShow(target, options, globals) {
50
50
  const ctx = await resolveContext("docs show", json, options, globals);
51
51
  if (!ctx)
52
52
  return;
53
- const documents = await listDocuments(ctx.baseUrl, ctx.accessToken, ctx.workspaceId);
53
+ const documents = await listDocuments(ctx.baseUrl, ctx.accessToken, ctx.projectId);
54
54
  const match = documents.find((d) => d.id === target || d.slug === target);
55
55
  if (!match) {
56
- emitFail("docs show", json, "DOCUMENT_NOT_FOUND", `No Document "${target}" in this Workspace.`);
56
+ emitFail("docs show", json, "DOCUMENT_NOT_FOUND", `No Document "${target}" in this Project.`);
57
57
  return;
58
58
  }
59
59
  const payload = await getDocument(ctx.baseUrl, ctx.accessToken, match.id);
@@ -97,7 +97,7 @@ export async function runDocsStatus(options, globals) {
97
97
  const ctx = await resolveContext("docs status", json, options, globals);
98
98
  if (!ctx)
99
99
  return;
100
- const documents = await listDocuments(ctx.baseUrl, ctx.accessToken, ctx.workspaceId);
100
+ const documents = await listDocuments(ctx.baseUrl, ctx.accessToken, ctx.projectId);
101
101
  const rows = documents.map((doc) => ({
102
102
  slug: doc.slug,
103
103
  documentId: doc.id,
@@ -106,11 +106,11 @@ export async function runDocsStatus(options, globals) {
106
106
  convergence: "unmapped",
107
107
  }));
108
108
  if (json) {
109
- printJsonSuccess("docs status", { workspaceId: ctx.workspaceId, paths: rows });
109
+ printJsonSuccess("docs status", { projectId: ctx.projectId, paths: rows });
110
110
  return;
111
111
  }
112
112
  if (rows.length === 0) {
113
- console.log("This Workspace has no Documents to converge.");
113
+ console.log("This Project has no Documents to converge.");
114
114
  return;
115
115
  }
116
116
  for (const row of rows) {
@@ -1,10 +1,11 @@
1
1
  import path from "node:path";
2
2
  import { resolveNuStackUrl } from "../lib/nustack_client.js";
3
- import { ensureAccessToken } from "../lib/auth_grant.js";
4
- import { isKeychainAvailable } from "../lib/device_keychain.js";
5
- import { resolveWorkspaceId } from "../lib/workspace_context.js";
3
+ import { stat } from "node:fs/promises";
4
+ import { ensureAccessToken, ENV_TOKEN_VARIABLE, envToken } from "../lib/auth_grant.js";
5
+ import { credentialsFilePath, selectCredentialStore } from "../lib/credential_store.js";
6
+ import { resolveProjectId } from "../lib/project_context.js";
6
7
  import { readManagedValues } from "../lib/env_file.js";
7
- import { readWorkspaceManifests, diagnoseManifests } from "../lib/onboarding/manifest_writer.js";
8
+ import { readProjectManifests, diagnoseManifests } from "../lib/onboarding/manifest_writer.js";
8
9
  import { fetchToolkitProfile, ToolkitProfileUnavailableError } from "../lib/etna/profile.js";
9
10
  import { toolkitCompatibility } from "../lib/etna/reconcile.js";
10
11
  import { openLocalStore } from "../lib/local_store/database.js";
@@ -19,6 +20,33 @@ import { NotARepositoryError } from "../lib/git-sync/core/git_runner.js";
19
20
  import { listBundles, sweepExpiredBundles } from "../lib/git-sync/recovery/store.js";
20
21
  import { inspectRecoveryKey } from "../lib/git-sync/recovery/crypto.js";
21
22
  import { NUSTACK_JSON_VERSION } from "../lib/json_output.js";
23
+ async function credentialStoreRows(environmentInUse) {
24
+ if (environmentInUse)
25
+ return [{ check: "Credential source", status: "ok", detail: "environment — no store is read" }];
26
+ let store;
27
+ try {
28
+ store = selectCredentialStore();
29
+ }
30
+ catch (error) {
31
+ return [{ check: "Credential source", status: "error", detail: error instanceof Error ? error.message : String(error) }];
32
+ }
33
+ const rows = [
34
+ { check: "Credential source", status: "ok", detail: store.name === "file" ? "file — no OS keychain on this machine" : store.name },
35
+ ];
36
+ if (store.name === "file") {
37
+ const filePath = credentialsFilePath();
38
+ try {
39
+ const mode = (await stat(filePath)).mode & 0o777;
40
+ rows.push(mode === 0o600
41
+ ? { check: "Credential file mode", status: "ok", detail: "0600" }
42
+ : { check: "Credential file mode", status: "warn", detail: `0${mode.toString(8)} — expected 0600; run \`chmod 600 ~/.nustack/credentials.json\`` });
43
+ }
44
+ catch {
45
+ rows.push({ check: "Credential file mode", status: "info", detail: "no credential file yet — sign in to create it" });
46
+ }
47
+ }
48
+ return rows;
49
+ }
22
50
  export async function runDoctor(options) {
23
51
  const cwd = process.cwd();
24
52
  const baseUrl = resolveNuStackUrl(options);
@@ -28,12 +56,18 @@ export async function runDoctor(options) {
28
56
  const add = (area, check, status, detail = "") => {
29
57
  rows.push({ area, check, status, detail });
30
58
  };
59
+ const fromEnv = envToken();
31
60
  const deviceAuth = await ensureAccessToken(baseUrl).catch(() => null);
32
- if (deviceAuth)
61
+ if (fromEnv?.kind === "malformed")
62
+ add("identity", "Device sign-in", "error", fromEnv.message);
63
+ else if (fromEnv)
64
+ add("identity", "Device sign-in", "ok", `${ENV_TOKEN_VARIABLE} is set — the environment's token is used`);
65
+ else if (deviceAuth)
33
66
  add("identity", "Device sign-in", "ok", `signed in as ${deviceAuth.userEmail}`);
34
67
  else
35
68
  add("identity", "Device sign-in", "error", "signed out — run `nustack login`");
36
- add("identity", "OS keychain", isKeychainAvailable() ? "ok" : "warn", isKeychainAvailable() ? "available" : "unavailable — sign-in relies on the 24h approval window");
69
+ for (const row of await credentialStoreRows(fromEnv !== null))
70
+ add("identity", row.check, row.status, row.detail);
37
71
  add("api", "NuStack reachability", ...(await probeReachability(baseUrl)));
38
72
  let store = null;
39
73
  try {
@@ -62,9 +96,9 @@ export async function runDoctor(options) {
62
96
  addRepositoryRows(add, repository);
63
97
  }
64
98
  const contextRoot = repository?.repositoryRoot ?? cwd;
65
- const workspaceResolution = await resolveWorkspaceId(contextRoot, { workspace: options.workspace });
66
- const workspaceId = workspaceResolution.ok ? workspaceResolution.workspaceId : null;
67
- const cloudProfile = deviceAuth && workspaceId ? await fetchCloudProfile(baseUrl, deviceAuth.accessToken, workspaceId) : null;
99
+ const projectResolution = await resolveProjectId(contextRoot, { project: options.project });
100
+ const projectId = projectResolution.ok ? projectResolution.projectId : null;
101
+ const cloudProfile = deviceAuth && projectId ? await fetchCloudProfile(baseUrl, deviceAuth.accessToken, projectId) : null;
68
102
  const binding = store && repository ? getBindingByRepositoryRoot(store, repository.repositoryRoot) : null;
69
103
  await addManifestRows(add, contextRoot, cloudProfile);
70
104
  addBindingRows(add, store, binding, cloudProfile);
@@ -127,9 +161,9 @@ function addRepositoryRows(add, repository) {
127
161
  else
128
162
  add("repository", "HEAD", "info", "unborn branch — no commits yet");
129
163
  }
130
- async function fetchCloudProfile(baseUrl, accessToken, workspaceId) {
164
+ async function fetchCloudProfile(baseUrl, accessToken, projectId) {
131
165
  try {
132
- return await fetchToolkitProfile(baseUrl, accessToken, workspaceId);
166
+ return await fetchToolkitProfile(baseUrl, accessToken, projectId);
133
167
  }
134
168
  catch (error) {
135
169
  if (error instanceof ToolkitProfileUnavailableError)
@@ -138,12 +172,12 @@ async function fetchCloudProfile(baseUrl, accessToken, workspaceId) {
138
172
  }
139
173
  }
140
174
  async function addManifestRows(add, root, cloud) {
141
- const manifests = await readWorkspaceManifests(root);
142
- if (!manifests.workspaceManifest && !manifests.serviceToolkit) {
143
- add("manifests", "Workspace binding markers", "info", "no `.nustack/app.json` or Service toolkit block — repository not attached");
175
+ const manifests = await readProjectManifests(root);
176
+ if (!manifests.projectManifest && !manifests.serviceToolkit) {
177
+ add("manifests", "Project binding markers", "info", "no `.nustack/app.json` or Service toolkit block — repository not attached");
144
178
  return;
145
179
  }
146
- const diagnosis = diagnoseManifests(cloud ? { name: cloud.name, version: cloud.version } : null, manifests.workspaceManifest?.toolkit ?? null, manifests.serviceToolkit);
180
+ const diagnosis = diagnoseManifests(cloud ? { name: cloud.name, version: cloud.version } : null, manifests.projectManifest?.toolkit ?? null, manifests.serviceToolkit);
147
181
  const status = diagnosis === "aligned" ? "ok" : diagnosis === "missing" ? "warn" : "warn";
148
182
  const suffix = diagnosis === "aligned" || diagnosis === "missing" ? "" : " — run `nustack toolkit reconcile`";
149
183
  add("manifests", "Marker / Service agreement", status, `${diagnosis}${cloud ? "" : " (no cloud pin reachable)"}${suffix}`);
@@ -154,7 +188,7 @@ function addBindingRows(add, store, binding, cloud) {
154
188
  return;
155
189
  }
156
190
  add("sync", "Sync Whitelist cache", binding.whitelistManifestSha256 ? "ok" : "info", binding.whitelistManifestSha256 ? "cached" : "not cached yet");
157
- add("sync", "Workspace cursor", "ok", `applied Workspace sequence ${binding.lastAppliedWorkspaceSequence}`);
191
+ add("sync", "Project cursor", "ok", `applied Project sequence ${binding.lastAppliedProjectSequence}`);
158
192
  if (store) {
159
193
  const pending = listApplicationsForBinding(store, binding.id, [
160
194
  "received",
@@ -208,7 +242,7 @@ function addTelemetryRows(add, store) {
208
242
  async function addRecoveryRows(add, store) {
209
243
  await sweepExpiredBundles(store).catch(() => { });
210
244
  const active = listBundles(store).filter((bundle) => bundle.state === "active");
211
- add("recovery", "Recovery bundles", active.length > 0 ? "info" : "ok", active.length > 0 ? `${active.length} active — \`nustack workspace sync recovery list\`` : "none");
245
+ add("recovery", "Recovery bundles", active.length > 0 ? "info" : "ok", active.length > 0 ? `${active.length} active — \`nustack project sync recovery list\`` : "none");
212
246
  const deviceRef = readDeviceState(store)?.deviceRef ?? null;
213
247
  if (!deviceRef)
214
248
  return;
@@ -1,15 +1,15 @@
1
1
  import { fetchHandoffManifest, getHandoff, listHandoffs, resolveNuStackUrl, } from "../lib/nustack_client.js";
2
2
  import { ensureAuthWithRecovery, lastAuthFailure } from "../lib/auth_recovery.js";
3
- import { resolveWorkspaceId } from "../lib/workspace_context.js";
3
+ import { resolveProjectId } from "../lib/project_context.js";
4
4
  import { openLocalStore } from "../lib/local_store/database.js";
5
5
  import { getBindingByRepositoryRoot } from "../lib/local_store/bindings.js";
6
6
  import { resolveRepository } from "../lib/git-sync/core/repository.js";
7
7
  import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
8
8
  async function resolveContext(command, json, options, globals) {
9
9
  const baseUrl = resolveNuStackUrl(globals);
10
- const workspace = await resolveWorkspaceId(process.cwd(), options);
11
- if (!workspace.ok) {
12
- emitFail(command, json, "WORKSPACE_CONTEXT_MISSING", workspace.message);
10
+ const project = await resolveProjectId(process.cwd(), options);
11
+ if (!project.ok) {
12
+ emitFail(command, json, "PROJECT_CONTEXT_MISSING", project.message);
13
13
  return null;
14
14
  }
15
15
  const deviceAuth = await ensureAuthWithRecovery(baseUrl, json);
@@ -18,7 +18,7 @@ async function resolveContext(command, json, options, globals) {
18
18
  emitFail(command, json, failure.code, failure.message);
19
19
  return null;
20
20
  }
21
- return { baseUrl, accessToken: deviceAuth.accessToken, workspaceId: workspace.workspaceId };
21
+ return { baseUrl, accessToken: deviceAuth.accessToken, projectId: project.projectId };
22
22
  }
23
23
  function emitFail(command, json, code, message) {
24
24
  if (json)
@@ -33,13 +33,13 @@ export async function runHandoffList(options, globals) {
33
33
  const ctx = await resolveContext("handoff list", json, options, globals);
34
34
  if (!ctx)
35
35
  return;
36
- const handoffs = await listHandoffs(ctx.baseUrl, ctx.accessToken, ctx.workspaceId);
36
+ const handoffs = await listHandoffs(ctx.baseUrl, ctx.accessToken, ctx.projectId);
37
37
  if (json) {
38
- printJsonSuccess("handoff list", { workspaceId: ctx.workspaceId, handoffs });
38
+ printJsonSuccess("handoff list", { projectId: ctx.projectId, handoffs });
39
39
  return;
40
40
  }
41
41
  if (handoffs.length === 0) {
42
- console.log("This Workspace has no Handoffs yet.");
42
+ console.log("This Project has no Handoffs yet.");
43
43
  return;
44
44
  }
45
45
  for (const h of handoffs) {
@@ -1,15 +1,15 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import * as p from "@clack/prompts";
4
- import { createWorkspaceViaAsk, createOrganization, getWorkspace, listWorkspaces, listOrganizations, resolveNuStackUrl, } from "../lib/nustack_client.js";
4
+ import { createProjectViaAsk, createOrganization, getProject, listProjects, listOrganizations, resolveNuStackUrl, } from "../lib/nustack_client.js";
5
5
  import { ensureAccessToken } from "../lib/auth_grant.js";
6
6
  import { getOrCreateDeviceId } from "../lib/device_identity.js";
7
7
  import { isInteractive } from "../lib/interactive.js";
8
- import { resolveOrganizationId } from "../lib/workspace_context.js";
8
+ import { resolveOrganizationId } from "../lib/project_context.js";
9
9
  import { openLocalStore } from "../lib/local_store/database.js";
10
10
  import { readDeviceState, upsertDeviceState } from "../lib/local_store/device_state.js";
11
11
  import { hasManifest, scaffoldManifest } from "../lib/manifest.js";
12
- import { readWorkspaceManifests } from "../lib/onboarding/manifest_writer.js";
12
+ import { readProjectManifests } from "../lib/onboarding/manifest_writer.js";
13
13
  import { attachRejection, classifyFolder } from "../lib/onboarding/folder_state.js";
14
14
  import { attachExistingGit } from "../lib/onboarding/attach_existing_git.js";
15
15
  import { initFreshFolder } from "../lib/onboarding/init_fresh_folder.js";
@@ -30,7 +30,7 @@ export async function runInit(options) {
30
30
  return;
31
31
  await runOnboardingFlow(baseUrl, accessToken, {
32
32
  yes: options.yes,
33
- workspace: options.workspace,
33
+ project: options.project,
34
34
  organization: options.organization,
35
35
  });
36
36
  }
@@ -75,18 +75,18 @@ export async function runOnboardingFlow(baseUrl, accessToken, options) {
75
75
  }
76
76
  }
77
77
  async function routeOnboarding(ctx, options) {
78
- if (options.workspaceId) {
79
- await routeWebFirst(ctx, options, options.workspaceId);
78
+ if (options.projectId) {
79
+ await routeWebFirst(ctx, options, options.projectId);
80
80
  return;
81
81
  }
82
82
  const state = classifyFolder(ctx.cwd);
83
83
  if (state.class === "git_worktree" && state.repository && state.git) {
84
- await routeExistingGit(ctx, options, state.repository.repositoryRoot, state.git.markerWorkspaceId);
84
+ await routeExistingGit(ctx, options, state.repository.repositoryRoot, state.git.markerProjectId);
85
85
  return;
86
86
  }
87
87
  await routeFreshFolder(ctx, options, state.class === "populated_non_git");
88
88
  }
89
- async function routeExistingGit(ctx, options, root, markerWorkspaceId) {
89
+ async function routeExistingGit(ctx, options, root, markerProjectId) {
90
90
  const state = classifyFolder(ctx.cwd);
91
91
  const rejection = state.git ? attachRejection(state.git) : null;
92
92
  if (rejection) {
@@ -96,12 +96,12 @@ async function routeExistingGit(ctx, options, root, markerWorkspaceId) {
96
96
  if (!hasManifest(root)) {
97
97
  await scaffoldManifest(root, deriveRepoName(path.basename(root)));
98
98
  }
99
- let workspaceId;
99
+ let projectId;
100
100
  let organizationId;
101
- if (markerWorkspaceId) {
102
- workspaceId = markerWorkspaceId;
103
- const manifests = await readWorkspaceManifests(root);
104
- const recorded = manifests.workspaceManifest?.organizationId ?? manifests.serviceWorkspaceBinding?.organizationId ?? null;
101
+ if (markerProjectId) {
102
+ projectId = markerProjectId;
103
+ const manifests = await readProjectManifests(root);
104
+ const recorded = manifests.projectManifest?.organizationId ?? manifests.serviceProjectBinding?.organizationId ?? null;
105
105
  const resolved = recorded ?? (await resolveOrganizationInteractive(ctx, options));
106
106
  if (!resolved)
107
107
  return;
@@ -112,14 +112,14 @@ async function routeExistingGit(ctx, options, root, markerWorkspaceId) {
112
112
  if (!resolved)
113
113
  return;
114
114
  organizationId = resolved;
115
- const chosen = await chooseOrCreateWorkspace(ctx, organizationId, root, "existing_local");
115
+ const chosen = await chooseOrCreateProject(ctx, organizationId, root, "existing_local");
116
116
  if (!chosen)
117
117
  return;
118
- workspaceId = chosen;
118
+ projectId = chosen;
119
119
  }
120
120
  const spinner = p.spinner();
121
121
  spinner.start("Attaching this repository");
122
- const outcome = await attachExistingGit(ctx, { workspaceId, organizationId, confirmRebind: true });
122
+ const outcome = await attachExistingGit(ctx, { projectId, organizationId, confirmRebind: true });
123
123
  spinner.stop(outcome.ok ? "Attached" : "Attach failed", outcome.ok ? 0 : 1);
124
124
  if (outcome.ok)
125
125
  reportSuccess(outcome.result);
@@ -130,7 +130,7 @@ async function routeFreshFolder(ctx, options, populated) {
130
130
  const organizationId = await resolveOrganizationInteractive(ctx, options);
131
131
  if (!organizationId)
132
132
  return;
133
- const identity = await promptWorkspaceIdentity(ctx);
133
+ const identity = await promptProjectIdentity(ctx);
134
134
  if (!identity)
135
135
  return;
136
136
  const input = { organizationId, name: identity.name, slug: identity.slug, hasUi: true };
@@ -148,26 +148,26 @@ async function routeFreshFolder(ctx, options, populated) {
148
148
  else
149
149
  reportFailure(outcome);
150
150
  }
151
- async function routeWebFirst(ctx, options, workspaceId) {
151
+ async function routeWebFirst(ctx, options, projectId) {
152
152
  const organizationId = await resolveOrganizationInteractive(ctx, options);
153
153
  if (!organizationId)
154
154
  return;
155
- let outcome = await webFirstAttach(ctx, { workspaceId, organizationId, confirmPopulated: ctx.yes || undefined });
155
+ let outcome = await webFirstAttach(ctx, { projectId, organizationId, confirmPopulated: ctx.yes || undefined });
156
156
  if (!outcome.ok && outcome.code === "CONFIRMATION_REQUIRED") {
157
157
  const confirmed = ctx.yes || (ctx.interactive && (await confirm(outcome.message)));
158
158
  if (!confirmed) {
159
159
  p.outro("No changes made — the folder was left untouched.");
160
160
  return;
161
161
  }
162
- outcome = await webFirstAttach(ctx, { workspaceId, organizationId, confirmPopulated: true });
162
+ outcome = await webFirstAttach(ctx, { projectId, organizationId, confirmPopulated: true });
163
163
  }
164
164
  if (!outcome.ok) {
165
165
  reportFailure(outcome);
166
166
  return;
167
167
  }
168
168
  if (outcome.result.overwriteRequired) {
169
- p.log.warn(`Local content diverges from the Workspace on ${outcome.result.divergent.map((d) => d.path).join(", ")}. ` +
170
- "Nothing was changed — review and approve with `nustack workspace sync approve` (a recovery bundle is kept).");
169
+ p.log.warn(`Local content diverges from the Project on ${outcome.result.divergent.map((d) => d.path).join(", ")}. ` +
170
+ "Nothing was changed — review and approve with `nustack project sync approve` (a recovery bundle is kept).");
171
171
  }
172
172
  reportSuccess(outcome.result);
173
173
  }
@@ -192,7 +192,7 @@ async function resolveOrganizationInteractive(ctx, options) {
192
192
  return organization.id;
193
193
  }
194
194
  const pick = await p.select({
195
- message: "Which Organization should this Workspace live in?",
195
+ message: "Which Organization should this Project live in?",
196
196
  options: memberships.map((m) => ({ value: m.organization.id, label: `${m.organization.label} (${m.organization.slug})` })),
197
197
  });
198
198
  if (p.isCancel(pick))
@@ -200,15 +200,15 @@ async function resolveOrganizationInteractive(ctx, options) {
200
200
  upsertDeviceState(ctx.store, { activeOrganizationId: String(pick) });
201
201
  return String(pick);
202
202
  }
203
- async function chooseOrCreateWorkspace(ctx, organizationId, root, entryMode) {
203
+ async function chooseOrCreateProject(ctx, organizationId, root, entryMode) {
204
204
  const CREATE = "__create__";
205
- const workspaces = ctx.interactive ? await listWorkspaces(ctx.baseUrl, ctx.accessToken, organizationId) : [];
205
+ const projects = ctx.interactive ? await listProjects(ctx.baseUrl, ctx.accessToken, organizationId) : [];
206
206
  const pick = ctx.interactive
207
207
  ? await p.select({
208
- message: "Attach this repository to which Workspace?",
208
+ message: "Attach this repository to which Project?",
209
209
  options: [
210
- { value: CREATE, label: "Create a new Workspace" },
211
- ...workspaces.map((a) => ({ value: a.id, label: `${a.label} (${a.id})` })),
210
+ { value: CREATE, label: "Create a new Project" },
211
+ ...projects.map((a) => ({ value: a.id, label: `${a.label} (${a.id})` })),
212
212
  ],
213
213
  })
214
214
  : CREATE;
@@ -216,33 +216,33 @@ async function chooseOrCreateWorkspace(ctx, organizationId, root, entryMode) {
216
216
  return null;
217
217
  if (pick !== CREATE)
218
218
  return String(pick);
219
- const identity = await promptWorkspaceIdentity(ctx, deriveRepoName(path.basename(root)));
219
+ const identity = await promptProjectIdentity(ctx, deriveRepoName(path.basename(root)));
220
220
  if (!identity)
221
221
  return null;
222
- return createAndAwaitWorkspace(ctx, organizationId, identity, entryMode);
222
+ return createAndAwaitProject(ctx, organizationId, identity, entryMode);
223
223
  }
224
- async function createAndAwaitWorkspace(ctx, organizationId, identity, entryMode) {
224
+ async function createAndAwaitProject(ctx, organizationId, identity, entryMode) {
225
225
  void entryMode;
226
226
  const spinner = p.spinner();
227
- spinner.start(`Creating Workspace "${identity.name}"`);
227
+ spinner.start(`Creating Project "${identity.name}"`);
228
228
  try {
229
- const outcome = await createWorkspaceViaAsk(ctx.baseUrl, ctx.accessToken, organizationId, `Create a workspace named "${identity.name}".`);
230
- const final = await getWorkspace(ctx.baseUrl, ctx.accessToken, outcome.workspaceId);
229
+ const outcome = await createProjectViaAsk(ctx.baseUrl, ctx.accessToken, organizationId, `Create a project named "${identity.name}".`);
230
+ const final = await getProject(ctx.baseUrl, ctx.accessToken, outcome.projectId);
231
231
  const failed = final.status === "initialization_failed" || final.initStatus === "failed";
232
- spinner.stop(failed ? "Workspace initialization failed" : `Workspace ${outcome.workspaceId} ready`, failed ? 1 : 0);
233
- return failed ? null : outcome.workspaceId;
232
+ spinner.stop(failed ? "Project initialization failed" : `Project ${outcome.projectId} ready`, failed ? 1 : 0);
233
+ return failed ? null : outcome.projectId;
234
234
  }
235
235
  catch (error) {
236
- spinner.stop("Workspace creation failed", 1);
236
+ spinner.stop("Project creation failed", 1);
237
237
  p.log.error(error instanceof Error ? error.message : String(error));
238
238
  return null;
239
239
  }
240
240
  }
241
- async function promptWorkspaceIdentity(ctx, fallback) {
241
+ async function promptProjectIdentity(ctx, fallback) {
242
242
  const defaultName = fallback ?? deriveRepoName(path.basename(ctx.cwd));
243
243
  if (!ctx.interactive)
244
244
  return { name: defaultName, slug: deriveRepoName(defaultName) };
245
- const name = await p.text({ message: "Name this Workspace:", defaultValue: defaultName, placeholder: defaultName });
245
+ const name = await p.text({ message: "Name this Project:", defaultValue: defaultName, placeholder: defaultName });
246
246
  if (p.isCancel(name))
247
247
  return null;
248
248
  const resolved = String(name).trim() || defaultName;
@@ -253,9 +253,9 @@ async function confirm(message) {
253
253
  return !p.isCancel(answer) && answer === true;
254
254
  }
255
255
  function reportSuccess(result) {
256
- p.log.success(`Bound to Workspace ${result.workspaceId} on branch ${result.boundBranch} (binding ${result.bindingId}).`);
256
+ p.log.success(`Bound to Project ${result.projectId} on branch ${result.boundBranch} (binding ${result.bindingId}).`);
257
257
  p.log.info(`Toolkit ${result.toolkit.name}@${result.toolkit.version}. No Git push, pull, or remote was created.`);
258
- p.outro("Ready — `nustack workspace status` · `nustack docs list` · `nustack workspace sync now`.");
258
+ p.outro("Ready — `nustack project status` · `nustack docs list` · `nustack project sync now`.");
259
259
  }
260
260
  function reportFailure(failure) {
261
261
  p.log.error(failure.message);
@@ -264,8 +264,8 @@ function reportFailure(failure) {
264
264
  }
265
265
  function rejectionMessage(rejection) {
266
266
  switch (rejection) {
267
- case "nested_workspace":
268
- return "This repository is nested inside another NuStack Workspace — one Workspace has one logical repository root (F-014).";
267
+ case "nested_project":
268
+ return "This repository is nested inside another NuStack Project — one Project has one logical repository root (F-014).";
269
269
  case "operation_in_progress":
270
270
  return "A merge, rebase, cherry-pick, or revert is in progress — finish or abort it, then re-run.";
271
271
  case "detached_head":
@@ -1,9 +1,9 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { resolveNuStackUrl } from "../lib/nustack_client.js";
3
3
  import { launchBrowser } from "../lib/browser_opener.js";
4
- import { isKeychainAvailable } from "../lib/device_keychain.js";
4
+ import { selectCredentialStore } from "../lib/credential_store.js";
5
5
  import { isInteractive } from "../lib/interactive.js";
6
- import { ensureAccessToken, loginWithLoopback, loginWithPastedCode, prepareLogin, } from "../lib/auth_grant.js";
6
+ import { ensureAccessToken, ENV_TOKEN_VARIABLE, envToken, loginWithLoopback, loginWithPastedCode, prepareLogin, } from "../lib/auth_grant.js";
7
7
  async function readStdinLine() {
8
8
  return new Promise((resolve) => {
9
9
  let buffered = "";
@@ -79,8 +79,16 @@ async function loopbackFlow(ctx, flags) {
79
79
  }
80
80
  }
81
81
  export async function performDeviceLogin(baseUrl, _cwd = process.cwd(), flags = {}) {
82
- if (!isKeychainAvailable()) {
83
- p.log.error("No OS keychain on this platform — NuStack keeps a machine's sign-in only in the keychain.");
82
+ if (envToken()) {
83
+ p.log.info(`${ENV_TOKEN_VARIABLE} is set — this process authenticates with it, and no sign-in is stored on this machine. Unset it to sign in as a person.`);
84
+ return true;
85
+ }
86
+ let store;
87
+ try {
88
+ store = selectCredentialStore();
89
+ }
90
+ catch (error) {
91
+ p.log.error(error instanceof Error ? error.message : String(error));
84
92
  return false;
85
93
  }
86
94
  if (!flags.force && !flags.reauthorize) {
@@ -118,7 +126,12 @@ export async function performDeviceLogin(baseUrl, _cwd = process.cwd(), flags =
118
126
  return false;
119
127
  }
120
128
  p.log.success(`Signed in as ${result.userEmail || "this account"}`);
121
- p.log.info("This machine's grant is saved to your keychain — the desktop shares it. Manage it with `nustack device list`.");
129
+ if (store.name === "file") {
130
+ p.log.info(`This machine's grant is saved to ~/.nustack/credentials.json, readable only by you — no OS keychain here. Manage it with \`nustack device list\`.`);
131
+ }
132
+ else {
133
+ p.log.info("This machine's grant is saved to your keychain — the desktop shares it. Manage it with `nustack device list`.");
134
+ }
122
135
  return true;
123
136
  }
124
137
  export async function runLogin(options, flags = {}) {
@@ -130,5 +143,5 @@ export async function runLogin(options, flags = {}) {
130
143
  process.exitCode = 1;
131
144
  return;
132
145
  }
133
- p.outro("Signed in on this device.");
146
+ p.outro(envToken() ? "Nothing was written." : "Signed in on this device.");
134
147
  }
@@ -1,13 +1,20 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { resolveNuStackUrl } from "../lib/nustack_client.js";
3
3
  import { clearDeviceId } from "../lib/device_identity.js";
4
- import { deleteDeviceCredential, isKeychainAvailable } from "../lib/device_keychain.js";
5
- import { fetchAuthConfig, readGrant, revokeGrant } from "../lib/auth_grant.js";
4
+ import { deleteDeviceCredential } from "../lib/device_keychain.js";
5
+ import { selectCredentialStore } from "../lib/credential_store.js";
6
+ import { ENV_TOKEN_VARIABLE, envToken, fetchAuthConfig, readGrant, revokeGrant } from "../lib/auth_grant.js";
6
7
  const SHARED_SIGNOUT_NOTE = "This signs out both the CLI and the desktop on this machine.";
7
8
  export async function runLogout(options, flags = {}) {
8
9
  p.intro("nustack logout");
9
10
  const baseUrl = resolveNuStackUrl(options);
10
- const grant = isKeychainAvailable() ? await readGrant() : null;
11
+ if (envToken()) {
12
+ p.log.info(`${ENV_TOKEN_VARIABLE} is set — the environment holds this process's credential, and there is nothing stored on this machine to end. Unset the variable, or revoke the token with \`nustack token revoke\`.`);
13
+ p.outro("Nothing was changed.");
14
+ return;
15
+ }
16
+ const store = selectCredentialStore();
17
+ const grant = await readGrant();
11
18
  if (grant) {
12
19
  const clientId = grant.clientId ?? (await fetchAuthConfig(baseUrl).then((c) => c.clientId).catch(() => null));
13
20
  const outcome = clientId ? await revokeGrant({ authBaseUrl: grant.authBaseUrl, refreshToken: grant.grantToken, clientId }) : "unreachable";
@@ -25,12 +32,13 @@ export async function runLogout(options, flags = {}) {
25
32
  }
26
33
  const cleared = await deleteDeviceCredential(grant.deviceId);
27
34
  if (cleared)
28
- p.log.success("Cleared the keychain grant.");
35
+ p.log.success(store.name === "keychain" ? "Cleared the keychain grant." : `Cleared the stored grant (${store.name}).`);
29
36
  }
30
37
  else {
31
- p.log.info("No keychain credential to clear on this machine.");
38
+ p.log.info(store.name === "keychain" ? "No keychain credential to clear on this machine." : `No stored credential to clear on this machine (${store.name}).`);
32
39
  }
33
- p.log.info(SHARED_SIGNOUT_NOTE);
40
+ if (store.name === "keychain")
41
+ p.log.info(SHARED_SIGNOUT_NOTE);
34
42
  if (flags.forgetDevice) {
35
43
  const forgot = await clearDeviceId();
36
44
  if (forgot) {