@feralfile/cli 2.10.1 → 4.0.0

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.
Files changed (59) hide show
  1. package/README.md +31 -23
  2. package/config.json.example +6 -1
  3. package/dist/index.js +10 -2
  4. package/dist/package.json +5 -2
  5. package/dist/src/commands/build.js +40 -0
  6. package/dist/src/commands/config.js +1 -0
  7. package/dist/src/commands/device.js +85 -40
  8. package/dist/src/commands/enrich.js +18 -23
  9. package/dist/src/commands/find.js +144 -106
  10. package/dist/src/commands/helpers/device-control.js +937 -0
  11. package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
  12. package/dist/src/commands/helpers/playlist-display.js +2 -15
  13. package/dist/src/commands/helpers/prompt.js +85 -10
  14. package/dist/src/commands/play.js +162 -84
  15. package/dist/src/commands/report.js +251 -0
  16. package/dist/src/commands/setup.js +82 -7
  17. package/dist/src/commands/status.js +4 -1
  18. package/dist/src/config.js +256 -6
  19. package/dist/src/main.js +14 -5
  20. package/dist/src/utilities/control/codec.js +578 -0
  21. package/dist/src/utilities/control/control-errors.js +157 -0
  22. package/dist/src/utilities/control/controller-store.js +964 -0
  23. package/dist/src/utilities/control/jwk.js +141 -0
  24. package/dist/src/utilities/control/pairing-channel.js +52 -0
  25. package/dist/src/utilities/control/pairing.js +416 -0
  26. package/dist/src/utilities/control/registration.js +391 -0
  27. package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
  28. package/dist/src/utilities/control/secure-transport.js +850 -0
  29. package/dist/src/utilities/control/strict-json.js +376 -0
  30. package/dist/src/utilities/device-lookup.js +36 -11
  31. package/dist/src/utilities/device-network.js +129 -0
  32. package/dist/src/utilities/device-upsert.js +1 -1
  33. package/dist/src/utilities/feral-file-artwork.js +2 -2
  34. package/dist/src/utilities/ff1-compatibility.js +87 -11
  35. package/dist/src/utilities/ff1-device.js +161 -78
  36. package/dist/src/utilities/ff1-relayer.js +34 -14
  37. package/dist/src/utilities/handoff-client.js +23 -9
  38. package/dist/src/utilities/index.js +270 -259
  39. package/dist/src/utilities/media-url.js +117 -0
  40. package/dist/src/utilities/neort-marketplace.js +3 -3
  41. package/dist/src/utilities/nft-indexer.js +12 -376
  42. package/dist/src/utilities/playlist-builder.js +11 -2
  43. package/dist/src/utilities/playlist-cast.js +6 -6
  44. package/dist/src/utilities/playlist-source.js +223 -34
  45. package/dist/src/utilities/raster-client.js +101 -14
  46. package/dist/src/utilities/relay-client.js +774 -0
  47. package/dist/src/utilities/ssh-access.js +192 -58
  48. package/dist/src/utilities/support-report.js +511 -0
  49. package/dist/src/utilities/temporary-channel.js +139 -0
  50. package/dist/src/utilities/token-item.js +428 -0
  51. package/dist/src/utilities/token-source-contract.js +55 -0
  52. package/dist/src/utilities/token-source-indexer.js +99 -0
  53. package/dist/src/utilities/token-source-raster.js +378 -0
  54. package/dist/src/utilities/token-source.js +48 -0
  55. package/docs/CONFIGURATION.md +331 -28
  56. package/docs/EXAMPLES.md +33 -33
  57. package/docs/PROJECT_SPEC.md +55 -36
  58. package/docs/README.md +145 -43
  59. package/package.json +5 -2
