@nodii/dev 0.6.10 → 0.6.12
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 +54 -4
- package/bin/nodii-dev +11 -1
- package/dist/cli.js +53 -33
- package/dist/core.js +1 -1
- package/dist/devops-openapi.json +1 -1
- package/dist/doctor.js +14 -1
- package/dist/figma.js +175 -1
- package/dist/hooks.js +26 -15
- package/dist/oauth.js +163 -28
- package/package.json +1 -1
package/dist/doctor.js
CHANGED
|
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { devRequest, encode, isOAuthConfig, query, setAuthProvider } from "./core.js";
|
|
5
5
|
import { OAuthAuthProvider, decodeClaims, loadRefreshToken } from "./oauth.js";
|
|
6
6
|
import { inferGitContext, repoName } from "./lanes.js";
|
|
7
|
-
import { figmaFileKey, notRegisteredHint } from "./figma.js";
|
|
7
|
+
import { figmaFileKey, figmaLockCheck, holdsSet, notRegisteredHint, scopeText } from "./figma.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* What a lane's commands need, in Dev's route policy (staffRuleFor in Dev's
|
|
@@ -237,6 +237,19 @@ export async function doctorCommand(deps = {}) {
|
|
|
237
237
|
const file = (await request("GET", `/api/v1/design/files/${encode(fileKey)}`)).data;
|
|
238
238
|
checks.push(ok("figma_file", `${fileKey} is registered: ${file.name} (${file.kind}${file.module ? `, ${file.module}` : ""})`,
|
|
239
239
|
{ fileKey, registered: true }));
|
|
240
|
+
// The lock scopes this agent holds in the file (DEC-0032).
|
|
241
|
+
if (config.agentId) {
|
|
242
|
+
try {
|
|
243
|
+
const held = (await figmaLockCheck(fileKey, config.agentId, request)).data;
|
|
244
|
+
const mine = holdsSet(held);
|
|
245
|
+
const scopes = mine ? (held.scopes ?? []) : [];
|
|
246
|
+
checks.push(ok("figma_scopes", mine
|
|
247
|
+
? `${held.changeSetId} (${held.state}) holds ${scopes.length ? scopes.map(scopeText).join(", ") : "the whole file"}`
|
|
248
|
+
: `holds no scopes in ${fileKey}`, { fileKey, changeSetId: mine ? held.changeSetId : null, scopes }));
|
|
249
|
+
} catch (error) {
|
|
250
|
+
checks.push(cannot("figma_scopes", `checkDesignFileLock failed (${devReason(error)})`, { fileKey }));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
240
253
|
} catch (error) {
|
|
241
254
|
if (error?.code === "DESIGN_FILE_NOT_FOUND")
|
|
242
255
|
checks.push(missing("figma_file", `${fileKey} is NOT registered: run figma file add; figma lock fails with DESIGN_FILE_NOT_FOUND`,
|
package/dist/figma.js
CHANGED
|
@@ -68,7 +68,7 @@ export async function figmaResubmit(id, file, { request = devRequest, read = (f)
|
|
|
68
68
|
return await request("POST", `/api/v1/design/change-sets/${encode(id)}/resubmit`, body);
|
|
69
69
|
} catch (error) {
|
|
70
70
|
const say = RESUBMIT_REFUSALS[error?.code];
|
|
71
|
-
if (!say) throw error;
|
|
71
|
+
if (!say) throw scopeRefusal(error, id) ?? error;
|
|
72
72
|
throw new CliError(error.code, say(id, error.details, error.message), error.exitCode ?? 1, error.details);
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -104,3 +104,177 @@ export function figmaChangeSetText(cs) {
|
|
|
104
104
|
if (cs.previousRounds?.length) lines.push(`${cs.previousRounds.length} earlier-round item(s) replaced by a re-submit; --json lists them.`);
|
|
105
105
|
return lines.join("\n");
|
|
106
106
|
}
|
|
107
|
+
|
|
108
|
+
// Scoped design locks (DEC-0032, MW1-57.6 to 57.9). A scope is written
|
|
109
|
+
// kind:id on the command line: component:NODE, page:NODE, foundations, or
|
|
110
|
+
// create:PAGE:NAME. A node id from a figma.com link (12-34) is Figma's 12:34.
|
|
111
|
+
const nodeId = (text) => (/^\d+-\d+$/.test(text) ? text.replace("-", ":") : text);
|
|
112
|
+
const SCOPE_USAGE = "--scope component:NODE_ID | page:NODE_ID | foundations | create:PAGE_ID:NAME";
|
|
113
|
+
|
|
114
|
+
export function parseScope(value) {
|
|
115
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
116
|
+
const bad = () => new CliError("USAGE", `bad scope ${JSON.stringify(text)}: use ${SCOPE_USAGE}`, 2);
|
|
117
|
+
if (text === "foundations") return { kind: "foundations" };
|
|
118
|
+
const create = text.match(/^create:(\d+[:-]\d+):(.*)$/);
|
|
119
|
+
if (create && create[2].trim() && create[2].trim().length <= 200)
|
|
120
|
+
return { kind: "create", pageId: nodeId(create[1]), name: create[2].trim() };
|
|
121
|
+
// Dev takes a scope at a top-level node: a plain id, never an instance's (I1:2;3:4).
|
|
122
|
+
const node = text.match(/^(component|page):(\d+[:-]\d+)$/);
|
|
123
|
+
if (!node) throw bad();
|
|
124
|
+
return { kind: node[1], nodeId: nodeId(node[2]) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Every --scope flag as a scope; none at all is a whole-file lock (MW1-57.9). */
|
|
128
|
+
export function scopesFrom(flags) {
|
|
129
|
+
const given = flags.scope === undefined ? [] : [flags.scope].flat();
|
|
130
|
+
return given.map((value) => {
|
|
131
|
+
if (value === true) throw new CliError("USAGE", `--scope needs a value: ${SCOPE_USAGE}`, 2);
|
|
132
|
+
return parseScope(value);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const scopeText = (s) =>
|
|
137
|
+
!s ? "?"
|
|
138
|
+
: s.kind === "foundations" ? "foundations"
|
|
139
|
+
: s.kind === "file" ? "whole file"
|
|
140
|
+
: s.kind === "create" ? `create:${s.pageId}:${s.name}`
|
|
141
|
+
: `${s.kind}:${s.nodeId}`;
|
|
142
|
+
|
|
143
|
+
const conflictText = (message, details) => {
|
|
144
|
+
const held = details?.heldBy;
|
|
145
|
+
if (!held) return message;
|
|
146
|
+
return [
|
|
147
|
+
message,
|
|
148
|
+
`${scopeText(details.requested)} overlaps ${scopeText(held.scope)}, held by ${held.changeSetId}${held.title ? ` "${held.title}"` : ""}.`,
|
|
149
|
+
`Claim a scope that doesn't overlap, or wait until ${held.changeSetId} reaches awaiting_publish (scopes release there).`,
|
|
150
|
+
].join("\n");
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const FOUNDATIONS_ID = /^(?:S:|VariableID:|VariableCollectionId:)/;
|
|
154
|
+
|
|
155
|
+
/** Dev's WRITE_OUT_OF_SCOPE, with what to do about it (MW1-57.7). */
|
|
156
|
+
export function outOfScopeText(message, details, id = details?.changeSetId ?? "CHANGE_SET") {
|
|
157
|
+
const ids = details?.nodeIds ?? [];
|
|
158
|
+
if (details?.reason === "unknown-ids" || (ids.length && ids.every((x) => x === "unknown")))
|
|
159
|
+
return [
|
|
160
|
+
message,
|
|
161
|
+
`The write returned no node ids, so Dev can't check it against ${id}'s scopes.`,
|
|
162
|
+
`Return the ids you wrote from the script, then run: nodii-dev figma log-write ${id} --tool TOOL --nodes ID,ID`,
|
|
163
|
+
].join("\n");
|
|
164
|
+
const foundations = ids.filter((x) => FOUNDATIONS_ID.test(x));
|
|
165
|
+
return [
|
|
166
|
+
message,
|
|
167
|
+
...(ids.length ? [`Out of scope: ${ids.join(", ")}`] : []),
|
|
168
|
+
...(foundations.length
|
|
169
|
+
? [`Styles and variables (${foundations.join(", ")}) need the foundations scope: nodii-dev figma scope add ${id} --scope foundations`]
|
|
170
|
+
: []),
|
|
171
|
+
`Widen with nodii-dev figma scope add ${id} --scope component:NODE_ID|page:NODE_ID if the scope is free, or revert the node exactly to how it was at the set's base version, then submit again.`,
|
|
172
|
+
].join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Keep Dev's code and message; add what to do next.
|
|
176
|
+
function scopeRefusal(error, id) {
|
|
177
|
+
if (error?.code === "SCOPE_CONFLICT")
|
|
178
|
+
return new CliError(error.code, conflictText(error.message, error.details), error.exitCode ?? 1, error.details);
|
|
179
|
+
if (error?.code === "WRITE_OUT_OF_SCOPE")
|
|
180
|
+
return new CliError(error.code, outOfScopeText(error.message, error.details, id), error.exitCode ?? 1, error.details);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A change-set route whose scope refusals say what to do next. */
|
|
185
|
+
const changeSetCall = (id, request) => async (method, path, body) => {
|
|
186
|
+
try {
|
|
187
|
+
return await request(method, path, body);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
throw scopeRefusal(error, id) ?? error;
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** A file route that names the `figma file add` command for an unregistered file. */
|
|
194
|
+
const fileCall = (key, request) => async (method, path, body) => {
|
|
195
|
+
try {
|
|
196
|
+
return await request(method, path, body);
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error?.code === "DESIGN_FILE_NOT_FOUND")
|
|
199
|
+
throw new CliError(error.code, notRegisteredHint(key), error.exitCode ?? 1, error.details);
|
|
200
|
+
throw scopeRefusal(error) ?? error;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** May this agent write to the file? Its own set, state and scopes (checkDesignFileLock). */
|
|
205
|
+
export const figmaLockCheck = (fileKey, agentId, request = devRequest) =>
|
|
206
|
+
request("GET", `/api/v1/design/files/${encode(fileKey)}/lock/check?agentId=${encode(agentId)}`);
|
|
207
|
+
|
|
208
|
+
/** Whether a lock check answers for this agent's own set (it holds one), not another agent's. */
|
|
209
|
+
export const holdsSet = (check) => Boolean(check?.changeSetId) && (check.allowed || check.reason === "change-set-not-editable");
|
|
210
|
+
|
|
211
|
+
const isWholeFile = (scopes) => !scopes?.length || scopes.some((s) => s.kind === "file");
|
|
212
|
+
|
|
213
|
+
export async function figmaLock(value, title, { scopes = [], request = devRequest } = {}) {
|
|
214
|
+
const key = figmaFileKey(value);
|
|
215
|
+
const result = await fileCall(key, request)("POST", `/api/v1/design/files/${encode(key)}/lock`, {
|
|
216
|
+
changeSetTitle: title,
|
|
217
|
+
...(scopes.length ? { scopes } : {}),
|
|
218
|
+
});
|
|
219
|
+
// A Dev that doesn't know scopes ignores them and locks the whole file,
|
|
220
|
+
// which blocks every other lane: say so loudly instead of exiting 0.
|
|
221
|
+
if (scopes.length && isWholeFile(result?.data?.scopes))
|
|
222
|
+
throw new CliError(
|
|
223
|
+
"SCOPES_NOT_APPLIED",
|
|
224
|
+
`Dev locked the whole of ${key} as ${result?.data?.id ?? "a change set"}, not the scopes asked for (this Dev predates scoped locks). It blocks every other change set on the file: run nodii-dev figma abandon ${result?.data?.id ?? "CHANGE_SET"}.`,
|
|
225
|
+
1,
|
|
226
|
+
result?.data,
|
|
227
|
+
);
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const figmaLockText = (cs) =>
|
|
232
|
+
[
|
|
233
|
+
`${cs.id} ${cs.state ?? "editing"} ${cs.title}`,
|
|
234
|
+
isWholeFile(cs.scopes)
|
|
235
|
+
? `Locked the whole of ${cs.fileKey}: this conflicts with every other change set on the file. Claim scopes instead with ${SCOPE_USAGE}.`
|
|
236
|
+
: `Holds ${cs.scopes.length} scope(s) in ${cs.fileKey}: ${cs.scopes.map(scopeText).join(", ")}`,
|
|
237
|
+
].join("\n");
|
|
238
|
+
|
|
239
|
+
export async function figmaScopeAdd(id, scopes, { request = devRequest } = {}) {
|
|
240
|
+
if (!id || !scopes.length) throw new CliError("USAGE", `figma scope add CHANGE_SET ${SCOPE_USAGE} [--scope ...]`, 2);
|
|
241
|
+
return changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/scopes`, { scopes });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export const figmaSubmit = (id, payload, { request = devRequest } = {}) =>
|
|
245
|
+
changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/submit`, payload);
|
|
246
|
+
|
|
247
|
+
export const figmaLogWrite = (id, tool, nodeIds, { request = devRequest } = {}) =>
|
|
248
|
+
changeSetCall(id, request)("POST", `/api/v1/design/change-sets/${encode(id)}/writes`, { tool, nodeIds });
|
|
249
|
+
|
|
250
|
+
/** One publish for the file: every awaiting_publish set on it (MW1-57.8). */
|
|
251
|
+
export async function figmaFilePublished(value, payload, { request = devRequest } = {}) {
|
|
252
|
+
const key = figmaFileKey(value);
|
|
253
|
+
return fileCall(key, request)("POST", `/api/v1/design/files/${encode(key)}/published`, payload);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// A change set id (CS-123) takes the per-set route; a file key or link takes the file's.
|
|
257
|
+
export const isChangeSetId = (value) => typeof value === "string" && /^CS-\d+$/i.test(value);
|
|
258
|
+
|
|
259
|
+
export async function figmaPublishPlan(value, { request = devRequest } = {}) {
|
|
260
|
+
const key = figmaFileKey(value);
|
|
261
|
+
return fileCall(key, request)("GET", `/api/v1/design/files/${encode(key)}/publish-plan`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const planLine = (x) => ` ${x.nodeId ?? scopeText(x)} ${x.name ?? ""} (${x.changeSetId} ${x.state}${x.title ? `: ${x.title}` : ""})`;
|
|
265
|
+
|
|
266
|
+
/** The publish plan as two groups: what to publish, and what to leave unchecked in Figma's publish dialog. */
|
|
267
|
+
export function figmaPublishPlanText(plan) {
|
|
268
|
+
const publish = plan.publish ?? [];
|
|
269
|
+
const leave = plan.leaveUnchecked ?? [];
|
|
270
|
+
return [
|
|
271
|
+
`Publish plan for ${plan.fileKey}${plan.sinceVersion ? ` (changes since version ${plan.sinceVersion})` : ""}`,
|
|
272
|
+
`publish (${publish.length}): sets awaiting publish`,
|
|
273
|
+
...(publish.length ? publish.map(planLine) : [" nothing awaiting publish"]),
|
|
274
|
+
`leave unchecked (${leave.length}): sets still editing or in review`,
|
|
275
|
+
...(leave.length ? leave.map(planLine) : [" none"]),
|
|
276
|
+
...(publish.length
|
|
277
|
+
? [`After publishing in Figma: nodii-dev figma published ${plan.fileKey} --figma-version VERSION`]
|
|
278
|
+
: []),
|
|
279
|
+
].join("\n");
|
|
280
|
+
}
|
package/dist/hooks.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CliError, devRequest } from "./core.js";
|
|
2
|
+
import { figmaLockCheck, outOfScopeText } from "./figma.js";
|
|
2
3
|
import { execFile } from "node:child_process";
|
|
3
4
|
import { promisify } from "node:util";
|
|
4
5
|
|
|
@@ -88,10 +89,7 @@ export async function preHook(payload, request = devRequest) {
|
|
|
88
89
|
const { agentId } = await config();
|
|
89
90
|
let response;
|
|
90
91
|
try {
|
|
91
|
-
response = await request
|
|
92
|
-
"GET",
|
|
93
|
-
`/api/v1/design/files/${encodeURIComponent(fileKey)}/lock/check?agentId=${encodeURIComponent(agentId)}`,
|
|
94
|
-
);
|
|
92
|
+
response = await figmaLockCheck(fileKey, agentId, request);
|
|
95
93
|
} catch (error) {
|
|
96
94
|
throw new CliError(
|
|
97
95
|
"FIGMA_CHECK_FAILED",
|
|
@@ -111,17 +109,14 @@ export async function preHook(payload, request = devRequest) {
|
|
|
111
109
|
return { allowed: true, fileKey, changeSetId: response.data.changeSetId };
|
|
112
110
|
}
|
|
113
111
|
|
|
114
|
-
export async function postHook(payload, request = devRequest) {
|
|
112
|
+
export async function postHook(payload, request = devRequest, config = async () => (await import("./core.js")).readConfig()) {
|
|
115
113
|
const name = payload.tool_name ?? "";
|
|
116
114
|
if (!isFigmaTool(name) || isReadTool(name))
|
|
117
115
|
return { logged: false, ignored: true };
|
|
118
116
|
const fileKey = extractFileKey(payload.tool_input);
|
|
119
117
|
if (!fileKey) return { logged: false, reason: "no file key" };
|
|
120
|
-
const { agentId } = await (
|
|
121
|
-
const checked = await request
|
|
122
|
-
"GET",
|
|
123
|
-
`/api/v1/design/files/${encodeURIComponent(fileKey)}/lock/check?agentId=${encodeURIComponent(agentId)}`,
|
|
124
|
-
);
|
|
118
|
+
const { agentId } = await config();
|
|
119
|
+
const checked = await figmaLockCheck(fileKey, agentId, request);
|
|
125
120
|
if (!checked.data?.allowed || !checked.data.changeSetId)
|
|
126
121
|
return { logged: false, reason: checked.data?.reason ?? "lock denied" };
|
|
127
122
|
const nodeIds = extractNodeIds({
|
|
@@ -129,14 +124,30 @@ export async function postHook(payload, request = devRequest) {
|
|
|
129
124
|
response: payload.tool_response,
|
|
130
125
|
});
|
|
131
126
|
if (!nodeIds.length) nodeIds.push("unknown");
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
127
|
+
try {
|
|
128
|
+
await request(
|
|
129
|
+
"POST",
|
|
130
|
+
`/api/v1/design/change-sets/${encodeURIComponent(checked.data.changeSetId)}/writes`,
|
|
131
|
+
{ tool: name, nodeIds },
|
|
132
|
+
);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
// Name the set in the fix even when Dev's details leave it out.
|
|
135
|
+
if (error?.code === "WRITE_OUT_OF_SCOPE")
|
|
136
|
+
throw new CliError(error.code, error.message, error.exitCode ?? 1, { ...error.details, changeSetId: error.details?.changeSetId ?? checked.data.changeSetId });
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
137
139
|
return { logged: true, changeSetId: checked.data.changeSetId, nodeIds };
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
// What the post-hook says when logging a write fails. A write logged outside
|
|
143
|
+
// the set's scopes (MW1-57.7) exits 2, so Claude Code shows the agent why its
|
|
144
|
+
// submit will be refused; any other failure stays a note that blocks nothing.
|
|
145
|
+
export function postHookFailure(error) {
|
|
146
|
+
if (error?.code === "WRITE_OUT_OF_SCOPE")
|
|
147
|
+
return { exitCode: 2, message: `nodii-dev post hook: WRITE_OUT_OF_SCOPE: ${outOfScopeText(error.message, error.details)}` };
|
|
148
|
+
return { exitCode: 0, message: `nodii-dev post hook: ${error?.code ?? "ERROR"}: ${error?.message ?? String(error)}` };
|
|
149
|
+
}
|
|
150
|
+
|
|
140
151
|
export const settingsSnippet = () => ({
|
|
141
152
|
hooks: {
|
|
142
153
|
PreToolUse: [
|
package/dist/oauth.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { mkdir, open, readFile, rm, stat, utimes } from "node:fs/promises";
|
|
4
4
|
import { homedir, platform } from "node:os";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
@@ -45,73 +45,159 @@ function run(command, args, { input } = {}) {
|
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// `security -i` reads each command line into a 4096-byte buffer. A longer line
|
|
49
|
+
// is cut there: the item is stored TRUNCATED and the rest of the line runs as a
|
|
50
|
+
// second, failing command (Dev issue 88: the access-token cache, once the
|
|
51
|
+
// JWT outgrew 4 KB, was stored cut short, every process read it as empty, and
|
|
52
|
+
// every command rotated the shared refresh token). Lines stay well under it.
|
|
53
|
+
export const SECURITY_LINE_MAX = 4000;
|
|
54
|
+
// A secret longer than one line is stored in parts, then a header naming the
|
|
55
|
+
// part count and the digest of the whole; a reader that sees a torn or partial
|
|
56
|
+
// set (a writer mid-way) gets nothing rather than a wrong secret.
|
|
57
|
+
const MAX_PARTS = 64;
|
|
58
|
+
const CHUNKED = "chunked:v1:";
|
|
59
|
+
const TOKEN_ALPHABET = /^[A-Za-z0-9._~+/=-]+$/;
|
|
60
|
+
const part = (name, i) => `${name}:part:${i}`;
|
|
61
|
+
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
|
62
|
+
const addLine = (name, svc, secret) => `add-generic-password -U -a ${name} -s ${svc} -w ${secret}`;
|
|
63
|
+
// Whether `security -i` takes the secret whole on one stdin line.
|
|
64
|
+
const fitsOneLine = (name, svc, secret) => TOKEN_ALPHABET.test(secret) && addLine(name, svc, secret).length <= SECURITY_LINE_MAX;
|
|
65
|
+
const checkName = (name) => {
|
|
66
|
+
if (!/^[A-Za-z0-9_.:-]+$/.test(name)) throw new CliError("KEYCHAIN_UNAVAILABLE", "The OAuth client id has characters the Keychain helper cannot take; nothing was stored.", 2);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
async function keychainPut(name, secret, os, { exec, svc }) {
|
|
49
70
|
if (os === "darwin") {
|
|
50
|
-
|
|
71
|
+
checkName(name);
|
|
51
72
|
// Through `security -i` on stdin, so the secret never appears in argv
|
|
52
73
|
// (visible to `ps`). The interactive parser splits on whitespace and reads
|
|
53
74
|
// quotes, so a secret outside a token's own alphabet falls back to argv
|
|
54
75
|
// rather than being dropped (it may be a refresh token just rotated).
|
|
55
|
-
if (
|
|
56
|
-
else await
|
|
76
|
+
if (fitsOneLine(name, svc, secret)) await exec("security", ["-i"], { input: `${addLine(name, svc, secret)}\n` });
|
|
77
|
+
else await exec("security", ["add-generic-password", "-U", "-a", name, "-s", svc, "-w", secret]);
|
|
57
78
|
return "macOS Keychain";
|
|
58
79
|
}
|
|
59
80
|
if (os === "linux") {
|
|
60
81
|
try {
|
|
61
|
-
await
|
|
82
|
+
await exec("secret-tool", ["store", "--label=nodii-dev OAuth", "service", svc, "account", name], { input: secret });
|
|
62
83
|
return "Secret Service";
|
|
63
84
|
} catch {}
|
|
64
85
|
}
|
|
65
86
|
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);
|
|
66
87
|
}
|
|
67
88
|
|
|
68
|
-
async function
|
|
69
|
-
if (os === "darwin") return (await
|
|
70
|
-
if (os === "linux") return (await
|
|
89
|
+
async function keychainGet(name, os, { exec, svc }) {
|
|
90
|
+
if (os === "darwin") return (await exec("security", ["find-generic-password", "-a", name, "-s", svc, "-w"])).trim();
|
|
91
|
+
if (os === "linux") return (await exec("secret-tool", ["lookup", "service", svc, "account", name])).trim();
|
|
71
92
|
throw new Error("no keychain");
|
|
72
93
|
}
|
|
73
94
|
|
|
74
|
-
async function
|
|
95
|
+
async function removeItem(name, os, { exec, svc }) {
|
|
75
96
|
try {
|
|
76
|
-
if (os === "darwin") await
|
|
77
|
-
else if (os === "linux") await
|
|
78
|
-
|
|
97
|
+
if (os === "darwin") await exec("security", ["delete-generic-password", "-a", name, "-s", svc]);
|
|
98
|
+
else if (os === "linux") await exec("secret-tool", ["clear", "service", svc, "account", name]);
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Parts are numbered from 1 without gaps, so the first one missing ends the
|
|
106
|
+
// sweep, whatever header (or none) the parts were written under.
|
|
107
|
+
async function removeParts(name, from, os, kc) {
|
|
108
|
+
if (os !== "darwin") return;
|
|
109
|
+
for (let i = from; i <= MAX_PARTS && (await removeItem(part(name, i), os, kc)); i++);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function keychainStore(name, secret, os = platform(), { exec = run, svc = service } = {}) {
|
|
113
|
+
const kc = { exec, svc };
|
|
114
|
+
if (os !== "darwin" || !TOKEN_ALPHABET.test(secret) || fitsOneLine(name, svc, secret)) {
|
|
115
|
+
const stored = await keychainPut(name, secret, os, kc);
|
|
116
|
+
await removeParts(name, 1, os, kc);
|
|
117
|
+
return stored;
|
|
118
|
+
}
|
|
119
|
+
checkName(name);
|
|
120
|
+
const size = SECURITY_LINE_MAX - addLine(part(name, MAX_PARTS), svc, "").length;
|
|
121
|
+
const parts = [];
|
|
122
|
+
if (size >= 256) for (let i = 0; i < secret.length && parts.length <= MAX_PARTS; i += size) parts.push(secret.slice(i, i + size));
|
|
123
|
+
if (!parts.length || parts.length > MAX_PARTS) throw new CliError("KEYCHAIN_UNAVAILABLE", "The secret is too long for the Keychain helper; nothing was stored.", 2);
|
|
124
|
+
// The parts in one `security` run, then, only once they are all in, the
|
|
125
|
+
// header that names them (`security -i` goes on past a failed line).
|
|
126
|
+
await exec("security", ["-i"], { input: `${parts.map((chunk, i) => addLine(part(name, i + 1), svc, chunk)).join("\n")}\n` });
|
|
127
|
+
await exec("security", ["-i"], { input: `${addLine(name, svc, `${CHUNKED}${parts.length}:${sha256(secret)}`)}\n` });
|
|
128
|
+
await removeParts(name, parts.length + 1, os, kc);
|
|
129
|
+
return "macOS Keychain";
|
|
79
130
|
}
|
|
80
131
|
|
|
81
|
-
export async function
|
|
82
|
-
|
|
132
|
+
export async function keychainLoad(name, os = platform(), { exec = run, svc = service } = {}) {
|
|
133
|
+
const value = await keychainGet(name, os, { exec, svc });
|
|
134
|
+
if (!value.startsWith(CHUNKED)) return value;
|
|
135
|
+
const [count, digest] = value.slice(CHUNKED.length).split(":");
|
|
136
|
+
const n = Number.parseInt(count, 10);
|
|
137
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_PARTS) throw new Error("bad chunk header");
|
|
138
|
+
let secret = "";
|
|
139
|
+
for (let i = 1; i <= n; i++) secret += await keychainGet(part(name, i), os, { exec, svc });
|
|
140
|
+
if (sha256(secret) !== digest) throw new Error("chunks do not match their header");
|
|
141
|
+
return secret;
|
|
83
142
|
}
|
|
84
143
|
|
|
85
|
-
export async function
|
|
144
|
+
export async function keychainDelete(name, os = platform(), { exec = run, svc = service } = {}) {
|
|
145
|
+
const kc = { exec, svc };
|
|
146
|
+
await removeItem(name, os, kc);
|
|
147
|
+
await removeParts(name, 1, os, kc);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Read back after writing: a store that "succeeds" but keeps something else (a
|
|
151
|
+
// truncated write) loses the rotated token, and the next refresh replays the
|
|
152
|
+
// old one, which revokes the whole chain.
|
|
153
|
+
export async function storeRefreshToken(clientId, token, os = platform(), keychain = {}) {
|
|
154
|
+
const stored = await keychainStore(account(clientId), token, os, keychain);
|
|
155
|
+
if ((await keychainLoad(account(clientId), os, keychain).catch(() => null)) !== token)
|
|
156
|
+
throw new CliError("KEYCHAIN_WRITE_FAILED", "The refresh token read back from the keychain differs from the one written.", 1);
|
|
157
|
+
return stored;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function loadRefreshToken(clientId, os = platform(), keychain = {}) {
|
|
86
161
|
try {
|
|
87
|
-
const token = await keychainLoad(account(clientId), os);
|
|
162
|
+
const token = await keychainLoad(account(clientId), os, keychain);
|
|
88
163
|
if (token) return token;
|
|
89
164
|
} catch {}
|
|
90
165
|
throw new CliError("OAUTH_LOGIN_REQUIRED", "OAuth credentials are unavailable; run `nodii-dev login` again.", 2);
|
|
91
166
|
}
|
|
92
167
|
|
|
93
|
-
export async function deleteRefreshToken(clientId, os = platform()) {
|
|
94
|
-
await keychainDelete(account(clientId), os);
|
|
168
|
+
export async function deleteRefreshToken(clientId, os = platform(), keychain = {}) {
|
|
169
|
+
await keychainDelete(account(clientId), os, keychain);
|
|
95
170
|
}
|
|
96
171
|
|
|
97
172
|
// The access token is cached beside the refresh token so that every nodii-dev
|
|
98
173
|
// process on the machine shares one: without it each command rotated the
|
|
99
174
|
// refresh token, and N lanes rotated it N times as often.
|
|
100
|
-
export async function loadAccessToken(clientId, os = platform()) {
|
|
175
|
+
export async function loadAccessToken(clientId, os = platform(), keychain = {}) {
|
|
101
176
|
try {
|
|
102
|
-
const cached = JSON.parse(Buffer.from(await keychainLoad(accessAccount(clientId), os), "base64url").toString("utf8"));
|
|
177
|
+
const cached = JSON.parse(Buffer.from(await keychainLoad(accessAccount(clientId), os, keychain), "base64url").toString("utf8"));
|
|
103
178
|
return typeof cached?.token === "string" && Number.isFinite(cached.expiresAt) ? cached : null;
|
|
104
179
|
} catch {
|
|
105
180
|
return null;
|
|
106
181
|
}
|
|
107
182
|
}
|
|
108
183
|
|
|
109
|
-
|
|
110
|
-
|
|
184
|
+
let cacheWarned = false;
|
|
185
|
+
|
|
186
|
+
// A cache that cannot be written is not fatal to this command, but every other
|
|
187
|
+
// process would then refresh too, so it is said once, on stderr.
|
|
188
|
+
export async function storeAccessToken(clientId, cached, os = platform(), keychain = {}) {
|
|
189
|
+
const value = Buffer.from(JSON.stringify(cached)).toString("base64url");
|
|
190
|
+
try {
|
|
191
|
+
await keychainStore(accessAccount(clientId), value, os, keychain);
|
|
192
|
+
if ((await keychainLoad(accessAccount(clientId), os, keychain)) === value) return;
|
|
193
|
+
} catch {}
|
|
194
|
+
if (cacheWarned) return;
|
|
195
|
+
cacheWarned = true;
|
|
196
|
+
process.stderr.write("nodii-dev: warning: the access token could not be cached in the keychain, so every nodii-dev process will refresh the sign-in.\n");
|
|
111
197
|
}
|
|
112
198
|
|
|
113
|
-
export async function deleteAccessToken(clientId, os = platform()) {
|
|
114
|
-
await keychainDelete(accessAccount(clientId), os);
|
|
199
|
+
export async function deleteAccessToken(clientId, os = platform(), keychain = {}) {
|
|
200
|
+
await keychainDelete(accessAccount(clientId), os, keychain);
|
|
115
201
|
}
|
|
116
202
|
|
|
117
203
|
async function oauthPost(authUrl, path, fields, fetchImpl = fetch, timeoutMs = REFRESH_TIMEOUT_MS) {
|
|
@@ -326,7 +412,10 @@ export async function withRefreshLock(fn, { clientId, path = refreshLockPath(cli
|
|
|
326
412
|
// grant, so the credential is kept.
|
|
327
413
|
const DEAD_GRANT_ERRORS = new Set(["invalid_grant", "expired_token", "token_revoked"]);
|
|
328
414
|
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
329
|
-
|
|
415
|
+
// A refresh is never cut off early: once the request is sent, nodii-auth may
|
|
416
|
+
// rotate the token whether or not the answer arrives, so aborting it at 10 s
|
|
417
|
+
// lost the successor and the next run replayed the old token (Dev issue 88).
|
|
418
|
+
export const REFRESH_TIMEOUT_MS = 20_000;
|
|
330
419
|
const REFRESH_ATTEMPTS = 4;
|
|
331
420
|
const RETRY_AFTER_CAP_MS = 5_000;
|
|
332
421
|
// The whole refresh, retries included, fits well inside the lock's wait
|
|
@@ -353,7 +442,10 @@ async function postRefresh(authUrl, clientId, refreshToken, fetchImpl, timeoutMs
|
|
|
353
442
|
signal: AbortSignal.timeout(timeoutMs),
|
|
354
443
|
});
|
|
355
444
|
} catch (error) {
|
|
356
|
-
|
|
445
|
+
// Only a connection that never opened is known not to have reached the
|
|
446
|
+
// server; a timeout or a dropped connection may follow a rotation.
|
|
447
|
+
const reason = error?.name === "TimeoutError" ? "timeout" : UNSENT_CODES.has(error?.cause?.code ?? error?.code) ? "unsent" : "unreachable";
|
|
448
|
+
return { ok: false, status: 0, body: {}, json: false, reason };
|
|
357
449
|
}
|
|
358
450
|
let raw = "";
|
|
359
451
|
try { raw = await response.text(); } catch {}
|
|
@@ -364,12 +456,44 @@ async function postRefresh(authUrl, clientId, refreshToken, fetchImpl, timeoutMs
|
|
|
364
456
|
return { ok: response.ok, status: response.status, body, json, retryAfterMs: Number.isFinite(retryAfter) ? retryAfter * 1000 : undefined };
|
|
365
457
|
}
|
|
366
458
|
|
|
459
|
+
// Node's (undici) and Bun's codes for a connection that never opened.
|
|
460
|
+
const UNSENT_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "UND_ERR_CONNECT_TIMEOUT", "ConnectionRefused", "FailedToOpenSocket"]);
|
|
461
|
+
// The answers that show the token was not used, so it may be sent again: the
|
|
462
|
+
// edge's rate limit, nodii-auth failing before it commits (500, 503), and a
|
|
463
|
+
// non-JSON 4xx (a proxy or WAF refusing before nodii-auth; it answers JSON).
|
|
464
|
+
// Anything else that is not an answer (a gateway timeout, a 2xx without
|
|
465
|
+
// tokens, an HTML page) may follow a rotation, and is not sent again here.
|
|
466
|
+
const RESEND_STATUS = new Set([429, 500, 503]);
|
|
467
|
+
const mayResend = (result) => (result.status === 0 ? result.reason === "unsent" : RESEND_STATUS.has(result.status) || (result.status >= 400 && result.status < 500 && !result.json));
|
|
468
|
+
|
|
469
|
+
// From the moment a refresh token is presented until its successor is stored,
|
|
470
|
+
// the process must not die: nodii-auth has already marked the old token used,
|
|
471
|
+
// so losing the new one means the next refresh replays the old one and the
|
|
472
|
+
// server revokes the whole chain, signing out every process on the machine.
|
|
473
|
+
// SIGINT, SIGTERM and SIGHUP are held over that window and re-raised on
|
|
474
|
+
// release, which comes after the refresh lock is let go. SIGHUP is re-raised
|
|
475
|
+
// only with a terminal attached: under nohup (no terminal) it was meant to be
|
|
476
|
+
// ignored, and re-raising it would kill what nohup kept alive.
|
|
477
|
+
export function holdSignals() {
|
|
478
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
479
|
+
let caught = null;
|
|
480
|
+
const hold = (signal) => { caught ??= signal; };
|
|
481
|
+
for (const signal of signals) process.on(signal, hold);
|
|
482
|
+
return {
|
|
483
|
+
release() {
|
|
484
|
+
for (const signal of signals) process.off(signal, hold);
|
|
485
|
+
if (caught && (caught !== "SIGHUP" || process.stdin.isTTY || process.stdout.isTTY)) process.kill(process.pid, caught);
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
367
490
|
const describe = (result) => result.status === 0 ? (result.reason === "timeout" ? "timed out" : "nodii-auth unreachable") : `HTTP ${result.status}${result.json && typeof result.body.error === "string" ? ` ${result.body.error}` : ""}`;
|
|
368
491
|
|
|
369
492
|
export class OAuthAuthProvider {
|
|
370
493
|
#accessToken;
|
|
371
494
|
#expiresAt = 0;
|
|
372
495
|
#refreshPromise;
|
|
496
|
+
#signals;
|
|
373
497
|
constructor({ fetchImpl = fetch, loadToken = loadRefreshToken, storeToken = storeRefreshToken, deleteToken = deleteRefreshToken, lock = withRefreshLock,
|
|
374
498
|
loadAccess, storeAccess, deleteAccess, sleep = wait, timeoutMs = REFRESH_TIMEOUT_MS, budgetMs = REFRESH_BUDGET_MS } = {}) {
|
|
375
499
|
// Injected token storage (tests) gets no shared access cache unless one is
|
|
@@ -397,7 +521,7 @@ export class OAuthAuthProvider {
|
|
|
397
521
|
// rotated the refresh token and cached the access token it got.
|
|
398
522
|
if (this.#usable(await this.loadAccess(config.clientId))) return this.#accessToken;
|
|
399
523
|
return this.#refresh(config, held ?? (async () => true));
|
|
400
|
-
}, { clientId: config.clientId });
|
|
524
|
+
}, { clientId: config.clientId }).finally(() => this.#release());
|
|
401
525
|
})().finally(() => { this.#refreshPromise = undefined; });
|
|
402
526
|
return this.#refreshPromise;
|
|
403
527
|
}
|
|
@@ -409,9 +533,15 @@ export class OAuthAuthProvider {
|
|
|
409
533
|
// Never present the refresh token without the lock: a second holder
|
|
410
534
|
// would present the same one, and the server revokes the chain on reuse.
|
|
411
535
|
if (!(await held())) throw new CliError("OAUTH_REFRESH_BUSY", "Lost the refresh lock to another nodii-dev process; the saved sign-in was kept. Retry the command.", 1);
|
|
536
|
+
// Signals wait while a token is in flight; on success, until it is stored.
|
|
537
|
+
this.#signals ??= holdSignals();
|
|
412
538
|
result = await postRefresh(config.authUrl, config.clientId, refresh, this.fetchImpl, Math.max(floor, Math.min(this.timeoutMs, deadline - Date.now())));
|
|
413
539
|
const kind = classifyRefresh(result);
|
|
540
|
+
if (kind !== "ok") this.#release();
|
|
414
541
|
if (kind !== "retry" || attempt >= REFRESH_ATTEMPTS) return { kind, result, attempts: attempt };
|
|
542
|
+
// A request that may have reached the server is not sent again with the
|
|
543
|
+
// same token: if it was rotated, that is a replay and revokes the chain.
|
|
544
|
+
if (!mayResend(result)) return { kind, result, attempts: attempt };
|
|
415
545
|
const backoff = 500 * 2 ** (attempt - 1) + Math.floor(Math.random() * 250);
|
|
416
546
|
const pause = Math.min(Math.max(backoff, result.retryAfterMs ?? 0), RETRY_AFTER_CAP_MS);
|
|
417
547
|
if (Date.now() + pause + floor > deadline) return { kind, result, attempts: attempt };
|
|
@@ -461,6 +591,11 @@ export class OAuthAuthProvider {
|
|
|
461
591
|
await this.storeAccess(config.clientId, { token: this.#accessToken, expiresAt: this.#expiresAt });
|
|
462
592
|
return this.#accessToken;
|
|
463
593
|
}
|
|
594
|
+
#release() {
|
|
595
|
+
const signals = this.#signals;
|
|
596
|
+
this.#signals = undefined;
|
|
597
|
+
signals?.release();
|
|
598
|
+
}
|
|
464
599
|
async headers() {
|
|
465
600
|
const config = await readConfig();
|
|
466
601
|
return { authorization: `Bearer ${await this.accessToken()}`, ...(config.agentId ? { "x-agent-id": config.agentId } : {}) };
|