@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
@@ -1,6 +1,24 @@
1
1
  "use strict";
2
2
  /**
3
3
  * SSH access control for FF1 devices.
4
+ *
5
+ * The sshAccess command is LAN-only: it is POSTed straight to the device's
6
+ * `/api/cast` endpoint and never falls back to the relayer.
7
+ *
8
+ * Topic proof (feral-file#3551 phase 0): on a claimed FF1, newer FF OS rejects
9
+ * a LAN `sshAccess` (and `connect`) cast unless it carries the relayer topic in
10
+ * the `X-FF1-Topic` header, answering HTTP 401 `{"error":{"code":"topic_required"}}`.
11
+ * The topic is the secret that `ff-cli device pair` stores in the OS keyring;
12
+ * holding it proves the CLI was paired by the owner through the mobile app.
13
+ * Older firmware and unclaimed devices ignore the header, so the request is
14
+ * still sent without it when no topic is stored. The topic value must never be
15
+ * logged or placed in an error message.
16
+ *
17
+ * Secure control (feral-file#3551 M6): when ff-cli holds a controller file for
18
+ * the device, `sshAccess` is sent as an ff1-control-v1 envelope instead, still
19
+ * LAN-only. `sshAccess` is owner tier, so a controller-role grant gets
20
+ * `permission_denied`, mapped to "needs an owner grant". No topic header is
21
+ * sent on that path: the signed envelope is the proof.
4
22
  */
5
23
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
24
  if (k2 === undefined) k2 = k;
@@ -36,25 +54,28 @@ var __importStar = (this && this.__importStar) || (function () {
36
54
  };
37
55
  })();
38
56
  Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.FF1_TOPIC_HEADER = void 0;
39
58
  exports.sendSshAccessCommand = sendSshAccessCommand;
40
59
  const http_1 = require("./http");
41
60
  const logger = __importStar(require("../logger"));
42
61
  const ff1_compatibility_1 = require("./ff1-compatibility");
62
+ const topic_store_1 = require("./topic-store");
63
+ const secure_transport_1 = require("./control/secure-transport");
64
+ /** FF1_TOPIC_HEADER is the LAN cast header that carries the relayer topic proof. */
65
+ exports.FF1_TOPIC_HEADER = 'X-FF1-Topic';
66
+ /** TOPIC_REQUIRED_CODE is the device error code for a missing or wrong topic proof. */
67
+ const TOPIC_REQUIRED_CODE = 'topic_required';
43
68
  /**
44
- * Send an SSH access command to an FF1 device.
69
+ * sendSshAccessCommand sends an SSH access command to an FF1 device over the LAN.
45
70
  *
46
71
  * @param {Object} params - Function parameters
47
72
  * @param {boolean} params.enabled - Whether to enable SSH access
48
73
  * @param {string} [params.deviceName] - Device name to target (defaults to first configured)
49
74
  * @param {string} [params.publicKey] - SSH public key to authorize (required for enable)
50
75
  * @param {number} [params.ttlSeconds] - Time-to-live in seconds for auto-disable
51
- * @returns {Promise<Object>} Result object
52
- * @returns {boolean} returns.success - Whether the command succeeded
53
- * @returns {string} [returns.device] - Device host used
54
- * @returns {string} [returns.deviceName] - Device name used
55
- * @returns {Object} [returns.response] - Response from device
56
- * @returns {string} [returns.error] - Error message if failed
57
- * @throws {Error} When device configuration is invalid or missing
76
+ * @param {SshAccessDependencies} [overrides] - Optional fetch, topic-store, and config seams
77
+ * @returns {Promise<SshAccessResult>} Result object; never throws. A device that
78
+ * demands topic proof yields `success: false` with an actionable pairing error.
58
79
  * @example
59
80
  * // Enable SSH for 30 minutes
60
81
  * const result = await sendSshAccessCommand({
@@ -63,15 +84,15 @@ const ff1_compatibility_1 = require("./ff1-compatibility");
63
84
  * ttlSeconds: 1800,
64
85
  * });
65
86
  */
