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