@codedology/code-forge 0.2.0 → 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.
@@ -1,14 +1,42 @@
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
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
- 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
-
184
+ const { res, attempts } = await runOp(['op', 'read', ref], exec, timeoutMs);
40
185
  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})` };
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: 'op read returned nothing' };
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
  }