@codedology/code-forge 0.2.1 → 0.2.3

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