@nurix/nustack 0.10.0-dev.15 → 0.10.0-dev.16

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 CHANGED
@@ -74,4 +74,5 @@ nustack telemetry [status|enable|disable|reinstall]
74
74
 
75
75
  - **`--json` almost everywhere** — most commands emit the `nustackJsonVersion: 1` machine envelope with `--json`; errors are typed (`{ error: { code, message } }`), never a bare HTTP status. Four surfaces break the pattern by design — `describe`, `search`, `search services`, and the `workspace sync` family emit a bare array/object instead, because the desktop and the agents pin those exact shapes.
76
76
  - **Base URL** — `--nustack-url` flag → `NUSTACK_URL` env → the hosted default (`https://apps.nustack.ai`).
77
+ - **Credential** — `NUSTACK_TOKEN` (a personal access token from `nustack token create`, for pipelines and agents; wins over every store, never refreshes, never opens a browser) → the OS store (macOS Keychain, libsecret on Linux) → `~/.nustack/credentials.json` (`0600`) where no OS store exists. `NUSTACK_CREDENTIAL_STORE=auto|keychain|libsecret|file` overrides the probe.
77
78
  - **Organization context** — `nustack organization use` persists the selection locally; commands send it as a header the server re-proves against your memberships on every request.
@@ -1,7 +1,8 @@
1
1
  import path from "node:path";
2
2
  import { resolveNuStackUrl } from "../lib/nustack_client.js";
3
- import { ensureAccessToken } from "../lib/auth_grant.js";
4
- import { isKeychainAvailable } from "../lib/device_keychain.js";
3
+ import { stat } from "node:fs/promises";
4
+ import { ensureAccessToken, ENV_TOKEN_VARIABLE, envToken } from "../lib/auth_grant.js";
5
+ import { credentialsFilePath, selectCredentialStore } from "../lib/credential_store.js";
5
6
  import { resolveProjectId } from "../lib/project_context.js";
6
7
  import { readManagedValues } from "../lib/env_file.js";
7
8
  import { readProjectManifests, diagnoseManifests } from "../lib/onboarding/manifest_writer.js";
@@ -19,6 +20,33 @@ import { NotARepositoryError } from "../lib/git-sync/core/git_runner.js";
19
20
  import { listBundles, sweepExpiredBundles } from "../lib/git-sync/recovery/store.js";
20
21
  import { inspectRecoveryKey } from "../lib/git-sync/recovery/crypto.js";
21
22
  import { NUSTACK_JSON_VERSION } from "../lib/json_output.js";
