@nurix/nustack 0.10.0-dev.15 → 0.10.0-dev.17
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/doctor.js +38 -4
- package/dist/commands/login.js +19 -6
- package/dist/commands/logout.js +14 -6
- package/dist/commands/token.js +102 -0
- package/dist/index.js +19 -0
- 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/libsecret_store.js +47 -0
- package/dist/lib/nustack_client.js +43 -1
- package/package.json +1 -1
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.
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { resolveNuStackUrl } from "../lib/nustack_client.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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 (
|
|
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
|
-
|
|
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 {
|
package/dist/commands/login.js
CHANGED
|
@@ -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 {
|
|
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 (
|
|
83
|
-
p.log.
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/logout.js
CHANGED
|
@@ -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
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
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
|
}
|
|
@@ -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