@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
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # `@nurix/nustack` — the `nustack` CLI
2
+
3
+ The developer's terminal surface for NuStack (`apps.nustack.ai`). It signs a machine in once through browser-approved device trust, then works against the same organizations, Workspaces, and documents the web studio serves — list and create Workspaces, read their documents, publish to the marketplace, search the public catalog, and mirror the machine's telemetry state.
4
+
5
+ Interactive UX is built on [`@clack/prompts`](https://github.com/bombshell-dev/clack).
6
+
7
+ ## Install
8
+
9
+ Published to npm with **public access** — no npm account or token required to run it.
10
+
11
+ ```
12
+ npx @nurix/nustack@latest --help # one-shot, no auth
13
+ npm install -g @nurix/nustack # or install the `nustack` binary globally
14
+ ```
15
+
16
+ The tarball ships no secrets. Authentication is the **device-trust flow**: `nustack login` registers this machine, you approve its code once in the browser (at `/device`, behind the studio's Google SSO), and the durable refresh material lands in your OS keychain — never in a file. Every authenticated command then rides a short-lived access token minted from it. Signing in on the CLI signs in the desktop workspace too: both clients share one keychain item and one `~/.nustack/device_id`.
17
+
18
+ A dead credential is never a dead end: if the server refuses this device's sign-in (revoked, stale), the CLI clears the dead keychain item and drops you straight back into `nustack login` — you never edit a config file by hand.
19
+
20
+ ## Commands
21
+
22
+ ### Identity & devices
23
+
24
+ ```
25
+ nustack login [--no-open] # browser-approved device trust; re-login inside the
26
+ # 24h window needs no browser
27
+ nustack logout # clears the shared keychain item (signs out CLI + desktop)
28
+ nustack logout --revoke # server-side self-revoke first, then the local clear
29
+ nustack logout --forget-device # also mints a fresh machine identity on next login
30
+ nustack device list|status|revoke # this account's devices; revoke kills a machine's access
31
+ ```
32
+
33
+ ### Organizations & Workspaces
34
+
35
+ ```
36
+ nustack organization list|create|use # memberships; `use` persists the active Organization,
37
+ # sent as the X-NuStack-Organization selection header
38
+ nustack workspace list # the active Organization's Workspaces
39
+ nustack workspace create [--prompt <text> | --name <name>]
40
+ # prompt-first: the first prompt creates the Workspace and
41
+ # streams the founding turn (SSE)
42
+ nustack workspace attach [--workspace <id>] # bind the current folder to a Workspace
43
+ nustack workspace status|open # cloud + local binding view; open in the browser
44
+ nustack workspace publish|unpublish # role-gated marketplace visibility
45
+ ```
46
+
47
+ ### Documents
48
+
49
+ ```
50
+ nustack docs list [--workspace <id>] # the Workspace's documents: slug, type, status, written/empty
51
+ nustack docs show <slug-or-id> # the document body inline (bounded)
52
+ nustack docs status # per-document workability and row versions
53
+ ```
54
+
55
+ ### Discovery & health
56
+
57
+ ```
58
+ nustack list # one line per catalogue service
59
+ nustack describe <svc> [--json] # full catalogue entry
60
+ nustack search <keywords...> # public Workspace search (the headline)
61
+ nustack search services <keywords...>
62
+ # service registry search (then `nustack add <name>`)
63
+ nustack graph [--mermaid] # consumes-graph from the catalogue
64
+ nustack validate [manifest] # nustack.service.json schema validation
65
+ nustack doctor # connectivity + manifest conformance probes
66
+ nustack open <svc> # open a service's endpoint or repo
67
+ nustack telemetry [status|enable|disable|reinstall]
68
+ # device-wide Filebeat telemetry control, mirrored server-side
69
+ ```
70
+
71
+ `nustack workspace sync …` (the document-sync engine) is present but dormant: every call reaches the studio Worker's `/api/*` catch-all `404`, and its disposition — grow a surface, keep it against a rebuilt binding seam, or drop it — is an open product decision tracked in the backlog.
72
+
73
+ ## Conventions
74
+
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
+ - **Base URL** — `--nustack-url` flag → `NUSTACK_URL` env → the hosted default (`https://apps.nustack.ai`).
77
+ - **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.
@@ -0,0 +1,130 @@
1
+ import path from "node:path";
2
+ import * as p from "@clack/prompts";
3
+ import { fetchCatalogue, resolveNuStackUrl } from "../lib/nustack_client.js";
4
+ import { detectPackageManager, installPackage } from "../lib/package_manager.js";
5
+ import { appendConsumesEdge, hasManifest, scaffoldManifest } from "../lib/manifest.js";
6
+ import { confirmOrFallback, isInteractive } from "../lib/interactive.js";
7
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
8
+ const COMMAND = "add";
9
+ export async function runAdd(serviceArg, options) {
10
+ const cwd = process.cwd();
11
+ const baseUrl = resolveNuStackUrl(options);
12
+ const json = options.json === true;
13
+ if (!json)
14
+ p.intro(`nustack add${serviceArg ? ` ${serviceArg}` : ""}`);
15
+ if (json && !serviceArg) {
16
+ printJsonError(COMMAND, { code: "SERVICE_REQUIRED", message: "nustack add --json requires an explicit service argument." });
17
+ return;
18
+ }
19
+ const catalogue = await fetchCatalogue(baseUrl).catch(() => null);
20
+ const services = catalogue?.services ?? [];
21
+ const serviceName = serviceArg ?? (await pickService(services));
22
+ if (!serviceName) {
23
+ if (!json)
24
+ p.outro("Nothing was written.");
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ const catalogueEntry = services.find((service) => service.name === serviceName);
29
+ if (!catalogueEntry) {
30
+ return failAdd(json, "SERVICE_NOT_IN_CATALOGUE", `'${serviceName}' is not in the catalogue at ${baseUrl}.`, "Nothing was written. Run `nustack list` to see what is available.");
31
+ }
32
+ if (!json) {
33
+ const endpoint = catalogueEntry.http?.endpoints?.dev ?? catalogueEntry.http?.endpoints?.production;
34
+ p.log.info(`${catalogueEntry.name}${catalogueEntry.type ? ` · ${catalogueEntry.type}` : ""}` +
35
+ `${catalogueEntry.description ? `\n${catalogueEntry.description}` : ""}` +
36
+ `${endpoint ? `\nEndpoint: ${endpoint}` : ""}`);
37
+ }
38
+ const packageInstalled = await maybeInstallPackage(catalogueEntry, cwd, json, options.yes === true);
39
+ const consumesRecorded = await recordConsumesEdge(catalogueEntry, serviceName, cwd, json, options.yes === true);
40
+ if (json) {
41
+ printJsonSuccess(COMMAND, {
42
+ service: serviceName,
43
+ packageInstalled,
44
+ consumesRecorded,
45
+ credential: "deferred",
46
+ });
47
+ return;
48
+ }
49
+ p.log.info("No credential was issued — the credential plane is deferred by design, so `.env` was not touched.");
50
+ p.outro(`'${serviceName}' is recorded in this repo's manifest.`);
51
+ }
52
+ function failAdd(json, code, message, humanTail) {
53
+ if (json) {
54
+ printJsonError(COMMAND, { code, message });
55
+ return;
56
+ }
57
+ p.log.error(message);
58
+ p.outro(humanTail);
59
+ process.exitCode = 1;
60
+ }
61
+ async function pickService(services) {
62
+ if (services.length === 0) {
63
+ p.log.error("The catalogue is empty — pass the service name explicitly: nustack add <service>");
64
+ return undefined;
65
+ }
66
+ if (!isInteractive()) {
67
+ p.log.error("No terminal for the picker — pass the service name explicitly: nustack add <service>");
68
+ return undefined;
69
+ }
70
+ const selection = await p.select({
71
+ message: "Which service do you want to add?",
72
+ options: services.map((service) => ({
73
+ value: service.name,
74
+ label: service.name,
75
+ hint: [service.type ?? service.kind, service.status].filter(Boolean).join(" · "),
76
+ })),
77
+ });
78
+ if (p.isCancel(selection)) {
79
+ p.cancel("add cancelled.");
80
+ return undefined;
81
+ }
82
+ return selection;
83
+ }
84
+ async function maybeInstallPackage(entry, cwd, json, yes) {
85
+ const packageName = entry?.npm?.package;
86
+ if (!packageName)
87
+ return false;
88
+ const manager = detectPackageManager(cwd);
89
+ const shouldInstall = json ? yes : await confirmOrFallback(`Install ${packageName} with ${manager}?`, true);
90
+ if (!shouldInstall) {
91
+ if (!json)
92
+ p.log.info(`Skipped the package install — run: ${manager} add ${packageName}`);
93
+ return false;
94
+ }
95
+ try {
96
+ await installPackage(manager, packageName, cwd);
97
+ if (!json)
98
+ p.log.success(`Installed ${packageName}`);
99
+ return true;
100
+ }
101
+ catch (error) {
102
+ if (!json)
103
+ p.log.warn(`Package install failed (non-blocking): ${error instanceof Error ? error.message : error}`);
104
+ return false;
105
+ }
106
+ }
107
+ async function recordConsumesEdge(entry, serviceName, cwd, json, yes) {
108
+ if (!hasManifest(cwd)) {
109
+ const repoName = path.basename(cwd).toLowerCase().replace(/[^a-z0-9-]+/g, "-");
110
+ const shouldScaffold = json
111
+ ? yes
112
+ : await confirmOrFallback(`No nustack.service.json — scaffold one for "${repoName}" to record the consumes edge?`, true);
113
+ if (!shouldScaffold) {
114
+ if (!json)
115
+ p.log.info("Skipped — the consumes edge (bill of materials) was not recorded.");
116
+ return false;
117
+ }
118
+ await scaffoldManifest(cwd, repoName);
119
+ if (!json)
120
+ p.log.success("Scaffolded nustack.service.json");
121
+ }
122
+ const via = entry?.npm?.package ? "npm" : "http";
123
+ const wasAppended = await appendConsumesEdge(cwd, { service: serviceName, via, endpoint: "dev" });
124
+ if (!json) {
125
+ p.log.success(wasAppended
126
+ ? `Recorded the consumes edge: ${serviceName} (via ${via})`
127
+ : `Consumes edge for ${serviceName} already declared`);
128
+ }
129
+ return true;
130
+ }
@@ -0,0 +1,100 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { installSessionStartHook, removeSessionStartHook } from "../lib/agent/claudeHooks.js";
5
+ const PROBE_TIMEOUT_MS = 1500;
6
+ function desktopConfigDir() {
7
+ return (process.env.NUSTACK_DESKTOP_CONFIG_DIR ||
8
+ path.join(os.homedir(), "Library", "Application Support", "co.nurix.nustack"));
9
+ }
10
+ async function readServerRecord() {
11
+ const recordPath = path.join(desktopConfigDir(), "server.json");
12
+ try {
13
+ const parsed = JSON.parse(await readFile(recordPath, "utf8"));
14
+ if (typeof parsed.port !== "number" ||
15
+ typeof parsed.token !== "string" ||
16
+ typeof parsed.pid !== "number" ||
17
+ typeof parsed.startedAt !== "string") {
18
+ return null;
19
+ }
20
+ return parsed;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ function isPidAlive(pid) {
27
+ try {
28
+ process.kill(pid, 0);
29
+ return true;
30
+ }
31
+ catch (error) {
32
+ return error.code === "EPERM";
33
+ }
34
+ }
35
+ function isRosterProject(value) {
36
+ return (value !== null &&
37
+ typeof value === "object" &&
38
+ typeof value.path === "string" &&
39
+ typeof value.label === "string");
40
+ }
41
+ async function probeProjects(record) {
42
+ try {
43
+ const response = await fetch(`http://127.0.0.1:${record.port}/api/projects`, {
44
+ headers: { Authorization: `Bearer ${record.token}` },
45
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
46
+ });
47
+ if (!response.ok)
48
+ return null;
49
+ const body = (await response.json());
50
+ const projects = body.data?.projects;
51
+ return Array.isArray(projects) ? projects.filter(isRosterProject) : null;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ function describeContext(record, projects) {
58
+ const baseUrl = `http://127.0.0.1:${record.port}`;
59
+ const roster = projects.length
60
+ ? projects.map((project) => `- ${project.label} (${project.path})`).join("\n")
61
+ : "(no projects open)";
62
+ return [
63
+ `The NuStack desktop is running and reachable at ${baseUrl}.`,
64
+ `Bearer token for its API: ${record.token}`,
65
+ `Open projects (${projects.length}):`,
66
+ roster,
67
+ "This confirms NuStack answered just now — it says nothing about whether any project's code graph is current; ask `nustack graph` for that.",
68
+ ].join("\n");
69
+ }
70
+ export async function runAgentContext() {
71
+ const record = await readServerRecord();
72
+ if (!record)
73
+ return;
74
+ if (!isPidAlive(record.pid))
75
+ return;
76
+ const projects = await probeProjects(record);
77
+ if (projects === null)
78
+ return;
79
+ console.log(JSON.stringify({
80
+ hookSpecificOutput: {
81
+ hookEventName: "SessionStart",
82
+ additionalContext: describeContext(record, projects),
83
+ },
84
+ }));
85
+ }
86
+ function claudeSettingsPath() {
87
+ return path.join(os.homedir(), ".claude", "settings.json");
88
+ }
89
+ export async function runAgentHookInstall() {
90
+ const { changed } = await installSessionStartHook(claudeSettingsPath());
91
+ console.log(changed
92
+ ? "Installed the SessionStart hook."
93
+ : "Installed the SessionStart hook (already installed — no change).");
94
+ }
95
+ export async function runAgentHookRemove() {
96
+ const { removed } = await removeSessionStartHook(claudeSettingsPath());
97
+ console.log(removed
98
+ ? "Removed the SessionStart hook."
99
+ : "Removed the SessionStart hook (already absent — no change).");
100
+ }
@@ -0,0 +1,54 @@
1
+ import * as p from "@clack/prompts";
2
+ import { resolveNuStackUrl } from "../lib/nustack_client.js";
3
+ import { isInteractive } from "../lib/interactive.js";
4
+ import { ensureDeviceTelemetry } from "../lib/session_telemetry.js";
5
+ import { openLocalStore } from "../lib/local_store/database.js";
6
+ import { getBindingByRepositoryRoot } from "../lib/local_store/bindings.js";
7
+ import { listApplicationsForBinding } from "../lib/local_store/handoff_applications.js";
8
+ import { classifyFolder } from "../lib/onboarding/folder_state.js";
9
+ import { ensureSignedIn, runOnboardingFlow } from "./init.js";
10
+ export async function runBootstrap(options) {
11
+ const cwd = process.cwd();
12
+ const baseUrl = resolveNuStackUrl(options);
13
+ p.intro("nustack");
14
+ if (!isInteractive()) {
15
+ p.log.error("Bare `nustack` needs an interactive terminal (browser sign-in + prompts).");
16
+ p.outro("In scripted contexts run `nustack login --no-open`, then `nustack init`.");
17
+ process.exitCode = 1;
18
+ return;
19
+ }
20
+ const accessToken = await ensureSignedIn(baseUrl, cwd);
21
+ if (!accessToken)
22
+ return;
23
+ await ensureDeviceTelemetry({ baseUrl, log: (message) => p.log.info(message) });
24
+ const state = classifyFolder(cwd);
25
+ const bound = state.class === "git_worktree" && state.repository && state.git?.markerWorkspaceId
26
+ ? await printBoundStatus(state.repository.repositoryRoot)
27
+ : false;
28
+ if (bound)
29
+ return;
30
+ await runOnboardingFlow(baseUrl, accessToken, {});
31
+ }
32
+ async function printBoundStatus(root) {
33
+ const store = await openLocalStore();
34
+ try {
35
+ const binding = getBindingByRepositoryRoot(store, root);
36
+ if (!binding)
37
+ return false;
38
+ const pending = listApplicationsForBinding(store, binding.id, [
39
+ "received",
40
+ "evaluating",
41
+ "technical_wait",
42
+ "decision_required",
43
+ "approved",
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}` +
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.");
49
+ return true;
50
+ }
51
+ finally {
52
+ store.close();
53
+ }
54
+ }
@@ -0,0 +1,34 @@
1
+ import * as p from "@clack/prompts";
2
+ import { fetchCatalogue, resolveNuStackUrl } from "../lib/nustack_client.js";
3
+ export async function runDescribe(serviceName, options, globals) {
4
+ const baseUrl = resolveNuStackUrl(globals);
5
+ const catalogue = await fetchCatalogue(baseUrl);
6
+ const entry = (catalogue.services ?? []).find((service) => service.name === serviceName);
7
+ if (!entry) {
8
+ p.log.error(`'${serviceName}' is not in the catalogue at ${baseUrl} (${catalogue.services?.length ?? 0} services listed).`);
9
+ process.exitCode = 1;
10
+ return;
11
+ }
12
+ if (options.json) {
13
+ console.log(JSON.stringify(entry, null, 2));
14
+ return;
15
+ }
16
+ console.log(`${entry.name} — ${entry.description ?? "(no description)"}`);
17
+ console.log(` kind/type: ${entry.kind ?? "?"} / ${entry.type ?? "?"}`);
18
+ console.log(` status: ${entry.status ?? "?"}`);
19
+ console.log(` environment: ${entry.environmentKey ?? "dev"}`);
20
+ if (entry.repository)
21
+ console.log(` repository: ${entry.repository}`);
22
+ if (entry.npm?.package)
23
+ console.log(` npm: ${entry.npm.package}`);
24
+ for (const [envName, url] of Object.entries(entry.http?.endpoints ?? {})) {
25
+ console.log(` endpoint: ${envName} → ${url}`);
26
+ }
27
+ if (entry.env?.required?.length)
28
+ console.log(` env (required): ${entry.env.required.join(", ")}`);
29
+ if (entry.env?.optional?.length)
30
+ console.log(` env (optional): ${entry.env.optional.join(", ")}`);
31
+ for (const edge of entry.consumes ?? []) {
32
+ console.log(` consumes: ${edge.service}${edge.via ? ` (via ${edge.via})` : ""}`);
33
+ }
34
+ }
@@ -0,0 +1,159 @@
1
+ import * as p from "@clack/prompts";
2
+ import { listDevices, resolveNuStackUrl } from "../lib/nustack_client.js";
3
+ import { readGrant, revokeGrant } from "../lib/auth_grant.js";
4
+ import { ensureAuthWithRecovery, lastAuthFailure } from "../lib/auth_recovery.js";
5
+ import { deleteDeviceCredential } from "../lib/device_keychain.js";
6
+ import { isInteractive } from "../lib/interactive.js";
7
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
8
+ function clientLabel(client) {
9
+ if (client === "nustack-cli")
10
+ return "NuStack CLI";
11
+ if (client === "nustack-desktop")
12
+ return "NuStack desktop";
13
+ if (client === "browser")
14
+ return "Browser";
15
+ return client;
16
+ }
17
+ function relativeTime(iso) {
18
+ const then = Date.parse(iso);
19
+ if (Number.isNaN(then))
20
+ return iso;
21
+ const seconds = Math.max(0, Math.round((Date.now() - then) / 1000));
22
+ if (seconds < 60)
23
+ return `${seconds}s ago`;
24
+ const minutes = Math.round(seconds / 60);
25
+ if (minutes < 60)
26
+ return `${minutes}m ago`;
27
+ const hours = Math.round(minutes / 60);
28
+ if (hours < 48)
29
+ return `${hours}h ago`;
30
+ return `${Math.round(hours / 24)}d ago`;
31
+ }
32
+ function formatRow(device) {
33
+ const name = device.machine ?? clientLabel(device.client);
34
+ const platform = device.platform && device.platform !== name ? device.platform : "";
35
+ return (`${device.deviceRef.padEnd(38)} ${name.padEnd(22)} ${clientLabel(device.client).padEnd(16)} ${platform.padEnd(10)} ` +
36
+ `active ${device.lastActiveAt ? relativeTime(device.lastActiveAt) : "never"}${device.isCurrent ? " (this machine)" : ""}`);
37
+ }
38
+ function toView(device, currentDeviceRef) {
39
+ return {
40
+ id: device.id,
41
+ deviceRef: device.deviceRef,
42
+ client: device.clientName ?? "unknown",
43
+ machine: device.label ?? device.displayName ?? null,
44
+ platform: device.platform ?? null,
45
+ lastActiveAt: device.lastSeenAt ?? null,
46
+ isCurrent: device.deviceRef === currentDeviceRef,
47
+ };
48
+ }
49
+ function failSignedOut(command, json) {
50
+ const failure = lastAuthFailure();
51
+ if (json)
52
+ printJsonError(command, { code: failure.code, message: failure.message });
53
+ else {
54
+ p.log.error(failure.message);
55
+ process.exitCode = 1;
56
+ }
57
+ }
58
+ function fail(command, json, code, message) {
59
+ if (json)
60
+ printJsonError(command, { code, message });
61
+ else {
62
+ p.log.error(message);
63
+ process.exitCode = 1;
64
+ }
65
+ }
66
+ async function liveGrant(baseUrl, json) {
67
+ const auth = await ensureAuthWithRecovery(baseUrl, json);
68
+ if (!auth)
69
+ return null;
70
+ const grant = await readGrant();
71
+ return grant ? { grant, accessToken: auth.accessToken } : null;
72
+ }
73
+ export async function runDeviceList(options, globals) {
74
+ const baseUrl = resolveNuStackUrl(globals);
75
+ const json = options.json === true;
76
+ const live = await liveGrant(baseUrl, json);
77
+ if (!live)
78
+ return failSignedOut("device list", json);
79
+ const devices = (await listDevices(baseUrl, live.accessToken)).map((row) => toView(row, live.grant.deviceId));
80
+ if (json) {
81
+ printJsonSuccess("device list", { devices });
82
+ return;
83
+ }
84
+ if (devices.length === 0) {
85
+ console.log("No devices signed in.");
86
+ return;
87
+ }
88
+ console.log(`${"DEVICE".padEnd(38)} ${"MACHINE".padEnd(22)} ${"CLIENT".padEnd(16)} ${"PLATFORM".padEnd(10)} LAST ACTIVE`);
89
+ for (const device of devices)
90
+ console.log(formatRow(device));
91
+ }
92
+ export async function runDeviceStatus(options, globals) {
93
+ const baseUrl = resolveNuStackUrl(globals);
94
+ const json = options.json === true;
95
+ const live = await liveGrant(baseUrl, json);
96
+ if (!live)
97
+ return failSignedOut("device status", json);
98
+ const { grant, accessToken } = live;
99
+ const rows = await listDevices(baseUrl, accessToken);
100
+ const current = rows.map((row) => toView(row, grant.deviceId)).find((d) => d.isCurrent) ?? null;
101
+ if (json) {
102
+ printJsonSuccess("device status", {
103
+ deviceId: grant.deviceId,
104
+ email: grant.userEmail,
105
+ authBaseUrl: grant.authBaseUrl,
106
+ resources: grant.grantedResources,
107
+ keychainMaterial: true,
108
+ server: current,
109
+ });
110
+ return;
111
+ }
112
+ p.intro("nustack device status");
113
+ p.note([
114
+ `device id: ${grant.deviceId}`,
115
+ `signed in as: ${grant.userEmail || "—"}`,
116
+ `auth service: ${grant.authBaseUrl}`,
117
+ `resources: ${grant.grantedResources.join(", ") || "—"}`,
118
+ `keychain grant: present`,
119
+ `server record: ${current ? `${current.id} (${clientLabel(current.client)})` : "not found"}`,
120
+ `last active: ${current?.lastActiveAt ?? "—"}`,
121
+ ].join("\n"), "This device");
122
+ p.outro(grant.userEmail || "signed in");
123
+ }
124
+ export async function runDeviceRevoke(deviceRefArg, options, globals) {
125
+ const baseUrl = resolveNuStackUrl(globals);
126
+ const json = options.json === true;
127
+ const live = await liveGrant(baseUrl, json);
128
+ if (!live)
129
+ return failSignedOut("device revoke", json);
130
+ const { grant, accessToken } = live;
131
+ const views = (await listDevices(baseUrl, accessToken)).map((row) => toView(row, grant.deviceId));
132
+ const target = deviceRefArg ? views.find((view) => view.deviceRef === deviceRefArg || view.id === deviceRefArg) : (views.find((view) => view.isCurrent) ?? null);
133
+ if (deviceRefArg && !target)
134
+ return fail("device revoke", json, "DEVICE_NOT_FOUND", "No device with that id — pass one from `nustack device list`.");
135
+ if (target && !target.isCurrent) {
136
+ const label = target.machine ? `${clientLabel(target.client)} on ${target.machine}` : clientLabel(target.client);
137
+ return fail("device revoke", json, "NOT_THIS_MACHINE", `Only this machine's own access can be ended from here. Sign ${label} out from that machine, or revoke it from Profile › Devices.`);
138
+ }
139
+ if (options.yes !== true) {
140
+ if (json || !isInteractive())
141
+ return fail("device revoke", json, "CONFIRMATION_REQUIRED", "Refusing to revoke without confirmation — re-run with --yes.");
142
+ const answer = await p.confirm({ message: "Revoke this machine's access? The CLI and the desktop here will be signed out." });
143
+ if (p.isCancel(answer) || !answer) {
144
+ p.outro("Cancelled — nothing revoked.");
145
+ return;
146
+ }
147
+ }
148
+ const clientId = grant.clientId;
149
+ const outcome = clientId ? await revokeGrant({ authBaseUrl: grant.authBaseUrl, refreshToken: grant.grantToken, clientId }) : "unreachable";
150
+ if (outcome === "refused" || outcome === "unreachable") {
151
+ return fail("device revoke", json, "REVOKE_FAILED", outcome === "refused" ? "The auth service refused to end the grant." : "Couldn't reach the auth service to end the grant.");
152
+ }
153
+ await deleteDeviceCredential(grant.deviceId).catch(() => false);
154
+ if (json) {
155
+ printJsonSuccess("device revoke", { deviceId: grant.deviceId, revoked: true, current: true });
156
+ return;
157
+ }
158
+ p.log.success("Revoked this machine's access.");
159
+ }
@@ -0,0 +1,119 @@
1
+ import { getDocument, listDocuments, resolveNuStackUrl, } from "../lib/nustack_client.js";
2
+ import { ensureAuthWithRecovery, lastAuthFailure } from "../lib/auth_recovery.js";
3
+ import { resolveWorkspaceId } from "../lib/workspace_context.js";
4
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
5
+ const INLINE_BODY_LIMIT = 16 * 1024;
6
+ async function resolveContext(command, json, options, globals) {
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);
11
+ return null;
12
+ }
13
+ const deviceAuth = await ensureAuthWithRecovery(baseUrl, json);
14
+ if (!deviceAuth) {
15
+ const failure = lastAuthFailure();
16
+ emitFail(command, json, failure.code, failure.message);
17
+ return null;
18
+ }
19
+ return { baseUrl, accessToken: deviceAuth.accessToken, workspaceId: workspace.workspaceId };
20
+ }
21
+ function emitFail(command, json, code, message) {
22
+ if (json)
23
+ printJsonError(command, { code, message });
24
+ else {
25
+ console.error(message);
26
+ process.exitCode = 1;
27
+ }
28
+ }
29
+ export async function runDocsList(options, globals) {
30
+ const json = options.json === true;
31
+ const ctx = await resolveContext("docs list", json, options, globals);
32
+ if (!ctx)
33
+ return;
34
+ const documents = await listDocuments(ctx.baseUrl, ctx.accessToken, ctx.workspaceId);
35
+ if (json) {
36
+ printJsonSuccess("docs list", { workspaceId: ctx.workspaceId, documents });
37
+ return;
38
+ }
39
+ if (documents.length === 0) {
40
+ console.log("This Workspace has no Documents yet.");
41
+ return;
42
+ }
43
+ for (const doc of documents) {
44
+ const written = doc.hasBody ? "written" : "empty";
45
+ console.log(`${doc.slug.padEnd(28)} ${(doc.type ?? "-").padEnd(16)} ${doc.status.padEnd(8)} ${written.padEnd(8)} ${doc.label}`);
46
+ }
47
+ }
48
+ export async function runDocsShow(target, options, globals) {
49
+ const json = options.json === true;
50
+ const ctx = await resolveContext("docs show", json, options, globals);
51
+ if (!ctx)
52
+ return;
53
+ const documents = await listDocuments(ctx.baseUrl, ctx.accessToken, ctx.workspaceId);
54
+ const match = documents.find((d) => d.id === target || d.slug === target);
55
+ if (!match) {
56
+ emitFail("docs show", json, "DOCUMENT_NOT_FOUND", `No Document "${target}" in this Workspace.`);
57
+ return;
58
+ }
59
+ const payload = await getDocument(ctx.baseUrl, ctx.accessToken, match.id);
60
+ const doc = payload.document;
61
+ const body = doc.body || null;
62
+ const byteSize = body ? Buffer.byteLength(body) : 0;
63
+ const inlineable = body !== null && byteSize <= INLINE_BODY_LIMIT;
64
+ if (json) {
65
+ printJsonSuccess("docs show", {
66
+ document: {
67
+ id: doc.id,
68
+ slug: doc.slug,
69
+ label: doc.label,
70
+ documentType: doc.docType,
71
+ status: doc.status,
72
+ bodySha256: doc.bodySha256,
73
+ blockedBy: doc.blockedBy,
74
+ },
75
+ body: inlineable ? body : null,
76
+ byteSize,
77
+ });
78
+ return;
79
+ }
80
+ console.log(`# ${doc.label} (${doc.slug})`);
81
+ console.log(`type: ${doc.docType ?? "-"} status: ${doc.status} bodySha256: ${doc.bodySha256.slice(0, 12)}`);
82
+ if (doc.blockedBy.length > 0)
83
+ console.log(`blocked by: ${doc.blockedBy.join(", ")}`);
84
+ if (inlineable && body !== null) {
85
+ console.log("");
86
+ console.log(body);
87
+ }
88
+ else if (body !== null) {
89
+ console.log(`Body is ${byteSize} bytes — larger than the inline limit; open it in the studio.`);
90
+ }
91
+ else {
92
+ console.log("Nothing has been written into this document yet.");
93
+ }
94
+ }
95
+ export async function runDocsStatus(options, globals) {
96
+ const json = options.json === true;
97
+ const ctx = await resolveContext("docs status", json, options, globals);
98
+ if (!ctx)
99
+ return;
100
+ const documents = await listDocuments(ctx.baseUrl, ctx.accessToken, ctx.workspaceId);
101
+ const rows = documents.map((doc) => ({
102
+ slug: doc.slug,
103
+ documentId: doc.id,
104
+ bodySha256: doc.bodySha256,
105
+ status: doc.status,
106
+ convergence: "unmapped",
107
+ }));
108
+ if (json) {
109
+ printJsonSuccess("docs status", { workspaceId: ctx.workspaceId, paths: rows });
110
+ return;
111
+ }
112
+ if (rows.length === 0) {
113
+ console.log("This Workspace has no Documents to converge.");
114
+ return;
115
+ }
116
+ for (const row of rows) {
117
+ console.log(`${row.slug.padEnd(28)} ${row.convergence.padEnd(10)} ${row.status.padEnd(8)} ${row.bodySha256 ? row.bodySha256.slice(0, 12) : "-"}`);
118
+ }
119
+ }