@@ -0,0 +1,376 @@
1
+ "use strict";
2
+ /**
3
+ * Strict JSON and base64url decoding for the FF1 control protocol v1
4
+ * (ffos-user docs/ff1-control-v1.md §1).
5
+ *
6
+ * Why this exists instead of `JSON.parse`: two implementations that disagree
7
+ * about what a byte string means (which of two duplicate `kid` members wins,
8
+ * whether `1.0` is the integer 1) are a signature-confusion bug. `JSON.parse`
9
+ * silently keeps the LAST duplicate and erases the difference between `1`,
10
+ * `1.0` and `1e0`, so every header, claims object and JWK that crosses the
11
+ * trust boundary is parsed here instead. The Go reference (control/json.go)
12
+ * applies the same rules; keep the two in step.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.ControlFormatError = void 0;
16
+ exports.b64uDecode = b64uDecode;
17
+ exports.b64uEncode = b64uEncode;
18
+ exports.isB64uOfLength = isB64uOfLength;
19
+ exports.utf8Decode = utf8Decode;
20
+ exports.parseStrictJson = parseStrictJson;
21
+ exports.isPlainObject = isPlainObject;
22
+ exports.strictObject = strictObject;
23
+ exports.stringMember = stringMember;
24
+ exports.uintMember = uintMember;
25
+ exports.boolMember = boolMember;
26
+ exports.toPlainJson = toPlainJson;
27
+ /** ControlFormatError is thrown for every malformed protocol object. */
28
+ class ControlFormatError extends Error {
29
+ constructor(message) {
30
+ super(`ff1-control: ${message}`);
31
+ this.name = 'ControlFormatError';
32
+ }
33
+ }
34
+ exports.ControlFormatError = ControlFormatError;
35
+ /** MAX_SAFE_CLAIM_INT is 2^53-1, the integer ceiling of §1. */
36
+ const MAX_SAFE_CLAIM_INT = Number.MAX_SAFE_INTEGER;
37
+ /** MAX_JSON_DEPTH bounds nesting, matching the Go reference's maxJSONDepth. */
38
+ const MAX_JSON_DEPTH = 128;
39
+ /**
40
+ * rawNumbers remembers the exact source text of every number member of every
41
+ * parsed object, so integer claims can be checked for canonical form after
42
+ * parsing (JSON.parse-style values cannot tell `1` from `1.0`).
43
+ */
44
+ const rawNumbers = new WeakMap();
45
+ const B64U_ALPHABET = /^[A-Za-z0-9_-]*$/;
46
+ /**
47
+ * b64uDecode decodes unpadded base64url strictly: `=` padding, whitespace,
48
+ * any non-alphabet byte and non-zero trailing bits are all rejected (§1). The
49
+ * round-trip comparison is what catches non-canonical trailing bits, which
50
+ * Node's lenient decoder would otherwise accept.
51
+ *
52
+ * @param value - Candidate base64url text
53
+ * @returns The decoded bytes
54
+ * @throws {ControlFormatError} When the text is not canonical base64url
55
+ */
56
+ function b64uDecode(value) {
57
+ if (typeof value !== 'string' || !B64U_ALPHABET.test(value) || value.length % 4 === 1) {
58
+ throw new ControlFormatError('invalid base64url');
59
+ }
60
+ const bytes = Buffer.from(value, 'base64url');
61
+ if (bytes.toString('base64url') !== value) {
62
+ throw new ControlFormatError('non-canonical base64url');
63
+ }
64
+ return new Uint8Array(bytes);
65
+ }
66
+ /**
67
+ * b64uEncode encodes bytes as unpadded base64url.
68
+ *
69
+ * @param bytes - Bytes or UTF-8 text to encode
70
+ * @returns Unpadded base64url text
71
+ */
72
+ function b64uEncode(bytes) {
73
+ return Buffer.from(bytes).toString('base64url');
74
+ }
75
+ /**
76
+ * isB64uOfLength reports whether value is canonical base64url of exactly n bytes.
77
+ *
78
+ * @param value - Candidate value
79
+ * @param n - Required decoded length
80
+ * @returns True when the value decodes to exactly n bytes
81
+ */
82
+ function isB64uOfLength(value, n) {
83
+ if (typeof value !== 'string') {
84
+ return false;
85
+ }
86
+ try {
87
+ return b64uDecode(value).length === n;
88
+ }
89
+ catch {
90
+ return false;
91
+ }
92
+ }
93
+ /**
94
+ * utf8Decode decodes bytes as UTF-8, rejecting invalid sequences (§1). A
95
+ * lenient decoder would map them to U+FFFD and let two different byte strings
96
+ * read as the same JSON.
97
+ *
98
+ * @param bytes - Raw bytes
99
+ * @returns The decoded string
100
+ * @throws {ControlFormatError} On invalid UTF-8
101
+ */
102
+ function utf8Decode(bytes) {
103
+ try {
104
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
105
+ }
106
+ catch {
107
+ throw new ControlFormatError('invalid UTF-8');
108
+ }
109
+ }
110
+ /** StrictParser is a small recursive-descent JSON parser with duplicate-key detection. */
111
+ class StrictParser {
112
+ text;
113
+ pos = 0;
114
+ constructor(text) {
115
+ this.text = text;
116
+ }
117
+ parseDocument() {
118
+ const value = this.parseValue(0);
119
+ this.skipWhitespace();
120
+ if (this.pos !== this.text.length) {
121
+ throw new ControlFormatError('trailing data after JSON value');
122
+ }
123
+ return value;
124
+ }
125
+ fail(message) {
126
+ throw new ControlFormatError(`invalid JSON at ${this.pos}: ${message}`);
127
+ }
128
+ skipWhitespace() {
129
+ while (this.pos < this.text.length && ' \t\r\n'.includes(this.text[this.pos])) {
130
+ this.pos += 1;
131
+ }
132
+ }
133
+ parseValue(depth) {
134
+ if (depth > MAX_JSON_DEPTH) {
135
+ this.fail('nested too deeply');
136
+ }
137
+ this.skipWhitespace();
138
+ const ch = this.text[this.pos];
139
+ if (ch === '{') {
140
+ return this.parseObject(depth);
141
+ }
142
+ if (ch === '[') {
143
+ return this.parseArray(depth);
144
+ }
145
+ if (ch === '"') {
146
+ return this.parseString();
147
+ }
148
+ if (ch === '-' || (ch >= '0' && ch <= '9')) {
149
+ return Number(this.parseNumberText());
150
+ }
151
+ for (const [literal, value] of [
152
+ ['true', true],
153
+ ['false', false],
154
+ ['null', null],
155
+ ]) {
156
+ if (this.text.startsWith(literal, this.pos)) {
157
+ this.pos += literal.length;
158
+ return value;
159
+ }
160
+ }
161
+ return this.fail('unexpected token');
162
+ }
163
+ parseObject(depth) {
164
+ this.pos += 1;
165
+ // A null-prototype object keeps a member named "__proto__" an ordinary
166
+ // member instead of a prototype assignment.
167
+ const out = Object.create(null);
168
+ const numbers = new Map();
169
+ this.skipWhitespace();
170
+ if (this.text[this.pos] === '}') {
171
+ this.pos += 1;
172
+ rawNumbers.set(out, numbers);
173
+ return out;
174
+ }
175
+ for (;;) {
176
+ this.skipWhitespace();
177
+ if (this.text[this.pos] !== '"') {
178
+ this.fail('object key is not a string');
179
+ }
180
+ // Keys are compared after unescaping, so "typ" and "typ" collide.
181
+ const key = this.parseString();
182
+ if (Object.prototype.hasOwnProperty.call(out, key)) {
183
+ this.fail(`duplicate member ${JSON.stringify(key)}`);
184
+ }
185
+ this.skipWhitespace();
186
+ if (this.text[this.pos] !== ':') {
187
+ this.fail('expected ":"');
188
+ }
189
+ this.pos += 1;
190
+ this.skipWhitespace();
191
+ const start = this.pos;
192
+ out[key] = this.parseValue(depth + 1);
193
+ if (typeof out[key] === 'number') {
194
+ numbers.set(key, this.text.slice(start, this.pos));
195
+ }
196
+ this.skipWhitespace();
197
+ const sep = this.text[this.pos];
198
+ this.pos += 1;
199
+ if (sep === '}') {
200
+ rawNumbers.set(out, numbers);
201
+ return out;
202
+ }
203
+ if (sep !== ',') {
204
+ this.fail('expected "," or "}"');
205
+ }
206
+ }
207
+ }
208
+ parseArray(depth) {
209
+ this.pos += 1;
210
+ const out = [];
211
+ this.skipWhitespace();
212
+ if (this.text[this.pos] === ']') {
213
+ this.pos += 1;
214
+ return out;
215
+ }
216
+ for (;;) {
217
+ out.push(this.parseValue(depth + 1));
218
+ this.skipWhitespace();
219
+ const sep = this.text[this.pos];
220
+ this.pos += 1;
221
+ if (sep === ']') {
222
+ return out;
223
+ }
224
+ if (sep !== ',') {
225
+ this.fail('expected "," or "]"');
226
+ }
227
+ }
228
+ }
229
+ parseString() {
230
+ const start = this.pos;
231
+ this.pos += 1;
232
+ for (;;) {
233
+ const ch = this.text[this.pos];
234
+ if (ch === undefined) {
235
+ this.fail('unterminated string');
236
+ }
237
+ if (ch === '"') {
238
+ this.pos += 1;
239
+ break;
240
+ }
241
+ if (ch === '\\') {
242
+ this.pos += 2;
243
+ continue;
244
+ }
245
+ if (ch < ' ') {
246
+ this.fail('control character in string');
247
+ }
248
+ this.pos += 1;
249
+ }
250
+ // The span is grammar-checked by JSON.parse, which also unescapes it.
251
+ try {
252
+ return JSON.parse(this.text.slice(start, this.pos));
253
+ }
254
+ catch {
255
+ return this.fail('invalid string escape');
256
+ }
257
+ }
258
+ parseNumberText() {
259
+ const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(this.text.slice(this.pos));
260
+ if (!match) {
261
+ this.fail('invalid number');
262
+ }
263
+ this.pos += match[0].length;
264
+ return match[0];
265
+ }
266
+ }
267
+ /**
268
+ * parseStrictJson parses UTF-8 JSON text with the §1 rules: one value, no
269
+ * trailing data, no duplicate member names at any depth.
270
+ *
271
+ * @param input - JSON text or raw UTF-8 bytes
272
+ * @returns The parsed value; objects have a null prototype
273
+ * @throws {ControlFormatError} On any violation
274
+ */
275
+ function parseStrictJson(input) {
276
+ const text = typeof input === 'string' ? input : utf8Decode(input);
277
+ return new StrictParser(text).parseDocument();
278
+ }
279
+ /**
280
+ * isPlainObject reports whether value is a parsed JSON object (not an array or null).
281
+ *
282
+ * @param value - Candidate value
283
+ * @returns True for JSON objects
284
+ */
285
+ function isPlainObject(value) {
286
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
287
+ }
288
+ /**
289
+ * strictObject requires value to be an object whose member names are all in
290
+ * required ∪ optional, with every required member present (§1 closed sets).
291
+ *
292
+ * @param value - Parsed JSON value
293
+ * @param required - Members that must be present
294
+ * @param optional - Members that may be present
295
+ * @param what - Name used in error messages
296
+ * @returns The object, typed as a record
297
+ * @throws {ControlFormatError} On any extra or missing member
298
+ */
299
+ function strictObject(value, required, optional, what) {
300
+ if (!isPlainObject(value)) {
301
+ throw new ControlFormatError(`${what} is not a JSON object`);
302
+ }
303
+ for (const key of Object.keys(value)) {
304
+ if (!required.includes(key) && !optional.includes(key)) {
305
+ throw new ControlFormatError(`${what} has unexpected member ${JSON.stringify(key)}`);
306
+ }
307
+ }
308
+ for (const key of required) {
309
+ if (!Object.prototype.hasOwnProperty.call(value, key)) {
310
+ throw new ControlFormatError(`${what} is missing member ${JSON.stringify(key)}`);
311
+ }
312
+ }
313
+ return value;
314
+ }
315
+ /**
316
+ * stringMember returns a required string member.
317
+ *
318
+ * @param obj - Parsed object
319
+ * @param key - Member name
320
+ * @returns The string value
321
+ * @throws {ControlFormatError} When the member is absent or not a string
322
+ */
323
+ function stringMember(obj, key) {
324
+ const value = obj[key];
325
+ if (typeof value !== 'string') {
326
+ throw new ControlFormatError(`member ${JSON.stringify(key)} is not a string`);
327
+ }
328
+ return value;
329
+ }
330
+ /**
331
+ * uintMember returns a canonical non-negative integer member in [0, 2^53-1].
332
+ * `1.0`, `1e3`, `-0`, `01` and quoted numbers are rejected (§1): the Dart,
333
+ * JS and Go encoders never produce them, so seeing one means a hand-built or
334
+ * hostile object. Only objects produced by parseStrictJson can be checked.
335
+ *
336
+ * @param obj - Object returned by parseStrictJson
337
+ * @param key - Member name
338
+ * @returns The integer value
339
+ * @throws {ControlFormatError} When absent, non-canonical or out of range
340
+ */
341
+ function uintMember(obj, key) {
342
+ const raw = rawNumbers.get(obj)?.get(key);
343
+ if (raw === undefined || !/^(?:0|[1-9]\d*)$/.test(raw)) {
344
+ throw new ControlFormatError(`member ${JSON.stringify(key)} is not a canonical integer`);
345
+ }
346
+ const value = Number(raw);
347
+ if (!Number.isSafeInteger(value) || value > MAX_SAFE_CLAIM_INT) {
348
+ throw new ControlFormatError(`member ${JSON.stringify(key)} is out of range`);
349
+ }
350
+ return value;
351
+ }
352
+ /**
353
+ * boolMember returns a required boolean member.
354
+ *
355
+ * @param obj - Parsed object
356
+ * @param key - Member name
357
+ * @returns The boolean value
358
+ * @throws {ControlFormatError} When absent or not a boolean
359
+ */
360
+ function boolMember(obj, key) {
361
+ const value = obj[key];
362
+ if (typeof value !== 'boolean') {
363
+ throw new ControlFormatError(`member ${JSON.stringify(key)} is not a boolean`);
364
+ }
365
+ return value;
366
+ }
367
+ /**
368
+ * toPlainJson deep-copies a parsed value onto ordinary prototypes so callers
369
+ * outside the codec (printing, deepEqual in tests) see normal objects.
370
+ *
371
+ * @param value - Value from parseStrictJson
372
+ * @returns Equivalent value with ordinary object prototypes
373
+ */
374
+ function toPlainJson(value) {
375
+ return JSON.parse(JSON.stringify(value));
376
+ }
@@ -11,8 +11,9 @@ const device_normalize_1 = require("./device-normalize");
11
11
  * so a stale row that happens to share the same IP cannot shadow the correct
