@nodii/dev 0.4.1 → 0.6.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 +117 -29
- package/dist/api.js +20 -11
- package/dist/cli.js +607 -0
- package/dist/core.js +80 -210
- package/dist/devops-openapi.json +1 -0
- package/dist/docs.js +204 -0
- package/dist/harness.js +343 -0
- package/dist/hooks.js +4 -4
- package/dist/lanes.js +7 -7
- package/dist/nodii-dev.js +2 -629
- package/dist/oauth.js +1 -1
- package/dist/setup.js +7 -7
- package/dist/upload.js +291 -0
- package/package.json +5 -4
package/dist/core.js
CHANGED
|
@@ -1,36 +1,22 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { createHash
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
3
|
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { homedir, platform } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
|
|
7
|
-
export const VERSION = "0.
|
|
8
|
-
export const DEFAULT_HUB_URL = "https://planning.dev.nucleus-cloud.in";
|
|
9
|
-
export const OPERATOR_AGENT_ID = "00000000-0000-0000-0000-000000000001";
|
|
7
|
+
export const VERSION = "0.6.1";
|
|
10
8
|
export const configPath = () =>
|
|
11
9
|
process.env.NODII_DEV_CONFIG_PATH ??
|
|
12
10
|
join(homedir(), ".config/nodii-dev/config.json");
|
|
13
|
-
|
|
11
|
+
// Where releases before 0.5.0 kept a shared signing secret. Read by nothing;
|
|
12
|
+
// kept only so an upgrade can delete what an old login left behind.
|
|
13
|
+
export const legacySecretPath = () =>
|
|
14
14
|
process.env.NODII_DEV_SECRET_PATH ??
|
|
15
15
|
join(dirname(configPath()), "hmac-secret");
|
|
16
16
|
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
method,
|
|
21
|
-
path,
|
|
22
|
-
body = "",
|
|
23
|
-
timestamp = Math.floor(Date.now() / 1000),
|
|
24
|
-
}) {
|
|
25
|
-
const upper = method.toUpperCase();
|
|
26
|
-
const bodyHash = createHash("sha256").update(body).digest("hex");
|
|
27
|
-
const payload = `${timestamp}.${upper}.${new URL(path, "http://local").pathname}.${bodyHash}`;
|
|
28
|
-
return {
|
|
29
|
-
"x-agent-id": agentId,
|
|
30
|
-
"x-timestamp": String(timestamp),
|
|
31
|
-
"x-signature": createHmac("sha256", secret).update(payload).digest("hex"),
|
|
32
|
-
};
|
|
33
|
-
}
|
|
17
|
+
export const LEGACY_LOGIN_MESSAGE =
|
|
18
|
+
"nodii-dev 0.5.0 talks only to Dev, through the platform edge, signed in as the platform tenant's OAuth app. " +
|
|
19
|
+
"Your old shared-secret login was removed from this machine. Run `nodii-dev login`.";
|
|
34
20
|
|
|
35
21
|
export function requestHash({ toService, kind, specMd, at = new Date() }) {
|
|
36
22
|
const minute = at.toISOString().slice(0, 16);
|
|
@@ -40,13 +26,25 @@ export function requestHash({ toService, kind, specMd, at = new Date() }) {
|
|
|
40
26
|
.slice(0, 16);
|
|
41
27
|
}
|
|
42
28
|
|
|
29
|
+
/** A config is current only when it was written by the OAuth device login. */
|
|
30
|
+
export const isOAuthConfig = (config) =>
|
|
31
|
+
config?.authProvider === "oauth" && typeof config.edgeUrl === "string" && config.edgeUrl !== "";
|
|
32
|
+
|
|
43
33
|
export async function readConfig(required = true) {
|
|
34
|
+
let config;
|
|
44
35
|
try {
|
|
45
|
-
|
|
36
|
+
config = JSON.parse(await readFile(configPath(), "utf8"));
|
|
46
37
|
} catch (error) {
|
|
47
38
|
if (!required && error?.code === "ENOENT") return null;
|
|
48
39
|
throw new CliError("NOT_LOGGED_IN", "Run `nodii-dev login` first.", 2);
|
|
49
40
|
}
|
|
41
|
+
if (isOAuthConfig(config)) return config;
|
|
42
|
+
// A config from before 0.5.0 (shared-secret signing). It cannot be used:
|
|
43
|
+
// delete it and its stored secret, then point at the OAuth login.
|
|
44
|
+
await deleteCredentials();
|
|
45
|
+
if (required) throw new CliError("LOGIN_REQUIRED", LEGACY_LOGIN_MESSAGE, 2);
|
|
46
|
+
process.stderr.write(`${LEGACY_LOGIN_MESSAGE}\n`);
|
|
47
|
+
return null;
|
|
50
48
|
}
|
|
51
49
|
|
|
52
50
|
export async function writeConfig(value) {
|
|
@@ -56,133 +54,26 @@ export async function writeConfig(value) {
|
|
|
56
54
|
await chmod(file, 0o600);
|
|
57
55
|
}
|
|
58
56
|
|
|
59
|
-
function run(command, args
|
|
57
|
+
function run(command, args) {
|
|
60
58
|
return new Promise((resolve, reject) => {
|
|
61
|
-
const child = spawn(command, args, {
|
|
62
|
-
...options,
|
|
63
|
-
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
|
64
|
-
});
|
|
65
|
-
let stdout = "";
|
|
66
|
-
let stderr = "";
|
|
67
|
-
child.stdout?.on("data", (x) => {
|
|
68
|
-
stdout += x;
|
|
69
|
-
});
|
|
70
|
-
child.stderr?.on("data", (x) => {
|
|
71
|
-
stderr += x;
|
|
72
|
-
});
|
|
59
|
+
const child = spawn(command, args, { stdio: ["ignore", "ignore", "ignore"] });
|
|
73
60
|
child.on("error", reject);
|
|
74
|
-
child.on("close", (code) =>
|
|
75
|
-
code === 0
|
|
76
|
-
? resolve(stdout)
|
|
77
|
-
: reject(new Error(stderr.trim() || `${command} exited ${code}`)),
|
|
78
|
-
);
|
|
61
|
+
child.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`${command} exited ${code}`))));
|
|
79
62
|
});
|
|
80
63
|
}
|
|
81
64
|
|
|
82
|
-
|
|
83
|
-
export async function
|
|
84
|
-
if (
|
|
85
|
-
platform() === "darwin" &&
|
|
86
|
-
process.env.NODII_DEV_DISABLE_KEYCHAIN !== "1"
|
|
87
|
-
) {
|
|
88
|
-
try {
|
|
89
|
-
await run("security", [
|
|
90
|
-
"add-generic-password",
|
|
91
|
-
"-U",
|
|
92
|
-
"-a",
|
|
93
|
-
"hmac",
|
|
94
|
-
"-s",
|
|
95
|
-
keychainService,
|
|
96
|
-
"-w",
|
|
97
|
-
secret,
|
|
98
|
-
]);
|
|
99
|
-
await rm(secretPath(), { force: true });
|
|
100
|
-
return "keychain";
|
|
101
|
-
} catch {
|
|
102
|
-
/* fall through for minimal/non-interactive macOS images */
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
const file = secretPath();
|
|
106
|
-
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
107
|
-
await writeFile(file, secret, { mode: 0o600 });
|
|
108
|
-
await chmod(file, 0o600);
|
|
109
|
-
return "file";
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export async function loadSecret() {
|
|
113
|
-
if (process.env.PLANNING_HUB_HMAC_SECRET)
|
|
114
|
-
return process.env.PLANNING_HUB_HMAC_SECRET;
|
|
115
|
-
if (
|
|
116
|
-
platform() === "darwin" &&
|
|
117
|
-
process.env.NODII_DEV_DISABLE_KEYCHAIN !== "1"
|
|
118
|
-
) {
|
|
119
|
-
try {
|
|
120
|
-
return (
|
|
121
|
-
await run("security", [
|
|
122
|
-
"find-generic-password",
|
|
123
|
-
"-a",
|
|
124
|
-
"hmac",
|
|
125
|
-
"-s",
|
|
126
|
-
keychainService,
|
|
127
|
-
"-w",
|
|
128
|
-
])
|
|
129
|
-
).trim();
|
|
130
|
-
} catch {
|
|
131
|
-
/* try fallback file */
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
try {
|
|
135
|
-
return (await readFile(secretPath(), "utf8")).trim();
|
|
136
|
-
} catch {
|
|
137
|
-
throw new CliError(
|
|
138
|
-
"SECRET_NOT_FOUND",
|
|
139
|
-
"No HMAC credential is stored; run `nodii-dev login`.",
|
|
140
|
-
2,
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export async function deleteCredentials() {
|
|
146
|
-
if (
|
|
147
|
-
platform() === "darwin" &&
|
|
148
|
-
process.env.NODII_DEV_DISABLE_KEYCHAIN !== "1"
|
|
149
|
-
) {
|
|
65
|
+
/** Delete a shared secret stored by a release before 0.5.0, wherever it lives. */
|
|
66
|
+
export async function deleteLegacySecret() {
|
|
67
|
+
if (platform() === "darwin" && process.env.NODII_DEV_DISABLE_KEYCHAIN !== "1") {
|
|
150
68
|
try {
|
|
151
|
-
await run("security", [
|
|
152
|
-
"delete-generic-password",
|
|
153
|
-
"-a",
|
|
154
|
-
"hmac",
|
|
155
|
-
"-s",
|
|
156
|
-
keychainService,
|
|
157
|
-
]);
|
|
69
|
+
await run("security", ["delete-generic-password", "-a", "hmac", "-s", "dev.nodii.cli"]);
|
|
158
70
|
} catch {}
|
|
159
71
|
}
|
|
160
|
-
await
|
|
161
|
-
rm(configPath(), { force: true }),
|
|
162
|
-
rm(secretPath(), { force: true }),
|
|
163
|
-
]);
|
|
72
|
+
await rm(legacySecretPath(), { force: true });
|
|
164
73
|
}
|
|
165
74
|
|
|
166
|
-
export async function
|
|
167
|
-
|
|
168
|
-
"secretsmanager",
|
|
169
|
-
"get-secret-value",
|
|
170
|
-
"--secret-id",
|
|
171
|
-
"nodii/dev/app/planning_hub",
|
|
172
|
-
"--region",
|
|
173
|
-
"ap-south-1",
|
|
174
|
-
"--query",
|
|
175
|
-
"SecretString",
|
|
176
|
-
"--output",
|
|
177
|
-
"text",
|
|
178
|
-
]);
|
|
179
|
-
const value = JSON.parse(raw);
|
|
180
|
-
if (!value.PLANNING_HUB_HMAC_SECRET)
|
|
181
|
-
throw new CliError(
|
|
182
|
-
"AWS_SECRET_KEY_MISSING",
|
|
183
|
-
"The AWS secret does not contain PLANNING_HUB_HMAC_SECRET.",
|
|
184
|
-
);
|
|
185
|
-
return value.PLANNING_HUB_HMAC_SECRET;
|
|
75
|
+
export async function deleteCredentials() {
|
|
76
|
+
await Promise.all([deleteLegacySecret(), rm(configPath(), { force: true })]);
|
|
186
77
|
}
|
|
187
78
|
|
|
188
79
|
export class CliError extends Error {
|
|
@@ -195,64 +86,43 @@ export class CliError extends Error {
|
|
|
195
86
|
}
|
|
196
87
|
|
|
197
88
|
/** @typedef {{headers(input: {method:string, path:string, body:string}): Promise<Record<string,string>>}} AuthProvider */
|
|
198
|
-
/** HMAC v1 provider. A nodii-auth OAuth provider can implement the same boundary. */
|
|
199
|
-
export class HmacAuthProvider {
|
|
200
|
-
async headers({ method, path, body }) {
|
|
201
|
-
const config = await readConfig();
|
|
202
|
-
return signRequest({
|
|
203
|
-
secret: await loadSecret(),
|
|
204
|
-
agentId: config.agentId,
|
|
205
|
-
method,
|
|
206
|
-
path,
|
|
207
|
-
body,
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
89
|
let oauthAuthProvider;
|
|
213
|
-
export async function authProvider(
|
|
214
|
-
|
|
215
|
-
if (config.authProvider === "oauth") {
|
|
90
|
+
export async function authProvider() {
|
|
91
|
+
if (!oauthAuthProvider) {
|
|
216
92
|
const { OAuthAuthProvider } = await import("./oauth.js");
|
|
217
|
-
oauthAuthProvider
|
|
218
|
-
return oauthAuthProvider;
|
|
93
|
+
oauthAuthProvider = new OAuthAuthProvider();
|
|
219
94
|
}
|
|
220
|
-
return
|
|
95
|
+
return oauthAuthProvider;
|
|
96
|
+
}
|
|
97
|
+
/** Replace the process-wide OAuth provider (tests inject one with fake token storage). */
|
|
98
|
+
export function setAuthProvider(provider) {
|
|
99
|
+
oauthAuthProvider = provider;
|
|
221
100
|
}
|
|
222
101
|
|
|
102
|
+
/** Every Dev call goes to the platform edge's /devops prefix; there is no other host. */
|
|
223
103
|
export function requestUrl(config, path) {
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
return `${config.hubUrl.replace(/\/$/, "")}${path}`;
|
|
104
|
+
if (!path.startsWith("/")) throw new CliError("USAGE", `Dev paths start with /: ${path}`, 2);
|
|
105
|
+
return `${config.edgeUrl.replace(/\/$/, "")}/devops${path}`;
|
|
227
106
|
}
|
|
228
107
|
|
|
229
|
-
|
|
230
|
-
method,
|
|
231
|
-
path,
|
|
232
|
-
body,
|
|
233
|
-
{ auth } = {},
|
|
234
|
-
) {
|
|
108
|
+
async function devFetch(method, path, bodyText, contentType, accept) {
|
|
235
109
|
const config = await readConfig();
|
|
236
|
-
auth
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
Object.assign(headers, await auth.headers({ method, path, body: text }));
|
|
110
|
+
const auth = await authProvider();
|
|
111
|
+
const headers = { accept };
|
|
112
|
+
if (bodyText !== undefined) headers["content-type"] = contentType;
|
|
113
|
+
Object.assign(headers, await auth.headers({ method, path, body: bodyText ?? "" }));
|
|
114
|
+
const url = requestUrl(config, path);
|
|
242
115
|
let response;
|
|
243
116
|
try {
|
|
244
|
-
response = await fetch(
|
|
245
|
-
method,
|
|
246
|
-
headers,
|
|
247
|
-
body: body === undefined ? undefined : text,
|
|
248
|
-
});
|
|
117
|
+
response = await fetch(url, { method, headers, body: bodyText });
|
|
249
118
|
} catch (error) {
|
|
250
|
-
throw new CliError(
|
|
251
|
-
"HUB_UNREACHABLE",
|
|
252
|
-
`Cannot reach the hub: ${error.message}`,
|
|
253
|
-
);
|
|
119
|
+
throw new CliError("DEV_UNREACHABLE", `Cannot reach Dev at ${new URL(url).origin}: ${error.message}`);
|
|
254
120
|
}
|
|
255
|
-
|
|
121
|
+
return { response, raw: await response.text() };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function devRawRequest(method, path, bodyText, contentType = "application/json") {
|
|
125
|
+
const { response, raw } = await devFetch(method, path, bodyText, contentType, "application/json");
|
|
256
126
|
let payload;
|
|
257
127
|
try {
|
|
258
128
|
payload = raw ? JSON.parse(raw) : {};
|
|
@@ -262,39 +132,37 @@ export async function hubRequest(
|
|
|
262
132
|
if (!response.ok)
|
|
263
133
|
throw new CliError(
|
|
264
134
|
payload.code ?? `HTTP_${response.status}`,
|
|
265
|
-
payload.message ?? `
|
|
135
|
+
payload.message ?? `Dev returned HTTP ${response.status}`,
|
|
266
136
|
1,
|
|
267
137
|
payload.details,
|
|
268
138
|
);
|
|
269
139
|
return payload;
|
|
270
140
|
}
|
|
271
141
|
|
|
272
|
-
|
|
142
|
+
/** A Dev route that answers with text (e.g. a document body as text/markdown). Errors are Dev's JSON. */
|
|
143
|
+
export async function devTextRequest(method, path) {
|
|
144
|
+
const { response, raw } = await devFetch(method, path, undefined, undefined, "text/markdown, text/plain, application/json");
|
|
145
|
+
if (response.ok) return raw;
|
|
146
|
+
let payload = {};
|
|
147
|
+
try { payload = JSON.parse(raw); } catch { payload = { message: raw }; }
|
|
148
|
+
throw new CliError(payload.code ?? `HTTP_${response.status}`, payload.message ?? `Dev returned HTTP ${response.status}`, 1, payload.details);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const devRequest = (method, path, body) =>
|
|
152
|
+
devRawRequest(method, path, body === undefined ? undefined : JSON.stringify(body));
|
|
153
|
+
|
|
154
|
+
/** Proves the stored OAuth login works: a token refresh, then the agent read once registered. */
|
|
155
|
+
export async function checkLogin() {
|
|
273
156
|
const config = await readConfig();
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (config.authProvider === "oauth" || !["GET", "HEAD", "OPTIONS"].includes(method))
|
|
278
|
-
Object.assign(headers, await auth.headers({ method, path, body: bodyText ?? "" }));
|
|
279
|
-
let response;
|
|
280
|
-
try {
|
|
281
|
-
response = await fetch(requestUrl(config, path), {
|
|
282
|
-
method,
|
|
283
|
-
headers,
|
|
284
|
-
body: bodyText === undefined ? undefined : bodyText,
|
|
285
|
-
});
|
|
286
|
-
} catch (error) {
|
|
287
|
-
throw new CliError("HUB_UNREACHABLE", `Cannot reach the hub: ${error.message}`);
|
|
288
|
-
}
|
|
289
|
-
const raw = await response.text();
|
|
290
|
-
let payload;
|
|
291
|
-
try { payload = raw ? JSON.parse(raw) : {}; } catch { payload = { message: raw }; }
|
|
292
|
-
if (!response.ok)
|
|
293
|
-
throw new CliError(payload.code ?? `HTTP_${response.status}`, payload.message ?? `Hub returned HTTP ${response.status}`, 1, payload.details);
|
|
294
|
-
return payload;
|
|
157
|
+
await (await authProvider()).headers({ method: "GET", path: "/", body: "" });
|
|
158
|
+
if (config.agentId) await devRequest("GET", `/api/v1/agents/${encode(config.agentId)}`);
|
|
159
|
+
return config;
|
|
295
160
|
}
|
|
296
161
|
|
|
297
162
|
export const encode = (value) => encodeURIComponent(value);
|
|
163
|
+
// Switches never take the next token as their value, so
|
|
164
|
+
// `docs upload --wait rulebook/` keeps rulebook/ as a path.
|
|
165
|
+
const SWITCHES = new Set(["json", "wait", "dry-run", "check", "all", "fleet", "help", "yes", "mine", "no-browser", "include-drafts"]);
|
|
298
166
|
export function parseArgs(argv) {
|
|
299
167
|
const flags = {};
|
|
300
168
|
const args = [];
|
|
@@ -304,11 +172,13 @@ export function parseArgs(argv) {
|
|
|
304
172
|
args.push(token);
|
|
305
173
|
continue;
|
|
306
174
|
}
|
|
307
|
-
const
|
|
175
|
+
const at = token.indexOf("=");
|
|
176
|
+
const key = at === -1 ? token.slice(2) : token.slice(2, at);
|
|
177
|
+
const inline = at === -1 ? undefined : token.slice(at + 1);
|
|
308
178
|
const value =
|
|
309
179
|
inline !== undefined
|
|
310
180
|
? inline
|
|
311
|
-
: argv[i + 1] && !argv[i + 1].startsWith("--")
|
|
181
|
+
: !SWITCHES.has(key) && argv[i + 1] && !argv[i + 1].startsWith("--")
|
|
312
182
|
? argv[++i]
|
|
313
183
|
: true;
|
|
314
184
|
flags[key] =
|