@nurix/nustack 0.10.0-dev.10

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 (131) hide show
  1. package/README.md +77 -0
  2. package/dist/commands/add.js +130 -0
  3. package/dist/commands/agent.js +100 -0
  4. package/dist/commands/bootstrap.js +54 -0
  5. package/dist/commands/describe.js +34 -0
  6. package/dist/commands/device.js +159 -0
  7. package/dist/commands/docs.js +119 -0
  8. package/dist/commands/doctor.js +284 -0
  9. package/dist/commands/graph.js +34 -0
  10. package/dist/commands/handoff.js +108 -0
  11. package/dist/commands/init.js +280 -0
  12. package/dist/commands/lane.js +12 -0
  13. package/dist/commands/list.js +20 -0
  14. package/dist/commands/login.js +134 -0
  15. package/dist/commands/logout.js +44 -0
  16. package/dist/commands/open.js +54 -0
  17. package/dist/commands/organization.js +114 -0
  18. package/dist/commands/review.js +152 -0
  19. package/dist/commands/search.js +57 -0
  20. package/dist/commands/sessions.js +62 -0
  21. package/dist/commands/sync.js +85 -0
  22. package/dist/commands/telemetry.js +112 -0
  23. package/dist/commands/toolkit.js +167 -0
  24. package/dist/commands/validate.js +67 -0
  25. package/dist/commands/workspace-sync.js +645 -0
  26. package/dist/commands/workspace.js +479 -0
  27. package/dist/index.js +357 -0
  28. package/dist/lib/agent/claudeHooks.js +77 -0
  29. package/dist/lib/auth_grant.js +398 -0
  30. package/dist/lib/auth_recovery.js +49 -0
  31. package/dist/lib/browser_opener.js +24 -0
  32. package/dist/lib/device_identity.js +28 -0
  33. package/dist/lib/device_keychain.js +101 -0
  34. package/dist/lib/env_file.js +75 -0
  35. package/dist/lib/etna/manager.js +43 -0
  36. package/dist/lib/etna/profile.js +41 -0
  37. package/dist/lib/etna/reconcile.js +77 -0
  38. package/dist/lib/filebeat_installer.js +246 -0
  39. package/dist/lib/git-sync/core/commit_range.js +37 -0
  40. package/dist/lib/git-sync/core/engine.js +263 -0
  41. package/dist/lib/git-sync/core/git_runner.js +137 -0
  42. package/dist/lib/git-sync/core/operation_state.js +22 -0
  43. package/dist/lib/git-sync/core/path_normalizer.js +98 -0
  44. package/dist/lib/git-sync/core/reconciliation_lock.js +103 -0
  45. package/dist/lib/git-sync/core/repository.js +49 -0
  46. package/dist/lib/git-sync/core/watch_stream.js +86 -0
  47. package/dist/lib/git-sync/core/watcher.js +61 -0
  48. package/dist/lib/git-sync/inbound/applier.js +131 -0
  49. package/dist/lib/git-sync/inbound/apply_commit.js +225 -0
  50. package/dist/lib/git-sync/inbound/approval.js +117 -0
  51. package/dist/lib/git-sync/inbound/content_decision.js +148 -0
  52. package/dist/lib/git-sync/inbound/download.js +223 -0
  53. package/dist/lib/git-sync/inbound/event_sync.js +133 -0
  54. package/dist/lib/git-sync/inbound/index.js +10 -0
  55. package/dist/lib/git-sync/inbound/notification.js +39 -0
  56. package/dist/lib/git-sync/inbound/state_fingerprint.js +32 -0
  57. package/dist/lib/git-sync/inbound/technical_wait.js +39 -0
  58. package/dist/lib/git-sync/inbound/undo.js +198 -0
  59. package/dist/lib/git-sync/outbound/extraction.js +107 -0
  60. package/dist/lib/git-sync/outbound/history_decision.js +112 -0
  61. package/dist/lib/git-sync/outbound/index.js +26 -0
  62. package/dist/lib/git-sync/outbound/manifest_builder.js +58 -0
  63. package/dist/lib/git-sync/outbound/publisher.js +51 -0
  64. package/dist/lib/git-sync/outbound/queue.js +123 -0
  65. package/dist/lib/git-sync/outbound/state.js +12 -0
  66. package/dist/lib/git-sync/outbound/upload_client.js +141 -0
  67. package/dist/lib/git-sync/recovery/bundle.js +56 -0
  68. package/dist/lib/git-sync/recovery/crypto.js +114 -0
  69. package/dist/lib/git-sync/recovery/index.js +3 -0
  70. package/dist/lib/git-sync/recovery/store.js +55 -0
  71. package/dist/lib/git-sync/whitelist/index.js +4 -0
  72. package/dist/lib/git-sync/whitelist/matcher.js +62 -0
  73. package/dist/lib/git-sync/whitelist/store.js +99 -0
  74. package/dist/lib/git-sync/whitelist/types.js +46 -0
  75. package/dist/lib/git-sync/whitelist/validation.js +60 -0
  76. package/dist/lib/graph/answer.js +171 -0
  77. package/dist/lib/graph/freshness.js +29 -0
  78. package/dist/lib/graph/library.js +19 -0
  79. package/dist/lib/guards.js +106 -0
  80. package/dist/lib/interactive.js +12 -0
  81. package/dist/lib/json_output.js +8 -0
  82. package/dist/lib/lanes/claudeAdapter.js +135 -0
  83. package/dist/lib/lanes/frames.js +73 -0
  84. package/dist/lib/lanes/supervisor.js +247 -0
  85. package/dist/lib/lanes/worktree.js +33 -0
  86. package/dist/lib/local_store/accepted_uploads.js +49 -0
  87. package/dist/lib/local_store/bindings.js +163 -0
  88. package/dist/lib/local_store/commit_origins.js +25 -0
  89. package/dist/lib/local_store/database.js +71 -0
  90. package/dist/lib/local_store/device_state.js +72 -0
  91. package/dist/lib/local_store/filebeat_state.js +133 -0
  92. package/dist/lib/local_store/handoff_applications.js +166 -0
  93. package/dist/lib/local_store/history_decisions.js +48 -0
  94. package/dist/lib/local_store/index.js +14 -0
  95. package/dist/lib/local_store/migrations.js +240 -0
  96. package/dist/lib/local_store/paths.js +24 -0
  97. package/dist/lib/local_store/pending_uploads.js +76 -0
  98. package/dist/lib/local_store/recovery_bundles.js +66 -0
  99. package/dist/lib/local_store/sync_vocabulary.js +33 -0
  100. package/dist/lib/local_store/workspace_events_cache.js +54 -0
  101. package/dist/lib/loopback.js +78 -0
  102. package/dist/lib/manifest.js +83 -0
  103. package/dist/lib/nustack_client.js +547 -0
  104. package/dist/lib/nustack_store.js +17 -0
  105. package/dist/lib/onboarding/attach_existing_git.js +65 -0
  106. package/dist/lib/onboarding/binding.js +91 -0
  107. package/dist/lib/onboarding/folder_state.js +99 -0
  108. package/dist/lib/onboarding/git_init.js +10 -0
  109. package/dist/lib/onboarding/handoff_apply.js +68 -0
  110. package/dist/lib/onboarding/init_fresh_folder.js +100 -0
  111. package/dist/lib/onboarding/manifest_writer.js +161 -0
  112. package/dist/lib/onboarding/web_first_attach.js +133 -0
  113. package/dist/lib/package_manager.js +23 -0
  114. package/dist/lib/review/client.js +66 -0
  115. package/dist/lib/schema_validator.js +24 -0
  116. package/dist/lib/secure_file.js +10 -0
  117. package/dist/lib/session_telemetry.js +263 -0
  118. package/dist/lib/sessions/client.js +48 -0
  119. package/dist/lib/sessions/outsideSessions.js +54 -0
  120. package/dist/lib/sse_framing.js +48 -0
  121. package/dist/lib/telemetry/config_renderer.js +175 -0
  122. package/dist/lib/telemetry/health_report.js +173 -0
  123. package/dist/lib/telemetry/provider_paths.js +38 -0
  124. package/dist/lib/telemetry/redaction_policy.js +90 -0
  125. package/dist/lib/telemetry/reload.js +84 -0
  126. package/dist/lib/workspace_context.js +121 -0
  127. package/dist/spec_schema.json +422 -0
  128. package/dist/wire/auth.js +1 -0
  129. package/dist/wire/sync-paths.js +25 -0
  130. package/dist/wire/telemetry.js +1 -0
  131. package/package.json +49 -0
