@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.
- package/README.md +31 -23
- package/config.json.example +6 -1
- package/dist/index.js +10 -2
- package/dist/package.json +5 -2
- package/dist/src/commands/build.js +40 -0
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/device.js +85 -40
- package/dist/src/commands/enrich.js +18 -23
- package/dist/src/commands/find.js +144 -106
- package/dist/src/commands/helpers/device-control.js +937 -0
- package/dist/src/commands/helpers/hidden-alias-help.js +37 -0
- package/dist/src/commands/helpers/playlist-display.js +2 -15
- package/dist/src/commands/helpers/prompt.js +85 -10
- package/dist/src/commands/play.js +162 -84
- package/dist/src/commands/report.js +251 -0
- package/dist/src/commands/setup.js +82 -7
- package/dist/src/commands/status.js +4 -1
- package/dist/src/config.js +256 -6
- package/dist/src/main.js +14 -5
- package/dist/src/utilities/control/codec.js +578 -0
- package/dist/src/utilities/control/control-errors.js +157 -0
- package/dist/src/utilities/control/controller-store.js +964 -0
- package/dist/src/utilities/control/jwk.js +141 -0
- package/dist/src/utilities/control/pairing-channel.js +52 -0
- package/dist/src/utilities/control/pairing.js +416 -0
- package/dist/src/utilities/control/registration.js +391 -0
- package/dist/src/utilities/control/relay-pairing-channel.js +191 -0
- package/dist/src/utilities/control/secure-transport.js +850 -0
- package/dist/src/utilities/control/strict-json.js +376 -0
- package/dist/src/utilities/device-lookup.js +36 -11
- package/dist/src/utilities/device-network.js +129 -0
- package/dist/src/utilities/device-upsert.js +1 -1
- package/dist/src/utilities/feral-file-artwork.js +2 -2
- package/dist/src/utilities/ff1-compatibility.js +87 -11
- package/dist/src/utilities/ff1-device.js +161 -78
- package/dist/src/utilities/ff1-relayer.js +34 -14
- package/dist/src/utilities/handoff-client.js +23 -9
- package/dist/src/utilities/index.js +270 -259
- package/dist/src/utilities/media-url.js +117 -0
- package/dist/src/utilities/neort-marketplace.js +3 -3
- package/dist/src/utilities/nft-indexer.js +12 -376
- package/dist/src/utilities/playlist-builder.js +11 -2
- package/dist/src/utilities/playlist-cast.js +6 -6
- package/dist/src/utilities/playlist-source.js +223 -34
- package/dist/src/utilities/raster-client.js +101 -14
- package/dist/src/utilities/relay-client.js +774 -0
- package/dist/src/utilities/ssh-access.js +192 -58
- package/dist/src/utilities/support-report.js +511 -0
- package/dist/src/utilities/temporary-channel.js +139 -0
- package/dist/src/utilities/token-item.js +428 -0
- package/dist/src/utilities/token-source-contract.js +55 -0
- package/dist/src/utilities/token-source-indexer.js +99 -0
- package/dist/src/utilities/token-source-raster.js +378 -0
- package/dist/src/utilities/token-source.js +48 -0
- package/docs/CONFIGURATION.md +331 -28
- package/docs/EXAMPLES.md +33 -33
- package/docs/PROJECT_SPEC.md +55 -36
- package/docs/README.md +145 -43
- package/package.json +5 -2
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Command actions for FF1 controller pairing and management
|
|
4
|
+
* (`ff-cli device pair|controllers|revoke|unpair`, feral-file#3551).
|
|
5
|
+
*
|
|
6
|
+
* Copy rules: never print private key material or the relayer topic; print
|
|
7
|
+
* the controllerId (a public thumbprint) freely. The controller QR and its
|
|
8
|
+
* pasteable string carry the one-time pairing secret by design (§5.6): they
|
|
9
|
+
* are shown for the owner to scan and are useless once this run ends.
|
|
10
|
+
*/
|
|
11
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
14
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
15
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
16
|
+
}
|
|
17
|
+
Object.defineProperty(o, k2, desc);
|
|
18
|
+
}) : (function(o, m, k, k2) {
|
|
19
|
+
if (k2 === undefined) k2 = k;
|
|
20
|
+
o[k2] = m[k];
|
|
21
|
+
}));
|
|
22
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
23
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
24
|
+
}) : function(o, v) {
|
|
25
|
+
o["default"] = v;
|
|
26
|
+
});
|
|
27
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
28
|
+
var ownKeys = function(o) {
|
|
29
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
30
|
+
var ar = [];
|
|
31
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
32
|
+
return ar;
|
|
33
|
+
};
|
|
34
|
+
return ownKeys(o);
|
|
35
|
+
};
|
|
36
|
+
return function (mod) {
|
|
37
|
+
if (mod && mod.__esModule) return mod;
|
|
38
|
+
var result = {};
|
|
39
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
40
|
+
__setModuleDefault(result, mod);
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
})();
|
|
44
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
45
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
46
|
+
};
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.CommandFailure = exports.UNPAIR_REVOKE_TIMEOUT_MS = void 0;
|
|
49
|
+
exports.requirePairableDevice = requirePairableDevice;
|
|
50
|
+
exports.parseTimeoutMs = parseTimeoutMs;
|
|
51
|
+
exports.parseLabel = parseLabel;
|
|
52
|
+
exports.runPair = runPair;
|
|
53
|
+
exports.pairDevice = pairDevice;
|
|
54
|
+
exports.confirmCorruptReplacement = confirmCorruptReplacement;
|
|
55
|
+
exports.noteReplacement = noteReplacement;
|
|
56
|
+
exports.storeRelayerTopic = storeRelayerTopic;
|
|
57
|
+
exports.formatCode = formatCode;
|
|
58
|
+
exports.formatControllerTable = formatControllerTable;
|
|
59
|
+
exports.runControllers = runControllers;
|
|
60
|
+
exports.runRevoke = runRevoke;
|
|
61
|
+
exports.runUnpair = runUnpair;
|
|
62
|
+
const node_crypto_1 = require("node:crypto");
|
|
63
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
64
|
+
const qrcode_terminal_1 = __importDefault(require("qrcode-terminal"));
|
|
65
|
+
const logger = __importStar(require("../../logger"));
|
|
66
|
+
const ff1_compatibility_1 = require("../../utilities/ff1-compatibility");
|
|
67
|
+
const handoff_client_1 = require("../../utilities/handoff-client");
|
|
68
|
+
const http_1 = require("../../utilities/http");
|
|
69
|
+
const topic_store_1 = require("../../utilities/topic-store");
|
|
70
|
+
const prompt_1 = require("./prompt");
|
|
71
|
+
const codec_1 = require("../../utilities/control/codec");
|
|
72
|
+
const jwk_1 = require("../../utilities/control/jwk");
|
|
73
|
+
const controller_store_1 = require("../../utilities/control/controller-store");
|
|
74
|
+
const pairing_1 = require("../../utilities/control/pairing");
|
|
75
|
+
const pairing_channel_1 = require("../../utilities/control/pairing-channel");
|
|
76
|
+
const registration_1 = require("../../utilities/control/registration");
|
|
77
|
+
const secure_transport_1 = require("../../utilities/control/secure-transport");
|
|
78
|
+
const control_errors_1 = require("../../utilities/control/control-errors");
|
|
79
|
+
/**
|
|
80
|
+
* UNPAIR_REVOKE_TIMEOUT_MS bounds each request of unpair's best-effort
|
|
81
|
+
* self-revoke, so an unreachable FF1 delays deleting the keys only briefly.
|
|
82
|
+
*/
|
|
83
|
+
exports.UNPAIR_REVOKE_TIMEOUT_MS = 5_000;
|
|
84
|
+
/** CommandFailure is thrown by actions and printed by the command wrapper. */
|
|
85
|
+
class CommandFailure extends Error {
|
|
86
|
+
details;
|
|
87
|
+
constructor(message, details) {
|
|
88
|
+
super(message);
|
|
89
|
+
this.details = details;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
exports.CommandFailure = CommandFailure;
|
|
93
|
+
/**
|
|
94
|
+
* requirePairableDevice resolves a configured device that has a stable ID.
|
|
95
|
+
*
|
|
96
|
+
* @param name - Optional device name
|
|
97
|
+
* @returns The device
|
|
98
|
+
* @throws {CommandFailure} When the device is missing or has no ID
|
|
99
|
+
*/
|
|
100
|
+
function requirePairableDevice(name) {
|
|
101
|
+
const selected = (0, ff1_compatibility_1.resolveConfiguredDevice)(name);
|
|
102
|
+
if (!selected.success || !selected.device) {
|
|
103
|
+
throw new CommandFailure(selected.error || 'FF1 device is not configured correctly');
|
|
104
|
+
}
|
|
105
|
+
const device = selected.device;
|
|
106
|
+
if (!device.id) {
|
|
107
|
+
throw new CommandFailure(`Device "${device.name || device.host}" has no stable device ID.`, 'Re-add it with `ff-cli device add` (discovery records the ID), or with `--host` and `--id FF1-XXXXXXXX` (the app shows it under Device Information).');
|
|
108
|
+
}
|
|
109
|
+
return device;
|
|
110
|
+
}
|
|
111
|
+
function deviceLabel(device) {
|
|
112
|
+
return device.name || device.id || device.host;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* parseTimeoutMs validates `--timeout <seconds>` (10 s to 10 minutes).
|
|
116
|
+
*
|
|
117
|
+
* @param value - Flag value
|
|
118
|
+
* @returns Milliseconds
|
|
119
|
+
* @throws {CommandFailure} When out of range
|
|
120
|
+
*/
|
|
121
|
+
function parseTimeoutMs(value) {
|
|
122
|
+
if (value === undefined) {
|
|
123
|
+
return pairing_1.DEFAULT_PAIR_TIMEOUT_MS;
|
|
124
|
+
}
|
|
125
|
+
const seconds = Number(value);
|
|
126
|
+
if (!Number.isInteger(seconds) || seconds < 10 || seconds * 1000 > pairing_1.MAX_PAIR_TIMEOUT_MS) {
|
|
127
|
+
throw new CommandFailure(`--timeout must be a whole number of seconds between 10 and ${pairing_1.MAX_PAIR_TIMEOUT_MS / 1000}`, 'If an owner needs longer, run the pairing again to show a new code.');
|
|
128
|
+
}
|
|
129
|
+
return seconds * 1000;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* parseLabel validates `--label`: 1-64 characters, no control or invisible
|
|
133
|
+
* formatting characters (the FF1 answers such a label with invalid_request).
|
|
134
|
+
*
|
|
135
|
+
* @param value - Flag value
|
|
136
|
+
* @returns The label
|
|
137
|
+
* @throws {CommandFailure} When empty or too long
|
|
138
|
+
*/
|
|
139
|
+
function parseLabel(value) {
|
|
140
|
+
if (value === undefined) {
|
|
141
|
+
return (0, pairing_1.defaultControllerLabel)();
|
|
142
|
+
}
|
|
143
|
+
const label = value.trim();
|
|
144
|
+
const problem = (0, codec_1.labelProblem)(label);
|
|
145
|
+
if (problem) {
|
|
146
|
+
throw new CommandFailure(`--label ${problem}.`, 'Use plain visible text, e.g. --label "studio-mac".');
|
|
147
|
+
}
|
|
148
|
+
return label;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* runPair is `ff-cli device pair [name]`: show the controller QR, wait for an
|
|
152
|
+
* owner to add ff-cli in the Feral File app, then enroll (§5.6), or finish an
|
|
153
|
+
* interrupted pairing with --resume.
|
|
154
|
+
*
|
|
155
|
+
* @param name - Optional device name
|
|
156
|
+
* @param options - Flags
|
|
157
|
+
* @param deps - Seams (tests)
|
|
158
|
+
*/
|
|
159
|
+
async function runPair(name, options, deps = {}) {
|
|
160
|
+
const device = requirePairableDevice(name);
|
|
161
|
+
const timeoutMs = parseTimeoutMs(options.timeout);
|
|
162
|
+
const label = parseLabel(options.label);
|
|
163
|
+
await pairDevice(device, {
|
|
164
|
+
label,
|
|
165
|
+
timeoutMs,
|
|
166
|
+
resume: options.resume,
|
|
167
|
+
yes: options.yes,
|
|
168
|
+
topicHandoff: options.topicHandoff,
|
|
169
|
+
}, deps);
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* pairDevice is runPair after flag parsing. It picks the pairing path from
|
|
173
|
+
* the FF1's LAN status: an FF1 without the control protocol (no `control`
|
|
174
|
+
* block), or with it but no owner yet (`legacy`), accepts plaintext and gets
|
|
175
|
+
* the legacy short-code topic handoff; a secured one gets owner-initiated
|
|
176
|
+
* registration. An unreadable status falls back to registration, and
|
|
177
|
+
* --topic-handoff forces the legacy path.
|
|
178
|
+
*
|
|
179
|
+
* @param device - Device with ID
|
|
180
|
+
* @param options - Parsed label and wait, and the resume, yes and topicHandoff flags
|
|
181
|
+
* @param deps - Seams (tests)
|
|
182
|
+
*/
|
|
183
|
+
async function pairDevice(device, options, deps = {}) {
|
|
184
|
+
if (options.resume) {
|
|
185
|
+
await resumePairing(device, deps);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (options.topicHandoff) {
|
|
189
|
+
await pairByTopicHandoff(device, options.timeoutMs, deps);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// The status is unauthenticated, so it may never downgrade a device this
|
|
193
|
+
// computer holds (or may hold) a grant for: any controller record or saved
|
|
194
|
+
// pairing keys means the QR flow, whatever the LAN answers.
|
|
195
|
+
const held = heldControlState(device.id, deps.controllerStore ?? {});
|
|
196
|
+
if (held) {
|
|
197
|
+
// Leftover pairing keys are often an earlier pairing that was stopped, not a
|
|
198
|
+
// grant: on an FF1 without secure control that QR can never complete, and
|
|
199
|
+
// nothing told the user how out. Read the status only to say so.
|
|
200
|
+
if (held === 'pairing keys' &&
|
|
201
|
+
(await (0, pairing_1.probeControlSupport)(device, deps.fetchFn)) === 'plaintext') {
|
|
202
|
+
console.log(chalk_1.default.yellow(`\nAn earlier pairing with ${deviceLabel(device)} left its keys here, so ff-cli shows the secure pairing code again. ` +
|
|
203
|
+
`The FF1’s LAN status, which is not authenticated, shows no secure control: if that is right, run \`${(0, control_errors_1.pairCommand)(deviceLabel(device))} --topic-handoff\` instead.`));
|
|
204
|
+
}
|
|
205
|
+
await pairByRegistration(device, options, deps);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const support = await (0, pairing_1.probeControlSupport)(device, deps.fetchFn);
|
|
209
|
+
if (support !== 'unknown') {
|
|
210
|
+
console.log(chalk_1.default.dim(`\nChose ${support === 'plaintext' ? 'the one-time code (no secure control)' : 'secure pairing'} from the FF1’s LAN status, which is not authenticated. ` +
|
|
211
|
+
`\`${(0, control_errors_1.pairCommand)(deviceLabel(device))} --topic-handoff\` forces the one-time code.`));
|
|
212
|
+
}
|
|
213
|
+
if (support === 'plaintext') {
|
|
214
|
+
await pairByTopicHandoff(device, options.timeoutMs, deps);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (support === 'unknown') {
|
|
218
|
+
console.log(chalk_1.default.yellow(`\nCould not read the status of ${deviceLabel(device)} over the LAN, so ff-cli shows the secure pairing code. ` +
|
|
219
|
+
`If this FF1 runs FF OS without secure control, run \`${(0, control_errors_1.pairCommand)(deviceLabel(device))} --topic-handoff\` instead.`));
|
|
220
|
+
}
|
|
221
|
+
await pairByRegistration(device, options, deps);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* heldControlState reports what this computer holds for the device: a
|
|
225
|
+
* controller record (main or staged, even corrupt), else saved pairing keys,
|
|
226
|
+
* else nothing. An unreadable file throws: its keys may be fine.
|
|
227
|
+
*/
|
|
228
|
+
function heldControlState(deviceId, store) {
|
|
229
|
+
if (loadUnlessCorrupt(deviceId, store) !== null ||
|
|
230
|
+
loadUnlessCorrupt(deviceId, (0, controller_store_1.stagingStore)(store)) !== null) {
|
|
231
|
+
return 'record';
|
|
232
|
+
}
|
|
233
|
+
return (0, controller_store_1.hasPairingKeys)(deviceId, store) ? 'pairing keys' : null;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* pairByRegistration is owner-initiated registration (§5.6): show the
|
|
237
|
+
* controller QR, wait for an owner's sealed result, enroll.
|
|
238
|
+
*/
|
|
239
|
+
async function pairByRegistration(device, options, deps) {
|
|
240
|
+
const store = deps.controllerStore ?? {};
|
|
241
|
+
// A staged replacement that already enrolled is this computer's confirmed
|
|
242
|
+
// grant; a new pairing would overwrite or drop it. --resume promotes it.
|
|
243
|
+
(0, controller_store_1.dropStagedController)(device.id, true, store);
|
|
244
|
+
// An unreadable controller file fails here, before the QR is shown.
|
|
245
|
+
const existing = loadUnlessCorrupt(device.id, store);
|
|
246
|
+
if (enrolledStagedPairing(device.id, store)) {
|
|
247
|
+
throw new CommandFailure(`An earlier pairing with ${deviceLabel(device)} was approved but not finished.`, `Run \`${(0, control_errors_1.pairCommand)(deviceLabel(device))} --resume\` to finish it.`);
|
|
248
|
+
}
|
|
249
|
+
const confirmFn = deps.confirmFn ?? confirmInteractively;
|
|
250
|
+
if (existing === 'corrupt') {
|
|
251
|
+
await confirmCorruptReplacement(device, options, confirmFn);
|
|
252
|
+
}
|
|
253
|
+
// The keys are chosen (and new pairing keys saved) only once the channel
|
|
254
|
+
// is open, so a run that cannot reach the relay leaves no key file behind.
|
|
255
|
+
let chosen;
|
|
256
|
+
const prepare = (channelId) => {
|
|
257
|
+
chosen = (0, pairing_1.chooseControllerKeys)(device.id, store);
|
|
258
|
+
return {
|
|
259
|
+
material: (0, registration_1.generatePairingMaterial)(chosen.keys, options.label, channelId),
|
|
260
|
+
keysNote: chosen.fromRecord
|
|
261
|
+
? 'this computer’s existing keys'
|
|
262
|
+
: chosen.reused
|
|
263
|
+
? 'the same keys as the last attempt'
|
|
264
|
+
: '',
|
|
265
|
+
};
|
|
266
|
+
};
|
|
267
|
+
const nowFn = deps.nowFn ?? (() => Math.floor(Date.now() / 1000));
|
|
268
|
+
const outcome = await withInterrupt((signal) => waitForOwner(device, prepare, options.timeoutMs, signal, deps));
|
|
269
|
+
if (outcome.status === 'interrupted') {
|
|
270
|
+
console.log(chalk_1.default.yellow(`\nPairing interrupted. Run \`${(0, control_errors_1.pairCommand)(deviceLabel(device))}\` to show a new code; it keeps the same controller keys.`));
|
|
271
|
+
process.exitCode = 130;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (outcome.status !== 'result') {
|
|
275
|
+
throw new CommandFailure(`Pairing was not completed: no owner answered within ${Math.round(options.timeoutMs / 1000)}s.`, `Run \`${(0, control_errors_1.pairCommand)(deviceLabel(device))}\` to show a new code. It keeps the same controller keys, so an owner who already added ff-cli only needs to scan the new code.`);
|
|
276
|
+
}
|
|
277
|
+
const result = outcome.result;
|
|
278
|
+
if (result.outcome === 'rejected') {
|
|
279
|
+
throw new CommandFailure(`An owner declined to add ff-cli to ${deviceLabel(device)}.`, `To try again, run \`${(0, control_errors_1.pairCommand)(deviceLabel(device))}\`.`);
|
|
280
|
+
}
|
|
281
|
+
const profile = result.profile;
|
|
282
|
+
const { keys, fromRecord } = chosen;
|
|
283
|
+
noteReplacement(device, profile, store);
|
|
284
|
+
// A topicId stays in the record until the enroll reply verifies under this
|
|
285
|
+
// result's profile (finishEnrollment): it arrived with the same unverified
|
|
286
|
+
// result, so until then it neither replaces the keyring topic nor routes
|
|
287
|
+
// the enroll (whose relayer fallback only sees a topic stored earlier).
|
|
288
|
+
const { record, staged } = (0, pairing_1.prepareRegisteredController)({ profile, keys, label: options.label, registration: (0, pairing_1.registrationRecordOf)(result, nowFn()) }, store);
|
|
289
|
+
if (!fromRecord) {
|
|
290
|
+
(0, controller_store_1.deletePairingKeys)(device.id, store); // the keys now live in the record
|
|
291
|
+
}
|
|
292
|
+
console.log(chalk_1.default.green(`\n✓ An owner added ff-cli to ${result.deviceName} as ${chalk_1.default.bold(result.role)}. Finishing the pairing...`));
|
|
293
|
+
await finishEnrollment(device, record, staged, deps);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* pairByTopicHandoff is the legacy short-code handoff for an FF1 without
|
|
297
|
+
* secure control: the owner's app sends the relayer topic end to end
|
|
298
|
+
* encrypted, and ff-cli stores it in the OS keyring. No controller keys are
|
|
299
|
+
* created; such an FF1 accepts plaintext commands.
|
|
300
|
+
*/
|
|
301
|
+
async function pairByTopicHandoff(device, timeoutMs, deps) {
|
|
302
|
+
const payload = await (deps.receiveTopicHandoff ?? receiveTopicHandoff)(device, timeoutMs);
|
|
303
|
+
(deps.storeTopicFn ?? topic_store_1.storeTopicId)(device.id, payload.topicId);
|
|
304
|
+
console.log(chalk_1.default.green('\n✓ Relayer access stored in the operating system secure storage.\n'));
|
|
305
|
+
}
|
|
306
|
+
/** receiveTopicHandoff runs the broker handoff and always closes the channel. */
|
|
307
|
+
async function receiveTopicHandoff(device, timeoutMs) {
|
|
308
|
+
let receiver;
|
|
309
|
+
try {
|
|
310
|
+
receiver = await handoff_client_1.HandoffReceiver.create();
|
|
311
|
+
console.log(chalk_1.default.blue('\nPair ff-cli with Feral File mobile\n'));
|
|
312
|
+
console.log(` Device: ${chalk_1.default.bold(device.name || device.id)}`);
|
|
313
|
+
console.log(` Device ID: ${chalk_1.default.dim(device.id)}`);
|
|
314
|
+
console.log("\nIn the mobile app, open this Art Computer's options and choose");
|
|
315
|
+
console.log(chalk_1.default.bold('“Pair with ff-cli”'), 'then enter:');
|
|
316
|
+
console.log(`\n ${chalk_1.default.bold(receiver.shortCode)}\n`);
|
|
317
|
+
console.log('Before sharing, confirm the mobile security check matches:');
|
|
318
|
+
console.log(`\n ${chalk_1.default.bold(receiver.verificationCode)}\n`);
|
|
319
|
+
console.log(chalk_1.default.dim(`This one-time code expires at ${receiver.expiresAt}. Waiting...`));
|
|
320
|
+
return await receiver.receiveHandoff({ expectedDeviceId: device.id, maxWaitMs: timeoutMs });
|
|
321
|
+
}
|
|
322
|
+
finally {
|
|
323
|
+
if (receiver) {
|
|
324
|
+
try {
|
|
325
|
+
await receiver.close();
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// The channel is short-lived and expires automatically. A close
|
|
329
|
+
// failure must not erase or misreport an already received handoff.
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* resumePairing is `device pair --resume`: enroll the record that holds an
|
|
336
|
+
* owner's verified result (no pairing channel is needed), then promote it
|
|
337
|
+
* when it replaces an existing record.
|
|
338
|
+
*/
|
|
339
|
+
async function resumePairing(device, deps) {
|
|
340
|
+
const store = deps.controllerStore ?? {};
|
|
341
|
+
// A staged pairing (one that will replace an existing record) wins: it is
|
|
342
|
+
// the one the last run was finishing. One whose keys the main file already
|
|
343
|
+
// holds was promoted by a run that stopped before removing it; its counter
|
|
344
|
+
// is stale, so the main file is used.
|
|
345
|
+
(0, controller_store_1.dropStagedController)(device.id, true, store);
|
|
346
|
+
const staged = (0, controller_store_1.loadController)(device.id, (0, controller_store_1.stagingStore)(store));
|
|
347
|
+
const record = staged ?? (0, controller_store_1.loadController)(device.id, store);
|
|
348
|
+
if (!record) {
|
|
349
|
+
throw new CommandFailure(`There is no pairing to resume for ${deviceLabel(device)}.`, `Run \`${(0, control_errors_1.pairCommand)(deviceLabel(device))}\` to start one.`);
|
|
350
|
+
}
|
|
351
|
+
await finishEnrollment(device, record, staged !== null, deps);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* waitForOwner joins the pairing channel, shows the controller QR and waits
|
|
355
|
+
* for a sealed result that passes every §5.6.3 check. Messages that do not
|
|
356
|
+
* open or fail a check are ignored: anyone who saw the QR can write to the
|
|
357
|
+
* channel, and only a verified result ends the wait. The channel is always
|
|
358
|
+
* closed.
|
|
359
|
+
*/
|
|
360
|
+
async function waitForOwner(device, prepare, timeoutMs, interrupt, deps) {
|
|
361
|
+
const timeout = new AbortController();
|
|
362
|
+
const timer = setTimeout(() => timeout.abort(), timeoutMs);
|
|
363
|
+
const signal = AbortSignal.any([interrupt, timeout.signal]);
|
|
364
|
+
const stopped = () => interrupt.aborted ? { status: 'interrupted' } : { status: 'timeout' };
|
|
365
|
+
let channel;
|
|
366
|
+
const channelId = (deps.newChannelId ?? node_crypto_1.randomUUID)();
|
|
367
|
+
// §5.6.1: the relay accepts 1-128 characters, the QR only 16-64 of
|
|
368
|
+
// [A-Za-z0-9_-]; a UUID fits, a future generator must too.
|
|
369
|
+
if (!(0, registration_1.isValidChannelId)(channelId)) {
|
|
370
|
+
throw new CommandFailure('ff-cli generated an invalid pairing channel ID; this is an ff-cli bug.', 'Please report it with `ff-cli --version`.');
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
try {
|
|
374
|
+
channel = await (deps.openChannel ?? (0, pairing_channel_1.pairingChannelOpener)())(channelId, signal);
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
if (signal.aborted) {
|
|
378
|
+
return stopped();
|
|
379
|
+
}
|
|
380
|
+
if (error instanceof pairing_channel_1.PairingTransportUnavailableError) {
|
|
381
|
+
throw new CommandFailure(`Pairing is not available: ${error.message}.`, 'Remove an empty ff1Relayer.v2Endpoint or FF1_RELAY_V2_ENDPOINT, or set an HTTPS or WSS relay v2 origin (see docs/CONFIGURATION.md, Relay v2).');
|
|
382
|
+
}
|
|
383
|
+
throw new CommandFailure(`Could not open the pairing channel: ${error.message}`, 'Pairing needs an internet connection to the Feral File relay. Check the connection and try again.');
|
|
384
|
+
}
|
|
385
|
+
const { material, keysNote } = prepare(channelId);
|
|
386
|
+
showPairingCode(device, material, timeoutMs, keysNote, deps.showQr);
|
|
387
|
+
const wantDev = (0, controller_store_1.normalizeDeviceId)(device.id);
|
|
388
|
+
const now = deps.nowFn ?? (() => Math.floor(Date.now() / 1000));
|
|
389
|
+
const messages = channel.messages()[Symbol.asyncIterator]();
|
|
390
|
+
for (;;) {
|
|
391
|
+
let next;
|
|
392
|
+
try {
|
|
393
|
+
next = await nextOrAbort(messages, signal);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
if (signal.aborted) {
|
|
397
|
+
return stopped();
|
|
398
|
+
}
|
|
399
|
+
throw new CommandFailure(`The pairing channel failed: ${error.message}`, `Run \`${(0, control_errors_1.pairCommand)(deviceLabel(device))}\` to show a new code.`);
|
|
400
|
+
}
|
|
401
|
+
if (next === null) {
|
|
402
|
+
return stopped();
|
|
403
|
+
}
|
|
404
|
+
if (next.done) {
|
|
405
|
+
throw new CommandFailure('The pairing channel closed before an owner answered.', `Run \`${(0, control_errors_1.pairCommand)(deviceLabel(device))}\` to show a new code.`);
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
return { status: 'result', result: (0, registration_1.openRegistration)(material, next.value, wantDev, now()) };
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
if (error instanceof registration_1.RegistrationForOtherDeviceError) {
|
|
412
|
+
// Only a holder of this QR's secret gets this far, normally the
|
|
413
|
+
// owner, so say so instead of waiting silently.
|
|
414
|
+
console.log(chalk_1.default.yellow(`An owner added ff-cli to a different FF1 (${JSON.stringify(error.dev)}), not ${deviceLabel(device)} (${wantDev}). Still waiting for an answer for ${deviceLabel(device)}...`));
|
|
415
|
+
}
|
|
416
|
+
logger.debug(`Ignored a pairing channel message: ${error.message}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
finally {
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
if (channel) {
|
|
423
|
+
try {
|
|
424
|
+
await channel.close();
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
// The relay keeps nothing; a close failure must not hide a result.
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/** nextOrAbort awaits the next channel message, or null once signal aborts. */
|
|
433
|
+
function nextOrAbort(iterator, signal) {
|
|
434
|
+
if (signal.aborted) {
|
|
435
|
+
return Promise.resolve(null);
|
|
436
|
+
}
|
|
437
|
+
return new Promise((resolve, reject) => {
|
|
438
|
+
const onAbort = () => resolve(null);
|
|
439
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
440
|
+
iterator.next().then((value) => {
|
|
441
|
+
signal.removeEventListener('abort', onAbort);
|
|
442
|
+
resolve(value);
|
|
443
|
+
}, (error) => {
|
|
444
|
+
signal.removeEventListener('abort', onAbort);
|
|
445
|
+
reject(error);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
/** renderQr renders a QR payload for a terminal (half-height blocks, level M). */
|
|
450
|
+
function renderQr(payload) {
|
|
451
|
+
let rendered = '';
|
|
452
|
+
qrcode_terminal_1.default.setErrorLevel('M');
|
|
453
|
+
qrcode_terminal_1.default.generate(payload, { small: true }, (qr) => {
|
|
454
|
+
rendered = qr;
|
|
455
|
+
});
|
|
456
|
+
return rendered;
|
|
457
|
+
}
|
|
458
|
+
/** showPairingCode prints what the owner scans or pastes, and what happens next. */
|
|
459
|
+
function showPairingCode(device, material, timeoutMs, keysNote, showQr = (payload) => console.log(renderQr(payload))) {
|
|
460
|
+
const payload = (0, registration_1.controllerQrString)(material.qr);
|
|
461
|
+
console.log(chalk_1.default.blue(`\nAdd ff-cli to ${deviceLabel(device)}\n`));
|
|
462
|
+
console.log('On an owner’s phone, open this FF1 in the Feral File app, choose “Add controller”, and scan this code:\n');
|
|
463
|
+
showQr(payload);
|
|
464
|
+
console.log('\nOr paste this text into the app:\n');
|
|
465
|
+
console.log(` ${payload}\n`);
|
|
466
|
+
console.log(chalk_1.default.dim(`Name the owner sees: ${material.qr.label}`));
|
|
467
|
+
console.log(chalk_1.default.dim(`Controller ID: ${(0, registration_1.controllerQrCtl)(material.qr)}${keysNote ? ` (${keysNote})` : ''}`));
|
|
468
|
+
console.log(chalk_1.default.dim(`Waiting up to ${Math.round(timeoutMs / 1000)}s for an owner. This needs an internet connection. Press Ctrl-C to stop.`));
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* finishEnrollment enrolls a record that holds a pinned profile, promotes a
|
|
472
|
+
* staged one, and reports the result.
|
|
473
|
+
*/
|
|
474
|
+
async function finishEnrollment(device, record, staged, deps) {
|
|
475
|
+
const mainStore = deps.controllerStore ?? {};
|
|
476
|
+
const pairDeps = staged ? { ...deps, controllerStore: (0, controller_store_1.stagingStore)(mainStore) } : deps;
|
|
477
|
+
const result = await withInterrupt(async (signal) => {
|
|
478
|
+
const enrolled = await (0, pairing_1.enrollRegistered)(device, record, pairDeps, {
|
|
479
|
+
cancelled: () => signal.aborted,
|
|
480
|
+
storeOptions: pairDeps.controllerStore,
|
|
481
|
+
retryMs: deps.enrollRetryMs,
|
|
482
|
+
});
|
|
483
|
+
if (enrolled.status === 'enrolled' && staged) {
|
|
484
|
+
return { status: 'enrolled', record: (0, controller_store_1.promoteStagedController)(device.id, mainStore) };
|
|
485
|
+
}
|
|
486
|
+
return enrolled;
|
|
487
|
+
});
|
|
488
|
+
if (result.status === 'enrolled') {
|
|
489
|
+
// Only now, with an enroll reply verified under the pinned profile, may
|
|
490
|
+
// the topic from the owner's result replace the keyring topic.
|
|
491
|
+
const topicId = result.record.registration?.topicId ?? record.registration?.topicId;
|
|
492
|
+
if (topicId) {
|
|
493
|
+
storeRelayerTopic(device.id, topicId, deps.storeTopicFn);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// Where the keys are now: a staged pairing moves into the main store only
|
|
497
|
+
// once its enroll reply verified, and stays staged otherwise (--resume).
|
|
498
|
+
const keysStore = staged && result.status !== 'enrolled' ? (0, controller_store_1.stagingStore)(mainStore) : mainStore;
|
|
499
|
+
console.log(chalk_1.default.dim(`Controller keys: ${(0, controller_store_1.controllerFilePath)(device.id, keysStore)}`));
|
|
500
|
+
reportEnrollment(device, result);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* loadUnlessCorrupt loads a controller file, mapping a corrupt one to the
|
|
504
|
+
* string 'corrupt'. An unreadable file still throws: its keys may be fine,
|
|
505
|
+
* so nothing may treat it as absent and write over it.
|
|
506
|
+
*/
|
|
507
|
+
function loadUnlessCorrupt(deviceId, options = {}) {
|
|
508
|
+
try {
|
|
509
|
+
return (0, controller_store_1.loadController)(deviceId, options);
|
|
510
|
+
}
|
|
511
|
+
catch (error) {
|
|
512
|
+
if (error instanceof controller_store_1.ControllerFileCorruptError) {
|
|
513
|
+
return 'corrupt';
|
|
514
|
+
}
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/** enrolledStagedPairing reports whether the staging store holds an enrolled record. */
|
|
519
|
+
function enrolledStagedPairing(deviceId, options = {}) {
|
|
520
|
+
const staged = loadUnlessCorrupt(deviceId, (0, controller_store_1.stagingStore)(options));
|
|
521
|
+
return staged !== null && staged !== 'corrupt' && (0, controller_store_1.isEnrolled)(staged);
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* confirmCorruptReplacement asks before a pairing replaces a corrupt
|
|
525
|
+
* controller file (it is moved aside, not deleted, once an owner's result
|
|
526
|
+
* arrives). Asked before the QR is shown, so a refusal costs the owner
|
|
527
|
+
* nothing. Ask on a TTY, or --yes.
|
|
528
|
+
*
|
|
529
|
+
* @param device - Device with ID
|
|
530
|
+
* @param options - `yes` skips the confirmation
|
|
531
|
+
* @param confirmFn - Confirmation prompt
|
|
532
|
+
* @throws {CommandFailure} When declined
|
|
533
|
+
*/
|
|
534
|
+
async function confirmCorruptReplacement(device, options, confirmFn = confirmInteractively) {
|
|
535
|
+
if (options.yes) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
console.log(chalk_1.default.yellow(`\nThe controller file for ${deviceLabel(device)} is corrupt. It is moved aside (not deleted) and replaced by the new pairing; ` +
|
|
539
|
+
'if it held this computer’s only owner keys for this FF1, the FF1 may need another owner or a factory reset.'));
|
|
540
|
+
if (!(await confirmFn('Move it aside and pair again?'))) {
|
|
541
|
+
throw new CommandFailure('Pairing cancelled; the controller file was left as it is.', 'Pass --yes to skip this check.');
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* noteReplacement says when a pairing for another control identity will
|
|
546
|
+
* replace an ENROLLED record (expected after a factory reset). It does not
|
|
547
|
+
* ask: an owner has already added ff-cli, and the new pairing replaces the
|
|
548
|
+
* old one only once its enroll reply verifies (the staging store), so a
|
|
549
|
+
* failed enroll leaves the current pairing as it is.
|
|
550
|
+
*
|
|
551
|
+
* @param device - Device with ID
|
|
552
|
+
* @param profile - Profile from the owner's verified result
|
|
553
|
+
* @param storeOptions - Store options (tests)
|
|
554
|
+
* @returns True when the notice was printed
|
|
555
|
+
*/
|
|
556
|
+
function noteReplacement(device, profile, storeOptions = {}) {
|
|
557
|
+
const existing = loadUnlessCorrupt(device.id, storeOptions);
|
|
558
|
+
if (existing === 'corrupt' ||
|
|
559
|
+
!existing ||
|
|
560
|
+
!(0, controller_store_1.isEnrolled)(existing) ||
|
|
561
|
+
(0, controller_store_1.sameIdentity)(existing.profile, profile)) {
|
|
562
|
+
return false;
|
|
563
|
+
}
|
|
564
|
+
console.log(chalk_1.default.yellow(`\nff-cli is already paired with ${deviceLabel(device)} as ${existing.role}, under a different FF1 identity than the owner’s app just sent. ` +
|
|
565
|
+
'That is expected after a factory reset. ' +
|
|
566
|
+
'Once the new pairing is enrolled, the current pairing is replaced' +
|
|
567
|
+
(existing.role === 'owner'
|
|
568
|
+
? '; if no other owner can reach this FF1, it is then left without a reachable owner until a factory reset.'
|
|
569
|
+
: '.')));
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* storeRelayerTopic stores the relayer topic an owner's registration result
|
|
574
|
+
* carried (§5.6.2 topicId). It only enables the legacy relayer route; the
|
|
575
|
+
* controller pairing works without it, so an unavailable keyring (headless
|
|
576
|
+
* Linux has no secret service) is a warning, not a failed pairing. The topic
|
|
577
|
+
* is never printed.
|
|
578
|
+
*
|
|
579
|
+
* @param deviceId - FF1 device ID
|
|
580
|
+
* @param topicId - Relayer topic from the result
|
|
581
|
+
* @param storeFn - Keyring writer (tests inject a failing one)
|
|
582
|
+
* @param warnFn - Warning sink
|
|
583
|
+
* @returns True when the topic was stored
|
|
584
|
+
*/
|
|
585
|
+
function storeRelayerTopic(deviceId, topicId, storeFn = topic_store_1.storeTopicId, warnFn = (m) => console.warn(chalk_1.default.yellow(m))) {
|
|
586
|
+
try {
|
|
587
|
+
storeFn(deviceId, topicId);
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
warnFn(`Warning: the operating system secure storage is unavailable (${error.message}). ` +
|
|
592
|
+
'Pairing continues, but the relayer fallback for this FF1 will not be available until it is.');
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* formatCode renders a 6-digit approval code as "123 456" for reading aloud.
|
|
598
|
+
*
|
|
599
|
+
* @param code - Six digits
|
|
600
|
+
* @returns Spaced code
|
|
601
|
+
*/
|
|
602
|
+
function formatCode(code) {
|
|
603
|
+
return `${code.slice(0, 3)} ${code.slice(3)}`;
|
|
604
|
+
}
|
|
605
|
+
/** reportEnrollment prints the final pairing state or throws a failure. */
|
|
606
|
+
function reportEnrollment(device, result) {
|
|
607
|
+
const resume = `${(0, control_errors_1.pairCommand)(deviceLabel(device))} --resume`;
|
|
608
|
+
switch (result.status) {
|
|
609
|
+
case 'enrolled': {
|
|
610
|
+
const role = result.record.role;
|
|
611
|
+
console.log(chalk_1.default.green(`\n✓ ff-cli is paired with ${deviceLabel(device)} as ${chalk_1.default.bold(role)}.`));
|
|
612
|
+
if (result.record.expiresAt !== undefined) {
|
|
613
|
+
console.log(chalk_1.default.dim(`This access expires at ${new Date(result.record.expiresAt * 1000).toISOString()}.`));
|
|
614
|
+
}
|
|
615
|
+
console.log(chalk_1.default.dim(role === 'owner'
|
|
616
|
+
? 'Owner access covers every command, including ssh and controller management.\n'
|
|
617
|
+
: 'Controller access covers displaying art and device status. SSH and controller management need an owner grant.\n'));
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
case 'cancelled':
|
|
621
|
+
console.log(chalk_1.default.yellow(`\nPairing interrupted. An owner already added ff-cli; run \`${resume}\` to finish.`));
|
|
622
|
+
process.exitCode = 130;
|
|
623
|
+
return;
|
|
624
|
+
default: {
|
|
625
|
+
// The record with the owner's verified result is kept, so --resume
|
|
626
|
+
// finishes it without a new code.
|
|
627
|
+
const details = result.failure.details ?? '';
|
|
628
|
+
throw new CommandFailure(result.failure.error, details.includes((0, control_errors_1.pairCommand)(deviceLabel(device)))
|
|
629
|
+
? details
|
|
630
|
+
: `${details ? `${details} ` : ''}To finish the pairing, run \`${resume}\`.`);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* withInterrupt runs fn with an abort signal wired to the first Ctrl-C. A
|
|
636
|
+
* second Ctrl-C exits immediately; that is safe because counter allocation
|
|
637
|
+
* and grant storage are synchronous, so no signal handler can run in the
|
|
638
|
+
* middle of a file update.
|
|
639
|
+
*/
|
|
640
|
+
async function withInterrupt(fn) {
|
|
641
|
+
const controller = new AbortController();
|
|
642
|
+
const onSigint = () => {
|
|
643
|
+
if (controller.signal.aborted) {
|
|
644
|
+
process.exit(130);
|
|
645
|
+
}
|
|
646
|
+
controller.abort();
|
|
647
|
+
console.log(chalk_1.default.yellow('\nStopping... (Ctrl-C again to quit now)'));
|
|
648
|
+
};
|
|
649
|
+
process.on('SIGINT', onSigint);
|
|
650
|
+
try {
|
|
651
|
+
return await fn(controller.signal);
|
|
652
|
+
}
|
|
653
|
+
finally {
|
|
654
|
+
process.off('SIGINT', onSigint);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* requireEnrolledController loads the device's enrolled controller record.
|
|
659
|
+
*
|
|
660
|
+
* @param device - Device with ID
|
|
661
|
+
* @returns The record
|
|
662
|
+
* @throws {CommandFailure} When ff-cli is not paired with the device
|
|
663
|
+
*/
|
|
664
|
+
function requireEnrolledController(device) {
|
|
665
|
+
const secure = (0, secure_transport_1.findSecureController)(device);
|
|
666
|
+
if (secure.failure) {
|
|
667
|
+
throw new CommandFailure(secure.failure.error, secure.failure.details);
|
|
668
|
+
}
|
|
669
|
+
if (!secure.record || !(0, controller_store_1.isEnrolled)(secure.record)) {
|
|
670
|
+
throw new CommandFailure(`ff-cli is not paired as a controller of ${deviceLabel(device)}.`, `Run \`${(0, control_errors_1.pairCommand)(deviceLabel(device))}\` first.`);
|
|
671
|
+
}
|
|
672
|
+
return secure.record;
|
|
673
|
+
}
|
|
674
|
+
/** runOwnerCommand sends an owner-tier control command (LAN, relayer fallback, no retry). */
|
|
675
|
+
async function runOwnerCommand(device, record, cmd, req, deps = {}) {
|
|
676
|
+
return (0, secure_transport_1.runSecureCommand)({ device, record, cmd, req, relayerFallback: true, retry: false }, deps);
|
|
677
|
+
}
|
|
678
|
+
/** OWNER_COMMAND_REASONS map §5.5 `res.error` reasons to copy. */
|
|
679
|
+
const OWNER_COMMAND_REASONS = {
|
|
680
|
+
not_found: 'No active controller or pending request has that ID.',
|
|
681
|
+
last_owner: 'That is the last owner of this FF1 and cannot be revoked.',
|
|
682
|
+
not_pending: 'There is no pending request for that controller.',
|
|
683
|
+
too_many_controllers: 'This FF1 already has the maximum number of controllers.',
|
|
684
|
+
invalid_request: 'The FF1 rejected the request as malformed.',
|
|
685
|
+
};
|
|
686
|
+
/** ownerFailure turns a failed owner command into a CommandFailure. */
|
|
687
|
+
function ownerFailure(result) {
|
|
688
|
+
if (result.code === 'command_failed' && result.details) {
|
|
689
|
+
try {
|
|
690
|
+
const reason = JSON.parse(result.details).error;
|
|
691
|
+
if (reason && OWNER_COMMAND_REASONS[reason]) {
|
|
692
|
+
return new CommandFailure(OWNER_COMMAND_REASONS[reason]);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
catch {
|
|
696
|
+
// fall through to the generic message
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
return new CommandFailure(result.error || 'The FF1 refused the request', result.details);
|
|
700
|
+
}
|
|
701
|
+
function formatTime(unix) {
|
|
702
|
+
return unix ? new Date(unix * 1000).toISOString().replace('T', ' ').slice(0, 16) : '-';
|
|
703
|
+
}
|
|
704
|
+
function rowStatus(row, nowSeconds) {
|
|
705
|
+
if (row.revokedAt) {
|
|
706
|
+
return `revoked ${formatTime(row.revokedAt)}`;
|
|
707
|
+
}
|
|
708
|
+
if (row.expiresAt && row.expiresAt <= nowSeconds) {
|
|
709
|
+
return `expired ${formatTime(row.expiresAt)}`;
|
|
710
|
+
}
|
|
711
|
+
return row.expiresAt ? `active until ${formatTime(row.expiresAt)}` : 'active';
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* rowLabel is the LABEL cell. A display row shows the site's origin, which
|
|
715
|
+
* is what the owner approved, marked when the broker did not attest it
|
|
716
|
+
* (§5.7: a device-initiated channel only asserts the origin).
|
|
717
|
+
*/
|
|
718
|
+
function rowLabel(row) {
|
|
719
|
+
if (row.role !== 'display' || row.origin === undefined) {
|
|
720
|
+
return row.label;
|
|
721
|
+
}
|
|
722
|
+
return row.originAttested ? row.origin : `${row.origin} (unverified)`;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* formatControllerTable renders a listControllers result as text lines.
|
|
726
|
+
*
|
|
727
|
+
* @param res - listControllers `res`
|
|
728
|
+
* @param nowSeconds - Current time for expiry display
|
|
729
|
+
* @returns Lines to print
|
|
730
|
+
*/
|
|
731
|
+
function formatControllerTable(res, nowSeconds) {
|
|
732
|
+
const lines = [];
|
|
733
|
+
const rows = (res.controllers ?? []).map((row) => [
|
|
734
|
+
row.self === true ? '*' : ' ',
|
|
735
|
+
row.role,
|
|
736
|
+
rowLabel(row),
|
|
737
|
+
row.platform,
|
|
738
|
+
formatTime(row.createdAt),
|
|
739
|
+
rowStatus(row, nowSeconds),
|
|
740
|
+
row.ctl,
|
|
741
|
+
]);
|
|
742
|
+
lines.push(...table(['', 'ROLE', 'LABEL', 'PLATFORM', 'ADDED (UTC)', 'STATUS', 'CONTROLLER ID'], rows));
|
|
743
|
+
const pending = res.pending ?? [];
|
|
744
|
+
if (pending.length > 0) {
|
|
745
|
+
lines.push('', 'Pending requests (approve in the Feral File app):');
|
|
746
|
+
lines.push(...table(['', 'CODE', 'LABEL', 'PLATFORM', 'EXPIRES (UTC)', 'CONTROLLER ID'], pending.map((p) => [
|
|
747
|
+
' ',
|
|
748
|
+
formatCode(p.code),
|
|
749
|
+
p.label,
|
|
750
|
+
p.platform,
|
|
751
|
+
formatTime(p.expiresAt),
|
|
752
|
+
p.ctl,
|
|
753
|
+
])));
|
|
754
|
+
}
|
|
755
|
+
return lines;
|
|
756
|
+
}
|
|
757
|
+
function table(header, rows) {
|
|
758
|
+
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => [...r[i]].length)));
|
|
759
|
+
const render = (cells) => cells
|
|
760
|
+
.map((c, i) => (i === cells.length - 1 ? c : c + ' '.repeat(widths[i] - [...c].length)))
|
|
761
|
+
.join(' ');
|
|
762
|
+
return [render(header), ...rows.map(render)];
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* runControllers is `ff-cli device controllers [name]` (owner only).
|
|
766
|
+
*
|
|
767
|
+
* @param name - Optional device name
|
|
768
|
+
*/
|
|
769
|
+
async function runControllers(name) {
|
|
770
|
+
const device = requirePairableDevice(name);
|
|
771
|
+
const record = requireEnrolledController(device);
|
|
772
|
+
const result = await runOwnerCommand(device, record, 'listControllers', {});
|
|
773
|
+
if (!result.success) {
|
|
774
|
+
throw ownerFailure(result);
|
|
775
|
+
}
|
|
776
|
+
console.log(chalk_1.default.blue(`\nControllers on ${deviceLabel(device)} (${device.id})\n`));
|
|
777
|
+
const res = (result.response ?? {});
|
|
778
|
+
for (const line of formatControllerTable(res, Math.floor(Date.now() / 1000))) {
|
|
779
|
+
console.log(` ${line}`);
|
|
780
|
+
}
|
|
781
|
+
console.log(chalk_1.default.dim('\n * = this ff-cli'));
|
|
782
|
+
if ((res.controllers ?? []).some((row) => row.role === 'display')) {
|
|
783
|
+
console.log(chalk_1.default.dim(' display = a Play site (browser) that may only cast to this FF1; `ff-cli device revoke` removes it'));
|
|
784
|
+
}
|
|
785
|
+
console.log();
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* runRevoke is `ff-cli device revoke <ctl> [name]` (owner only). Revoking a
|
|
789
|
+
* ctl that only has a pending request denies that request (§5.5).
|
|
790
|
+
*
|
|
791
|
+
* @param ctl - controllerId to revoke
|
|
792
|
+
* @param name - Optional device name
|
|
793
|
+
*/
|
|
794
|
+
async function runRevoke(ctl, name) {
|
|
795
|
+
if (!(0, jwk_1.isThumbprint)(ctl)) {
|
|
796
|
+
throw new CommandFailure('Controller ID must be the 43-character ID shown by `ff-cli device controllers`.');
|
|
797
|
+
}
|
|
798
|
+
const device = requirePairableDevice(name);
|
|
799
|
+
const record = requireEnrolledController(device);
|
|
800
|
+
const result = await runOwnerCommand(device, record, 'revokeController', { ctl });
|
|
801
|
+
if (!result.success) {
|
|
802
|
+
throw ownerFailure(result);
|
|
803
|
+
}
|
|
804
|
+
console.log(chalk_1.default.green(`\n✓ Revoked controller ${ctl} on ${deviceLabel(device)}.`));
|
|
805
|
+
if (ctl === (0, codec_1.controllerIdOf)(record.keys)) {
|
|
806
|
+
console.log(chalk_1.default.dim('That was this ff-cli. Run `ff-cli device unpair` to delete its keys.'));
|
|
807
|
+
}
|
|
808
|
+
console.log();
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* confirmInteractively asks a yes/no question on a TTY and refuses (false)
|
|
812
|
+
* when stdin is not interactive, so scripts must pass --yes explicitly.
|
|
813
|
+
*
|
|
814
|
+
* @param question - Question text
|
|
815
|
+
* @returns The answer
|
|
816
|
+
*/
|
|
817
|
+
async function confirmInteractively(question) {
|
|
818
|
+
if (!process.stdin.isTTY) {
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
const prompt = (0, prompt_1.createPrompt)();
|
|
822
|
+
try {
|
|
823
|
+
return await (0, prompt_1.promptYesNo)(prompt.ask, question, false);
|
|
824
|
+
}
|
|
825
|
+
finally {
|
|
826
|
+
prompt.close();
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* selfRevoke sends the best-effort sealed self-revoke of §14.7
|
|
831
|
+
* (`revokeController` with this controller's own ctl) before its keys are
|
|
832
|
+
* deleted. Every failure is ignored: old firmware answers permission_denied,
|
|
833
|
+
* a sole owner gets last_owner, the FF1 may be offline. Each request is
|
|
834
|
+
* bounded by UNPAIR_REVOKE_TIMEOUT_MS so an unreachable FF1 only briefly
|
|
835
|
+
* delays the delete.
|
|
836
|
+
*
|
|
837
|
+
* @param device - Device with ID
|
|
838
|
+
* @param record - Enrolled controller record
|
|
839
|
+
* @param deps - Transport seams; controllerStore is the store the record is in
|
|
840
|
+
* @returns True when the FF1 confirmed the revoke
|
|
841
|
+
*/
|
|
842
|
+
async function selfRevoke(device, record, deps) {
|
|
843
|
+
try {
|
|
844
|
+
const result = await runOwnerCommand(device, record, 'revokeController', { ctl: (0, codec_1.controllerIdOf)(record.keys) }, {
|
|
845
|
+
...deps,
|
|
846
|
+
fetchFn: deps.fetchFn ??
|
|
847
|
+
((input, init) => (0, http_1.fetchWithTimeout)(input, init ?? {}, exports.UNPAIR_REVOKE_TIMEOUT_MS)),
|
|
848
|
+
v2TimeoutMs: deps.v2TimeoutMs ?? exports.UNPAIR_REVOKE_TIMEOUT_MS,
|
|
849
|
+
noticeFn: () => undefined,
|
|
850
|
+
});
|
|
851
|
+
if (!result.success) {
|
|
852
|
+
logger.debug(`Self-revoke was not confirmed: ${result.error ?? ''} ${result.details ?? ''}`);
|
|
853
|
+
}
|
|
854
|
+
return result.success;
|
|
855
|
+
}
|
|
856
|
+
catch (error) {
|
|
857
|
+
logger.debug(`Self-revoke failed: ${error.message}`);
|
|
858
|
+
return false;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* runUnpair is `ff-cli device unpair [name]`: revoke this controller on the
|
|
863
|
+
* FF1 (best effort, §14.7), then delete the local controller file whatever
|
|
864
|
+
* the outcome.
|
|
865
|
+
*
|
|
866
|
+
* Deleting keys always asks first (or needs --yes), like Remove in the app
|
|
867
|
+
* (§14.3). An OWNER grant gets a stronger warning: it is the device's
|
|
868
|
+
* recovery path and the last reachable owner's keys cannot be recreated.
|
|
869
|
+
*
|
|
870
|
+
* @param name - Optional device name
|
|
871
|
+
* @param options - `yes` skips the confirmation
|
|
872
|
+
* @param confirmFn - Confirmation prompt (tests inject one)
|
|
873
|
+
* @param deps - Transport seams for the self-revoke (tests)
|
|
874
|
+
*/
|
|
875
|
+
async function runUnpair(name, options = {}, confirmFn = confirmInteractively, deps = {}) {
|
|
876
|
+
const device = requirePairableDevice(name);
|
|
877
|
+
const store = deps.controllerStore ?? {};
|
|
878
|
+
let record = null;
|
|
879
|
+
let role;
|
|
880
|
+
let present = false;
|
|
881
|
+
try {
|
|
882
|
+
record = (0, controller_store_1.loadController)(device.id, store);
|
|
883
|
+
role = record?.role;
|
|
884
|
+
present = record !== null;
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
if (!(error instanceof controller_store_1.ControllerFileCorruptError)) {
|
|
888
|
+
throw error; // unreadable: deleting it may remove a working owner key
|
|
889
|
+
}
|
|
890
|
+
present = true; // corrupt file: delete it anyway
|
|
891
|
+
}
|
|
892
|
+
// An approved but unfinished replacement pairing is deleted too; its role
|
|
893
|
+
// decides the warning as much as the main file's does.
|
|
894
|
+
const staged = loadUnlessCorrupt(device.id, (0, controller_store_1.stagingStore)(store));
|
|
895
|
+
if (staged !== null) {
|
|
896
|
+
present = true;
|
|
897
|
+
if (staged !== 'corrupt' && staged.role === 'owner') {
|
|
898
|
+
role = 'owner';
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
if (present && !options.yes) {
|
|
902
|
+
console.log(chalk_1.default.yellow(role === 'owner'
|
|
903
|
+
? `\nff-cli is an OWNER of ${deviceLabel(device)}. If no other owner can reach this FF1, deleting these keys leaves it without a reachable owner until a factory reset.`
|
|
904
|
+
: `\nff-cli loses its access to ${deviceLabel(device)}. Pairing again needs an owner to add it in the Feral File app.`));
|
|
905
|
+
if (!(await confirmFn(role === 'owner' ? 'Delete the owner keys anyway?' : 'Delete the controller keys?'))) {
|
|
906
|
+
throw new CommandFailure('Unpair cancelled; nothing was deleted.', 'Pass --yes to skip this check.');
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (staged !== null && staged !== 'corrupt' && (0, controller_store_1.isEnrolled)(staged)) {
|
|
910
|
+
await selfRevoke(device, staged, { ...deps, controllerStore: (0, controller_store_1.stagingStore)(store) });
|
|
911
|
+
}
|
|
912
|
+
const revoked = record !== null && (0, controller_store_1.isEnrolled)(record) && (await selfRevoke(device, record, deps));
|
|
913
|
+
const shownKeys = (0, controller_store_1.loadPairingKeys)(device.id, store);
|
|
914
|
+
(0, controller_store_1.deleteController)(device.id, (0, controller_store_1.stagingStore)(store)); // an unfinished replacement pairing
|
|
915
|
+
const deletedShown = (0, controller_store_1.deletePairingKeys)(device.id, store); // keys an interrupted pairing showed
|
|
916
|
+
if (!(0, controller_store_1.deleteController)(device.id, store)) {
|
|
917
|
+
if (deletedShown) {
|
|
918
|
+
const shownCtl = shownKeys ? (0, codec_1.controllerIdOf)(shownKeys) : undefined;
|
|
919
|
+
console.log(chalk_1.default.yellow(`\nDeleted the keys an unfinished pairing with ${deviceLabel(device)} showed. If an owner already added them, the FF1 still lists that controller` +
|
|
920
|
+
(shownCtl
|
|
921
|
+
? ` (${shownCtl}): ask an owner to remove it in the Feral File app (Controllers) or with \`ff-cli device revoke ${shownCtl}\`.\n`
|
|
922
|
+
: ': ask an owner to remove it in the Feral File app (Controllers).\n')));
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
console.log(chalk_1.default.dim(`\nff-cli has no controller keys for ${deviceLabel(device)}.\n`));
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
console.log(chalk_1.default.green(`\n✓ Deleted ff-cli's controller keys for ${deviceLabel(device)}.`));
|
|
929
|
+
if (revoked) {
|
|
930
|
+
console.log(chalk_1.default.dim('The FF1 revoked this controller’s access.'));
|
|
931
|
+
}
|
|
932
|
+
else if (record) {
|
|
933
|
+
const ctl = (0, codec_1.controllerIdOf)(record.keys);
|
|
934
|
+
console.log(chalk_1.default.dim(`The FF1 may still list controller ${ctl} until an owner revokes it in the Feral File app or with \`ff-cli device revoke ${ctl}\`.`));
|
|
935
|
+
}
|
|
936
|
+
console.log();
|
|
937
|
+
}
|