66
- async function sendSshAccessCommand({ enabled, deviceName, publicKey, ttlSeconds, }) {
87
+ async function sendSshAccessCommand({ enabled, deviceName, publicKey, ttlSeconds }, overrides = {}) {
88
+ const fetchFn = overrides.fetchFn ?? ((input, init) => (0, http_1.fetchWithTimeout)(input, init ?? {}));
67
89
  try {
68
90
  if (enabled && (!publicKey || !publicKey.trim())) {
69
- return {
70
- success: false,
71
- error: 'Public key is required to enable SSH access',
72
- };
91
+ return { success: false, error: 'Public key is required to enable SSH access' };
73
92
  }
74
- const resolved = (0, ff1_compatibility_1.resolveConfiguredDevice)(deviceName);
93
+ const resolved = (0, ff1_compatibility_1.resolveConfiguredDevice)(deviceName, {
94
+ getFF1DeviceConfigFn: overrides.getFF1DeviceConfigFn,
95
+ });
75
96
  if (!resolved.success || !resolved.device) {
76
97
  return {
77
98
  success: false,
@@ -79,64 +100,177 @@ async function sendSshAccessCommand({ enabled, deviceName, publicKey, ttlSeconds
79
100
  };
80
101
  }
81
102
  const device = resolved.device;
82
- const compatibility = await (0, ff1_compatibility_1.assertFF1CommandCompatibility)(device, 'sshAccess');
103
+ const secure = (0, secure_transport_1.findSecureController)(device, overrides.controllerStore);
104
+ if (secure.failure) {
105
+ return { success: false, error: secure.failure.error, details: secure.failure.details };
106
+ }
107
+ const secureDeps = {
108
+ fetchFn: overrides.fetchFn,
109
+ getTopicIdFn: overrides.getTopicIdFn,
110
+ controllerStore: overrides.controllerStore,
111
+ nowFn: overrides.nowFn,
112
+ };
113
+ const compatibility = await (0, ff1_compatibility_1.assertFF1CommandCompatibility)(device, 'sshAccess', {
114
+ fetchFn: overrides.fetchFn,
115
+ controller: secure.record ? { record: secure.record, deps: secureDeps } : undefined,
116
+ });
83
117
  if (!compatibility.compatible) {
84
118
  return {
85
119
  success: false,
86
120
  error: compatibility.error || 'FF1 OS does not support SSH access command',
87
- details: compatibility.version ? `Detected version ${compatibility.version}` : undefined,
121
+ details: compatibility.details ??
122
+ (compatibility.version ? `Detected version ${compatibility.version}` : undefined),
88
123
  };
89
124
  }
90
- const apiUrl = `${device.host}/api/cast`;
91
- const request = {
92
- enabled,
93
- };
94
- if (publicKey && publicKey.trim()) {
95
- request.publicKey = publicKey.trim();
96
- }
97
- if (typeof ttlSeconds === 'number') {
98
- request.ttlSeconds = ttlSeconds;
99
- }
100
- const requestBody = {
101
- command: 'sshAccess',
102
- request,
103
- };
104
- const headers = {
105
- 'Content-Type': 'application/json',
106
- };
107
- if (device.apiKey) {
108
- headers['API-KEY'] = device.apiKey;
125
+ const body = buildSshAccessBody(enabled, publicKey, ttlSeconds);
126
+ if (secure.record) {
127
+ return await sendSshAccessSecurely(device, secure.record, body.request, secureDeps);
109
128
  }
110
- const response = await (0, http_1.fetchWithTimeout)(apiUrl, {
129
+ const topicId = lookupTopicId(device, overrides.getTopicIdFn ?? topic_store_1.getTopicId);
130
+ const response = await fetchFn(`${device.host}/api/cast`, {
111
131
  method: 'POST',
112
- headers,
113
- body: JSON.stringify(requestBody),
132
+ headers: buildSshAccessHeaders(device, topicId),
133
+ body: JSON.stringify(body),
114
134
  });
115
- if (!response.ok) {
116
- const errorText = await response.text();
117
- logger.error(`SSH access request failed: ${response.status} ${response.statusText}`);
118
- logger.debug(`Error details: ${errorText}`);
119
- return {
120
- success: false,
121
- error: `Device returned error ${response.status}: ${response.statusText}`,
122
- details: errorText,
123
- };
124
- }
125
- const responseData = (await response.json());
126
- logger.info('SSH access command succeeded');
127
- logger.debug(`Device response: ${JSON.stringify(responseData)}`);
128
- return {
129
- success: true,
130
- device: device.host,
131
- deviceName: device.name || device.host,
132
- response: responseData,
133
- };
135
+ return await interpretSshAccessResponse(device, response);
134
136
  }
135
137
  catch (error) {
136
138
  logger.error(`Error sending SSH access command: ${error.message}`);
139
+ return { success: false, error: error.message };
140
+ }
141
+ }
142
+ /**
143
+ * sendSshAccessSecurely sends `sshAccess` as a secure envelope over the LAN
144
+ * (no retry and no relayer fallback, matching the plaintext path).
145
+ *
146
+ * @param device - Device with a controller record
147
+ * @param record - Enrolled controller record
148
+ * @param request - The unchanged sshAccess request object
149
+ * @param deps - Transport seams
150
+ * @returns SSH access result
151
+ */
152
+ async function sendSshAccessSecurely(device, record, request, deps) {
153
+ const result = await (0, secure_transport_1.runSecureCommand)({
154
+ device: device,
155
+ record,
156
+ cmd: 'sshAccess',
157
+ req: request,
158
+ relayerFallback: false,
159
+ retry: false,
160
+ }, deps);
161
+ if (!result.success) {
162
+ logger.error(`SSH access request failed: ${result.error}`);
163
+ return { success: false, error: result.error, details: result.details };
164
+ }
165
+ logger.info('SSH access command succeeded');
166
+ return {
167
+ success: true,
168
+ device: device.host,
169
+ deviceName: device.name || device.host,
170
+ response: result.response,
171
+ };
172
+ }
173
+ /**
174
+ * lookupTopicId reads the stored relayer topic for a device, or null.
175
+ *
176
+ * A missing device ID, a missing topic, or an unavailable keyring all degrade
177
+ * to "send without the header": unclaimed devices and older firmware accept
178
+ * that, and a device that does require proof answers 401 topic_required, which
179
+ * is mapped to the pairing guidance. Failing hard here would break SSH on every
180
+ * device the header is irrelevant to.
181
+ */
182
+ function lookupTopicId(device, getTopicIdFn) {
183
+ if (!device.id) {
184
+ return null;
185
+ }
186
+ try {
187
+ return getTopicIdFn(device.id);
188
+ }
189
+ catch (error) {
190
+ logger.debug(`Secure topic storage unavailable: ${error.message}`);
191
+ return null;
192
+ }
193
+ }
194
+ /** buildSshAccessHeaders returns the cast headers, adding topic proof only when known. */
195
+ function buildSshAccessHeaders(device, topicId) {
196
+ const headers = { 'Content-Type': 'application/json' };
197
+ if (device.apiKey) {
198
+ headers['API-KEY'] = device.apiKey;
199
+ }
200
+ if (topicId) {
201
+ headers[exports.FF1_TOPIC_HEADER] = topicId;
202
+ }
203
+ return headers;
204
+ }
205
+ /** buildSshAccessBody returns the `sshAccess` cast body. */
206
+ function buildSshAccessBody(enabled, publicKey, ttlSeconds) {
207
+ const request = { enabled };
208
+ if (publicKey && publicKey.trim()) {
209
+ request.publicKey = publicKey.trim();
210
+ }
211
+ if (typeof ttlSeconds === 'number') {
212
+ request.ttlSeconds = ttlSeconds;
213
+ }
214
+ return { command: 'sshAccess', request };
215
+ }
216
+ /** interpretSshAccessResponse maps the device HTTP response onto an SshAccessResult. */
217
+ async function interpretSshAccessResponse(device, response) {
218
+ if (!response.ok) {
219
+ const errorText = await response.text();
220
+ logger.error(`SSH access request failed: ${response.status} ${response.statusText}`);
221
+ logger.debug(`Error details: ${errorText}`);
222
+ if (isTopicRequired(response.status, errorText)) {
223
+ return { success: false, error: topicRequiredMessage(device), details: errorText };
224
+ }
137
225
  return {
138
226
  success: false,
139
- error: error.message,
227
+ error: `Device returned error ${response.status}: ${response.statusText}`,
228
+ details: errorText,
140
229
  };
141
230
  }
231
+ const responseData = (await response.json());
232
+ logger.info('SSH access command succeeded');
233
+ logger.debug(`Device response: ${JSON.stringify(responseData)}`);
234
+ return {
235
+ success: true,
236
+ device: device.host,
237
+ deviceName: device.name || device.host,
238
+ response: responseData,
239
+ };
240
+ }
241
+ /** isTopicRequired reports whether a device rejection is the topic-proof 401. */
242
+ function isTopicRequired(status, body) {
243
+ if (status !== 401) {
244
+ return false;
245
+ }
246
+ try {
247
+ const parsed = JSON.parse(body);
248
+ return parsed?.error?.code === TOPIC_REQUIRED_CODE;
249
+ }
250
+ catch {
251
+ return false;
252
+ }
253
+ }
254
+ /**
255
+ * topicRequiredMessage tells the user how to pair the CLI for this device.
256
+ *
257
+ * The same guidance covers "never paired" and "stored topic is stale" (the
258
+ * device rotated its topic): pairing again replaces the keyring entry. A device
259
+ * without a stable ID cannot be paired, so it is told to re-add first.
260
+ */
261
+ function topicRequiredMessage(device) {
262
+ const label = device.name || device.host;
263
+ const pairCommand = `ff-cli device pair ${quoteArg(label)}`;
264
+ const base = `This FF1 requires ff-cli to be paired before SSH access can be changed.`;
265
+ // Securing first makes the pairing an owner grant, which keeps working once
266
+ // the FF1 no longer has a relayer topic (feral-file#3551 relay v2).
267
+ const secure = `In the Feral File app, choose "Secure this FF1" for this device if it is offered`;
268
+ if (!device.id) {
269
+ return `${base} Re-add the device with its ID (ff-cli device add --id <FF1 device ID>). ${secure}, then run \`${pairCommand}\` and choose "Pair with ff-cli".`;
270
+ }
271
+ return `${base} ${secure}, then run \`${pairCommand}\` and choose "Pair with ff-cli", then retry.`;
272
+ }
273
+ /** quoteArg double-quotes a shell argument that contains whitespace or quotes. */
274
+ function quoteArg(value) {
275
+ return /[\s"'$`\\]/.test(value) ? JSON.stringify(value) : value;
142
276
  }