23
+ async function credentialStoreRows(environmentInUse) {
24
+ if (environmentInUse)
25
+ return [{ check: "Credential source", status: "ok", detail: "environment — no store is read" }];
26
+ let store;
27
+ try {
28
+ store = selectCredentialStore();
29
+ }
30
+ catch (error) {
31
+ return [{ check: "Credential source", status: "error", detail: error instanceof Error ? error.message : String(error) }];
32
+ }
33
+ const rows = [
34
+ { check: "Credential source", status: "ok", detail: store.name === "file" ? "file — no OS keychain on this machine" : store.name },
35
+ ];
36
+ if (store.name === "file") {
37
+ const filePath = credentialsFilePath();
38
+ try {
39
+ const mode = (await stat(filePath)).mode & 0o777;
40
+ rows.push(mode === 0o600
41
+ ? { check: "Credential file mode", status: "ok", detail: "0600" }
42
+ : { check: "Credential file mode", status: "warn", detail: `0${mode.toString(8)} — expected 0600; run \`chmod 600 ~/.nustack/credentials.json\`` });
43
+ }
44
+ catch {
45
+ rows.push({ check: "Credential file mode", status: "info", detail: "no credential file yet — sign in to create it" });
46
+ }
47
+ }
48
+ return rows;
49
+ }
22
50
  export async function runDoctor(options) {
23
51
  const cwd = process.cwd();
24
52
  const baseUrl = resolveNuStackUrl(options);
@@ -28,12 +56,18 @@ export async function runDoctor(options) {
28
56
  const add = (area, check, status, detail = "") => {
29
57
  rows.push({ area, check, status, detail });
30
58
  };
59
+ const fromEnv = envToken();
31
60
  const deviceAuth = await ensureAccessToken(baseUrl).catch(() => null);
32
- if (deviceAuth)
61
+ if (fromEnv?.kind === "malformed")
62
+ add("identity", "Device sign-in", "error", fromEnv.message);
63
+ else if (fromEnv)
64
+ add("identity", "Device sign-in", "ok", `${ENV_TOKEN_VARIABLE} is set — the environment's token is used`);
65
+ else if (deviceAuth)
33
66
  add("identity", "Device sign-in", "ok", `signed in as ${deviceAuth.userEmail}`);
34
67
  else
35
68
  add("identity", "Device sign-in", "error", "signed out — run `nustack login`");
36
- add("identity", "OS keychain", isKeychainAvailable() ? "ok" : "warn", isKeychainAvailable() ? "available" : "unavailable — sign-in relies on the 24h approval window");
69
+ for (const row of await credentialStoreRows(fromEnv !== null))
70
+ add("identity", row.check, row.status, row.detail);
37
71
  add("api", "NuStack reachability", ...(await probeReachability(baseUrl)));
38
72
  let store = null;
39
73
  try {
@@ -1,9 +1,9 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { resolveNuStackUrl } from "../lib/nustack_client.js";
3
3
  import { launchBrowser } from "../lib/browser_opener.js";
4
- import { isKeychainAvailable } from "../lib/device_keychain.js";
4
+ import { selectCredentialStore } from "../lib/credential_store.js";
5
5
  import { isInteractive } from "../lib/interactive.js";
6
- import { ensureAccessToken, loginWithLoopback, loginWithPastedCode, prepareLogin, } from "../lib/auth_grant.js";
6
+ import { ensureAccessToken, ENV_TOKEN_VARIABLE, envToken, loginWithLoopback, loginWithPastedCode, prepareLogin, } from "../lib/auth_grant.js";
7
7
  async function readStdinLine() {
8
8
  return new Promise((resolve) => {
9
9
  let buffered = "";
@@ -79,8 +79,16 @@ async function loopbackFlow(ctx, flags) {
79
79
  }
80
80
  }
81
81
  export async function performDeviceLogin(baseUrl, _cwd = process.cwd(), flags = {}) {
82
- if (!isKeychainAvailable()) {
83
- p.log.error("No OS keychain on this platform NuStack keeps a machine's sign-in only in the keychain.");
82
+ if (envToken()) {
83
+ p.log.info(`${ENV_TOKEN_VARIABLE} is set this process authenticates with it, and no sign-in is stored on this machine. Unset it to sign in as a person.`);
84
+ return true;
85
+ }
86
+ let store;
87
+ try {
88
+ store = selectCredentialStore();
89
+ }
90
+ catch (error) {
91
+ p.log.error(error instanceof Error ? error.message : String(error));
84
92
  return false;
85
93
  }
86
94
  if (!flags.force && !flags.reauthorize) {
@@ -118,7 +126,12 @@ export async function performDeviceLogin(baseUrl, _cwd = process.cwd(), flags =
118
126
  return false;
119
127
  }
120
128
  p.log.success(`Signed in as ${result.userEmail || "this account"}`);
121
- p.log.info("This machine's grant is saved to your keychain — the desktop shares it. Manage it with `nustack device list`.");
129
+ if (store.name === "file") {
130
+ p.log.info(`This machine's grant is saved to ~/.nustack/credentials.json, readable only by you — no OS keychain here. Manage it with \`nustack device list\`.`);
131
+ }
132
+ else {
133
+ p.log.info("This machine's grant is saved to your keychain — the desktop shares it. Manage it with `nustack device list`.");
134
+ }
122
135
  return true;
123
136
  }
124
137
  export async function runLogin(options, flags = {}) {
@@ -130,5 +143,5 @@ export async function runLogin(options, flags = {}) {
130
143
  process.exitCode = 1;
131
144
  return;
132
145
  }
133
- p.outro("Signed in on this device.");
146
+ p.outro(envToken() ? "Nothing was written." : "Signed in on this device.");
134
147
  }
@@ -1,13 +1,20 @@
1
1
  import * as p from "@clack/prompts";
2
2
  import { resolveNuStackUrl } from "../lib/nustack_client.js";
3
3
  import { clearDeviceId } from "../lib/device_identity.js";
4
- import { deleteDeviceCredential, isKeychainAvailable } from "../lib/device_keychain.js";
5
- import { fetchAuthConfig, readGrant, revokeGrant } from "../lib/auth_grant.js";
4
+ import { deleteDeviceCredential } from "../lib/device_keychain.js";
5
+ import { selectCredentialStore } from "../lib/credential_store.js";
6
+ import { ENV_TOKEN_VARIABLE, envToken, fetchAuthConfig, readGrant, revokeGrant } from "../lib/auth_grant.js";
6
7
  const SHARED_SIGNOUT_NOTE = "This signs out both the CLI and the desktop on this machine.";
7
8
  export async function runLogout(options, flags = {}) {
8
9
  p.intro("nustack logout");
9
10
  const baseUrl = resolveNuStackUrl(options);
10
- const grant = isKeychainAvailable() ? await readGrant() : null;
11
+ if (envToken()) {
12
+ p.log.info(`${ENV_TOKEN_VARIABLE} is set — the environment holds this process's credential, and there is nothing stored on this machine to end. Unset the variable, or revoke the token with \`nustack token revoke\`.`);
13
+ p.outro("Nothing was changed.");
14
+ return;
15
+ }
16
+ const store = selectCredentialStore();
17
+ const grant = await readGrant();
11
18
  if (grant) {
12
19
  const clientId = grant.clientId ?? (await fetchAuthConfig(baseUrl).then((c) => c.clientId).catch(() => null));
13
20
  const outcome = clientId ? await revokeGrant({ authBaseUrl: grant.authBaseUrl, refreshToken: grant.grantToken, clientId }) : "unreachable";
@@ -25,12 +32,13 @@ export async function runLogout(options, flags = {}) {
25
32
  }
26
33
  const cleared = await deleteDeviceCredential(grant.deviceId);
27
34
  if (cleared)
28
- p.log.success("Cleared the keychain grant.");
35
+ p.log.success(store.name === "keychain" ? "Cleared the keychain grant." : `Cleared the stored grant (${store.name}).`);
29
36
  }
30
37
  else {
31
- p.log.info("No keychain credential to clear on this machine.");
38
+ p.log.info(store.name === "keychain" ? "No keychain credential to clear on this machine." : `No stored credential to clear on this machine (${store.name}).`);
32
39
  }
33
- p.log.info(SHARED_SIGNOUT_NOTE);
40
+ if (store.name === "keychain")
41
+ p.log.info(SHARED_SIGNOUT_NOTE);
34
42
  if (flags.forgetDevice) {
35
43
  const forgot = await clearDeviceId();
36
44
  if (forgot) {
@@ -0,0 +1,102 @@
1
+ import { createPersonalToken, listPersonalTokens, PersonalTokenRequestError, resolveNuStackUrl, revokePersonalToken } from "../lib/nustack_client.js";
2
+ import { ensureAuthWithRecovery, lastAuthFailure } from "../lib/auth_recovery.js";
3
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
4
+ const MAX_EXPIRY_DAYS = 365;
5
+ async function requireAuth(command, json, globals) {
6
+ const baseUrl = resolveNuStackUrl(globals);
7
+ const auth = await ensureAuthWithRecovery(baseUrl, json);
8
+ if (!auth) {
9
+ const failure = lastAuthFailure();
10
+ if (json)
11
+ printJsonError(command, { code: failure.code, message: failure.message });
12
+ else {
13
+ console.error(failure.message);
14
+ process.exitCode = 1;
15
+ }
16
+ return null;
17
+ }
18
+ return { baseUrl, accessToken: auth.accessToken };
19
+ }
20
+ function refuse(command, json, error) {
21
+ const code = error instanceof PersonalTokenRequestError ? error.code : "REQUEST_FAILED";
22
+ const message = error instanceof Error ? error.message : String(error);
23
+ if (json)
24
+ printJsonError(command, { code, message });
25
+ else {
26
+ console.error(message);
27
+ process.exitCode = 1;
28
+ }
29
+ }
30
+ export async function runTokenCreate(options, globals) {
31
+ const json = options.json === true;
32
+ const label = options.label?.trim() ?? "";
33
+ if (!label)
34
+ return refuse("token create", json, new PersonalTokenRequestError("VALIDATION_FAILED", "A label is required: `nustack token create --label <name>`."));
35
+ let expiresInDays;
36
+ if (options.expires !== undefined) {
37
+ const parsed = Number(options.expires);
38
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_EXPIRY_DAYS) {
39
+ return refuse("token create", json, new PersonalTokenRequestError("VALIDATION_FAILED", `--expires is a whole number of days, 1–${MAX_EXPIRY_DAYS}.`));
40
+ }
41
+ expiresInDays = parsed;
42
+ }
43
+ const auth = await requireAuth("token create", json, globals);
44
+ if (!auth)
45
+ return;
46
+ try {
47
+ const minted = await createPersonalToken(auth.baseUrl, auth.accessToken, { label, ...(expiresInDays ? { expiresInDays } : {}) });
48
+ if (json) {
49
+ printJsonSuccess("token create", minted);
50
+ return;
51
+ }
52
+ console.error(`Minted \`${minted.token.label}\` (${minted.token.tokenPrefix}…)${minted.token.expiresAt ? `, expires ${minted.token.expiresAt}` : ""}.`);
53
+ console.error(`This is the only time the secret is shown. Store it as ${"NUSTACK_TOKEN"} in your pipeline; revoke it with \`nustack token revoke ${minted.token.id}\`.`);
54
+ console.log(minted.secret);
55
+ }
56
+ catch (error) {
57
+ refuse("token create", json, error);
58
+ }
59
+ }
60
+ export async function runTokenList(options, globals) {
61
+ const json = options.json === true;
62
+ const auth = await requireAuth("token list", json, globals);
63
+ if (!auth)
64
+ return;
65
+ try {
66
+ const tokens = await listPersonalTokens(auth.baseUrl, auth.accessToken);
67
+ if (json) {
68
+ printJsonSuccess("token list", { tokens });
69
+ return;
70
+ }
71
+ if (tokens.length === 0) {
72
+ console.log("No personal access tokens. Mint one with `nustack token create --label <name>`.");
73
+ return;
74
+ }
75
+ for (const token of tokens) {
76
+ const state = token.revokedAt ? `revoked ${token.revokedAt}` : token.expiresAt ? `expires ${token.expiresAt}` : "no expiry";
77
+ console.log(`${token.id.padEnd(38)} ${`${token.tokenPrefix}…`.padEnd(18)} ${token.label.padEnd(24)} ${state.padEnd(30)} last used ${token.lastUsedAt ?? "never"}`);
78
+ }
79
+ }
80
+ catch (error) {
81
+ refuse("token list", json, error);
82
+ }
83
+ }
84
+ export async function runTokenRevoke(tokenId, options, globals) {
85
+ const json = options.json === true;
86
+ if (!tokenId?.trim())
87
+ return refuse("token revoke", json, new PersonalTokenRequestError("VALIDATION_FAILED", "A token id is required: `nustack token revoke <id>` (see `nustack token list`)."));
88
+ const auth = await requireAuth("token revoke", json, globals);
89
+ if (!auth)
90
+ return;
91
+ try {
92
+ const revoked = await revokePersonalToken(auth.baseUrl, auth.accessToken, tokenId.trim());
93
+ if (json) {
94
+ printJsonSuccess("token revoke", revoked);
95
+ return;
96
+ }
97
+ console.log(`Revoked ${revoked.id} at ${revoked.revokedAt}.`);
98
+ }
99
+ catch (error) {
100
+ refuse("token revoke", json, error);
101
+ }
102
+ }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { runDescribe } from "./commands/describe.js";
10
10
  import { runValidate } from "./commands/validate.js";
11
11
  import { runGraph } from "./commands/graph.js";
12
12
  import { runDoctor } from "./commands/doctor.js";
13
+ import { runTokenCreate, runTokenList, runTokenRevoke } from "./commands/token.js";
13
14
  import { runOpen } from "./commands/open.js";
14
15
  import { runBootstrap } from "./commands/bootstrap.js";
15
16
  import { runLogout } from "./commands/logout.js";
@@ -108,6 +109,24 @@ program
108
109
  .option("--env <name>", "pick a specific endpoint environment (default: dev)")
109
110
  .option("--json", "print the resolved URL as the machine envelope instead of launching")
110
111
  .action(async (service, options) => runOpen(service, options, program.opts()));
112
+ const token = program.command("token").description("Mint, list and revoke your personal access tokens (the NUSTACK_TOKEN a pipeline or agent uses)");
113
+ token
114
+ .command("create")
115
+ .description("Mint a personal access token; the secret is printed once, on stdout alone")
116
+ .requiredOption("--label <label>", "a name for the token, unique among your live ones")
117
+ .option("--expires <days>", "days until the token expires (1–365); no expiry when omitted")
118
+ .option("--json", "emit the versioned machine envelope")
119
+ .action(async (options) => runTokenCreate(options, program.opts()));
120
+ token
121
+ .command("list")
122
+ .description("List your personal access tokens (never a secret)")
123
+ .option("--json", "emit the versioned machine envelope")
124
+ .action(async (options) => runTokenList(options, program.opts()));
125
+ token
126
+ .command("revoke <token-id>")
127
+ .description("Revoke a personal access token; it stops working on the next request")
128
+ .option("--json", "emit the versioned machine envelope")
129
+ .action(async (tokenId, options) => runTokenRevoke(tokenId, options, program.opts()));
111
130
  const device = program.command("device").description("Inspect your signed-in devices and end this machine's access");
112
131
  device
113
132
  .command("list")
@@ -279,6 +279,28 @@ export async function revokeGrant(input) {
279
279
  return "unreachable";
280
280
  }
281
281
  }
282
+ export const ENV_TOKEN_VARIABLE = "NUSTACK_TOKEN";
283
+ export const PERSONAL_TOKEN_PREFIX = "nst_pat_";
284
+ export function envToken() {
285
+ const raw = process.env[ENV_TOKEN_VARIABLE]?.trim();
286
+ if (!raw)
287
+ return null;
288
+ const halves = raw.startsWith(PERSONAL_TOKEN_PREFIX) ? raw.slice(PERSONAL_TOKEN_PREFIX.length).split("_") : [];
289
+ if (halves.length !== 2 || !halves[0] || !halves[1]) {
290
+ return {
291
+ kind: "malformed",
292
+ message: `${ENV_TOKEN_VARIABLE} is not a NuStack personal access token — expected \`${PERSONAL_TOKEN_PREFIX}<id>_<secret>\`, as \`nustack token create\` prints it.`,
293
+ };
294
+ }
295
+ return { kind: "ok", token: raw };
296
+ }
297
+ export class TokenRefusedError extends Error {
298
+ code = "TOKEN_REFUSED";
299
+ constructor() {
300
+ super(`${ENV_TOKEN_VARIABLE} was refused by NuStack — it is revoked, expired, or was minted for another NuStack. Mint a new one with \`nustack token create\`.`);
301
+ this.name = "TokenRefusedError";
302
+ }
303
+ }
282
304
  async function readStoredGrant() {
283
305
  const deviceId = await getOrCreateDeviceId();
284
306
  resolvedDeviceId = deviceId;
@@ -332,6 +354,14 @@ async function refreshAndPersist(grant, audience, nustackBaseUrl) {
332
354
  return refreshed;
333
355
  }
334
356
  export async function resolveAccessToken(nustackBaseUrl, resource) {
357
+ const fromEnv = envToken();
358
+ if (fromEnv) {
359
+ if (fromEnv.kind === "malformed")
360
+ return { kind: "malformed_token", message: fromEnv.message };
361
+ const deviceId = resolvedDeviceId ?? (await getOrCreateDeviceId());
362
+ resolvedDeviceId = deviceId;
363
+ return { kind: "ok", session: { accessToken: fromEnv.token, userEmail: "", deviceId, authBaseUrl: "" } };
364
+ }
335
365
  const { deviceId, grant, failure } = await readStoredGrant();
336
366
  if (!grant) {
337
367
  if (failure)
@@ -363,6 +393,8 @@ export async function ensureAccessToken(nustackBaseUrl, resource) {
363
393
  return outcome.kind === "ok" ? { accessToken: outcome.session.accessToken, userEmail: outcome.session.userEmail } : null;
364
394
  }
365
395
  export async function refreshAccessTokenAfterRefusal(staleToken, nustackBaseUrl, resource) {
396
+ if (envToken())
397
+ return null;
366
398
  const { deviceId, grant } = await readStoredGrant();
367
399
  if (!grant)
368
400
  return null;
@@ -13,6 +13,8 @@ function describeFailure(outcome) {
13
13
  if (outcome.kind === "needs_reauthorization") {
14
14
  return { code: "REAUTHORIZATION_REQUIRED", message: `This machine isn't approved for ${outcome.resource} yet — run \`nustack login\` to approve it.`, retryable: false };
15
15
  }
16
+ if (outcome.kind === "malformed_token")
17
+ return { code: "TOKEN_MALFORMED", message: outcome.message, retryable: false };
16
18
  return { code: "SIGNED_OUT", message: deadCredentialCopy(outcome.reason), retryable: false };
17
19
  }
18
20
  function reconnectingCopy(outcome) {
@@ -36,7 +38,7 @@ export async function ensureAuthWithRecovery(baseUrl, json) {
36
38
  if (outcome.kind === "ok")
37
39
  return { accessToken: outcome.session.accessToken, userEmail: outcome.session.userEmail };
38
40
  lastFailure = describeFailure(outcome);
39
- if (json || !isInteractive())
41
+ if (json || !isInteractive() || outcome.kind === "malformed_token")
40
42
  return null;
41
43
  p.log.warn(reconnectingCopy(outcome));
42
44
  const didLogin = await performDeviceLogin(baseUrl, process.cwd(), { reauthorize: true });
@@ -0,0 +1,106 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { ensureNuStackLayout, nustackHomeDir } from "./local_store/paths.js";
7
+ import { libsecretStore, isLibsecretAvailable } from "./libsecret_store.js";
8
+ const runFile = promisify(execFile);
9
+ const DEFAULT_SECURITY_BIN = "/usr/bin/security";
10
+ const SERVICE = "nustack-device";
11
+ const OWNER_ONLY_FILE = 0o600;
12
+ export class CredentialStoreUnavailableError extends Error {
13
+ constructor(name) {
14
+ super(`NUSTACK_CREDENTIAL_STORE=${name} names a store this machine does not have — unset it, or use \`file\`.`);
15
+ this.name = "CredentialStoreUnavailableError";
16
+ }
17
+ }
18
+ function securityBin() {
19
+ return process.env.NUSTACK_KEYCHAIN_BIN?.trim() || DEFAULT_SECURITY_BIN;
20
+ }
21
+ export function isKeychainAvailable() {
22
+ const override = process.env.NUSTACK_KEYCHAIN_BIN?.trim();
23
+ if (override)
24
+ return existsSync(override);
25
+ return process.platform === "darwin" && existsSync(DEFAULT_SECURITY_BIN);
26
+ }
27
+ export const keychainStore = {
28
+ name: "keychain",
29
+ async readRaw(account) {
30
+ try {
31
+ const { stdout } = await runFile(securityBin(), ["find-generic-password", "-s", SERVICE, "-a", account, "-w"]);
32
+ return stdout.trim();
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ },
38
+ async writeRaw(account, payload) {
39
+ await runFile(securityBin(), ["add-generic-password", "-U", "-s", SERVICE, "-a", account, "-w", payload]);
40
+ },
41
+ async deleteRaw(account) {
42
+ try {
43
+ await runFile(securityBin(), ["delete-generic-password", "-s", SERVICE, "-a", account]);
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ },
50
+ };
51
+ export function credentialsFilePath() {
52
+ return path.join(nustackHomeDir(), "credentials.json");
53
+ }
54
+ async function readCredentialsFile(account) {
55
+ try {
56
+ const raw = await readFile(credentialsFilePath(), "utf8");
57
+ const parsed = JSON.parse(raw);
58
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
59
+ return null;
60
+ if (parsed.device_id !== account)
61
+ return null;
62
+ return raw.trim();
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ export const fileStore = {
69
+ name: "file",
70
+ readRaw: readCredentialsFile,
71
+ async writeRaw(_account, payload) {
72
+ await ensureNuStackLayout();
73
+ const target = credentialsFilePath();
74
+ const staging = `${target}.${process.pid}.tmp`;
75
+ await writeFile(staging, `${payload}\n`, { encoding: "utf8", mode: OWNER_ONLY_FILE });
76
+ await rename(staging, target);
77
+ },
78
+ async deleteRaw(account) {
79
+ if (!(await readCredentialsFile(account)))
80
+ return false;
81
+ await rm(credentialsFilePath(), { force: true });
82
+ return true;
83
+ },
84
+ };
85
+ export function selectCredentialStore() {
86
+ const requested = (process.env.NUSTACK_CREDENTIAL_STORE ?? "auto").trim().toLowerCase();
87
+ if (requested === "keychain") {
88
+ if (!isKeychainAvailable())
89
+ throw new CredentialStoreUnavailableError(requested);
90
+ return keychainStore;
91
+ }
92
+ if (requested === "libsecret") {
93
+ if (!isLibsecretAvailable())
94
+ throw new CredentialStoreUnavailableError(requested);
95
+ return libsecretStore;
96
+ }
97
+ if (requested === "file")
98
+ return fileStore;
99
+ if (requested !== "auto")
100
+ throw new CredentialStoreUnavailableError(requested);
101
+ if (isKeychainAvailable())
102
+ return keychainStore;
103
+ if (isLibsecretAvailable())
104
+ return libsecretStore;
105
+ return fileStore;
106
+ }
@@ -1,22 +1,22 @@
1
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { readFile, rm } from "node:fs/promises";
2
2
  import { existsSync } from "node:fs";
3
3
  import { randomBytes } from "node:crypto";
4
- import os from "node:os";
5
4
  import path from "node:path";
5
+ import { ensureNuStackLayout, nustackHomeDir } from "./local_store/paths.js";
6
+ import { writeFileSecure } from "./secure_file.js";
6
7
  function deviceIdFilePath() {
7
- return path.join(os.homedir(), ".nustack", "device_id");
8
+ return path.join(nustackHomeDir(), "device_id");
8
9
  }
9
10
  export async function getOrCreateDeviceId() {
10
11
  const file = deviceIdFilePath();
11
- const dir = path.dirname(file);
12
12
  if (existsSync(file)) {
13
13
  const existing = (await readFile(file, "utf8")).trim();
14
14
  if (existing)
15
15
  return existing;
16
16
  }
17
17
  const deviceId = `dev_${randomBytes(16).toString("hex")}`;
18
- await mkdir(dir, { recursive: true });
19
- await writeFile(file, `${deviceId}\n`, "utf8");
18
+ await ensureNuStackLayout();
19
+ await writeFileSecure(file, `${deviceId}\n`);
20
20
  return deviceId;
21
21
  }
22
22
  export async function clearDeviceId() {
@@ -1,18 +1,6 @@
1
- import { execFile } from "node:child_process";
2
- import { existsSync } from "node:fs";
3
- import { promisify } from "node:util";
4
- const runFile = promisify(execFile);
5
- const DEFAULT_SECURITY_BIN = "/usr/bin/security";
6
- const SERVICE = "nustack-device";
1
+ import { selectCredentialStore } from "./credential_store.js";
2
+ export { isKeychainAvailable } from "./credential_store.js";
7
3
  export const KEYCHAIN_SCHEMA_VERSION = 3;
8
- function securityBin() {
9
- return process.env.NUSTACK_KEYCHAIN_BIN?.trim() || DEFAULT_SECURITY_BIN;
10
- }
11
- export function isKeychainAvailable() {
12
- if (process.env.NUSTACK_KEYCHAIN_BIN)
13
- return true;
14
- return process.platform === "darwin" && existsSync(DEFAULT_SECURITY_BIN);
15
- }
16
4
  const REQUIRED_FIELDS = ["refresh_token", "auth_base_url", "issuer", "device_id", "user_email"];
17
5
  export function decodeCredential(raw) {
18
6
  let parsed;
@@ -51,7 +39,7 @@ export function mergeCredential(existing, update) {
51
39
  export async function writeDeviceCredential(input) {
52
40
  const stored = await readRawCredential(input.device_id);
53
41
  const payload = mergeCredential(stored, input);
54
- await runFile(securityBin(), ["add-generic-password", "-U", "-s", SERVICE, "-a", input.device_id, "-w", JSON.stringify(payload)]);
42
+ await selectCredentialStore().writeRaw(input.device_id, JSON.stringify(payload));
55
43
  }
56
44
  export async function readDeviceCredential(deviceId) {
57
45
  const decoded = await readDecoded(deviceId);
@@ -70,32 +58,23 @@ async function readRawCredential(deviceId) {
70
58
  return decoded.reason === "older-schema" || decoded.reason === "future-schema" ? decoded.stored : null;
71
59
  }
72
60
  async function readDecoded(deviceId) {
61
+ const raw = await selectCredentialStore().readRaw(deviceId);
62
+ if (raw === null)
63
+ return null;
64
+ const decoded = decodeCredential(raw);
65
+ if (decoded.ok)
66
+ return decoded;
67
+ let stored = null;
73
68
  try {
74
- const { stdout } = await runFile(securityBin(), ["find-generic-password", "-s", SERVICE, "-a", deviceId, "-w"]);
75
- const decoded = decodeCredential(stdout.trim());
76
- if (decoded.ok)
77
- return decoded;
78
- let stored = null;
79
- try {
80
- const parsed = JSON.parse(stdout.trim());
81
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
82
- stored = parsed;
83
- }
84
- catch {
85
- stored = null;
86
- }
87
- return { ...decoded, stored };
69
+ const parsed = JSON.parse(raw.trim());
70
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
71
+ stored = parsed;
88
72
  }
89
73
  catch {
90
- return null;
74
+ stored = null;
91
75
  }
76
+ return { ...decoded, stored };
92
77
  }
93
78
  export async function deleteDeviceCredential(deviceId) {
94
- try {
95
- await runFile(securityBin(), ["delete-generic-password", "-s", SERVICE, "-a", deviceId]);
96
- return true;
97
- }
98
- catch {
99
- return false;
100
- }
79
+ return selectCredentialStore().deleteRaw(deviceId);
101
80
  }
@@ -0,0 +1,47 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { promisify } from "node:util";
4
+ const runFile = promisify(execFile);
5
+ const DEFAULT_SECRET_TOOL_BIN = "/usr/bin/secret-tool";
6
+ const SERVICE = "nustack-device";
7
+ const LABEL = "NuStack device credential";
8
+ function secretToolBin() {
9
+ return process.env.NUSTACK_SECRET_TOOL_BIN?.trim() || DEFAULT_SECRET_TOOL_BIN;
10
+ }
11
+ export function isLibsecretAvailable() {
12
+ if (process.env.NUSTACK_SECRET_TOOL_BIN)
13
+ return true;
14
+ return process.platform === "linux" && existsSync(DEFAULT_SECRET_TOOL_BIN);
15
+ }
16
+ function attributes(account) {
17
+ return ["service", SERVICE, "account", account];
18
+ }
19
+ export const libsecretStore = {
20
+ name: "libsecret",
21
+ async readRaw(account) {
22
+ try {
23
+ const { stdout } = await runFile(secretToolBin(), ["lookup", ...attributes(account)]);
24
+ return stdout.trim() || null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ },
30
+ writeRaw(account, payload) {
31
+ return new Promise((resolve, reject) => {
32
+ const child = execFile(secretToolBin(), ["store", `--label=${LABEL}`, ...attributes(account)], (error) => error ? reject(error) : resolve());
33
+ child.stdin?.end(payload);
34
+ });
35
+ },
36
+ async deleteRaw(account) {
37
+ if (!(await this.readRaw(account)))
38
+ return false;
39
+ try {
40
+ await runFile(secretToolBin(), ["clear", ...attributes(account)]);
41
+ return true;
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ },
47
+ };
@@ -1,6 +1,6 @@
1
1
  import { assertSafeNuStackUrl, assertSafeOpaqueId } from "./guards.js";
2
2
  import { createSseParser } from "./sse_framing.js";
3
- import { DEVICE_HEADER, knownDeviceId, refreshAccessTokenAfterRefusal, userAgent } from "./auth_grant.js";
3
+ import { DEVICE_HEADER, envToken, knownDeviceId, refreshAccessTokenAfterRefusal, TokenRefusedError, userAgent } from "./auth_grant.js";
4
4
  import { getOrCreateDeviceId } from "./device_identity.js";
5
5
  const DEFAULT_NUSTACK_URL = "https://apps.nustack.ai";
6
6
  const REQUEST_TIMEOUT_MS = 10_000;
@@ -12,6 +12,8 @@ async function authorizedFetch(input, init = {}) {
12
12
  const bearer = headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
13
13
  if (response.status !== 401 || !bearer || isStreamBody(init.body))
14
14
  return response;
15
+ if (envToken())
16
+ throw new TokenRefusedError();
15
17
  const fresh = await refreshAccessTokenAfterRefusal(bearer).catch(() => null);
16
18
  if (!fresh)
17
19
  return response;
@@ -282,6 +284,46 @@ export async function listOrganizations(baseUrl, accessToken) {
282
284
  }
283
285
  return payload.data.items;
284
286
  }
287
+ export class PersonalTokenRequestError extends Error {
288
+ code;
289
+ constructor(code, message) {
290
+ super(message);
291
+ this.code = code;
292
+ this.name = "PersonalTokenRequestError";
293
+ }
294
+ }
295
+ async function readTokenEnvelope(response, label) {
296
+ const payload = (await response.json().catch(() => null));
297
+ if (!response.ok || payload?.data === undefined) {
298
+ throw new PersonalTokenRequestError(payload?.error?.code ?? "REQUEST_FAILED", errorMessage(payload, response.status, label));
299
+ }
300
+ return payload.data;
301
+ }
302
+ export async function listPersonalTokens(baseUrl, accessToken) {
303
+ const response = await authorizedFetch(`${baseUrl}/api/v2/tokens`, {
304
+ headers: authHeaders(accessToken),
305
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
306
+ });
307
+ const data = await readTokenEnvelope(response, "token list failed");
308
+ return Array.isArray(data.items) ? data.items : [];
309
+ }
310
+ export async function createPersonalToken(baseUrl, accessToken, params) {
311
+ const response = await authorizedFetch(`${baseUrl}/api/v2/tokens`, {
312
+ method: "POST",
313
+ headers: { ...authHeaders(accessToken), "Content-Type": "application/json" },
314
+ body: JSON.stringify(params),
315
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
316
+ });
317
+ return readTokenEnvelope(response, "token create failed");
318
+ }
319
+ export async function revokePersonalToken(baseUrl, accessToken, tokenId) {
320
+ const response = await authorizedFetch(`${baseUrl}/api/v2/tokens/${encodeURIComponent(assertSafeOpaqueId("The token id", tokenId))}`, {
321
+ method: "DELETE",
322
+ headers: authHeaders(accessToken),
323
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
324
+ });
325
+ return readTokenEnvelope(response, "token revoke failed");
326
+ }
285
327
  export async function createOrganization(baseUrl, accessToken, params, idempotencyKey) {
286
328
  const response = await authorizedFetch(`${baseUrl}/api/v2/organizations`, {
287
329
  method: "POST",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/nustack",
3
- "version": "0.10.0-dev.15",
3
+ "version": "0.10.0-dev.16",
4
4
  "description": "The nustack CLI — bootstrap NuStack services into a repo via the NuStack discovery plane.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -33,8 +33,8 @@
33
33
  "@types/node": "^22.19.20",
34
34
  "typescript": "^5.7.2",
35
35
  "vitest": "^4.1.11",
36
- "@nurix/nustack.studio": "0.0.0",
37
- "@nurix/nustack.ui": "0.0.0"
36
+ "@nurix/nustack.ui": "0.0.0",
37
+ "@nurix/nustack.studio": "0.0.0"
38
38
  },
39
39
  "publishConfig": {
40
40
  "access": "public"