@@ -0,0 +1,280 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import path from "node:path";
3
+ import * as p from "@clack/prompts";
4
+ import { createWorkspaceViaAsk, createOrganization, getWorkspace, listWorkspaces, listOrganizations, resolveNuStackUrl, } from "../lib/nustack_client.js";
5
+ import { ensureAccessToken } from "../lib/auth_grant.js";
6
+ import { getOrCreateDeviceId } from "../lib/device_identity.js";
7
+ import { isInteractive } from "../lib/interactive.js";
8
+ import { resolveOrganizationId } from "../lib/workspace_context.js";
9
+ import { openLocalStore } from "../lib/local_store/database.js";
10
+ import { readDeviceState, upsertDeviceState } from "../lib/local_store/device_state.js";
11
+ import { hasManifest, scaffoldManifest } from "../lib/manifest.js";
12
+ import { readWorkspaceManifests } from "../lib/onboarding/manifest_writer.js";
13
+ import { attachRejection, classifyFolder } from "../lib/onboarding/folder_state.js";
14
+ import { attachExistingGit } from "../lib/onboarding/attach_existing_git.js";
15
+ import { initFreshFolder } from "../lib/onboarding/init_fresh_folder.js";
16
+ import { webFirstAttach } from "../lib/onboarding/web_first_attach.js";
17
+ import { performDeviceLogin } from "./login.js";
18
+ export async function runInit(options) {
19
+ const cwd = process.cwd();
20
+ const baseUrl = resolveNuStackUrl(options);
21
+ p.intro("nustack init");
22
+ if (!isInteractive()) {
23
+ p.log.error("nustack init needs an interactive terminal (browser sign-in + prompts).");
24
+ p.outro("Re-run in a terminal, or run `nustack login --no-open` in a scripted context first.");
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ const accessToken = await ensureSignedIn(baseUrl, cwd);
29
+ if (!accessToken)
30
+ return;
31
+ await runOnboardingFlow(baseUrl, accessToken, {
32
+ yes: options.yes,
33
+ workspace: options.workspace,
34
+ organization: options.organization,
35
+ });
36
+ }
37
+ export async function ensureSignedIn(baseUrl, cwd) {
38
+ const deviceAuth = await ensureAccessToken(baseUrl).catch(() => null);
39
+ if (deviceAuth) {
40
+ p.log.success(`Signed in as ${deviceAuth.userEmail}`);
41
+ return deviceAuth.accessToken;
42
+ }
43
+ if (!(await performDeviceLogin(baseUrl, cwd))) {
44
+ p.outro("Sign-in failed — nothing was written.");
45
+ process.exitCode = 1;
46
+ return null;
47
+ }
48
+ const refreshed = await ensureAccessToken(baseUrl).catch(() => null);
49
+ if (!refreshed) {
50
+ p.outro("Sign-in did not complete — run `nustack login` and retry.");
51
+ process.exitCode = 1;
52
+ return null;
53
+ }
54
+ return refreshed.accessToken;
55
+ }
56
+ export async function runOnboardingFlow(baseUrl, accessToken, options) {
57
+ const store = await openLocalStore();
58
+ try {
59
+ const device = readDeviceState(store);
60
+ const ctx = {
61
+ store,
62
+ baseUrl,
63
+ accessToken,
64
+ deviceRef: device?.deviceRef ?? (await getOrCreateDeviceId()),
65
+ userId: device?.activeUserId ?? null,
66
+ cwd: process.cwd(),
67
+ json: false,
68
+ yes: options.yes === true,
69
+ interactive: isInteractive(),
70
+ };
71
+ await routeOnboarding(ctx, options);
72
+ }
73
+ finally {
74
+ store.close();
75
+ }
76
+ }
77
+ async function routeOnboarding(ctx, options) {
78
+ if (options.workspaceId) {
79
+ await routeWebFirst(ctx, options, options.workspaceId);
80
+ return;
81
+ }
82
+ const state = classifyFolder(ctx.cwd);
83
+ if (state.class === "git_worktree" && state.repository && state.git) {
84
+ await routeExistingGit(ctx, options, state.repository.repositoryRoot, state.git.markerWorkspaceId);
85
+ return;
86
+ }
87
+ await routeFreshFolder(ctx, options, state.class === "populated_non_git");
88
+ }
89
+ async function routeExistingGit(ctx, options, root, markerWorkspaceId) {
90
+ const state = classifyFolder(ctx.cwd);
91
+ const rejection = state.git ? attachRejection(state.git) : null;
92
+ if (rejection) {
93
+ reportFailure({ code: rejection.toUpperCase(), message: rejectionMessage(rejection) });
94
+ return;
95
+ }
96
+ if (!hasManifest(root)) {
97
+ await scaffoldManifest(root, deriveRepoName(path.basename(root)));
98
+ }
99
+ let workspaceId;
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;
105
+ const resolved = recorded ?? (await resolveOrganizationInteractive(ctx, options));
106
+ if (!resolved)
107
+ return;
108
+ organizationId = resolved;
109
+ }
110
+ else {
111
+ const resolved = await resolveOrganizationInteractive(ctx, options);
112
+ if (!resolved)
113
+ return;
114
+ organizationId = resolved;
115
+ const chosen = await chooseOrCreateWorkspace(ctx, organizationId, root, "existing_local");
116
+ if (!chosen)
117
+ return;
118
+ workspaceId = chosen;
119
+ }
120
+ const spinner = p.spinner();
121
+ spinner.start("Attaching this repository");
122
+ const outcome = await attachExistingGit(ctx, { workspaceId, organizationId, confirmRebind: true });
123
+ spinner.stop(outcome.ok ? "Attached" : "Attach failed", outcome.ok ? 0 : 1);
124
+ if (outcome.ok)
125
+ reportSuccess(outcome.result);
126
+ else
127
+ reportFailure(outcome);
128
+ }
129
+ async function routeFreshFolder(ctx, options, populated) {
130
+ const organizationId = await resolveOrganizationInteractive(ctx, options);
131
+ if (!organizationId)
132
+ return;
133
+ const identity = await promptWorkspaceIdentity(ctx);
134
+ if (!identity)
135
+ return;
136
+ const input = { organizationId, name: identity.name, slug: identity.slug, hasUi: true };
137
+ let outcome = await initFreshFolder(ctx, populated ? { ...input, confirmPopulated: ctx.yes || undefined } : input);
138
+ if (!outcome.ok && outcome.code === "CONFIRMATION_REQUIRED") {
139
+ const confirmed = ctx.yes || (ctx.interactive && (await confirm(outcome.message)));
140
+ if (!confirmed) {
141
+ p.outro("No changes made — the folder was left untouched.");
142
+ return;
143
+ }
144
+ outcome = await initFreshFolder(ctx, { ...input, confirmPopulated: true });
145
+ }
146
+ if (outcome.ok)
147
+ reportSuccess(outcome.result);
148
+ else
149
+ reportFailure(outcome);
150
+ }
151
+ async function routeWebFirst(ctx, options, workspaceId) {
152
+ const organizationId = await resolveOrganizationInteractive(ctx, options);
153
+ if (!organizationId)
154
+ return;
155
+ let outcome = await webFirstAttach(ctx, { workspaceId, organizationId, confirmPopulated: ctx.yes || undefined });
156
+ if (!outcome.ok && outcome.code === "CONFIRMATION_REQUIRED") {
157
+ const confirmed = ctx.yes || (ctx.interactive && (await confirm(outcome.message)));
158
+ if (!confirmed) {
159
+ p.outro("No changes made — the folder was left untouched.");
160
+ return;
161
+ }
162
+ outcome = await webFirstAttach(ctx, { workspaceId, organizationId, confirmPopulated: true });
163
+ }
164
+ if (!outcome.ok) {
165
+ reportFailure(outcome);
166
+ return;
167
+ }
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).");
171
+ }
172
+ reportSuccess(outcome.result);
173
+ }
174
+ async function resolveOrganizationInteractive(ctx, options) {
175
+ const memberships = await listOrganizations(ctx.baseUrl, ctx.accessToken);
176
+ const active = readDeviceState(ctx.store)?.activeOrganizationId ?? null;
177
+ const resolved = resolveOrganizationId({ organization: options.organization }, active, memberships.map((m) => ({ id: m.organization.id, slug: m.organization.slug })));
178
+ if (resolved.ok)
179
+ return resolved.organizationId;
180
+ if (!ctx.interactive) {
181
+ reportFailure({ code: "ORGANIZATION_CONTEXT_MISSING", message: resolved.message });
182
+ return null;
183
+ }
184
+ if (memberships.length === 0) {
185
+ const name = await p.text({ message: "Name your first Organization:", placeholder: "my-team" });
186
+ if (p.isCancel(name) || String(name).trim() === "") {
187
+ reportFailure({ code: "ORGANIZATION_CONTEXT_MISSING", message: "An Organization is required to continue." });
188
+ return null;
189
+ }
190
+ const organization = await createOrganization(ctx.baseUrl, ctx.accessToken, { label: String(name).trim() }, randomUUID());
191
+ upsertDeviceState(ctx.store, { activeOrganizationId: organization.id });
192
+ return organization.id;
193
+ }
194
+ const pick = await p.select({
195
+ message: "Which Organization should this Workspace live in?",
196
+ options: memberships.map((m) => ({ value: m.organization.id, label: `${m.organization.label} (${m.organization.slug})` })),
197
+ });
198
+ if (p.isCancel(pick))
199
+ return null;
200
+ upsertDeviceState(ctx.store, { activeOrganizationId: String(pick) });
201
+ return String(pick);
202
+ }
203
+ async function chooseOrCreateWorkspace(ctx, organizationId, root, entryMode) {
204
+ const CREATE = "__create__";
205
+ const workspaces = ctx.interactive ? await listWorkspaces(ctx.baseUrl, ctx.accessToken, organizationId) : [];
206
+ const pick = ctx.interactive
207
+ ? await p.select({
208
+ message: "Attach this repository to which Workspace?",
209
+ options: [
210
+ { value: CREATE, label: "Create a new Workspace" },
211
+ ...workspaces.map((a) => ({ value: a.id, label: `${a.label} (${a.id})` })),
212
+ ],
213
+ })
214
+ : CREATE;
215
+ if (p.isCancel(pick))
216
+ return null;
217
+ if (pick !== CREATE)
218
+ return String(pick);
219
+ const identity = await promptWorkspaceIdentity(ctx, deriveRepoName(path.basename(root)));
220
+ if (!identity)
221
+ return null;
222
+ return createAndAwaitWorkspace(ctx, organizationId, identity, entryMode);
223
+ }
224
+ async function createAndAwaitWorkspace(ctx, organizationId, identity, entryMode) {
225
+ void entryMode;
226
+ const spinner = p.spinner();
227
+ spinner.start(`Creating Workspace "${identity.name}"`);
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);
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;
234
+ }
235
+ catch (error) {
236
+ spinner.stop("Workspace creation failed", 1);
237
+ p.log.error(error instanceof Error ? error.message : String(error));
238
+ return null;
239
+ }
240
+ }
241
+ async function promptWorkspaceIdentity(ctx, fallback) {
242
+ const defaultName = fallback ?? deriveRepoName(path.basename(ctx.cwd));
243
+ if (!ctx.interactive)
244
+ return { name: defaultName, slug: deriveRepoName(defaultName) };
245
+ const name = await p.text({ message: "Name this Workspace:", defaultValue: defaultName, placeholder: defaultName });
246
+ if (p.isCancel(name))
247
+ return null;
248
+ const resolved = String(name).trim() || defaultName;
249
+ return { name: resolved, slug: deriveRepoName(resolved) };
250
+ }
251
+ async function confirm(message) {
252
+ const answer = await p.confirm({ message });
253
+ return !p.isCancel(answer) && answer === true;
254
+ }
255
+ function reportSuccess(result) {
256
+ p.log.success(`Bound to Workspace ${result.workspaceId} on branch ${result.boundBranch} (binding ${result.bindingId}).`);
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`.");
259
+ }
260
+ function reportFailure(failure) {
261
+ p.log.error(failure.message);
262
+ p.outro(`Onboarding stopped (${failure.code}). Nothing was pushed.`);
263
+ process.exitCode = 1;
264
+ }
265
+ function rejectionMessage(rejection) {
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).";
269
+ case "operation_in_progress":
270
+ return "A merge, rebase, cherry-pick, or revert is in progress — finish or abort it, then re-run.";
271
+ case "detached_head":
272
+ return "HEAD is detached — check out a branch before onboarding.";
273
+ }
274
+ }
275
+ function deriveRepoName(source) {
276
+ return source
277
+ .toLowerCase()
278
+ .replace(/[^a-z0-9-]+/g, "-")
279
+ .replace(/^-+|-+$/g, "");
280
+ }
@@ -0,0 +1,12 @@
1
+ import { LaneSupervisor } from "../lib/lanes/supervisor.js";
2
+ import { sessionsClientFromEnv } from "../lib/sessions/client.js";
3
+ export async function runLaneServe(engineVersion) {
4
+ const supervisor = new LaneSupervisor({
5
+ input: process.stdin,
6
+ output: process.stdout,
7
+ engineVersion,
8
+ sessions: sessionsClientFromEnv(process.env),
9
+ });
10
+ await supervisor.run();
11
+ process.exit(0);
12
+ }
@@ -0,0 +1,20 @@
1
+ import { fetchCatalogue, resolveNuStackUrl } from "../lib/nustack_client.js";
2
+ import { printJsonSuccess } from "../lib/json_output.js";
3
+ export async function runList(options, globals) {
4
+ const baseUrl = resolveNuStackUrl(globals);
5
+ const catalogue = await fetchCatalogue(baseUrl);
6
+ const services = catalogue.services ?? [];
7
+ if (options.json) {
8
+ printJsonSuccess("list", { services });
9
+ return;
10
+ }
11
+ if (services.length === 0) {
12
+ console.log(`Catalogue at ${baseUrl} is empty.`);
13
+ return;
14
+ }
15
+ for (const service of services) {
16
+ const classifier = service.type ?? service.kind ?? "?";
17
+ const status = service.status ?? "?";
18
+ console.log(`${service.name.padEnd(30)} ${classifier.padEnd(18)} ${status.padEnd(14)} ${service.description ?? ""}`);
19
+ }
20
+ }
@@ -0,0 +1,134 @@
1
+ import * as p from "@clack/prompts";
2
+ import { resolveNuStackUrl } from "../lib/nustack_client.js";
3
+ import { launchBrowser } from "../lib/browser_opener.js";
4
+ import { isKeychainAvailable } from "../lib/device_keychain.js";
5
+ import { isInteractive } from "../lib/interactive.js";
6
+ import { ensureAccessToken, loginWithLoopback, loginWithPastedCode, prepareLogin, } from "../lib/auth_grant.js";
7
+ async function readStdinLine() {
8
+ return new Promise((resolve) => {
9
+ let buffered = "";
10
+ const onData = (chunk) => {
11
+ buffered += chunk.toString();
12
+ const newline = buffered.indexOf("\n");
13
+ if (newline >= 0) {
14
+ process.stdin.off("data", onData);
15
+ process.stdin.pause();
16
+ resolve(buffered.slice(0, newline));
17
+ }
18
+ };
19
+ process.stdin.on("data", onData);
20
+ process.stdin.once("end", () => resolve(buffered));
21
+ process.stdin.resume();
22
+ });
23
+ }
24
+ async function promptForCode() {
25
+ if (!isInteractive())
26
+ return readStdinLine();
27
+ const answer = await p.text({ message: "Paste the code from the sign-in page", placeholder: "code" });
28
+ if (p.isCancel(answer))
29
+ throw new Error("Sign-in cancelled.");
30
+ return String(answer);
31
+ }
32
+ function waitForEnter() {
33
+ if (!isInteractive())
34
+ return undefined;
35
+ return () => new Promise((resolve) => {
36
+ const onData = (chunk) => {
37
+ if (chunk.toString().includes("\n") || chunk.toString().includes("\r")) {
38
+ process.stdin.off("data", onData);
39
+ process.stdin.pause();
40
+ resolve();
41
+ }
42
+ };
43
+ process.stdin.on("data", onData);
44
+ process.stdin.resume();
45
+ });
46
+ }
47
+ async function pasteFlow(ctx, flags) {
48
+ return loginWithPastedCode(ctx, {
49
+ force: flags.force,
50
+ onAuthorizeUrl: (url) => p.note(`${url}\n\nSign in there, then copy the code the page shows.`, "Open this URL in any browser"),
51
+ promptForCode,
52
+ });
53
+ }
54
+ async function loopbackFlow(ctx, flags) {
55
+ const ui = { spinner: null };
56
+ const switchToPaste = waitForEnter();
57
+ try {
58
+ return await loginWithLoopback(ctx, {
59
+ force: flags.force,
60
+ onAuthorizeUrl: (url) => {
61
+ p.note(`${url}\n\n${isInteractive() ? "Press Enter to paste a code instead." : ""}`.trim(), "Sign in with your browser");
62
+ if (flags.open !== false)
63
+ launchBrowser(url);
64
+ ui.spinner = p.spinner();
65
+ ui.spinner.start("Waiting for the browser");
66
+ },
67
+ switchToPaste: switchToPaste
68
+ ? async () => {
69
+ await switchToPaste();
70
+ ui.spinner?.stop("Switching to a pasted code");
71
+ ui.spinner = null;
72
+ }
73
+ : undefined,
74
+ promptForCode,
75
+ });
76
+ }
77
+ finally {
78
+ ui.spinner?.stop("Done");
79
+ }
80
+ }
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.");
84
+ return false;
85
+ }
86
+ if (!flags.force && !flags.reauthorize) {
87
+ const live = await ensureAccessToken(baseUrl).catch(() => null);
88
+ if (live) {
89
+ p.log.success(`Signed in as ${live.userEmail || "this account"} — already signed in on this machine (use --force to switch accounts).`);
90
+ return true;
91
+ }
92
+ }
93
+ let result;
94
+ try {
95
+ const ctx = await prepareLogin(baseUrl);
96
+ const useManual = flags.manual === true;
97
+ if (isInteractive() && !useManual && flags.manual === undefined) {
98
+ const choice = await p.select({
99
+ message: "How do you want to sign in?",
100
+ options: [
101
+ { value: "browser", label: "Log in with browser", hint: "opens your browser on this machine" },
102
+ { value: "paste", label: "Paste a code", hint: "sign in anywhere and paste the code here" },
103
+ ],
104
+ initialValue: "browser",
105
+ });
106
+ if (p.isCancel(choice)) {
107
+ p.log.warn("Sign-in cancelled.");
108
+ return false;
109
+ }
110
+ result = choice === "paste" ? await pasteFlow(ctx, flags) : await loopbackFlow(ctx, flags);
111
+ }
112
+ else {
113
+ result = useManual ? await pasteFlow(ctx, flags) : await loopbackFlow(ctx, flags);
114
+ }
115
+ }
116
+ catch (error) {
117
+ p.log.error(error instanceof Error ? error.message : String(error));
118
+ return false;
119
+ }
120
+ 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`.");
122
+ return true;
123
+ }
124
+ export async function runLogin(options, flags = {}) {
125
+ const baseUrl = resolveNuStackUrl(options);
126
+ p.intro("nustack login");
127
+ const didLogin = await performDeviceLogin(baseUrl, process.cwd(), flags);
128
+ if (!didLogin) {
129
+ p.outro("Nothing was written.");
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ p.outro("Signed in on this device.");
134
+ }
@@ -0,0 +1,44 @@
1
+ import * as p from "@clack/prompts";
2
+ import { resolveNuStackUrl } from "../lib/nustack_client.js";
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";
6
+ const SHARED_SIGNOUT_NOTE = "This signs out both the CLI and the desktop on this machine.";
7
+ export async function runLogout(options, flags = {}) {
8
+ p.intro("nustack logout");
9
+ const baseUrl = resolveNuStackUrl(options);
10
+ const grant = isKeychainAvailable() ? await readGrant() : null;
11
+ if (grant) {
12
+ const clientId = grant.clientId ?? (await fetchAuthConfig(baseUrl).then((c) => c.clientId).catch(() => null));
13
+ const outcome = clientId ? await revokeGrant({ authBaseUrl: grant.authBaseUrl, refreshToken: grant.grantToken, clientId }) : "unreachable";
14
+ if (outcome === "revoked") {
15
+ p.log.success("Ended this machine's grant at the auth service — it can no longer mint access.");
16
+ }
17
+ else if (outcome === "already_ended") {
18
+ p.log.info("This machine's grant had already been ended at the auth service.");
19
+ }
20
+ else if (outcome === "refused") {
21
+ p.log.warn("The auth service refused to end the grant — clearing local material anyway. Revoke it from Profile › Devices.");
22
+ }
23
+ else {
24
+ p.log.warn("Couldn't reach the auth service to end the grant — clearing local material anyway. Revoke it from Profile › Devices when you are back online.");
25
+ }
26
+ const cleared = await deleteDeviceCredential(grant.deviceId);
27
+ if (cleared)
28
+ p.log.success("Cleared the keychain grant.");
29
+ }
30
+ else {
31
+ p.log.info("No keychain credential to clear on this machine.");
32
+ }
33
+ p.log.info(SHARED_SIGNOUT_NOTE);
34
+ if (flags.forgetDevice) {
35
+ const forgot = await clearDeviceId();
36
+ if (forgot) {
37
+ p.log.success("Cleared ~/.nustack/device_id (the shared machine identity) — the next login mints a new one.");
38
+ }
39
+ else {
40
+ p.log.info("No ~/.nustack/device_id to clear.");
41
+ }
42
+ }
43
+ p.outro("Signed out. Run `nustack login` to sign back in.");
44
+ }
@@ -0,0 +1,54 @@
1
+ import * as p from "@clack/prompts";
2
+ import { fetchCatalogue, resolveNuStackUrl } from "../lib/nustack_client.js";
3
+ import { launchBrowser } from "../lib/browser_opener.js";
4
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
5
+ export async function runOpen(serviceName, options, globals) {
6
+ const baseUrl = resolveNuStackUrl(globals);
7
+ const json = options.json === true;
8
+ const catalogue = await fetchCatalogue(baseUrl);
9
+ const entry = (catalogue.services ?? []).find((service) => service.name === serviceName);
10
+ if (!entry) {
11
+ const message = `'${serviceName}' is not in the catalogue at ${baseUrl}.`;
12
+ if (json)
13
+ printJsonError("open", { code: "SERVICE_NOT_FOUND", message });
14
+ else {
15
+ p.log.error(message);
16
+ process.exitCode = 1;
17
+ }
18
+ return;
19
+ }
20
+ const endpoints = entry.http?.endpoints ?? {};
21
+ const wanted = options.env ?? "dev";
22
+ const resolved = resolveUrl(endpoints, wanted, entry.repository);
23
+ if (!resolved) {
24
+ const message = `'${serviceName}' declares no endpoints and no repository to open.`;
25
+ if (json)
26
+ printJsonError("open", { code: "NOTHING_TO_OPEN", message });
27
+ else {
28
+ p.log.error(message);
29
+ process.exitCode = 1;
30
+ }
31
+ return;
32
+ }
33
+ if (json) {
34
+ printJsonSuccess("open", { service: serviceName, url: resolved.url, source: resolved.source });
35
+ return;
36
+ }
37
+ launchBrowser(resolved.url);
38
+ p.log.success(`Opening ${resolved.url} (${resolved.source})`);
39
+ }
40
+ function resolveUrl(endpoints, wanted, repository) {
41
+ const wantedUrl = endpoints[wanted];
42
+ if (wantedUrl)
43
+ return { url: wantedUrl, source: wanted };
44
+ if (endpoints.dev)
45
+ return { url: endpoints.dev, source: "dev" };
46
+ if (endpoints.production)
47
+ return { url: endpoints.production, source: "production" };
48
+ const [firstEnv, firstUrl] = Object.entries(endpoints)[0] ?? [];
49
+ if (firstEnv && firstUrl)
50
+ return { url: firstUrl, source: firstEnv };
51
+ if (repository)
52
+ return { url: repository, source: "repository" };
53
+ return null;
54
+ }
@@ -0,0 +1,114 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createOrganization, listOrganizations, resolveNuStackUrl, } from "../lib/nustack_client.js";
3
+ import { ensureAuthWithRecovery, lastAuthFailure } from "../lib/auth_recovery.js";
4
+ import { openLocalStore } from "../lib/local_store/database.js";
5
+ import { readDeviceState, upsertDeviceState } from "../lib/local_store/device_state.js";
6
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
7
+ async function requireAuth(command, json, globals) {
8
+ const baseUrl = resolveNuStackUrl(globals);
9
+ const deviceAuth = await ensureAuthWithRecovery(baseUrl, json);
10
+ if (!deviceAuth) {
11
+ const failure = lastAuthFailure();
12
+ if (json)
13
+ printJsonError(command, { code: failure.code, message: failure.message });
14
+ else {
15
+ console.error(failure.message);
16
+ process.exitCode = 1;
17
+ }
18
+ return null;
19
+ }
20
+ return { baseUrl, accessToken: deviceAuth.accessToken };
21
+ }
22
+ export async function runOrganizationList(options, globals) {
23
+ const json = options.json === true;
24
+ const auth = await requireAuth("organization list", json, globals);
25
+ if (!auth)
26
+ return;
27
+ const memberships = await listOrganizations(auth.baseUrl, auth.accessToken);
28
+ const active = await readActiveOrganizationId();
29
+ if (json) {
30
+ printJsonSuccess("organization list", {
31
+ activeOrganizationId: active,
32
+ organizations: memberships.map((m) => ({ ...m.organization, role: m.role, active: m.organization.id === active })),
33
+ });
34
+ return;
35
+ }
36
+ if (memberships.length === 0) {
37
+ console.log("No Organization memberships. Create one with `nustack organization create <name>`.");
38
+ return;
39
+ }
40
+ for (const m of memberships) {
41
+ const marker = m.organization.id === active ? "*" : " ";
42
+ console.log(`${marker} ${m.organization.id.padEnd(24)} ${m.organization.slug.padEnd(20)} ${m.organization.label.padEnd(24)} ${m.role}`);
43
+ }
44
+ }
45
+ export async function runOrganizationCreate(name, options, globals) {
46
+ const json = options.json === true;
47
+ if (!name || name.trim() === "") {
48
+ const message = "An Organization name is required: `nustack organization create <name>`.";
49
+ if (json)
50
+ printJsonError("organization create", { code: "VALIDATION_FAILED", message });
51
+ else {
52
+ console.error(message);
53
+ process.exitCode = 1;
54
+ }
55
+ return;
56
+ }
57
+ const auth = await requireAuth("organization create", json, globals);
58
+ if (!auth)
59
+ return;
60
+ const organization = await createOrganization(auth.baseUrl, auth.accessToken, { label: name.trim() }, randomUUID());
61
+ if (json) {
62
+ printJsonSuccess("organization create", { organization });
63
+ return;
64
+ }
65
+ console.log(`Created Organization ${organization.label} (${organization.id}, slug ${organization.slug}).`);
66
+ console.log("Set it active with `nustack organization use " + organization.id + "`.");
67
+ }
68
+ export async function runOrganizationUse(target, options, globals) {
69
+ const json = options.json === true;
70
+ const auth = await requireAuth("organization use", json, globals);
71
+ if (!auth)
72
+ return;
73
+ const memberships = await listOrganizations(auth.baseUrl, auth.accessToken);
74
+ const match = memberships.find((m) => m.organization.id === target || m.organization.slug === target);
75
+ if (!match) {
76
+ const message = `No Organization "${target}" among your memberships — run \`nustack organization list\`.`;
77
+ if (json)
78
+ printJsonError("organization use", { code: "ORGANIZATION_CONTEXT_MISSING", message });
79
+ else {
80
+ console.error(message);
81
+ process.exitCode = 1;
82
+ }
83
+ return;
84
+ }
85
+ const store = await openLocalStore();
86
+ try {
87
+ upsertDeviceState(store, { activeOrganizationId: match.organization.id });
88
+ }
89
+ finally {
90
+ store.close();
91
+ }
92
+ if (json) {
93
+ printJsonSuccess("organization use", { activeOrganizationId: match.organization.id, organization: match.organization });
94
+ return;
95
+ }
96
+ console.log(`Active Organization set to ${match.organization.label} (${match.organization.id}).`);
97
+ }
98
+ async function readActiveOrganizationId() {
99
+ try {
100
+ const store = await openLocalStore();
101
+ try {
102
+ return readDeviceState(store)?.activeOrganizationId ?? null;
103
+ }
104
+ finally {
105
+ store.close();
106
+ }
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ }
112
+ export async function readActiveOrganizationContext() {
113
+ return readActiveOrganizationId();
114
+ }