12
12
  * entry. Requires the stored entry to have been written with an id field.
13
13
  * 2. Exact URL match — normal case after ID (same host format, no migration).
14
- * 3. mDNS hostname component match — both URLs are parsed and only the hostname
15
- * part is compared (same .local name, different underlying IP or port).
14
+ * 3. Hostname component match — both URLs are parsed and the hostname compared.
15
+ * For a `.local` name, port and protocol are ignored (same device, stored a
16
+ * different way); for any other host the port must match too.
16
17
  * 4a. Discovered IP → stored IP: the mDNS discovery reports a resolved IP that
17
18
  * matches the IP in a stored entry. Bridges .local ↔ stored-IP migration for
18
19
  * pre-id configs with no stored id.
@@ -41,18 +42,28 @@ function findExistingDeviceEntry(existingDevices, newHost, discoveredName, disco
41
42
  if (byHost) {
42
43
  return byHost;
43
44
  }
44
- // 3. mDNS hostname component match (ignores port and protocol differences)
45
- let newHostname = '';
45
+ // 3. Hostname component match. A `.local` name is the device itself, so port
46
+ // and protocol differences are ignored. Any other host (an IP, a tunnel or
47
+ // port-forwarding front) can serve several devices on different ports, so
48
+ // the port must match as well: matching on the address alone made
49
+ // `device add --host <same-ip>:<other-port> --name Kitchen` overwrite the
50
+ // existing device while reporting "Added device: Kitchen".
51
+ let newUrl = null;
46
52
  try {
47
- newHostname = new URL(newHost).hostname;
53
+ newUrl = new URL(newHost);
48
54
  }
49
55
  catch {
50
56
  // not a valid URL — skip hostname matching
51
57
  }
52
- if (newHostname) {
58
+ if (newUrl?.hostname) {
59
+ const candidate = newUrl;
53
60
  const byHostname = existingDevices.find((d) => {
54
61
  try {
55
- return new URL(d.host || '').hostname === newHostname;
62
+ const stored = new URL(d.host || '');
63
+ if (stored.hostname !== candidate.hostname) {
64
+ return false;
65
+ }
66
+ return (candidate.hostname.toLowerCase().endsWith('.local') || stored.port === candidate.port);
56
67
  }
57
68
  catch {
58
69
  return false;
@@ -76,8 +87,12 @@ function findExistingDeviceEntry(existingDevices, newHost, discoveredName, disco
76
87
  return false; // different physical devices — do not conflate
77
88
  }
78
89
  try {
79
- const storedIp = stripBrackets(new URL(d.host || '').hostname);
80
- return discoveredAddresses.includes(storedIp);
90
+ const stored = new URL(d.host || '');
91
+ const storedIp = stripBrackets(stored.hostname);
92
+ // Same port rule as steps 3 and 4b: an address match on another port is
93
+ // another device. (A matching stable id already returned in step 1.)
94
+ const samePort = stored.port === (newUrl?.port ?? stored.port);
95
+ return discoveredAddresses.includes(storedIp) && samePort;
81
96
  }
82
97
  catch {
83
98
  return false;
@@ -91,9 +106,19 @@ function findExistingDeviceEntry(existingDevices, newHost, discoveredName, disco
91
106
  // and a stored entry has that IP in its stored addresses list (populated from
92
107
  // prior mDNS discoveries). This bridges --host <ip/ipv6> → existing .local entry.
93
108
  // Strip IPv6 brackets before comparing (Node URL.hostname returns '[fe80::1]').
94
- const rawHostname = stripBrackets(newHostname);
109
+ // The port must match the stored entry's port, for the same reason as step 3:
110
+ // another port at a remembered address is another device, not this one.
111
+ const rawHostname = stripBrackets(newUrl?.hostname ?? '');
95
112
  if (rawHostname && (/^[0-9.]+$/.test(rawHostname) || rawHostname.includes(':'))) {
96
- const byStoredAddress = existingDevices.find((d) => d.addresses?.includes(rawHostname));
113
+ const samePort = (stored) => {
114
+ try {
115
+ return new URL(stored || '').port === newUrl?.port;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ };
121
+ const byStoredAddress = existingDevices.find((d) => d.addresses?.includes(rawHostname) && samePort(d.host));
97
122
  if (byStoredAddress) {
98
123
  return byStoredAddress;
99
124
  }
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ /**
3
+ * Network classification shared by the FF1 LAN delivery paths (legacy
4
+ * plaintext casts in ff1-device.ts and the secure envelope transport in
5
+ * control/secure-transport.ts), so both apply the same retry and relayer
6
+ * fallback rules.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.isTransientDeviceNetworkError = isTransientDeviceNetworkError;
10
+ exports.isConnectPhaseNetworkError = isConnectPhaseNetworkError;
11
+ exports.parseRetryAfterMs = parseRetryAfterMs;
12
+ /**
13
+ * isTransientDeviceNetworkError returns true when a send failure is likely temporary.
14
+ *
15
+ * This classifier intentionally targets resolver and route-level failures that are
16
+ * common on local mDNS/Wi-Fi environments. Permanent command errors should surface
17
+ * immediately without retry loops.
18
+ *
19
+ * @param {unknown} error - Error thrown by fetch
20
+ * @returns {boolean} True when retrying is likely to recover
21
+ */
22
+ function isTransientDeviceNetworkError(error) {
23
+ if (!(error instanceof Error)) {
24
+ return false;
25
+ }
26
+ const networkCodes = new Set([
27
+ 'EHOSTUNREACH',
28
+ 'ENETUNREACH',
29
+ 'ENOTFOUND',
30
+ 'EAI_AGAIN',
31
+ 'ETIMEDOUT',
32
+ 'ECONNREFUSED',
33
+ 'ECONNRESET',
34
+ ]);
35
+ const message = error.message || '';
36
+ const messageLooksTransient = message.includes('fetch failed') ||
37
+ message.includes('getaddrinfo') ||
38
+ message.includes('EHOSTUNREACH') ||
39
+ message.includes('ENOTFOUND') ||
40
+ message.includes('ETIMEDOUT') ||
41
+ message.includes('network timeout') ||
42
+ message.includes('No route to host');
43
+ if (messageLooksTransient) {
44
+ return true;
45
+ }
46
+ const causeCode = error.cause &&
47
+ typeof error.cause === 'object' &&
48
+ 'code' in error.cause &&
49
+ typeof error.cause.code === 'string'
50
+ ? error.cause.code
51
+ : undefined;
52
+ return Boolean(causeCode && networkCodes.has(causeCode));
53
+ }
54
+ // Codes that can only come from opening the connection (resolving the name,
55
+ // routing, or a refused/timed-out TCP connect): the request bytes were never
56
+ // written, so the FF1 cannot have run it.
57
+ const CONNECT_PHASE_CODES = new Set([
58
+ 'ECONNREFUSED',
59
+ 'EHOSTUNREACH',
60
+ 'ENETUNREACH',
61
+ 'ENOTFOUND',
62
+ 'EAI_AGAIN',
63
+ 'UND_ERR_CONNECT_TIMEOUT',
64
+ ]);
65
+ // Codes that prove the same only when the failing call was the TCP connect.
66
+ // macOS answers a connect to an absent LAN host with EHOSTDOWN (no ARP reply),
67
+ // where Linux says EHOSTUNREACH.
68
+ const CONNECT_SYSCALL_CODES = new Set(['ETIMEDOUT', 'EHOSTDOWN', 'ENETDOWN', 'EADDRNOTAVAIL']);
69
+ /**
70
+ * isConnectPhaseNetworkError reports a send failure that proves the request
71
+ * never reached the FF1, so sending it again (even on another route, with a new
72
+ * counter) cannot run it twice. `fetch failed` on its own, ECONNRESET, a closed
73
+ * socket or a response timeout may follow a delivered request, and are not
74
+ * connect-phase (feral-file#3551 spec §14.4).
75
+ *
76
+ * @param {unknown} error - Error thrown by fetch
77
+ * @returns {boolean} True when the failure happened before any request byte was sent
78
+ */
79
+ function isConnectPhaseNetworkError(error) {
80
+ if (!(error instanceof Error)) {
81
+ return false;
82
+ }
83
+ const causes = [error, error.cause];
84
+ const cause = error.cause;
85
+ if (cause && Array.isArray(cause.errors)) {
86
+ // Happy-eyeballs connects report one error per address.
87
+ causes.push(...cause.errors);
88
+ }
89
+ return causes.some((candidate) => {
90
+ if (!candidate || typeof candidate !== 'object') {
91
+ return false;
92
+ }
93
+ const { code, syscall } = candidate;
94
+ if (typeof code !== 'string') {
95
+ return false;
96
+ }
97
+ return (CONNECT_PHASE_CODES.has(code) || (syscall === 'connect' && CONNECT_SYSCALL_CODES.has(code)));
98
+ });
99
+ }
100
+ /**
101
+ * parseRetryAfterMs converts an HTTP `Retry-After` header into milliseconds.
102
+ *
103
+ * Supports both forms from RFC 7231: a delta-seconds integer (e.g. "5") and an
104
+ * HTTP-date (e.g. "Wed, 21 Oct 2025 07:28:00 GMT"). Returns undefined when the
105
+ * header is absent or unparseable, so callers can fall back to their own
106
+ * backoff. A past date clamps to 0 (retry immediately).
107
+ *
108
+ * @param {string | null | undefined} headerValue - Raw Retry-After header value
109
+ * @returns {number | undefined} Delay in milliseconds, or undefined if unknown
110
+ */
111
+ function parseRetryAfterMs(headerValue) {
112
+ if (!headerValue) {
113
+ return undefined;
114
+ }
115
+ const trimmed = headerValue.trim();
116
+ if (/^\d+$/.test(trimmed)) {
117
+ return Number(trimmed) * 1000;
118
+ }
119
+ // A valid HTTP-date always contains letters (month/day names, or the ISO
120
+ // 'T'/'Z'). Requiring one rejects bare numeric junk (e.g. "-5", "5.5") that
121
+ // Date.parse would otherwise misinterpret as a date.
122
+ if (/[a-zA-Z]/.test(trimmed)) {
123
+ const dateMs = Date.parse(trimmed);
124
+ if (!Number.isNaN(dateMs)) {
125
+ return Math.max(0, dateMs - Date.now());
126
+ }
127
+ }
128
+ return undefined;
129
+ }
@@ -58,7 +58,7 @@ function applyPatch(existing, patch) {
58
58
  * 1. Same mDNS device ID → update in-place (preserves position, handles host change).
59
59
  * 2. Same host → update in-place (preserves position and metadata).
60
60
  * 3. Same name, different host → replace in-place (preserves position so that
61
- * devices[0] — the implicit default for play/send/ssh — does not silently change).
61
+ * devices[0] — the implicit default for display/send/ssh — does not silently change).
62
62
  * 4. Neither match → append.
63
63
  */
64
64
  function upsertDevice(existingDevices, newDevice,
@@ -13,12 +13,12 @@ exports.parseFeralFileArtworkId = parseFeralFileArtworkId;
13
13
  const http_1 = require("./http");
14
14
  const user_agent_1 = require("./user-agent");
15
15
  /**
16
- * resolveFeralFileArtwork resolves a public Feral File artwork id into indexer
16
+ * resolveFeralFileArtwork resolves a public Feral File artwork id into token
17
17
  * token coordinates using the self-describing artwork API response.
18
18
  *
19
19
  * @param artworkId - Public artwork id from Feral File.
20
20
  * @param fetchImpl - Fetch implementation, injected by tests.
21
- * @returns Token coordinates suitable for ff-indexer-v2 lookup.
21
+ * @returns Token coordinates for the configured token source.
22
22
  */
23
23
  async function resolveFeralFileArtwork(artworkId, fetchImpl = http_1.defaultDeadlineFetch) {
24
24
  const normalizedArtworkId = artworkId.trim();