@nodii/dev 0.0.0-stage → 0.3.1
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 +131 -2
- package/bin/nodii-dev +12 -0
- package/dist/api.js +151 -0
- package/dist/core.js +339 -0
- package/dist/hooks.js +153 -0
- package/dist/nodii-dev.js +586 -0
- package/dist/oauth.js +195 -0
- package/package.json +25 -4
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { open, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { platform } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { CliError, configPath, readConfig, writeConfig } from "./core.js";
|
|
6
|
+
|
|
7
|
+
// nodii-auth is reached through the platform edge (/oauth/device/authorize and
|
|
8
|
+
// /oauth/token are public there), so auth and API calls share one host.
|
|
9
|
+
export const DEFAULT_EDGE_URL = "https://platform.nucleus-cloud.in";
|
|
10
|
+
export const DEFAULT_AUTH_URL = DEFAULT_EDGE_URL;
|
|
11
|
+
// The public client id of the nodii-dev OAuth app in the platform tenant
|
|
12
|
+
// (tenant-service #843, D1368). Public by design: it carries no secret.
|
|
13
|
+
export const DEFAULT_CLIENT_ID = "nodii_app_gPo9hUo5bb88AZD3inz7c7";
|
|
14
|
+
export const PLATFORM_TENANT = "5914ae43-2af1-4755-bfa4-96b14301f036";
|
|
15
|
+
export const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
16
|
+
export const FORBIDDEN_SCOPE = "devops.releases.promote";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The scope sent at device authorization. By default none is sent, and
|
|
20
|
+
* nodii-auth grants the app's provisioned ceiling (every live devops.* key
|
|
21
|
+
* except promotion, D1368). An explicit --scope narrows it; promotion is
|
|
22
|
+
* refused locally as well as by auth and the hub.
|
|
23
|
+
*/
|
|
24
|
+
export function requestedScope(scope) {
|
|
25
|
+
const keys = (scope ?? "").split(/[\s,]+/).filter(Boolean);
|
|
26
|
+
if (keys.includes(FORBIDDEN_SCOPE)) throw new CliError("OAUTH_SCOPE_FORBIDDEN", `${FORBIDDEN_SCOPE} is never granted to nodii-dev.`, 2);
|
|
27
|
+
return keys.length ? keys.join(" ") : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const service = "dev.nodii.cli.oauth";
|
|
31
|
+
const account = (clientId) => `refresh:${clientId}`;
|
|
32
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
33
|
+
|
|
34
|
+
function run(command, args, { input } = {}) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const child = spawn(command, args, { stdio: [input ? "pipe" : "ignore", "pipe", "pipe"] });
|
|
37
|
+
let stdout = "";
|
|
38
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
39
|
+
child.stderr.on("data", () => {}); // Credential helpers can echo secrets in diagnostics.
|
|
40
|
+
child.on("error", reject);
|
|
41
|
+
child.on("close", (code) => code === 0 ? resolve(stdout) : reject(new Error(`${command} failed`)));
|
|
42
|
+
if (input) child.stdin.end(input);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function storeRefreshToken(clientId, token, os = platform()) {
|
|
47
|
+
if (os === "darwin") {
|
|
48
|
+
await run("security", ["add-generic-password", "-U", "-a", account(clientId), "-s", service, "-w", token]);
|
|
49
|
+
return "macOS Keychain";
|
|
50
|
+
}
|
|
51
|
+
if (os === "linux") {
|
|
52
|
+
try {
|
|
53
|
+
await run("secret-tool", ["store", "--label=nodii-dev OAuth", "service", service, "account", account(clientId)], { input: token });
|
|
54
|
+
return "Secret Service";
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
throw new CliError("KEYCHAIN_UNAVAILABLE", "OAuth refresh tokens require macOS Keychain or Linux Secret Service. No credential was stored; re-run login when a keychain is available.", 2);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function loadRefreshToken(clientId, os = platform()) {
|
|
61
|
+
try {
|
|
62
|
+
if (os === "darwin") return (await run("security", ["find-generic-password", "-a", account(clientId), "-s", service, "-w"])).trim();
|
|
63
|
+
if (os === "linux") return (await run("secret-tool", ["lookup", "service", service, "account", account(clientId)])).trim();
|
|
64
|
+
} catch {}
|
|
65
|
+
throw new CliError("OAUTH_LOGIN_REQUIRED", "OAuth credentials are unavailable; run `nodii-dev login` again.", 2);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function deleteRefreshToken(clientId, os = platform()) {
|
|
69
|
+
try {
|
|
70
|
+
if (os === "darwin") await run("security", ["delete-generic-password", "-a", account(clientId), "-s", service]);
|
|
71
|
+
else if (os === "linux") await run("secret-tool", ["clear", "service", service, "account", account(clientId)]);
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function oauthPost(authUrl, path, fields, fetchImpl = fetch) {
|
|
76
|
+
let response;
|
|
77
|
+
try {
|
|
78
|
+
response = await fetchImpl(`${authUrl.replace(/\/$/, "")}${path}`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
|
|
81
|
+
body: new URLSearchParams(fields),
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw new CliError("AUTH_UNREACHABLE", `Cannot reach nodii-auth: ${error.message}`, 1);
|
|
85
|
+
}
|
|
86
|
+
const raw = await response.text();
|
|
87
|
+
let body = {};
|
|
88
|
+
try { body = raw ? JSON.parse(raw) : {}; } catch {}
|
|
89
|
+
return { ok: response.ok, status: response.status, body };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function pollDeviceToken({ authUrl, clientId, deviceCode, interval, expiresIn, fetchImpl = fetch, sleep = wait, now = Date.now }) {
|
|
93
|
+
const deadline = now() + expiresIn * 1000;
|
|
94
|
+
let delay = Math.max(1, interval) * 1000;
|
|
95
|
+
while (now() < deadline) {
|
|
96
|
+
await sleep(delay);
|
|
97
|
+
const result = await oauthPost(authUrl, "/oauth/token", { grant_type: DEVICE_GRANT, device_code: deviceCode, client_id: clientId }, fetchImpl);
|
|
98
|
+
if (result.ok) return result.body;
|
|
99
|
+
const code = result.body.error;
|
|
100
|
+
if (code === "authorization_pending") continue;
|
|
101
|
+
if (code === "slow_down") { delay += 5000; continue; }
|
|
102
|
+
if (code === "access_denied") throw new CliError("ACCESS_DENIED", "The sign-in request was denied.", 2);
|
|
103
|
+
if (code === "expired_token") throw new CliError("DEVICE_CODE_EXPIRED", "The sign-in code expired; run `nodii-dev login` again.", 2);
|
|
104
|
+
throw new CliError("OAUTH_TOKEN_FAILED", result.body.error_description ?? "OAuth token exchange failed.", 1);
|
|
105
|
+
}
|
|
106
|
+
throw new CliError("DEVICE_CODE_EXPIRED", "The sign-in code expired; run `nodii-dev login` again.", 2);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function openBrowser(url) {
|
|
110
|
+
const command = platform() === "darwin" ? "open" : "xdg-open";
|
|
111
|
+
const child = spawn(command, [url], { detached: true, stdio: "ignore" });
|
|
112
|
+
child.unref();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function deviceLogin({ clientId, authUrl = DEFAULT_AUTH_URL, edgeUrl = DEFAULT_EDGE_URL, tenantHint = PLATFORM_TENANT, noBrowser = false, scope, fetchImpl = fetch, output = console.log }) {
|
|
116
|
+
const requested = requestedScope(scope);
|
|
117
|
+
const start = await oauthPost(authUrl, "/oauth/device/authorize", {
|
|
118
|
+
client_id: clientId, tenant_hint: tenantHint, ...(requested ? { scope: requested } : {}),
|
|
119
|
+
}, fetchImpl);
|
|
120
|
+
if (!start.ok) throw new CliError("OAUTH_DEVICE_AUTHORIZE_FAILED", start.body.error_description ?? "Could not start device sign-in.", 1);
|
|
121
|
+
const d = start.body;
|
|
122
|
+
output(`Open ${d.verification_uri} and enter code ${d.user_code}`);
|
|
123
|
+
if (!noBrowser && process.stdout.isTTY) openBrowser(d.verification_uri_complete ?? d.verification_uri);
|
|
124
|
+
const token = await pollDeviceToken({ authUrl, clientId, deviceCode: d.device_code, interval: d.interval, expiresIn: d.expires_in, fetchImpl });
|
|
125
|
+
if (!token.refresh_token) throw new CliError("OAUTH_REFRESH_MISSING", "nodii-auth did not return a refresh token.", 1);
|
|
126
|
+
const storage = await storeRefreshToken(clientId, token.refresh_token);
|
|
127
|
+
const claims = decodeClaims(token.access_token);
|
|
128
|
+
await writeConfig({ authProvider: "oauth", authUrl, edgeUrl, clientId, tenantHint, agentId: null, person: claims.sub ?? null, installationId: claims.metadata?.oauth_app_installation_id ?? claims.oauth_app_installation_id ?? null });
|
|
129
|
+
return { storage, claims };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function decodeClaims(token) {
|
|
133
|
+
try { return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8")); } catch { return {}; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function withRefreshLock(fn, { sleep = wait } = {}) {
|
|
137
|
+
const path = join(dirname(configPath()), "oauth-refresh.lock");
|
|
138
|
+
for (let attempt = 0; attempt < 300; attempt++) {
|
|
139
|
+
try {
|
|
140
|
+
const handle = await open(path, "wx", 0o600);
|
|
141
|
+
try { return await fn(); } finally { await handle.close(); await rm(path, { force: true }); }
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error.code !== "EEXIST") throw error;
|
|
144
|
+
try {
|
|
145
|
+
if (Date.now() - (await stat(path)).mtimeMs > 60_000) {
|
|
146
|
+
await rm(path, { force: true });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
} catch (statError) {
|
|
150
|
+
if (statError.code !== "ENOENT") throw statError;
|
|
151
|
+
}
|
|
152
|
+
await sleep(100);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
throw new CliError("OAUTH_REFRESH_BUSY", "Timed out waiting for another nodii-dev token refresh.", 1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export class OAuthAuthProvider {
|
|
159
|
+
#accessToken;
|
|
160
|
+
#expiresAt = 0;
|
|
161
|
+
#refreshPromise;
|
|
162
|
+
constructor({ fetchImpl = fetch, loadToken = loadRefreshToken, storeToken = storeRefreshToken, deleteToken = deleteRefreshToken, lock = withRefreshLock } = {}) {
|
|
163
|
+
Object.assign(this, { fetchImpl, loadToken, storeToken, deleteToken, lock });
|
|
164
|
+
}
|
|
165
|
+
async accessToken() {
|
|
166
|
+
if (this.#accessToken && Date.now() < this.#expiresAt - 30_000) return this.#accessToken;
|
|
167
|
+
this.#refreshPromise ??= this.lock(async () => {
|
|
168
|
+
const config = await readConfig();
|
|
169
|
+
const refresh = await this.loadToken(config.clientId);
|
|
170
|
+
const result = await oauthPost(config.authUrl, "/oauth/token", { grant_type: "refresh_token", client_id: config.clientId, refresh_token: refresh }, this.fetchImpl);
|
|
171
|
+
if (!result.ok || !result.body.refresh_token) {
|
|
172
|
+
await this.deleteToken(config.clientId);
|
|
173
|
+
throw new CliError("OAUTH_LOGIN_REQUIRED", "OAuth refresh failed and the saved credential was cleared; run `nodii-dev login` again.", 2);
|
|
174
|
+
}
|
|
175
|
+
await this.storeToken(config.clientId, result.body.refresh_token);
|
|
176
|
+
this.#accessToken = result.body.access_token;
|
|
177
|
+
this.#expiresAt = Date.now() + result.body.expires_in * 1000;
|
|
178
|
+
return this.#accessToken;
|
|
179
|
+
}).finally(() => { this.#refreshPromise = undefined; });
|
|
180
|
+
return this.#refreshPromise;
|
|
181
|
+
}
|
|
182
|
+
async headers() {
|
|
183
|
+
const config = await readConfig();
|
|
184
|
+
return { authorization: `Bearer ${await this.accessToken()}`, ...(config.agentId ? { "x-agent-id": config.agentId } : {}) };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function revokeOAuth(config, fetchImpl = fetch) {
|
|
189
|
+
const token = await loadRefreshToken(config.clientId);
|
|
190
|
+
try {
|
|
191
|
+
await oauthPost(config.authUrl, "/oauth/revoke", { token, token_type_hint: "refresh_token", client_id: config.clientId }, fetchImpl);
|
|
192
|
+
} finally {
|
|
193
|
+
await deleteRefreshToken(config.clientId);
|
|
194
|
+
}
|
|
195
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodii/dev",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Agent CLI for the Nodii development hub",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nodii-dev": "bin/nodii-dev"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "node build.mjs",
|
|
19
|
+
"test": "node --test test/*.test.js",
|
|
20
|
+
"pack:smoke": "node scripts/pack-smoke.mjs",
|
|
21
|
+
"openapi:snapshot": "curl -sf https://planning.dev.nucleus-cloud.in/openapi.json -o test/fixtures/devops-openapi.snapshot.json"
|
|
22
|
+
},
|
|
23
|
+
"license": "UNLICENSED",
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
}
|
|
27
|
+
}
|