@codedology/code-forge 0.2.1 → 0.2.2
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/CHANGELOG.md +17 -0
- package/README.md +8 -3
- package/docs/reference/blocks.json +55 -3
- package/package.json +1 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/keys.mjs +39 -9
- package/src/cli/tools.mjs +221 -0
- package/src/doctor/index.mjs +10 -1
- package/src/doctor/local.mjs +29 -1
- package/src/install/tools.mjs +163 -0
- package/src/install/wizard/flags.mjs +7 -5
- package/src/install/wizard/run.mjs +58 -25
- package/src/install/wizard/steps.mjs +118 -32
- package/src/keys/onepassword.mjs +258 -15
package/src/keys/onepassword.mjs
CHANGED
|
@@ -1,14 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 1Password source: `op read <op://ref>` (plan §8.1)
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* 1Password source: `op read <op://ref>` (plan §8.1), and `op item get <id> --format json` to turn
|
|
3
|
+
* a bare item ID or item link into a full `op://<vaultId>/<itemId>/<fieldId>` reference (B25).
|
|
4
|
+
*
|
|
5
|
+
* Every `op` call is retried exactly once, and only when the FIRST call timed out (a cold `op`
|
|
6
|
+
* waiting on biometric unlock is the common case); any other failure is final. A failure is
|
|
7
|
+
* classified into one of a few kinds (`OP_ERROR_KINDS`) from the exit code, ENOENT and the timeout
|
|
8
|
+
* flag; well-known stderr phrases are matched for classification ONLY. No message ever carries
|
|
9
|
+
* stdout or stderr: `op item get` stdout holds the item's secret values, so only the vault ID, the
|
|
10
|
+
* item ID and field id/label/type are taken from it, and the parsed object is dropped once the
|
|
11
|
+
* reference is built.
|
|
6
12
|
*/
|
|
7
13
|
|
|
8
14
|
import { exec as realExec } from '../util/exec.mjs';
|
|
9
15
|
|
|
10
16
|
export const OP_TIMEOUT_MS = 20_000;
|
|
11
17
|
|
|
18
|
+
/** The 1Password failure kinds; agent JSON carries the kind. */
|
|
19
|
+
export const OP_ERROR_KINDS = Object.freeze(['op_missing', 'op_locked', 'op_timeout', 'op_not_found', 'op_bad_output', 'op_no_field', 'op_bad_input', 'op_failed']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The fixed user-facing messages, one per kind. None of them carries `op` output. `op_not_found`
|
|
23
|
+
* may get the item ID as a suffix, `op_no_field` the (sanitised) concealed field labels, and
|
|
24
|
+
* `op_failed` is built by {@link opFailedMessage} from the exit code or signal only.
|
|
25
|
+
*/
|
|
26
|
+
export const OP_MESSAGES = Object.freeze({
|
|
27
|
+
op_missing: '1Password CLI not found; install it with: brew install 1password-cli',
|
|
28
|
+
op_locked: '1Password is locked or not signed in; unlock the app (or run `op signin`) and try again',
|
|
29
|
+
op_timeout: '1Password did not answer in time; unlock the app and try again',
|
|
30
|
+
op_not_found: '1Password item or field not found, or no access',
|
|
31
|
+
op_bad_output: 'unexpected answer from op',
|
|
32
|
+
op_no_field: 'the 1Password item has no single key field; pass a full op://vault/item/field reference',
|
|
33
|
+
op_bad_input: 'not an op:// reference, 1Password item ID or item link',
|
|
34
|
+
op_failed: '1Password CLI failed',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Hosts a 1Password item link may come from (the host itself or a subdomain). */
|
|
38
|
+
const LINK_HOSTS = ['1password.com', '1password.ca', '1password.eu'];
|
|
39
|
+
|
|
12
40
|
/**
|
|
13
41
|
* @param {string} ref
|
|
14
42
|
* @returns {boolean}
|
|
@@ -19,28 +47,243 @@ export function isOpRef(ref) {
|
|
|
19
47
|
return typeof ref === 'string' && /^op:\/\/[^/\r\n]+\/[^/\r\n]+\/[^\r\n]+$/.test(ref);
|
|
20
48
|
}
|
|
21
49
|
|
|
50
|
+
/**
|
|
51
|
+
* @param {unknown} s
|
|
52
|
+
* @returns {boolean} a 1Password item ID: 26 lowercase letters and digits.
|
|
53
|
+
*/
|
|
54
|
+
export function isOpItemId(s) {
|
|
55
|
+
return typeof s === 'string' && /^[a-z0-9]{26}$/.test(s);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The item ID in a bare ID or a 1Password item link (`https://start.1password.com/open/i?…&i=<id>`).
|
|
60
|
+
* @param {unknown} s
|
|
61
|
+
* @returns {string|null}
|
|
62
|
+
*/
|
|
63
|
+
export function opItemIdFromInput(s) {
|
|
64
|
+
if (typeof s !== 'string') return null;
|
|
65
|
+
const text = s.trim();
|
|
66
|
+
if (isOpItemId(text)) return text;
|
|
67
|
+
if (!/^https:\/\//i.test(text)) return null;
|
|
68
|
+
let url;
|
|
69
|
+
try {
|
|
70
|
+
url = new URL(text);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (url.protocol !== 'https:') return null;
|
|
75
|
+
const host = url.hostname.toLowerCase();
|
|
76
|
+
if (!LINK_HOSTS.some((h) => host === h || host.endsWith(`.${h}`))) return null;
|
|
77
|
+
const id = url.searchParams.get('i');
|
|
78
|
+
return isOpItemId(id) ? id : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {unknown} s
|
|
83
|
+
* @returns {boolean} whether `s` has the shape of something {@link toOpRef} accepts (no `op` call).
|
|
84
|
+
*/
|
|
85
|
+
export function isOpInput(s) {
|
|
86
|
+
return (typeof s === 'string' && isOpRef(s.trim())) || opItemIdFromInput(s) !== null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** stderr phrases `op` prints when it is locked, signed out or cannot reach the desktop app. */
|
|
90
|
+
const LOCKED = /not (currently )?signed in|signin|sign in|locked|unlock|session (has )?expired|desktop app|biometric/i;
|
|
91
|
+
/** stderr phrases `op` prints when an item, vault or field does not exist or is not shared with you. */
|
|
92
|
+
const NOT_FOUND = /isn't an item|isn't a vault|isn't a field|no item found|(item|vault|field) not found|could not find (the )?(item|vault|field)|not authori[sz]ed|unauthori[sz]ed|no access|permission/i;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Classify a failed `op` call. stderr is matched, never returned. Sign-in wording wins over
|
|
96
|
+
* not-found wording; anything unrecognised is `op_failed`.
|
|
97
|
+
* @param {{code: number|null, timedOut: boolean, stderr?: string, error?: string}} res
|
|
98
|
+
* @returns {'op_missing'|'op_locked'|'op_timeout'|'op_not_found'|'op_failed'}
|
|
99
|
+
*/
|
|
100
|
+
export function classifyOpFailure(res) {
|
|
101
|
+
if (res.timedOut) return 'op_timeout';
|
|
102
|
+
if (typeof res.error === 'string' && /ENOENT/.test(res.error)) return 'op_missing';
|
|
103
|
+
if (res.code === 127) return 'op_missing';
|
|
104
|
+
const stderr = typeof res.stderr === 'string' ? res.stderr : '';
|
|
105
|
+
if (LOCKED.test(stderr)) return 'op_locked';
|
|
106
|
+
if (NOT_FOUND.test(stderr)) return 'op_not_found';
|
|
107
|
+
return 'op_failed';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The `op_failed` message: exit code or signal only, plus the command to run by hand.
|
|
112
|
+
* @param {{code: number|null, signal?: string|null}} res
|
|
113
|
+
* @param {string} command - e.g. `op item get <id>` (IDs/references only, never output).
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
export function opFailedMessage(res, command) {
|
|
117
|
+
const how = res.code !== null && res.code !== undefined ? `exit ${res.code}` : res.signal ? `signal ${res.signal}` : 'no exit code';
|
|
118
|
+
return `${OP_MESSAGES.op_failed} (${how}); run \`${command}\` yourself to see why`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {'op_missing'|'op_locked'|'op_timeout'|'op_not_found'|'op_failed'} kind
|
|
123
|
+
* @param {{code: number|null, signal?: string|null}} res
|
|
124
|
+
* @param {string} command
|
|
125
|
+
* @param {string} [notFoundSuffix]
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
function failureMessage(kind, res, command, notFoundSuffix = '') {
|
|
129
|
+
if (kind === 'op_failed') return opFailedMessage(res, command);
|
|
130
|
+
if (kind === 'op_not_found') return `${OP_MESSAGES.op_not_found}${notFoundSuffix}`;
|
|
131
|
+
return OP_MESSAGES[kind];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A field label made safe for a message: control characters removed, at most 64 characters,
|
|
136
|
+
* JSON-quoted.
|
|
137
|
+
* @param {unknown} label
|
|
138
|
+
* @returns {string}
|
|
139
|
+
*/
|
|
140
|
+
function safeLabel(label) {
|
|
141
|
+
return JSON.stringify(cleanText(label));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Text from `op` output (a title, vault name or field label) made safe to print: control
|
|
146
|
+
* characters removed, at most 64 characters.
|
|
147
|
+
* @param {unknown} text
|
|
148
|
+
* @returns {string}
|
|
149
|
+
*/
|
|
150
|
+
function cleanText(text) {
|
|
151
|
+
// eslint-disable-next-line no-control-regex
|
|
152
|
+
return String(text).replace(/[\x00-\x1f\x7f]/g, '').slice(0, 64);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** At most this many labels are listed in an `op_no_field` message. */
|
|
156
|
+
const MAX_LABELS = 10;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* @param {string[]} argv
|
|
160
|
+
* @param {typeof realExec} exec
|
|
161
|
+
* @param {number} timeoutMs
|
|
162
|
+
*/
|
|
163
|
+
async function runOp(argv, exec, timeoutMs) {
|
|
164
|
+
let attempts = 0;
|
|
165
|
+
let res;
|
|
166
|
+
do {
|
|
167
|
+
attempts += 1;
|
|
168
|
+
res = await exec(argv, { timeoutMs });
|
|
169
|
+
} while (res.result !== 'ok' && res.timedOut && attempts === 1);
|
|
170
|
+
return { res, attempts };
|
|
171
|
+
}
|
|
172
|
+
|
|
22
173
|
/**
|
|
23
174
|
* @param {string} ref - an `op://vault/item/field` reference.
|
|
24
175
|
* @param {object} [opts]
|
|
25
176
|
* @param {typeof realExec} [opts.exec]
|
|
26
177
|
* @param {number} [opts.timeoutMs]
|
|
27
|
-
* @returns {Promise<{value: string|null, attempts: number, error?: string}>}
|
|
178
|
+
* @returns {Promise<{value: string|null, attempts: number, error?: string, kind?: string}>}
|
|
28
179
|
*/
|
|
29
180
|
export async function opRead(ref, { exec = realExec, timeoutMs = OP_TIMEOUT_MS } = {}) {
|
|
30
181
|
if (!isOpRef(ref)) {
|
|
31
182
|
throw new TypeError('opRead: reference must look like op://vault/item/field');
|
|
32
183
|
}
|
|
33
|
-
|
|
34
|
-
let res;
|
|
35
|
-
do {
|
|
36
|
-
attempts += 1;
|
|
37
|
-
res = await exec(['op', 'read', ref], { timeoutMs });
|
|
38
|
-
} while (res.result !== 'ok' && res.timedOut && attempts === 1);
|
|
39
|
-
|
|
184
|
+
const { res, attempts } = await runOp(['op', 'read', ref], exec, timeoutMs);
|
|
40
185
|
if (res.result !== 'ok') {
|
|
41
|
-
const
|
|
42
|
-
return { value: null, attempts, error: `op read
|
|
186
|
+
const kind = classifyOpFailure(res);
|
|
187
|
+
return { value: null, attempts, error: failureMessage(kind, res, `op read ${ref}`, '; check the reference'), kind };
|
|
43
188
|
}
|
|
44
189
|
const value = res.stdout.replace(/\r?\n$/, '');
|
|
45
|
-
return value.length > 0 ? { value, attempts } : { value: null, attempts, error:
|
|
190
|
+
return value.length > 0 ? { value, attempts } : { value: null, attempts, error: OP_MESSAGES.op_bad_output, kind: 'op_bad_output' };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* @typedef {{ref: string|null, error?: string, kind?: string, field?: string, vault?: string, title?: string}} OpRefResult
|
|
195
|
+
* `ref` is null exactly when `error` and `kind` are set.
|
|
196
|
+
*/
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Pick the key field: id `credential`; else purpose PASSWORD; else the single CONCEALED field
|
|
200
|
+
* with a value.
|
|
201
|
+
* @param {any[]} fields
|
|
202
|
+
* @returns {{field: any} | {error: string}}
|
|
203
|
+
*/
|
|
204
|
+
function pickField(fields) {
|
|
205
|
+
const hasValue = (/** @type {any} */ f) => typeof f?.value === 'string' && f.value.length > 0;
|
|
206
|
+
const byId = fields.find((f) => f?.id === 'credential' && hasValue(f));
|
|
207
|
+
if (byId) return { field: byId };
|
|
208
|
+
const byPurpose = fields.find((f) => f?.purpose === 'PASSWORD');
|
|
209
|
+
if (byPurpose) return { field: byPurpose };
|
|
210
|
+
const concealed = fields.filter((f) => f?.type === 'CONCEALED');
|
|
211
|
+
const filled = concealed.filter(hasValue);
|
|
212
|
+
if (filled.length === 1) return { field: filled[0] };
|
|
213
|
+
const labels = concealed.slice(0, MAX_LABELS).map((f) => safeLabel(typeof f.label === 'string' ? f.label : f.id));
|
|
214
|
+
const more = concealed.length > MAX_LABELS ? ` and ${concealed.length - MAX_LABELS} more` : '';
|
|
215
|
+
const which = labels.length > 0 ? `concealed fields: ${labels.join(', ')}${more}` : 'no concealed field';
|
|
216
|
+
return { error: `${OP_MESSAGES.op_no_field} (${which})` };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve a 1Password item ID into `op://<vaultId>/<itemId>/<fieldId>`.
|
|
221
|
+
* @param {string} itemId
|
|
222
|
+
* @param {object} [opts]
|
|
223
|
+
* @param {typeof realExec} [opts.exec]
|
|
224
|
+
* @param {number} [opts.timeoutMs]
|
|
225
|
+
* @returns {Promise<OpRefResult>}
|
|
226
|
+
*/
|
|
227
|
+
export async function resolveOpItemRef(itemId, { exec = realExec, timeoutMs = OP_TIMEOUT_MS } = {}) {
|
|
228
|
+
if (!isOpItemId(itemId)) return { ref: null, error: OP_MESSAGES.op_bad_input, kind: 'op_bad_input' };
|
|
229
|
+
const { res } = await runOp(['op', 'item', 'get', itemId, '--format', 'json'], exec, timeoutMs);
|
|
230
|
+
if (res.result !== 'ok') {
|
|
231
|
+
const kind = classifyOpFailure(res);
|
|
232
|
+
return { ref: null, error: failureMessage(kind, res, `op item get ${itemId}`, ` (item ${itemId})`), kind };
|
|
233
|
+
}
|
|
234
|
+
/** @type {any} */
|
|
235
|
+
let item;
|
|
236
|
+
try {
|
|
237
|
+
item = JSON.parse(res.stdout);
|
|
238
|
+
} catch {
|
|
239
|
+
// never pass the parser's message on: it can quote the input
|
|
240
|
+
item = null;
|
|
241
|
+
}
|
|
242
|
+
const vaultId = item?.vault?.id;
|
|
243
|
+
const fields = item?.fields;
|
|
244
|
+
const id = item?.id;
|
|
245
|
+
if (typeof vaultId !== 'string' || !/^[a-z0-9]+$/.test(vaultId) || typeof id !== 'string' || id !== itemId || !Array.isArray(fields)) {
|
|
246
|
+
return { ref: null, error: OP_MESSAGES.op_bad_output, kind: 'op_bad_output' };
|
|
247
|
+
}
|
|
248
|
+
const picked = pickField(fields);
|
|
249
|
+
if ('error' in picked) return { ref: null, error: picked.error, kind: 'op_no_field' };
|
|
250
|
+
const fieldId = picked.field.id;
|
|
251
|
+
if (typeof fieldId !== 'string' || fieldId.length === 0 || /[/\r\n]/.test(fieldId)) {
|
|
252
|
+
return { ref: null, error: OP_MESSAGES.op_bad_output, kind: 'op_bad_output' };
|
|
253
|
+
}
|
|
254
|
+
const label = typeof picked.field.label === 'string' && picked.field.label.length > 0 ? picked.field.label : fieldId;
|
|
255
|
+
return {
|
|
256
|
+
ref: `op://${vaultId}/${itemId}/${fieldId}`,
|
|
257
|
+
field: cleanText(label),
|
|
258
|
+
vault: cleanText(typeof item.vault.name === 'string' ? item.vault.name : vaultId),
|
|
259
|
+
title: cleanText(typeof item.title === 'string' ? item.title : itemId),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* An `op://` reference passes unchanged (no `op` call); an item ID or link is resolved.
|
|
265
|
+
* @param {string} input
|
|
266
|
+
* @param {object} [opts]
|
|
267
|
+
* @param {typeof realExec} [opts.exec]
|
|
268
|
+
* @param {number} [opts.timeoutMs]
|
|
269
|
+
* @returns {Promise<OpRefResult>}
|
|
270
|
+
*/
|
|
271
|
+
export async function toOpRef(input, opts = {}) {
|
|
272
|
+
const text = typeof input === 'string' ? input.trim() : input;
|
|
273
|
+
if (isOpRef(text)) return { ref: /** @type {string} */ (text) };
|
|
274
|
+
const id = opItemIdFromInput(text);
|
|
275
|
+
if (id !== null) return resolveOpItemRef(id, opts);
|
|
276
|
+
return { ref: null, error: OP_MESSAGES.op_bad_input, kind: 'op_bad_input' };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* `1Password item "<title>" (vault <name>, field <label>)`, leaving out any part that is missing;
|
|
281
|
+
* null when nothing is known (a plain op:// input).
|
|
282
|
+
* @param {{title?: string, vault?: string, field?: string}} res
|
|
283
|
+
* @returns {string|null}
|
|
284
|
+
*/
|
|
285
|
+
export function describeOpItem(res) {
|
|
286
|
+
const parts = [res.vault ? `vault ${res.vault}` : null, res.field ? `field ${res.field}` : null].filter(Boolean);
|
|
287
|
+
if (!res.title && parts.length === 0) return null;
|
|
288
|
+
return `1Password item${res.title ? ` "${res.title}"` : ''}${parts.length > 0 ? ` (${parts.join(', ')})` : ''}`;
|
|
46
289
|
}
|