@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,251 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.reportCommand = void 0;
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const fs_1 = require("fs");
|
|
10
|
+
const config_1 = require("../config");
|
|
11
|
+
const ff1_compatibility_1 = require("../utilities/ff1-compatibility");
|
|
12
|
+
const support_report_1 = require("../utilities/support-report");
|
|
13
|
+
/** REPORT_HELP says what a report carries, so a caller knows what leaves the machine before sending. */
|
|
14
|
+
const REPORT_HELP = `
|
|
15
|
+
The report text comes from --message or --file (not both), or from stdin when
|
|
16
|
+
neither is given and stdin is not a terminal. It goes privately to Feral File
|
|
17
|
+
support with this ff-cli version, the OS, and the Node version, and with
|
|
18
|
+
--email when given. Nothing else leaves this computer: no config, keys, or
|
|
19
|
+
playlists.
|
|
20
|
+
|
|
21
|
+
With -d, the report also carries the Art Computer's device ID, FF OS version and
|
|
22
|
+
branch, and once it is in, the Art Computer is asked to upload its logs under
|
|
23
|
+
the same reference. It uploads in the background and needs internet. On FF OS
|
|
24
|
+
with secure control, that request needs ff-cli paired with the owner role.
|
|
25
|
+
|
|
26
|
+
Exit status: 0 when the report reached Feral File support, even if the Art
|
|
27
|
+
Computer did not take the log request; 1 when it did not.
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
ff-cli report --message "The work displays black on the Art Computer; in a desktop browser it renders." -d Office
|
|
31
|
+
ff-cli report --file report.txt --email you@example.com
|
|
32
|
+
printf '%s\\n' "$REPORT" | ff-cli report -d Office --dry-run
|
|
33
|
+
`;
|
|
34
|
+
/**
|
|
35
|
+
* `ff-cli report` sends a problem report to Feral File support, the same
|
|
36
|
+
* pipeline the app's Support & Feedback feeds, so an agent driving ff-cli can
|
|
37
|
+
* report what went wrong without a person pressing send. The wire contract and
|
|
38
|
+
* what may be sent live in utilities/support-report.ts.
|
|
39
|
+
*/
|
|
40
|
+
exports.reportCommand = new commander_1.Command('report')
|
|
41
|
+
.description("Send a problem report to Feral File support, with an Art Computer's logs when one is involved")
|
|
42
|
+
.option('--message <text>', 'The report text')
|
|
43
|
+
.option('--file <path>', 'Read the report text from this file')
|
|
44
|
+
.option('--email <address>', 'Where Feral File support may reply: a bare address, name@example.com')
|
|
45
|
+
.option('-d, --device <name>', 'The Art Computer involved: adds its device ID, version and branch, and asks it to upload its logs')
|
|
46
|
+
.option('--dry-run', 'Print the JSON that would be sent, and send nothing')
|
|
47
|
+
.addHelpText('after', REPORT_HELP)
|
|
48
|
+
.action(async (options, command) => {
|
|
49
|
+
try {
|
|
50
|
+
// The program's own version, so the report carries exactly what
|
|
51
|
+
// `ff-cli --version` prints (index.ts reads the root package.json).
|
|
52
|
+
await runReport(options, command.parent?.version() || 'unknown');
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
console.error(chalk_1.default.red('\nError:'), error.message);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
/**
|
|
60
|
+
* runReport checks the report, reads the Art Computer's status, posts the
|
|
61
|
+
* report, then asks the Art Computer for its logs. Every refusal before the
|
|
62
|
+
* post exits 1 with nothing sent; after the post the exit status is 0.
|
|
63
|
+
*
|
|
64
|
+
* @param options - Command options
|
|
65
|
+
* @param ffCliVersion - The version `ff-cli --version` prints
|
|
66
|
+
* @returns Promise that resolves when the report is done
|
|
67
|
+
*/
|
|
68
|
+
async function runReport(options, ffCliVersion) {
|
|
69
|
+
const report = await readReport(options);
|
|
70
|
+
const email = options.email?.trim();
|
|
71
|
+
checkReport(report, email);
|
|
72
|
+
const { baseUrl } = (0, config_1.getSupportLogsConfig)();
|
|
73
|
+
const device = options.device !== undefined ? selectDevice(options.device) : undefined;
|
|
74
|
+
const deviceBlock = device ? await describeDevice(device) : undefined;
|
|
75
|
+
// One serialization, so --dry-run prints the very bytes a real run posts.
|
|
76
|
+
const payload = (0, support_report_1.buildReportPayload)({ report, ffCliVersion, email, device: deviceBlock });
|
|
77
|
+
const body = JSON.stringify(payload, null, 2);
|
|
78
|
+
if (options.dryRun) {
|
|
79
|
+
printDryRun(body, baseUrl, device);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const posted = await (0, support_report_1.postReport)(baseUrl, body);
|
|
83
|
+
if (posted.ok === false) {
|
|
84
|
+
exitWith(posted);
|
|
85
|
+
}
|
|
86
|
+
printReceipt(posted.receipt, email);
|
|
87
|
+
await askForLogs(device, posted.receipt);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* readReport returns the trimmed report text from exactly one source. Flags
|
|
91
|
+
* win and stdin is read only when neither is given: an agent's stdin is often
|
|
92
|
+
* a pipe that carries nothing, and reading it beside a flag would wait on it.
|
|
93
|
+
*
|
|
94
|
+
* @param options - Command options
|
|
95
|
+
* @returns The report text, possibly empty
|
|
96
|
+
*/
|
|
97
|
+
async function readReport(options) {
|
|
98
|
+
if (options.message !== undefined && options.file !== undefined) {
|
|
99
|
+
exitWith({ error: 'Give the report once: --message or --file, not both.' });
|
|
100
|
+
}
|
|
101
|
+
if (options.message !== undefined) {
|
|
102
|
+
return options.message.trim();
|
|
103
|
+
}
|
|
104
|
+
if (options.file !== undefined) {
|
|
105
|
+
try {
|
|
106
|
+
return (await fs_1.promises.readFile(options.file, 'utf-8')).trim();
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
exitWith({ error: `Could not read --file ${options.file}: ${error.message}` });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (process.stdin.isTTY) {
|
|
113
|
+
exitWith({
|
|
114
|
+
error: 'No report given.',
|
|
115
|
+
details: 'Pass --message "<text>" or --file <path>, or pipe the text on stdin.',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return (await readStdin()).trim();
|
|
119
|
+
}
|
|
120
|
+
/** readStdin reads all of stdin as UTF-8. */
|
|
121
|
+
function readStdin() {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
let data = '';
|
|
124
|
+
process.stdin.setEncoding('utf8');
|
|
125
|
+
process.stdin.on('data', (chunk) => {
|
|
126
|
+
data += chunk;
|
|
127
|
+
});
|
|
128
|
+
process.stdin.on('end', () => resolve(data));
|
|
129
|
+
process.stdin.on('error', reject);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* checkReport refuses a report support-logs would refuse, a reply address
|
|
134
|
+
* that is not one, and a report carrying this computer's signing key.
|
|
135
|
+
*
|
|
136
|
+
* @param report - Trimmed report text
|
|
137
|
+
* @param email - Trimmed --email value; an empty one is refused, not dropped
|
|
138
|
+
*/
|
|
139
|
+
function checkReport(report, email) {
|
|
140
|
+
if (!report) {
|
|
141
|
+
exitWith({
|
|
142
|
+
error: 'The report is empty.',
|
|
143
|
+
details: 'If the text came from a shell variable, check that it is set.',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
const bytes = Buffer.byteLength(report, 'utf8');
|
|
147
|
+
if (bytes > support_report_1.MAX_REPORT_BYTES) {
|
|
148
|
+
exitWith({
|
|
149
|
+
error: `The report is ${bytes} bytes; Feral File support takes at most ${support_report_1.MAX_REPORT_BYTES}.`,
|
|
150
|
+
details: "Shorten it. With -d the Art Computer's logs travel separately, so leave log excerpts out.",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (email !== undefined && !(0, support_report_1.isBareAddress)(email)) {
|
|
154
|
+
exitWith({
|
|
155
|
+
error: `--email "${email}" is not an address Feral File support can reply to.`,
|
|
156
|
+
details: 'Give a bare ASCII address such as name@example.com, at most 254 characters. ' +
|
|
157
|
+
"Before the @: 1-64 characters of letters, digits and ! # $ % & ' * + / = ? ^ _ ` { | } ~ -, with single dots only between them. " +
|
|
158
|
+
'After it: two or more dot-separated labels of letters, digits and hyphens, no label starting or ending with a hyphen. ' +
|
|
159
|
+
'No display name, angle brackets, quotes, spaces, IP address, underscore in the domain, or non-ASCII characters.',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (email !== undefined && (0, support_report_1.isReservedAddress)(email)) {
|
|
163
|
+
exitWith({
|
|
164
|
+
error: `--email "${email}" is an address Feral File support does not reply to.`,
|
|
165
|
+
details: "Support's own address and postmaster or mailer-daemon mailboxes are refused. Give the address where you want the reply, or leave --email out.",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if ((0, support_report_1.containsSigningKey)(report, (0, config_1.getPlaylistConfig)().privateKey)) {
|
|
169
|
+
exitWith({
|
|
170
|
+
error: "The report contains this computer's playlist signing key.",
|
|
171
|
+
details: 'Remove the key and run the command again. Nothing was sent.',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** selectDevice resolves -d the way `display -d` does, or exits 1 naming the configured devices. */
|
|
176
|
+
function selectDevice(name) {
|
|
177
|
+
const selected = (0, ff1_compatibility_1.resolveConfiguredDevice)(name);
|
|
178
|
+
if (!selected.success || !selected.device) {
|
|
179
|
+
exitWith({ error: selected.error || 'The Art Computer is not configured correctly.' });
|
|
180
|
+
}
|
|
181
|
+
return selected.device;
|
|
182
|
+
}
|
|
183
|
+
/** describeDevice reads the Art Computer block, noting a failed status read without stopping. */
|
|
184
|
+
async function describeDevice(device) {
|
|
185
|
+
const status = await (0, support_report_1.readDeviceStatus)(device);
|
|
186
|
+
if (status.error) {
|
|
187
|
+
console.error(chalk_1.default.yellow(`Could not read the status of ${deviceLabel(device)} (${status.error}); the report goes without its version and branch.`));
|
|
188
|
+
}
|
|
189
|
+
return status.device;
|
|
190
|
+
}
|
|
191
|
+
/** printDryRun prints the body on stdout and what a real run would do on stderr. */
|
|
192
|
+
function printDryRun(body, baseUrl, device) {
|
|
193
|
+
process.stdout.write(`${body}\n`);
|
|
194
|
+
console.error(chalk_1.default.dim(`Dry run: nothing was sent. A real run posts this to ${baseUrl}/v2/cli/reports`));
|
|
195
|
+
if (device) {
|
|
196
|
+
console.error(chalk_1.default.dim(`Then it asks ${deviceLabel(device)} to upload its logs under the reference that comes back.`));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** printReceipt reports the reference in the app's `Support [<id>]` form and who support can reply to. */
|
|
200
|
+
function printReceipt(receipt, email) {
|
|
201
|
+
console.log(chalk_1.default.green('✓ Report sent to Feral File support'));
|
|
202
|
+
console.log(` Reference: Support [${receipt.supportBundleId}]`);
|
|
203
|
+
console.log(chalk_1.default.dim(email
|
|
204
|
+
? ` Support may reply to ${email}`
|
|
205
|
+
: ' No --email given, so support has no address to reply to'));
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* askForLogs asks the Art Computer to upload its logs under the report's
|
|
209
|
+
* reference and prints the outcome. Best effort: a failure prints a line and
|
|
210
|
+
* leaves the exit status at 0, since the report already reached support.
|
|
211
|
+
*
|
|
212
|
+
* @param device - Art Computer named with -d, if any
|
|
213
|
+
* @param receipt - The posted report's receipt
|
|
214
|
+
*/
|
|
215
|
+
async function askForLogs(device, receipt) {
|
|
216
|
+
if (!device) {
|
|
217
|
+
console.log(chalk_1.default.dim(' No Art Computer named (-d), so no device logs were requested'));
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const label = deviceLabel(device);
|
|
221
|
+
if (!receipt.uploadToken) {
|
|
222
|
+
console.log(chalk_1.default.yellow(`${label} was not asked to upload its logs: support returned no upload token.`));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const result = await (0, support_report_1.requestLogUpload)(device, {
|
|
226
|
+
supportBundleId: receipt.supportBundleId,
|
|
227
|
+
uploadToken: receipt.uploadToken,
|
|
228
|
+
});
|
|
229
|
+
if (result.success) {
|
|
230
|
+
console.log(chalk_1.default.green(`✓ Asked ${label} to upload its logs under the same reference`));
|
|
231
|
+
console.log(chalk_1.default.dim(' It uploads in the background and needs internet to do it'));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
console.log(chalk_1.default.yellow(`${label} did not take the request to upload its logs: ${result.error}`));
|
|
235
|
+
if (result.details) {
|
|
236
|
+
console.log(chalk_1.default.dim(` ${result.details}`));
|
|
237
|
+
}
|
|
238
|
+
console.log(chalk_1.default.dim(' The report itself reached support.'));
|
|
239
|
+
}
|
|
240
|
+
/** deviceLabel names a device in output. */
|
|
241
|
+
function deviceLabel(device) {
|
|
242
|
+
return device.name || device.host;
|
|
243
|
+
}
|
|
244
|
+
/** exitWith prints a failure and exits 1. */
|
|
245
|
+
function exitWith(failure) {
|
|
246
|
+
console.error(chalk_1.default.red(`\n${failure.error}`));
|
|
247
|
+
if (failure.details) {
|
|
248
|
+
console.error(chalk_1.default.dim(` ${failure.details}`));
|
|
249
|
+
}
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
@@ -17,7 +17,61 @@ const prompt_1 = require("./helpers/prompt");
|
|
|
17
17
|
const config_placeholders_1 = require("../utilities/config-placeholders");
|
|
18
18
|
const ed25519_key_derive_1 = require("../utilities/ed25519-key-derive");
|
|
19
19
|
const signing_key_source_1 = require("../utilities/signing-key-source");
|
|
20
|
+
const signing_identity_1 = require("../utilities/signing-identity");
|
|
20
21
|
const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
|
|
22
|
+
/**
|
|
23
|
+
* didKeyOrNull derives the signing identity for key material, or null when the
|
|
24
|
+
* material does not parse (a placeholder or corrupt value is not an identity
|
|
25
|
+
* anyone owns playlists with).
|
|
26
|
+
*
|
|
27
|
+
* @param {string} keyMaterial - Configured or supplied key material
|
|
28
|
+
* @returns {string | null} The did:key, or null
|
|
29
|
+
*/
|
|
30
|
+
function didKeyOrNull(keyMaterial) {
|
|
31
|
+
try {
|
|
32
|
+
return (0, signing_identity_1.playlistSigningDidKey)(keyMaterial);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* assertKeyReplacementAllowed refuses to replace a configured signing key with
|
|
40
|
+
* a different one unless the caller passed --force.
|
|
41
|
+
*
|
|
42
|
+
* The configured key is the curator identity that owns every playlist it
|
|
43
|
+
* published: `publish --replace` and `unpublish` only accept that key. setup
|
|
44
|
+
* keeps no copy of the old key, so replacing it silently is permanent. This
|
|
45
|
+
* happened in practice: an agent ran the documented `setup --non-interactive
|
|
46
|
+
* --generate-key` on a machine that was already configured and swapped its
|
|
47
|
+
* owner key without a word. The interactive "Keep existing signing key?"
|
|
48
|
+
* question is its own confirmation and does not come through here.
|
|
49
|
+
*
|
|
50
|
+
* "Configured" means the identity signing uses today: the config key, or
|
|
51
|
+
* PLAYLIST_PRIVATE_KEY when config holds none. A config key always wins over
|
|
52
|
+
* the environment (getPlaylistConfig), so writing one would mask an
|
|
53
|
+
* environment identity just as surely as overwriting a config key.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} currentKey - Effective key material (may be a placeholder or empty)
|
|
56
|
+
* @param {string} nextKey - Key material setup is about to write
|
|
57
|
+
* @param {string} flag - The flag that asked for the change, named in the error
|
|
58
|
+
* @param {boolean} force - Whether --force was passed
|
|
59
|
+
* @param {string} [source] - Where currentKey came from, when not config.json
|
|
60
|
+
* @throws {Error} When an existing, different identity would be replaced without --force
|
|
61
|
+
*/
|
|
62
|
+
function assertKeyReplacementAllowed(currentKey, nextKey, flag, force, source) {
|
|
63
|
+
if (force || (0, config_files_1.isMissingConfigValue)(currentKey)) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const currentDid = didKeyOrNull(currentKey);
|
|
67
|
+
if (currentDid === null || currentDid === didKeyOrNull(nextKey)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`A signing key is already configured (${currentDid}${source ? `, from ${source}` : ''}). ` +
|
|
71
|
+
'It owns the playlists you published; ' +
|
|
72
|
+
'replacing it means you can no longer replace or unpublish them. ' +
|
|
73
|
+
`Re-run without ${flag} to keep it, or add --force to replace it.`);
|
|
74
|
+
}
|
|
21
75
|
/** Generate a fresh Ed25519 signing key as base64 PKCS#8 DER (the recommended form). */
|
|
22
76
|
function generateSigningKeyBase64() {
|
|
23
77
|
const keyPair = crypto_1.default.generateKeyPairSync('ed25519');
|
|
@@ -28,7 +82,8 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
28
82
|
.option('-y, --non-interactive', 'Run without prompts (for agents/CI); uses flags and safe defaults. Auto-enabled when stdin is not a TTY.')
|
|
29
83
|
.option('--key <privateKey>', 'Signing key material (base64 PKCS#8 DER, 32-byte seed as hex/base64, or PEM)')
|
|
30
84
|
.option('--key-file <path>', 'Read the signing key from this file instead of the command line (preferred for provisioning)')
|
|
31
|
-
.option('--generate-key', 'Generate a new Ed25519 signing key')
|
|
85
|
+
.option('--generate-key', 'Generate a new Ed25519 signing key (only when none is configured)')
|
|
86
|
+
.option('--force', 'Allow --generate-key, --key, or --key-file to replace a configured signing key (it owns your published playlists)')
|
|
32
87
|
.option('--role <role>', `Signing role (${playlist_signing_role_1.DP1_PLAYLIST_SIGNING_ROLES.join(', ')})`)
|
|
33
88
|
.option('--device-host <host>', 'FF1 device host, e.g. http://192.168.1.50:1111 (skips mDNS discovery)')
|
|
34
89
|
.option('--device-name <name>', 'Friendly name for the device')
|
|
@@ -84,22 +139,38 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
84
139
|
const currentRole = config.playlist?.role || '';
|
|
85
140
|
let signingKey = currentKey;
|
|
86
141
|
let signingRole = currentRole;
|
|
142
|
+
// The identity signing uses today. With no usable key in config, signing falls back to
|
|
143
|
+
// PLAYLIST_PRIVATE_KEY, and a config key written now would silently take its place.
|
|
144
|
+
const envKey = process.env.PLAYLIST_PRIVATE_KEY?.trim() || '';
|
|
145
|
+
const keyFromEnv = (0, config_files_1.isMissingConfigValue)(currentKey) && !(0, config_files_1.isMissingConfigValue)(envKey);
|
|
146
|
+
const effectiveKey = keyFromEnv ? envKey : currentKey;
|
|
147
|
+
const keySource = keyFromEnv ? 'PLAYLIST_PRIVATE_KEY' : undefined;
|
|
148
|
+
const force = Boolean(options.force);
|
|
87
149
|
// Already resolved and validated above, before any file was created.
|
|
88
150
|
if (explicitKey !== undefined) {
|
|
151
|
+
assertKeyReplacementAllowed(effectiveKey, explicitKey.material, explicitKey.flag, force, keySource);
|
|
89
152
|
signingKey = explicitKey.material;
|
|
90
153
|
}
|
|
91
154
|
else if (nonInteractive) {
|
|
92
155
|
// Key precedence with no explicit key: --generate-key, then keep an
|
|
93
156
|
// existing usable key, otherwise generate one so signing works.
|
|
94
|
-
if (options.generateKey || (0, config_files_1.isMissingConfigValue)(
|
|
95
|
-
|
|
157
|
+
if (options.generateKey || (0, config_files_1.isMissingConfigValue)(effectiveKey)) {
|
|
158
|
+
const generated = generateSigningKeyBase64();
|
|
159
|
+
assertKeyReplacementAllowed(effectiveKey, generated, '--generate-key', force, keySource);
|
|
160
|
+
signingKey = generated;
|
|
96
161
|
console.log(chalk_1.default.dim('Generated a new Ed25519 signing key.'));
|
|
97
162
|
}
|
|
163
|
+
else if (keyFromEnv) {
|
|
164
|
+
console.log(chalk_1.default.dim('Signing with PLAYLIST_PRIVATE_KEY; config.json keeps no key.'));
|
|
165
|
+
}
|
|
98
166
|
}
|
|
99
167
|
else {
|
|
100
|
-
if ((0, config_files_1.isMissingConfigValue)(
|
|
168
|
+
if ((0, config_files_1.isMissingConfigValue)(effectiveKey)) {
|
|
101
169
|
signingKey = generateSigningKeyBase64();
|
|
102
170
|
}
|
|
171
|
+
else if (keyFromEnv) {
|
|
172
|
+
console.log(chalk_1.default.dim('Signing with PLAYLIST_PRIVATE_KEY; config.json keeps no key.'));
|
|
173
|
+
}
|
|
103
174
|
else {
|
|
104
175
|
const ask = getAsk();
|
|
105
176
|
const keepKey = await (0, prompt_1.promptYesNo)(ask, 'Keep existing signing key?', true);
|
|
@@ -109,7 +180,9 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
109
180
|
}
|
|
110
181
|
}
|
|
111
182
|
}
|
|
112
|
-
|
|
183
|
+
// The role applies to whichever identity signs, including PLAYLIST_PRIVATE_KEY when
|
|
184
|
+
// config keeps no key; skipping it there silently dropped --role.
|
|
185
|
+
if (signingKey || keyFromEnv) {
|
|
113
186
|
if (nonInteractive) {
|
|
114
187
|
// Role precedence: --role, then existing role, then 'agent'.
|
|
115
188
|
try {
|
|
@@ -139,7 +212,9 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
139
212
|
}
|
|
140
213
|
config.playlist = {
|
|
141
214
|
...(config.playlist || {}),
|
|
142
|
-
|
|
215
|
+
// With the identity in PLAYLIST_PRIVATE_KEY, keep whatever config held (empty or a
|
|
216
|
+
// placeholder) so the environment key keeps signing.
|
|
217
|
+
privateKey: signingKey || (config.playlist?.privateKey ?? ''),
|
|
143
218
|
role: signingRole,
|
|
144
219
|
};
|
|
145
220
|
}
|
|
@@ -243,7 +318,7 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
243
318
|
console.log(chalk_1.default.yellow(' • Add an FF1 device host'));
|
|
244
319
|
}
|
|
245
320
|
}
|
|
246
|
-
console.log(chalk_1.default.dim('\nRun: ff-cli
|
|
321
|
+
console.log(chalk_1.default.dim('\nRun: ff-cli display <url-or-playlist>'));
|
|
247
322
|
}
|
|
248
323
|
catch (error) {
|
|
249
324
|
console.error(chalk_1.default.red('\nSetup failed:'), error.message);
|
|
@@ -61,7 +61,10 @@ exports.statusCommand = new commander_1.Command('status')
|
|
|
61
61
|
}
|
|
62
62
|
const configPath = await (0, config_files_1.resolveExistingConfigPath)();
|
|
63
63
|
if (!configPath) {
|
|
64
|
-
|
|
64
|
+
// Name both places looked: when a caller's environment points the user config somewhere
|
|
65
|
+
// unexpected (a sandboxed HOME with XDG_CONFIG_HOME still set), the path is the clue.
|
|
66
|
+
const { localPath, userPath } = (0, config_1.getConfigPaths)();
|
|
67
|
+
console.log(chalk_1.default.red(`config.json not found (looked in ${localPath} and ${userPath})`));
|
|
65
68
|
console.log(chalk_1.default.dim('Run: ff-cli setup'));
|
|
66
69
|
// PLAYLIST_PRIVATE_KEY is a supported signing source on its own, and the did:key it implies is
|
|
67
70
|
// the one value a user must know before they can write a publishable playlist — it has to be
|