@feralfile/cli 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -1
- package/config.json.example +1 -1
- package/dist/package.json +1 -1
- package/dist/src/commands/find.js +29 -30
- package/dist/src/commands/helpers/config-files.js +1 -1
- package/dist/src/commands/helpers/device-discovery.js +7 -0
- package/dist/src/commands/play.js +41 -108
- package/dist/src/commands/setup.js +142 -71
- package/dist/src/config.js +9 -33
- package/dist/src/utilities/ed25519-key-derive.js +14 -0
- package/dist/src/utilities/ff1-device.js +49 -3
- package/dist/src/utilities/playlist-builder.js +23 -4
- package/dist/src/utilities/playlist-cast.js +128 -0
- package/dist/src/utilities/playlist-signer.js +68 -4
- package/dist/src/utilities/playlist-verifier.js +7 -3
- package/dist/src/utilities/raster-client.js +4 -2
- package/docs/CONFIGURATION.md +46 -11
- package/docs/EXAMPLES.md +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,13 +12,15 @@ ff-cli is a set of deterministic commands for building DP-1–conformant playlis
|
|
|
12
12
|
npm i -g @feralfile/cli
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
This is the recommended path and the most reliable for the agent workflow — it installs a complete package (including `config.json.example` and the bundled `dp1-js`). Requires Node.js 22+.
|
|
16
|
+
|
|
15
17
|
## Install (curl)
|
|
16
18
|
|
|
17
19
|
```bash
|
|
18
20
|
curl -fsSL https://feralfile.com/ff-cli-install | bash
|
|
19
21
|
```
|
|
20
22
|
|
|
21
|
-
Installs a prebuilt binary for macOS/Linux
|
|
23
|
+
Installs a prebuilt binary for macOS/Linux. Node.js 22+ must still be on your PATH (the bundle runs under `node`). If a command fails right after install, prefer the npm install above.
|
|
22
24
|
|
|
23
25
|
## One-off Usage (npx)
|
|
24
26
|
|
|
@@ -42,6 +44,23 @@ ff-cli config init
|
|
|
42
44
|
ff-cli config validate
|
|
43
45
|
```
|
|
44
46
|
|
|
47
|
+
### Non-interactive setup (agents / CI)
|
|
48
|
+
|
|
49
|
+
`ff-cli setup` is fully scriptable — no TTY required. It runs non-interactively when stdin is not a terminal, or when you pass `-y/--non-interactive`, and takes flags for every step:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Generate a signing key and register a device in one shot, no prompts
|
|
53
|
+
ff-cli setup --non-interactive \
|
|
54
|
+
--generate-key \
|
|
55
|
+
--device-host http://192.168.1.50:1111 \
|
|
56
|
+
--device-name "Living Room"
|
|
57
|
+
|
|
58
|
+
# Or supply your own key (base64 PKCS#8 DER, 32-byte seed as hex/base64, or PEM)
|
|
59
|
+
ff-cli setup -y --key "<private-key>" --role agent
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Device pairing is also scriptable on its own: `ff-cli device add --host http://<ip>:1111 --name <name>` skips mDNS discovery entirely.
|
|
63
|
+
|
|
45
64
|
## Find an artwork
|
|
46
65
|
|
|
47
66
|
`ff-cli find` resolves a URL, raw `chain:contract:tokenId`, or a wallet address into a playable DP-1 playlist. The focus is computational and generative art; PFP collections and pre-ERC-721 contracts have limits noted below.
|
package/config.json.example
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
}
|
|
16
16
|
],
|
|
17
17
|
"playlist": {
|
|
18
|
-
"privateKey": "
|
|
18
|
+
"privateKey": "YOUR_ED25519_PRIVATE_KEY__base64_PKCS8_DER_recommended__or_32byte_raw_seed_as_hex_or_base64__run_ff-cli_setup_to_generate",
|
|
19
19
|
"role": "agent"
|
|
20
20
|
},
|
|
21
21
|
"ff1Devices": {
|
package/dist/package.json
CHANGED
|
@@ -53,7 +53,7 @@ const fxhash_marketplace_1 = require("../utilities/fxhash-marketplace");
|
|
|
53
53
|
const neort_marketplace_1 = require("../utilities/neort-marketplace");
|
|
54
54
|
const verse_marketplace_1 = require("../utilities/verse-marketplace");
|
|
55
55
|
const raster_client_1 = require("../utilities/raster-client");
|
|
56
|
-
const
|
|
56
|
+
const playlist_cast_1 = require("../utilities/playlist-cast");
|
|
57
57
|
// nft-indexer + playlist-builder are still CommonJS; require keeps interop simple.
|
|
58
58
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
59
59
|
const { getNFTTokenInfoBatch } = require('../utilities/nft-indexer');
|
|
@@ -444,48 +444,47 @@ async function promptNextAction() {
|
|
|
444
444
|
throw new Error(`Unrecognized choice: "${answer}". Use P, S, or L.`);
|
|
445
445
|
}
|
|
446
446
|
async function doPlay(playlist, deviceName, skipVerify) {
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
// the wall.
|
|
447
|
+
// Same verify → send gate `ff-cli play` uses, via the shared castPlaylist
|
|
448
|
+
// helper. `find`'s playlists are already signed by buildDP1Playlist (when a
|
|
449
|
+
// key is configured), so no signing is requested here — an unsigned one
|
|
450
|
+
// fails the verify gate with an actionable message instead of reaching the
|
|
451
|
+
// wall unverifiable.
|
|
453
452
|
if (!skipVerify) {
|
|
454
|
-
const { verifyPlaylist } = await Promise.resolve().then(() => __importStar(require('../utilities/playlist-verifier')));
|
|
455
453
|
console.log(chalk_1.default.cyan('Verify playlist'));
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
454
|
+
}
|
|
455
|
+
const result = await (0, playlist_cast_1.castPlaylist)(playlist, { deviceName, skipVerify });
|
|
456
|
+
if (!result.success) {
|
|
457
|
+
if (result.stage === 'verify') {
|
|
458
|
+
console.error(chalk_1.default.red('Playlist verification failed:'), result.error);
|
|
459
|
+
if (result.details && result.details.length > 0) {
|
|
460
|
+
for (const d of result.details) {
|
|
461
461
|
console.error(chalk_1.default.dim(` ${d.path}: ${d.message}`));
|
|
462
462
|
}
|
|
463
463
|
}
|
|
464
464
|
console.error(chalk_1.default.dim(' Tip: configure a playlist signing key (config or PLAYLIST_PRIVATE_KEY), ' +
|
|
465
465
|
'or pass --skip-verify to bypass.'));
|
|
466
|
-
process.exitCode = 1;
|
|
467
|
-
return;
|
|
468
466
|
}
|
|
467
|
+
else {
|
|
468
|
+
console.error(chalk_1.default.red('Play failed:'), result.error);
|
|
469
|
+
if (result.deviceDetails) {
|
|
470
|
+
console.error(chalk_1.default.dim(` Details: ${result.deviceDetails}`));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
process.exitCode = 1;
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (result.verified) {
|
|
469
477
|
console.log(chalk_1.default.green('✓ Verified\n'));
|
|
470
478
|
}
|
|
471
479
|
console.log(chalk_1.default.blue('Play on FF1'));
|
|
472
|
-
|
|
473
|
-
if (result.
|
|
474
|
-
console.log(chalk_1.default.
|
|
475
|
-
if (result.deviceName) {
|
|
476
|
-
console.log(chalk_1.default.dim(` Device: ${result.deviceName}`));
|
|
477
|
-
}
|
|
478
|
-
if (result.device) {
|
|
479
|
-
console.log(chalk_1.default.dim(` Host: ${result.device}`));
|
|
480
|
-
}
|
|
481
|
-
console.log();
|
|
482
|
-
return;
|
|
480
|
+
console.log(chalk_1.default.green('✓ Playing'));
|
|
481
|
+
if (result.deviceName) {
|
|
482
|
+
console.log(chalk_1.default.dim(` Device: ${result.deviceName}`));
|
|
483
483
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
console.error(chalk_1.default.dim(` Details: ${result.details}`));
|
|
484
|
+
if (result.device) {
|
|
485
|
+
console.log(chalk_1.default.dim(` Host: ${result.device}`));
|
|
487
486
|
}
|
|
488
|
-
|
|
487
|
+
console.log();
|
|
489
488
|
}
|
|
490
489
|
async function doPublish(savedPath, serverArg, nonInteractive) {
|
|
491
490
|
console.log(chalk_1.default.blue('Publish to feed'));
|
|
@@ -17,7 +17,7 @@ async function readConfigFile(configPath) {
|
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
19
|
* Locate an existing config.json. Local (`./config.json`) wins over user
|
|
20
|
-
* (`~/.config/
|
|
20
|
+
* (`~/.config/ff-cli/config.json`). Returns null when neither exists.
|
|
21
21
|
*/
|
|
22
22
|
async function resolveExistingConfigPath() {
|
|
23
23
|
const { localPath, userPath } = (0, config_1.getConfigPaths)();
|
|
@@ -24,6 +24,8 @@ async function discoverAndSelectDevice(ask, existingDevices, options) {
|
|
|
24
24
|
? discoveryResult.error
|
|
25
25
|
: `${discoveryResult.error}.`;
|
|
26
26
|
console.log(chalk_1.default.dim(`mDNS discovery failed: ${errorMessage} Continuing with manual entry.`));
|
|
27
|
+
console.log(chalk_1.default.dim(' Tip: you can skip discovery entirely with ' +
|
|
28
|
+
'ff-cli device add --host http://<device-ip>:1111 --name <name>'));
|
|
27
29
|
}
|
|
28
30
|
else if (discoveryResult.error) {
|
|
29
31
|
console.log(chalk_1.default.dim(`mDNS discovery warning: ${discoveryResult.error}`));
|
|
@@ -101,6 +103,11 @@ async function discoverAndSelectDevice(ask, existingDevices, options) {
|
|
|
101
103
|
}
|
|
102
104
|
else if (!discoveryResult.error) {
|
|
103
105
|
console.log(chalk_1.default.dim('No FF1 devices found via mDNS. Continuing with manual entry.'));
|
|
106
|
+
// mDNS is best-effort and frequently fails across subnets (a common setup
|
|
107
|
+
// with mesh routers that put wired and Wi-Fi clients on different /24s).
|
|
108
|
+
// Point users at the reliable fallback.
|
|
109
|
+
console.log(chalk_1.default.dim(' Tip: mDNS often does not cross subnets. If the device is reachable by IP, ' +
|
|
110
|
+
'add it directly: ff-cli device add --host http://<device-ip>:1111 --name <name>'));
|
|
104
111
|
}
|
|
105
112
|
// Manual entry fallback
|
|
106
113
|
const idAnswer = await ask('Device ID or host (e.g. ff1-ABCD1234): ');
|
|
@@ -1,37 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
2
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
4
|
};
|
|
@@ -43,12 +10,8 @@ const chalk_1 = __importDefault(require("chalk"));
|
|
|
43
10
|
const config_1 = require("../config");
|
|
44
11
|
const playlist_source_1 = require("../utilities/playlist-source");
|
|
45
12
|
const marketplace_url_1 = require("../utilities/marketplace-url");
|
|
13
|
+
const playlist_cast_1 = require("../utilities/playlist-cast");
|
|
46
14
|
const playlist_display_1 = require("./helpers/playlist-display");
|
|
47
|
-
// ff1-device is still CommonJS; require keeps the interop simple.
|
|
48
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
49
|
-
const { sendPlaylistToDevice } = require('../utilities/ff1-device');
|
|
50
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
51
|
-
const { signPlaylist } = require('../utilities/playlist-signer');
|
|
52
15
|
/**
|
|
53
16
|
* Human label for a discovery input that `play` cannot cast directly.
|
|
54
17
|
*
|
|
@@ -59,12 +22,20 @@ const { signPlaylist } = require('../utilities/playlist-signer');
|
|
|
59
22
|
* can redirect the user instead of silently wrapping, say, an Art Blocks
|
|
60
23
|
* collection *page* as a web item. Returns null for anything `play` should
|
|
61
24
|
* handle itself (plain URLs and file paths parse to null here).
|
|
25
|
+
*
|
|
26
|
+
* A recognized-but-`unsupported` result (e.g. a legacy Art Blocks
|
|
27
|
+
* `/projects/{id}` URL, or an unsupported-chain OpenSea link) is also a
|
|
28
|
+
* discovery input — it's a marketplace URL, not a playable file — so it is
|
|
29
|
+
* redirected to `find` too, which surfaces the specific reason.
|
|
62
30
|
*/
|
|
63
31
|
function describeDiscoveryInput(source) {
|
|
64
32
|
const parsed = (0, marketplace_url_1.parseFindInput)(source);
|
|
65
|
-
if (!parsed
|
|
33
|
+
if (!parsed) {
|
|
66
34
|
return null;
|
|
67
35
|
}
|
|
36
|
+
if (parsed.kind === 'unsupported') {
|
|
37
|
+
return 'a marketplace URL';
|
|
38
|
+
}
|
|
68
39
|
switch (parsed.kind) {
|
|
69
40
|
case 'token':
|
|
70
41
|
return 'an on-chain token reference';
|
|
@@ -118,83 +89,45 @@ exports.playCommand = new commander_1.Command('play')
|
|
|
118
89
|
? `${resolved.sourceType}: ${resolved.source}`
|
|
119
90
|
: resolved.source;
|
|
120
91
|
console.log(chalk_1.default.blue('\nPlay on FF1\n'));
|
|
121
|
-
if (!options.skipVerify) {
|
|
122
|
-
|
|
123
|
-
const privateKey = playlistConfig?.privateKey || process.env.PLAYLIST_PRIVATE_KEY;
|
|
124
|
-
const { verifyPlaylist } = await Promise.resolve().then(() => __importStar(require('../utilities/playlist-verifier')));
|
|
125
|
-
if (isPlaylistSource) {
|
|
126
|
-
console.log(chalk_1.default.cyan(`Verify playlist (${sourceLabel})`));
|
|
127
|
-
}
|
|
128
|
-
const verifyResult = resolved.kind === 'media'
|
|
129
|
-
? await (async () => {
|
|
130
|
-
try {
|
|
131
|
-
if (!privateKey) {
|
|
132
|
-
return {
|
|
133
|
-
valid: false,
|
|
134
|
-
error: 'Cannot sign playlist for playback: no playlist signing key is configured',
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
const signature = await signPlaylist(resolved.playlist, privateKey);
|
|
138
|
-
const signedPlaylist = {
|
|
139
|
-
...resolved.playlist,
|
|
140
|
-
signature: undefined,
|
|
141
|
-
signatures: [signature],
|
|
142
|
-
};
|
|
143
|
-
const signedVerification = await verifyPlaylist(signedPlaylist);
|
|
144
|
-
if (signedVerification.valid) {
|
|
145
|
-
resolved.playlist = signedPlaylist;
|
|
146
|
-
return { valid: true, playlist: signedPlaylist };
|
|
147
|
-
}
|
|
148
|
-
return {
|
|
149
|
-
valid: false,
|
|
150
|
-
error: signedVerification.error,
|
|
151
|
-
details: signedVerification.details,
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
catch (error) {
|
|
155
|
-
return {
|
|
156
|
-
valid: false,
|
|
157
|
-
error: `Failed to sign playlist for playback: ${error.message}`,
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
})()
|
|
161
|
-
: await verifyPlaylist(resolved.playlist);
|
|
162
|
-
if (!verifyResult.valid) {
|
|
163
|
-
(0, playlist_display_1.printPlaylistVerificationFailure)({
|
|
164
|
-
valid: false,
|
|
165
|
-
error: verifyResult.error,
|
|
166
|
-
details: verifyResult.details,
|
|
167
|
-
}, isPlaylistSource ? `source: ${sourceLabel}` : undefined);
|
|
168
|
-
process.exit(1);
|
|
169
|
-
}
|
|
170
|
-
if (resolved.kind === 'media') {
|
|
171
|
-
console.log(chalk_1.default.green('✓ Signed and verified\n'));
|
|
172
|
-
}
|
|
173
|
-
else if (isPlaylistSource) {
|
|
174
|
-
console.log(chalk_1.default.green('✓ Verified\n'));
|
|
175
|
-
}
|
|
92
|
+
if (!options.skipVerify && isPlaylistSource) {
|
|
93
|
+
console.log(chalk_1.default.cyan(`Verify playlist (${sourceLabel})`));
|
|
176
94
|
}
|
|
177
|
-
|
|
178
|
-
|
|
95
|
+
// A direct media/web URL is wrapped in an unsigned single-item playlist,
|
|
96
|
+
// so it must be signed before the verify gate; a loaded playlist is sent
|
|
97
|
+
// as-is. castPlaylist owns the sign → verify → send sequence.
|
|
98
|
+
const privateKey = config.playlist?.privateKey || process.env.PLAYLIST_PRIVATE_KEY;
|
|
99
|
+
const result = await (0, playlist_cast_1.castPlaylist)(resolved.playlist, {
|
|
179
100
|
deviceName: options.device,
|
|
101
|
+
skipVerify: options.skipVerify,
|
|
102
|
+
requireSignature: resolved.kind === 'media',
|
|
103
|
+
signingKey: privateKey,
|
|
180
104
|
});
|
|
181
|
-
if (result.success) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
console.log(chalk_1.default.dim(` Device: ${result.deviceName}`));
|
|
105
|
+
if (!result.success) {
|
|
106
|
+
if (result.stage === 'verify') {
|
|
107
|
+
(0, playlist_display_1.printPlaylistVerificationFailure)({ valid: false, error: result.error, details: result.details }, isPlaylistSource ? `source: ${sourceLabel}` : undefined);
|
|
185
108
|
}
|
|
186
|
-
if (result.
|
|
187
|
-
console.
|
|
109
|
+
else if (result.stage === 'sign') {
|
|
110
|
+
console.error(chalk_1.default.red(`\n${result.error}`));
|
|
188
111
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
console.error(chalk_1.default.dim(` Details: ${result.details}`));
|
|
112
|
+
else {
|
|
113
|
+
console.error(chalk_1.default.red('\nPlay failed:'), result.error);
|
|
114
|
+
if (result.deviceDetails) {
|
|
115
|
+
console.error(chalk_1.default.dim(` Details: ${result.deviceDetails}`));
|
|
116
|
+
}
|
|
195
117
|
}
|
|
196
118
|
process.exit(1);
|
|
197
119
|
}
|
|
120
|
+
if (result.verified) {
|
|
121
|
+
console.log(chalk_1.default.green(result.signed ? '✓ Signed and verified\n' : '✓ Verified\n'));
|
|
122
|
+
}
|
|
123
|
+
console.log(chalk_1.default.green('✓ Playing'));
|
|
124
|
+
if (result.deviceName) {
|
|
125
|
+
console.log(chalk_1.default.dim(` Device: ${result.deviceName}`));
|
|
126
|
+
}
|
|
127
|
+
if (result.device) {
|
|
128
|
+
console.log(chalk_1.default.dim(` Host: ${result.device}`));
|
|
129
|
+
}
|
|
130
|
+
console.log();
|
|
198
131
|
}
|
|
199
132
|
catch (error) {
|
|
200
133
|
if ((0, playlist_source_1.isPlaylistSourceUrl)(source)) {
|
|
@@ -10,59 +10,103 @@ const crypto_1 = __importDefault(require("crypto"));
|
|
|
10
10
|
const fs_1 = require("fs");
|
|
11
11
|
const device_lookup_1 = require("../utilities/device-lookup");
|
|
12
12
|
const device_upsert_1 = require("../utilities/device-upsert");
|
|
13
|
+
const device_normalize_1 = require("../utilities/device-normalize");
|
|
13
14
|
const config_files_1 = require("./helpers/config-files");
|
|
14
15
|
const device_discovery_1 = require("./helpers/device-discovery");
|
|
15
16
|
const prompt_1 = require("./helpers/prompt");
|
|
16
17
|
const config_placeholders_1 = require("../utilities/config-placeholders");
|
|
18
|
+
const ed25519_key_derive_1 = require("../utilities/ed25519-key-derive");
|
|
17
19
|
const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
|
|
20
|
+
/** Generate a fresh Ed25519 signing key as base64 PKCS#8 DER (the recommended form). */
|
|
21
|
+
function generateSigningKeyBase64() {
|
|
22
|
+
const keyPair = crypto_1.default.generateKeyPairSync('ed25519');
|
|
23
|
+
return keyPair.privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64');
|
|
24
|
+
}
|
|
18
25
|
exports.setupCommand = new commander_1.Command('setup')
|
|
19
26
|
.description('Guided setup for config, signing key, and device')
|
|
20
|
-
.
|
|
27
|
+
.option('-y, --non-interactive', 'Run without prompts (for agents/CI); uses flags and safe defaults. Auto-enabled when stdin is not a TTY.')
|
|
28
|
+
.option('--key <privateKey>', 'Signing key material (base64 PKCS#8 DER, 32-byte seed as hex/base64, or PEM)')
|
|
29
|
+
.option('--generate-key', 'Generate a new Ed25519 signing key')
|
|
30
|
+
.option('--role <role>', `Signing role (${playlist_signing_role_1.DP1_PLAYLIST_SIGNING_ROLES.join(', ')})`)
|
|
31
|
+
.option('--device-host <host>', 'FF1 device host, e.g. http://192.168.1.50:1111 (skips mDNS discovery)')
|
|
32
|
+
.option('--device-name <name>', 'Friendly name for the device')
|
|
33
|
+
.action(async (options) => {
|
|
34
|
+
// Treat a non-TTY stdin as non-interactive: agents driving the CLI cannot
|
|
35
|
+
// answer prompts, and the report shows feeding stdin to the prompts does not
|
|
36
|
+
// work. The explicit flag forces it even on a TTY.
|
|
37
|
+
const nonInteractive = Boolean(options.nonInteractive) || !process.stdin.isTTY;
|
|
21
38
|
let prompt = null;
|
|
39
|
+
// Lazily open the prompt so non-interactive runs never touch stdin.
|
|
40
|
+
const getAsk = () => (prompt ??= (0, prompt_1.createPrompt)()).ask;
|
|
22
41
|
try {
|
|
23
42
|
const { path: configPath, created } = await (0, config_files_1.ensureConfigFile)();
|
|
24
43
|
if (created) {
|
|
25
44
|
console.log(chalk_1.default.green(`Created ${configPath}`));
|
|
26
45
|
}
|
|
27
46
|
const config = await (0, config_files_1.readConfigFile)(configPath);
|
|
28
|
-
console.log(chalk_1.default.blue(
|
|
29
|
-
prompt = (0, prompt_1.createPrompt)();
|
|
30
|
-
const ask = prompt.ask;
|
|
47
|
+
console.log(chalk_1.default.blue(`\nff-cli setup${nonInteractive ? ' (non-interactive)' : ''}\n`));
|
|
31
48
|
const currentKey = config.playlist?.privateKey || '';
|
|
32
49
|
const currentRole = config.playlist?.role || '';
|
|
33
50
|
let signingKey = currentKey;
|
|
34
51
|
let signingRole = currentRole;
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
if (nonInteractive) {
|
|
53
|
+
// Key precedence: explicit --key, then --generate-key, then keep an
|
|
54
|
+
// existing usable key, otherwise generate one so signing works.
|
|
55
|
+
if (options.key) {
|
|
56
|
+
// Validate eagerly so a bad key fails here with a clear message rather
|
|
57
|
+
// than later inside dp1-js during signing.
|
|
58
|
+
try {
|
|
59
|
+
(0, ed25519_key_derive_1.parsePlaylistPrivateKeyToKeyObject)(options.key);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
throw new Error(`Invalid --key: ${error.message}. ` +
|
|
63
|
+
'Expected base64 PKCS#8 DER, a 32-byte raw seed as hex/base64, or PEM.');
|
|
64
|
+
}
|
|
65
|
+
signingKey = options.key;
|
|
66
|
+
}
|
|
67
|
+
else if (options.generateKey || (0, config_files_1.isMissingConfigValue)(currentKey)) {
|
|
68
|
+
signingKey = generateSigningKeyBase64();
|
|
69
|
+
console.log(chalk_1.default.dim('Generated a new Ed25519 signing key.'));
|
|
70
|
+
}
|
|
38
71
|
}
|
|
39
72
|
else {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
signingKey =
|
|
49
|
-
.export({ format: 'der', type: 'pkcs8' })
|
|
50
|
-
.toString('base64');
|
|
73
|
+
if ((0, config_files_1.isMissingConfigValue)(currentKey)) {
|
|
74
|
+
signingKey = generateSigningKeyBase64();
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const ask = getAsk();
|
|
78
|
+
const keepKey = await (0, prompt_1.promptYesNo)(ask, 'Keep existing signing key?', true);
|
|
79
|
+
if (!keepKey) {
|
|
80
|
+
const keyAnswer = await ask('Paste signing key (base64 or hex), or leave blank to regenerate: ');
|
|
81
|
+
signingKey = keyAnswer || generateSigningKeyBase64();
|
|
51
82
|
}
|
|
52
83
|
}
|
|
53
84
|
}
|
|
54
85
|
if (signingKey) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const roleAnswer = await ask(`Signing role (${roleHint}) [${currentRole || 'agent'}]: `);
|
|
86
|
+
if (nonInteractive) {
|
|
87
|
+
// Role precedence: --role, then existing role, then 'agent'.
|
|
58
88
|
try {
|
|
59
|
-
signingRole = (0, playlist_signing_role_1.resolveDp1PlaylistSigningRole)(
|
|
60
|
-
break;
|
|
89
|
+
signingRole = (0, playlist_signing_role_1.resolveDp1PlaylistSigningRole)(options.role, signingRole || 'agent');
|
|
61
90
|
}
|
|
62
91
|
catch (error) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
92
|
+
throw new Error(`Invalid --role: ${error.message} ` +
|
|
93
|
+
`Supported roles: ${playlist_signing_role_1.DP1_PLAYLIST_SIGNING_ROLES.join(', ')}.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const ask = getAsk();
|
|
98
|
+
const roleHint = playlist_signing_role_1.DP1_PLAYLIST_SIGNING_ROLES.join(', ');
|
|
99
|
+
while (true) {
|
|
100
|
+
const roleAnswer = await ask(`Signing role (${roleHint}) [${currentRole || 'agent'}]: `);
|
|
101
|
+
try {
|
|
102
|
+
signingRole = (0, playlist_signing_role_1.resolveDp1PlaylistSigningRole)(roleAnswer, signingRole || 'agent');
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
console.log(chalk_1.default.red(error.message));
|
|
107
|
+
if (!roleAnswer.trim()) {
|
|
108
|
+
console.log(chalk_1.default.dim('Enter one of the supported roles above, or fix the stored value.'));
|
|
109
|
+
}
|
|
66
110
|
}
|
|
67
111
|
}
|
|
68
112
|
}
|
|
@@ -81,57 +125,84 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
81
125
|
if (existingDevices.length > 0) {
|
|
82
126
|
console.log(chalk_1.default.dim(`\nConfigured devices: ${existingDevices.map((d) => d.name || d.host).join(', ')}`));
|
|
83
127
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
128
|
+
if (nonInteractive) {
|
|
129
|
+
// Non-interactive device step: only act when a host is supplied. mDNS
|
|
130
|
+
// discovery is interactive and unreliable across subnets, so we never
|
|
131
|
+
// run it here — agents should pass --device-host.
|
|
132
|
+
if (options.deviceHost) {
|
|
133
|
+
const hostValue = (0, device_normalize_1.normalizeDeviceHost)(options.deviceHost);
|
|
134
|
+
const existingEntry = (0, device_lookup_1.findExistingDeviceEntry)(existingDevices, hostValue, '', undefined, undefined);
|
|
135
|
+
const existingIndex = existingEntry
|
|
136
|
+
? existingDevices.findIndex((d) => d === existingEntry)
|
|
137
|
+
: -1;
|
|
138
|
+
const deviceName = options.deviceName || existingEntry?.name || 'art-computer';
|
|
139
|
+
const result = (0, device_upsert_1.upsertDevice)(existingDevices, { name: deviceName, host: hostValue }, existingIndex !== -1 ? existingIndex : undefined);
|
|
140
|
+
console.log(chalk_1.default.dim(`${result.updated ? 'Updated' : 'Added'} device: ${deviceName}`));
|
|
141
|
+
config.ff1Devices = { devices: result.devices };
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
config.ff1Devices = { devices: existingDevices };
|
|
145
|
+
if (existingDevices.length === 0) {
|
|
146
|
+
console.log(chalk_1.default.dim('No device configured. Add one with: ff-cli device add --host http://<device-ip>:1111 --name <name>'));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
const ask = getAsk();
|
|
152
|
+
const selection = await (0, device_discovery_1.discoverAndSelectDevice)(ask, existingDevices, { allowSkip: true });
|
|
153
|
+
if (selection.hostValue) {
|
|
154
|
+
// Prefer the already-stored label so re-running setup (or re-adding a device
|
|
155
|
+
// that returned on a new IP) doesn't clobber the friendly name.
|
|
156
|
+
const existingEntry = (0, device_lookup_1.findExistingDeviceEntry)(existingDevices, selection.hostValue, selection.discoveredName, selection.discoveredId, selection.discoveredAddresses);
|
|
157
|
+
const existingIndex = existingEntry
|
|
158
|
+
? existingDevices.findIndex((d) => d === existingEntry)
|
|
159
|
+
: -1;
|
|
160
|
+
const existingName = existingEntry?.name || '';
|
|
161
|
+
const defaultName = existingName || selection.discoveredName || 'art-computer';
|
|
162
|
+
const namePrompt = defaultName !== 'art-computer'
|
|
163
|
+
? `Device name (kitchen, office, etc.) [${defaultName}]: `
|
|
164
|
+
: 'Device name (kitchen, office, etc.): ';
|
|
165
|
+
const nameAnswer = await ask(namePrompt);
|
|
166
|
+
let deviceName = nameAnswer || defaultName || 'art-computer';
|
|
167
|
+
// Same name-collision guard as `device add`: reject names that would
|
|
168
|
+
// clobber a different device entry. Only fires when existingIndex !== -1
|
|
169
|
+
// (we know the row); when existingIndex === -1, a same-name entry is the
|
|
170
|
+
// case-3 migration path.
|
|
171
|
+
const setupNameConflict = existingIndex !== -1
|
|
111
172
|
? existingDevices.find((d, i) => d.name === deviceName && i !== existingIndex)
|
|
112
173
|
: undefined;
|
|
113
|
-
if (
|
|
114
|
-
console.log(chalk_1.default.yellow(`"${deviceName}" is
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
174
|
+
if (setupNameConflict) {
|
|
175
|
+
console.log(chalk_1.default.yellow(`"${deviceName}" is already used by another device. Please choose a different name.`));
|
|
176
|
+
const retryAnswer = await ask('Device name: ');
|
|
177
|
+
deviceName = retryAnswer || 'art-computer';
|
|
178
|
+
const retryConflict = existingIndex !== -1
|
|
179
|
+
? existingDevices.find((d, i) => d.name === deviceName && i !== existingIndex)
|
|
180
|
+
: undefined;
|
|
181
|
+
if (retryConflict) {
|
|
182
|
+
console.log(chalk_1.default.yellow(`"${deviceName}" is also taken. Skipping device.`));
|
|
183
|
+
config.ff1Devices = { devices: existingDevices };
|
|
184
|
+
await fs_1.promises.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
118
187
|
}
|
|
188
|
+
const result = (0, device_upsert_1.upsertDevice)(existingDevices, {
|
|
189
|
+
name: deviceName,
|
|
190
|
+
host: selection.hostValue,
|
|
191
|
+
id: selection.discoveredId,
|
|
192
|
+
addresses: selection.discoveredAddresses,
|
|
193
|
+
}, existingIndex !== -1 ? existingIndex : undefined);
|
|
194
|
+
console.log(chalk_1.default.dim(`${result.updated ? 'Updated' : 'Added'} device: ${deviceName}`));
|
|
195
|
+
config.ff1Devices = { devices: result.devices };
|
|
196
|
+
}
|
|
197
|
+
else if (existingDevices.length > 0) {
|
|
198
|
+
config.ff1Devices = { devices: existingDevices };
|
|
119
199
|
}
|
|
120
|
-
const result = (0, device_upsert_1.upsertDevice)(existingDevices, {
|
|
121
|
-
name: deviceName,
|
|
122
|
-
host: selection.hostValue,
|
|
123
|
-
id: selection.discoveredId,
|
|
124
|
-
addresses: selection.discoveredAddresses,
|
|
125
|
-
}, existingIndex !== -1 ? existingIndex : undefined);
|
|
126
|
-
console.log(chalk_1.default.dim(`${result.updated ? 'Updated' : 'Added'} device: ${deviceName}`));
|
|
127
|
-
config.ff1Devices = { devices: result.devices };
|
|
128
|
-
}
|
|
129
|
-
else if (existingDevices.length > 0) {
|
|
130
|
-
config.ff1Devices = { devices: existingDevices };
|
|
131
200
|
}
|
|
132
201
|
await fs_1.promises.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
|
|
133
|
-
prompt
|
|
134
|
-
|
|
202
|
+
if (prompt) {
|
|
203
|
+
prompt.close();
|
|
204
|
+
prompt = null;
|
|
205
|
+
}
|
|
135
206
|
console.log(chalk_1.default.green('\nSetup complete'));
|
|
136
207
|
console.log(chalk_1.default.dim(` Config: ${configPath}`));
|
|
137
208
|
const hasSigningKey = !(0, config_files_1.isMissingConfigValue)(config.playlist?.privateKey || '');
|
package/dist/src/config.js
CHANGED
|
@@ -17,39 +17,10 @@ const path_1 = __importDefault(require("path"));
|
|
|
17
17
|
const os_1 = __importDefault(require("os"));
|
|
18
18
|
const playlist_signing_role_1 = require("./utilities/playlist-signing-role");
|
|
19
19
|
const config_placeholders_1 = require("./utilities/config-placeholders");
|
|
20
|
-
// One-shot legacy config migration: copy `$XDG_CONFIG_HOME/ff1/config.json` to
|
|
21
|
-
// `$XDG_CONFIG_HOME/ff-cli/config.json` on first read after upgrading from
|
|
22
|
-
// `ff1-cli`. The check is cheap (two fs.existsSync calls) and the
|
|
23
|
-
// `migrationChecked` flag keeps it to one attempt per process.
|
|
24
|
-
//
|
|
25
|
-
// Remove this block after a release cycle once telemetry suggests no remaining
|
|
26
|
-
// users still have only the legacy path populated.
|
|
27
|
-
let migrationChecked = false;
|
|
28
|
-
function migrateLegacyConfigIfNeeded(newUserPath) {
|
|
29
|
-
if (migrationChecked) {
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
migrationChecked = true;
|
|
33
|
-
const configBase = process.env.XDG_CONFIG_HOME || path_1.default.join(os_1.default.homedir(), '.config');
|
|
34
|
-
const legacyUserPath = path_1.default.join(configBase, 'ff1', 'config.json');
|
|
35
|
-
if (!fs_1.default.existsSync(legacyUserPath) || fs_1.default.existsSync(newUserPath)) {
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
try {
|
|
39
|
-
fs_1.default.mkdirSync(path_1.default.dirname(newUserPath), { recursive: true });
|
|
40
|
-
fs_1.default.copyFileSync(legacyUserPath, newUserPath);
|
|
41
|
-
console.log(`Migrated config from ${legacyUserPath} to ${newUserPath}`);
|
|
42
|
-
}
|
|
43
|
-
catch (error) {
|
|
44
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
45
|
-
console.warn(`Warning: Failed to migrate legacy config from ${legacyUserPath}: ${message}`);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
20
|
function getConfigPaths() {
|
|
49
21
|
const localPath = path_1.default.join(process.cwd(), 'config.json');
|
|
50
22
|
const configBase = process.env.XDG_CONFIG_HOME || path_1.default.join(os_1.default.homedir(), '.config');
|
|
51
23
|
const userPath = path_1.default.join(configBase, 'ff-cli', 'config.json');
|
|
52
|
-
migrateLegacyConfigIfNeeded(userPath);
|
|
53
24
|
return { localPath, userPath };
|
|
54
25
|
}
|
|
55
26
|
/**
|
|
@@ -301,16 +272,21 @@ async function createSampleConfig(targetPath) {
|
|
|
301
272
|
if (fs_1.default.existsSync(configPath)) {
|
|
302
273
|
throw new Error('config.json already exists');
|
|
303
274
|
}
|
|
304
|
-
// Look for config.json.example
|
|
305
|
-
//
|
|
306
|
-
//
|
|
275
|
+
// Look for config.json.example across the layouts we ship in:
|
|
276
|
+
// - cwd: running from a source checkout
|
|
277
|
+
// - __dirname/../..: npm install (this file is dist/src/config.js, template
|
|
278
|
+
// sits at the package root)
|
|
279
|
+
// - __dirname/..: single-file release bundle (this file is bundled into
|
|
280
|
+
// lib/ff-cli.js, template sits one level up at the package root)
|
|
307
281
|
const exampleCandidates = [
|
|
308
282
|
path_1.default.join(process.cwd(), 'config.json.example'),
|
|
309
283
|
path_1.default.join(__dirname, '../..', 'config.json.example'),
|
|
284
|
+
path_1.default.join(__dirname, '..', 'config.json.example'),
|
|
310
285
|
];
|
|
311
286
|
const examplePath = exampleCandidates.find((candidate) => fs_1.default.existsSync(candidate));
|
|
312
287
|
if (!examplePath) {
|
|
313
|
-
throw new Error('config.json.example not found
|
|
288
|
+
throw new Error('config.json.example not found (likely an incomplete install). ' +
|
|
289
|
+
'Reinstall with: npm i -g @feralfile/cli');
|
|
314
290
|
}
|
|
315
291
|
const exampleConfig = fs_1.default.readFileSync(examplePath, 'utf-8');
|
|
316
292
|
fs_1.default.mkdirSync(path_1.default.dirname(configPath), { recursive: true });
|
|
@@ -105,6 +105,20 @@ function parsePlaylistPrivateKeyToKeyObject(material) {
|
|
|
105
105
|
catch {
|
|
106
106
|
// Continue to other strategies (e.g. hex path may apply for unusual configs)
|
|
107
107
|
}
|
|
108
|
+
// A 32-byte base64 value is a raw Ed25519 seed (not PKCS#8). The example
|
|
109
|
+
// config's `_BASE64_OR_HEX` placeholder invites this, so accept it by
|
|
110
|
+
// wrapping the seed in a PKCS#8 envelope, mirroring the hex-seed path.
|
|
111
|
+
if (buf.length === 32) {
|
|
112
|
+
try {
|
|
113
|
+
const der = ed25519SeedBytesToPkcs8(buf);
|
|
114
|
+
const key = (0, node_crypto_1.createPrivateKey)({ key: der, format: 'der', type: 'pkcs8' });
|
|
115
|
+
assertEd25519(key);
|
|
116
|
+
return key;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// fall through to remaining strategies
|
|
120
|
+
}
|
|
121
|
+
}
|
|
108
122
|
}
|
|
109
123
|
if (hexRegex.test(trimmed)) {
|
|
110
124
|
const raw = Buffer.from(trimmed.replace(/^0x/i, ''), 'hex');
|
|
@@ -211,8 +211,14 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
211
211
|
if (device.apiKey) {
|
|
212
212
|
headers['API-KEY'] = device.apiKey;
|
|
213
213
|
}
|
|
214
|
-
// Make the API request with bounded retries for transient local network
|
|
214
|
+
// Make the API request with bounded retries for transient local network
|
|
215
|
+
// errors, device rate-limiting, and empty/non-JSON bodies (the device can
|
|
216
|
+
// return an empty response on the first cast after boot).
|
|
215
217
|
let response = null;
|
|
218
|
+
let responseData = {};
|
|
219
|
+
// Describes a 2xx response whose body could not be parsed, so the post-loop
|
|
220
|
+
// handler can report it clearly if every attempt is exhausted.
|
|
221
|
+
let lastBodyIssue = null;
|
|
216
222
|
for (let attempt = 1; attempt <= SEND_RETRY_ATTEMPTS; attempt += 1) {
|
|
217
223
|
try {
|
|
218
224
|
response = await fetch(apiUrl, {
|
|
@@ -231,6 +237,36 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
231
237
|
await waitForRetry(retryDelay);
|
|
232
238
|
continue;
|
|
233
239
|
}
|
|
240
|
+
// Non-2xx (other than 429 handled above): surface via the post-loop
|
|
241
|
+
// handler, which re-reads the body as error text.
|
|
242
|
+
if (!response.ok) {
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
// 2xx: read the body defensively. An empty or non-JSON body is a known
|
|
246
|
+
// transient hiccup (commonly the first cast after boot), so retry within
|
|
247
|
+
// the attempt budget instead of throwing "Unexpected end of JSON input".
|
|
248
|
+
const bodyText = (await response.text()).trim();
|
|
249
|
+
if (bodyText === '') {
|
|
250
|
+
lastBodyIssue = 'device returned an empty response body';
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
try {
|
|
254
|
+
responseData = JSON.parse(bodyText);
|
|
255
|
+
lastBodyIssue = null;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
lastBodyIssue = `device returned a non-JSON response body: ${bodyText.slice(0, 200)}`;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (attempt < SEND_RETRY_ATTEMPTS) {
|
|
263
|
+
const retryDelay = SEND_RETRY_BASE_DELAY_MS * attempt;
|
|
264
|
+
logger.warn(`${lastBodyIssue} (attempt ${attempt}/${SEND_RETRY_ATTEMPTS}); retrying in ${retryDelay}ms`);
|
|
265
|
+
response = null;
|
|
266
|
+
await waitForRetry(retryDelay);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
// Out of attempts; let the post-loop handler report the body issue.
|
|
234
270
|
break;
|
|
235
271
|
}
|
|
236
272
|
catch (error) {
|
|
@@ -273,8 +309,18 @@ async function sendPlaylistToDevice({ playlist, deviceName, }) {
|
|
|
273
309
|
details: errorText,
|
|
274
310
|
};
|
|
275
311
|
}
|
|
276
|
-
//
|
|
277
|
-
|
|
312
|
+
// 2xx but the body never parsed within the retry budget: report the HTTP
|
|
313
|
+
// status and what was wrong rather than crashing on a JSON parse error.
|
|
314
|
+
if (lastBodyIssue) {
|
|
315
|
+
const deviceLabel = device.name || device.host;
|
|
316
|
+
logger.error(`Cast to "${deviceLabel}" returned HTTP ${response.status} but ${lastBodyIssue}`);
|
|
317
|
+
return {
|
|
318
|
+
success: false,
|
|
319
|
+
error: `Device "${deviceLabel}" accepted the request (HTTP ${response.status}) but returned no usable response`,
|
|
320
|
+
details: `${lastBodyIssue}. This is often transient on the first cast after boot — ` +
|
|
321
|
+
'wait a few seconds and try again.',
|
|
322
|
+
};
|
|
323
|
+
}
|
|
278
324
|
logger.info('Successfully sent playlist to FF1 device');
|
|
279
325
|
logger.debug(`Device response: ${JSON.stringify(responseData)}`);
|
|
280
326
|
return {
|
|
@@ -534,18 +534,37 @@ function validateDP1Playlist(playlist) {
|
|
|
534
534
|
* misclassified HTML pages and extensionless URLs as static images, stamping
|
|
535
535
|
* them with a display duration that cut interactive artworks off.)
|
|
536
536
|
*
|
|
537
|
+
* The extension is taken from the URL *pathname* only — the query string and
|
|
538
|
+
* fragment are stripped first, so a signed URL like `art.png?sig=a.b` or
|
|
539
|
+
* `clip.mp4#t=1` still resolves to its real extension instead of parsing `b`
|
|
540
|
+
* or `mp4#t=1`. Misreading those would mislabel media as undetermined and
|
|
541
|
+
* (via buildUrlItem) route it through the interactive-web timing path.
|
|
542
|
+
*
|
|
537
543
|
* @param {string} url - Media or page URL
|
|
538
544
|
* @returns {string} MIME type, or '' when undetermined
|
|
539
545
|
* @example
|
|
540
|
-
* detectMimeType('https://example.com/image.png');
|
|
541
|
-
* detectMimeType('https://example.com/art.
|
|
542
|
-
* detectMimeType('https://example.com/
|
|
546
|
+
* detectMimeType('https://example.com/image.png'); // 'image/png'
|
|
547
|
+
* detectMimeType('https://example.com/art.png?sig=a.b'); // 'image/png'
|
|
548
|
+
* detectMimeType('https://example.com/art.html'); // 'text/html'
|
|
549
|
+
* detectMimeType('https://example.com/output/abc'); // ''
|
|
543
550
|
*/
|
|
544
551
|
function detectMimeType(url) {
|
|
545
552
|
if (!url) {
|
|
546
553
|
return '';
|
|
547
554
|
}
|
|
548
|
-
|
|
555
|
+
// Use the pathname only: drop query + fragment so a dot inside `?sig=a.b`
|
|
556
|
+
// or `#v=1.2` can't masquerade as the file extension.
|
|
557
|
+
let pathname;
|
|
558
|
+
try {
|
|
559
|
+
pathname = new URL(url).pathname;
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
// Not an absolute URL (bare path/filename) — strip fragment then query.
|
|
563
|
+
pathname = String(url).split('#')[0].split('?')[0];
|
|
564
|
+
}
|
|
565
|
+
const lastSegment = pathname.split('/').pop() || '';
|
|
566
|
+
const dotIndex = lastSegment.lastIndexOf('.');
|
|
567
|
+
const extension = dotIndex >= 0 ? lastSegment.slice(dotIndex + 1).toLowerCase() : '';
|
|
549
568
|
const mimeTypes = {
|
|
550
569
|
jpg: 'image/jpeg',
|
|
551
570
|
jpeg: 'image/jpeg',
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.castPlaylist = castPlaylist;
|
|
37
|
+
// playlist-signer and ff1-device are still CommonJS; require keeps interop simple.
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
39
|
+
const { signPlaylist } = require('./playlist-signer');
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
|
+
const { sendPlaylistToDevice } = require('./ff1-device');
|
|
42
|
+
/**
|
|
43
|
+
* Shared "(sign →) verify → send to device" sequence used by `play` and
|
|
44
|
+
* `find --play`.
|
|
45
|
+
*
|
|
46
|
+
* This is the single source of truth for getting a playlist onto an FF1: the
|
|
47
|
+
* verification gate, the synthesized-media auto-sign (rebuilding the
|
|
48
|
+
* `signatures[]` envelope), and the device transport. Callers own their
|
|
49
|
+
* console output and map the returned `stage`/`error` to their own messages —
|
|
50
|
+
* keeping delivery behavior consistent without coupling the two commands'
|
|
51
|
+
* UX. Previously this logic was duplicated across `play`, `find`, and the
|
|
52
|
+
* (now-removed) chat send path, and drifted between them.
|
|
53
|
+
*/
|
|
54
|
+
async function castPlaylist(playlist, options = {}) {
|
|
55
|
+
let current = playlist;
|
|
56
|
+
let signed = false;
|
|
57
|
+
let verified = false;
|
|
58
|
+
if (!options.skipVerify) {
|
|
59
|
+
if (options.requireSignature) {
|
|
60
|
+
if (!options.signingKey) {
|
|
61
|
+
return {
|
|
62
|
+
success: false,
|
|
63
|
+
playlist: current,
|
|
64
|
+
signed: false,
|
|
65
|
+
verified: false,
|
|
66
|
+
stage: 'sign',
|
|
67
|
+
error: 'Cannot sign playlist for playback: no playlist signing key is configured',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const signature = await signPlaylist(current, options.signingKey);
|
|
72
|
+
current = {
|
|
73
|
+
...current,
|
|
74
|
+
signature: undefined,
|
|
75
|
+
signatures: [signature],
|
|
76
|
+
};
|
|
77
|
+
signed = true;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
playlist: current,
|
|
83
|
+
signed: false,
|
|
84
|
+
verified: false,
|
|
85
|
+
stage: 'sign',
|
|
86
|
+
error: `Failed to sign playlist for playback: ${error.message}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const { verifyPlaylist } = await Promise.resolve().then(() => __importStar(require('./playlist-verifier')));
|
|
91
|
+
const verifyResult = await verifyPlaylist(current);
|
|
92
|
+
if (!verifyResult.valid) {
|
|
93
|
+
return {
|
|
94
|
+
success: false,
|
|
95
|
+
playlist: current,
|
|
96
|
+
signed,
|
|
97
|
+
verified: false,
|
|
98
|
+
stage: 'verify',
|
|
99
|
+
error: verifyResult.error,
|
|
100
|
+
details: verifyResult.details,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
verified = true;
|
|
104
|
+
}
|
|
105
|
+
const sendResult = await sendPlaylistToDevice({
|
|
106
|
+
playlist: current,
|
|
107
|
+
deviceName: options.deviceName,
|
|
108
|
+
});
|
|
109
|
+
if (sendResult.success) {
|
|
110
|
+
return {
|
|
111
|
+
success: true,
|
|
112
|
+
playlist: current,
|
|
113
|
+
signed,
|
|
114
|
+
verified,
|
|
115
|
+
deviceName: sendResult.deviceName,
|
|
116
|
+
device: sendResult.device,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
success: false,
|
|
121
|
+
playlist: current,
|
|
122
|
+
signed,
|
|
123
|
+
verified,
|
|
124
|
+
stage: 'send',
|
|
125
|
+
error: sendResult.error,
|
|
126
|
+
deviceDetails: sendResult.details,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -2,8 +2,63 @@
|
|
|
2
2
|
* Playlist Signing Utility.
|
|
3
3
|
* Uses the DP-1 v1.1.0 signing contract via the `dp1-js` package.
|
|
4
4
|
*/
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
+
}) : function(o, v) {
|
|
19
|
+
o["default"] = v;
|
|
20
|
+
});
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
5
38
|
const { getPlaylistConfig } = require('../config');
|
|
6
39
|
const { isDp1PlaylistSigningRole } = require('./playlist-signing-role');
|
|
40
|
+
const { parsePlaylistPrivateKeyToKeyObject } = require('./ed25519-key-derive');
|
|
41
|
+
/**
|
|
42
|
+
* Normalize any supported signing-key encoding to base64 PKCS#8 DER, the form
|
|
43
|
+
* dp1-js's signer expects. This makes hex seeds, base64 seeds, and PEM work for
|
|
44
|
+
* signing (not just base64 PKCS#8), and replaces dp1-js's cryptic OpenSSL error
|
|
45
|
+
* (e.g. "header too long") with actionable guidance when the key is malformed.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} material - Raw key string from config or --key
|
|
48
|
+
* @returns {string} base64-encoded PKCS#8 DER Ed25519 private key
|
|
49
|
+
*/
|
|
50
|
+
function normalizeSigningKeyToBase64Pkcs8(material) {
|
|
51
|
+
let keyObject;
|
|
52
|
+
try {
|
|
53
|
+
keyObject = parsePlaylistPrivateKeyToKeyObject(material);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw new Error(`Invalid Ed25519 signing key: ${error.message}. Provide a base64 PKCS#8 DER key ` +
|
|
57
|
+
'(recommended), a 32-byte raw seed as hex or base64, or a PEM key. ' +
|
|
58
|
+
'Run `ff-cli setup` to generate one.');
|
|
59
|
+
}
|
|
60
|
+
return keyObject.export({ format: 'der', type: 'pkcs8' }).toString('base64');
|
|
61
|
+
}
|
|
7
62
|
/**
|
|
8
63
|
* Sign a playlist using the DP-1 signing API.
|
|
9
64
|
* The signed payload excludes any pre-existing signature fields so the output
|
|
@@ -33,8 +88,9 @@ async function signPlaylist(playlist, privateKeyBase64, roleOverride) {
|
|
|
33
88
|
const raw = Buffer.from(JSON.stringify(playlistToSign));
|
|
34
89
|
const config = getPlaylistConfig();
|
|
35
90
|
const role = resolvePlaylistSigningRole(roleOverride || config.role);
|
|
91
|
+
const normalizedKey = normalizeSigningKeyToBase64Pkcs8(privateKey);
|
|
36
92
|
if (typeof dp1.SignMultiEd25519 === 'function') {
|
|
37
|
-
return dp1.SignMultiEd25519(raw,
|
|
93
|
+
return dp1.SignMultiEd25519(raw, normalizedKey, role, currentTimestamp());
|
|
38
94
|
}
|
|
39
95
|
throw new Error('dp1-js does not expose SignMultiEd25519');
|
|
40
96
|
}
|
|
@@ -162,11 +218,15 @@ async function buildSignedPlaylistEnvelope(playlist, privateKey, dp1, role) {
|
|
|
162
218
|
const playlistToSign = { ...playlist };
|
|
163
219
|
delete playlistToSign.signature;
|
|
164
220
|
delete playlistToSign.signatures;
|
|
221
|
+
// Normalize to base64 PKCS#8 DER so any supported key encoding (hex/base64
|
|
222
|
+
// seed, PEM) works and a malformed key surfaces a clear error instead of
|
|
223
|
+
// dp1-js's cryptic OpenSSL ASN.1 failure ("header too long" / "wrong tag").
|
|
224
|
+
const normalizedKey = normalizeSigningKeyToBase64Pkcs8(privateKey);
|
|
165
225
|
const existingSignatures = Array.isArray(playlist.signatures)
|
|
166
226
|
? playlist.signatures.filter((entry) => Boolean(entry))
|
|
167
227
|
: [];
|
|
168
228
|
if (typeof dp1.SignMultiEd25519 === 'function') {
|
|
169
|
-
const signature = await dp1.SignMultiEd25519(Buffer.from(JSON.stringify(playlistToSign)),
|
|
229
|
+
const signature = await dp1.SignMultiEd25519(Buffer.from(JSON.stringify(playlistToSign)), normalizedKey, role, currentTimestamp());
|
|
170
230
|
return {
|
|
171
231
|
...playlist,
|
|
172
232
|
signature: undefined,
|
|
@@ -195,9 +255,13 @@ async function verifySignedPlaylistEnvelope(signedPlaylist, dp1) {
|
|
|
195
255
|
}
|
|
196
256
|
return { valid: true };
|
|
197
257
|
}
|
|
198
|
-
/**
|
|
258
|
+
/**
|
|
259
|
+
* Loads `dp1-js`; env overrides are not supported (see playlist-verifier).
|
|
260
|
+
* Uses dynamic `import()` so the single-file release bundle inlines dp1-js
|
|
261
|
+
* instead of leaving a runtime require that can't resolve.
|
|
262
|
+
*/
|
|
199
263
|
async function loadDp1() {
|
|
200
|
-
return require('dp1-js');
|
|
264
|
+
return Promise.resolve().then(() => __importStar(require('dp1-js')));
|
|
201
265
|
}
|
|
202
266
|
function currentTimestamp() {
|
|
203
267
|
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
@@ -47,7 +47,6 @@ exports.verifyPlaylistFile = verifyPlaylistFile;
|
|
|
47
47
|
exports.printVerificationResult = printVerificationResult;
|
|
48
48
|
const chalk_1 = __importDefault(require("chalk"));
|
|
49
49
|
const fs_1 = require("fs");
|
|
50
|
-
const module_1 = require("module");
|
|
51
50
|
/**
|
|
52
51
|
* Cryptographically verify a playlist via dp1-js (after parsing succeeds).
|
|
53
52
|
*
|
|
@@ -155,10 +154,15 @@ async function parseDp1Playlist(playlist) {
|
|
|
155
154
|
* Loads the published DP-1 implementation bundled with the CLI (`dp1-js`).
|
|
156
155
|
* Local checkout overrides via environment are intentionally unsupported so
|
|
157
156
|
* resolution stays deterministic across machines and CI.
|
|
157
|
+
*
|
|
158
|
+
* Uses a dynamic `import()` (not `createRequire`) so the single-file release
|
|
159
|
+
* bundle inlines dp1-js: esbuild can follow `import('dp1-js')` and pull the
|
|
160
|
+
* module into the bundle, whereas a `createRequire(__filename)` require escapes
|
|
161
|
+
* bundling and fails at runtime with "Cannot find module 'dp1-js'". The import
|
|
162
|
+
* stays lazy, so dp1-js's transitive deps don't load on unrelated commands.
|
|
158
163
|
*/
|
|
159
164
|
async function loadDp1() {
|
|
160
|
-
|
|
161
|
-
return require('dp1-js');
|
|
165
|
+
return (await Promise.resolve().then(() => __importStar(require('dp1-js'))));
|
|
162
166
|
}
|
|
163
167
|
/**
|
|
164
168
|
* Verify playlist file
|
|
@@ -226,8 +226,10 @@ async function listArtworkTokens(artworkId, options = {}) {
|
|
|
226
226
|
contractAddress: raw.contractAddress,
|
|
227
227
|
tokenId: raw.tokenId,
|
|
228
228
|
mediaUrl: raw.media?.contentUrl ?? null,
|
|
229
|
-
// Prefer
|
|
230
|
-
//
|
|
229
|
+
// Prefer the real MIME (`contentType`); today it is usually empty, so we
|
|
230
|
+
// fall back to `previewType` as a last resort. NOTE: previewType only
|
|
231
|
+
// describes the preview encoding, not the work, so this fallback can
|
|
232
|
+
// misclassify (see the mediaType doc comment above). Tracked for revisit.
|
|
231
233
|
mediaType: raw.media?.contentType || raw.media?.previewType || null,
|
|
232
234
|
});
|
|
233
235
|
}
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -36,32 +36,39 @@ Optional settings used where headless/browser‑like behavior is needed.
|
|
|
36
36
|
|
|
37
37
|
Used for signing DP‑1 playlists.
|
|
38
38
|
|
|
39
|
-
- `playlist.privateKey` (string, Ed25519 private key in hex or base64): Used by the `sign` command to create DP-1 v1.1.0 multi-signatures. The `verify` command may derive the matching public key from this value (or `PLAYLIST_PRIVATE_KEY`) when you omit `--public-key`; **dp1-js applies that derived key only when verifying legacy flat `signature` strings**, not when checking `signatures[]` envelopes. If that derivation fails, `verify` prints a warning on stderr and continues without derived key material. The derived public key is emitted as PEM so Node can decode it without ambiguity. Hex may include or omit the `0x` prefix. You can also set this via `PLAYLIST_PRIVATE_KEY` in `.env`. `play` verifies playlists before delivery and only auto-signs the synthesized media URL fallback when signing is configured. `
|
|
39
|
+
- `playlist.privateKey` (string, Ed25519 private key in hex or base64): Used by the `sign` command to create DP-1 v1.1.0 multi-signatures. The `verify` command may derive the matching public key from this value (or `PLAYLIST_PRIVATE_KEY`) when you omit `--public-key`; **dp1-js applies that derived key only when verifying legacy flat `signature` strings**, not when checking `signatures[]` envelopes. If that derivation fails, `verify` prints a warning on stderr and continues without derived key material. The derived public key is emitted as PEM so Node can decode it without ambiguity. Hex may include or omit the `0x` prefix. You can also set this via `PLAYLIST_PRIVATE_KEY` in `.env`. `play` verifies playlists before delivery and only auto-signs the synthesized media URL fallback when signing is configured. `play` and `publish` verify before delivery or upload and reject unsigned or broken playlists.
|
|
40
40
|
|
|
41
|
-
**Signing and key encoding:** Signing paths (`sign`, deterministic `build` when configured, and `-k/--key` overrides)
|
|
41
|
+
**Signing and key encoding:** Signing paths (`sign`, deterministic `build` when configured, and `-k/--key` overrides) accept the private key in any of these encodings:
|
|
42
|
+
|
|
43
|
+
- **base64 PKCS#8 DER** — recommended; what `ff-cli setup` generates.
|
|
44
|
+
- **32-byte raw Ed25519 seed** as **hex** (optional `0x`) or **base64**.
|
|
45
|
+
- **PEM** (`-----BEGIN PRIVATE KEY-----`).
|
|
46
|
+
- **PKCS#8 DER as hex**.
|
|
47
|
+
|
|
48
|
+
ff-cli normalizes whichever form you supply to base64 PKCS#8 DER before handing it to **`dp1-js`** (`SignMultiEd25519`). A malformed key fails with an actionable message (e.g. _"Invalid Ed25519 signing key … run `ff-cli setup` to generate one"_) instead of dp1-js's cryptic OpenSSL ASN.1 error (`header too long` / `wrong tag`).
|
|
42
49
|
|
|
43
50
|
- `playlist.role` (string): DP-1 signing role that travels with the private key. Defaults to `agent` if omitted. You can also set this via `PLAYLIST_ROLE` in `.env`. Guided `ff-cli setup`, `config validate`, and `sign --role` only accept the usual DP-1 signing roles (`agent`, `feed`, `curator`, `institution`, `licensor`).
|
|
44
51
|
|
|
45
52
|
### Generate an Ed25519 private key
|
|
46
53
|
|
|
47
|
-
|
|
54
|
+
The simplest path is `ff-cli setup`, which generates a key for you (base64 PKCS#8 DER) and writes it to your config. To generate one yourself:
|
|
48
55
|
|
|
49
|
-
OpenSSL (recommended):
|
|
56
|
+
OpenSSL (recommended — produces a PKCS#8 DER key):
|
|
50
57
|
|
|
51
58
|
```bash
|
|
52
|
-
# Base64 (
|
|
59
|
+
# Base64 PKCS#8 DER (recommended)
|
|
53
60
|
openssl genpkey -algorithm ED25519 -outform DER | base64 | tr -d '\n'
|
|
54
61
|
|
|
55
|
-
# Hex (alternative)
|
|
62
|
+
# Hex of the same PKCS#8 DER (alternative)
|
|
56
63
|
openssl genpkey -algorithm ED25519 -outform DER | xxd -p -c 256
|
|
57
64
|
```
|
|
58
65
|
|
|
59
|
-
Paste
|
|
66
|
+
Paste the value into `playlist.privateKey`. Any of these are accepted:
|
|
60
67
|
|
|
61
|
-
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
-
|
|
68
|
+
- **base64 PKCS#8 DER** (recommended): `MC4CAQAwBQYDK2Vw...`
|
|
69
|
+
- **32-byte raw seed** as hex (`0x`-prefixed or not) or base64 — e.g. the bare seed, not the full PKCS#8 blob.
|
|
70
|
+
- **PEM**: a `-----BEGIN PRIVATE KEY-----` block.
|
|
71
|
+
- **PKCS#8 DER as hex**.
|
|
65
72
|
|
|
66
73
|
If you need a different role, set `playlist.role` to one of the DP-1 signing roles such as `agent`, `feed`, `curator`, `institution`, or `licensor`. The CLI rejects any other string before it reaches `dp1-js`.
|
|
67
74
|
|
|
@@ -114,6 +121,12 @@ Configure devices you want to play content on.
|
|
|
114
121
|
|
|
115
122
|
During `ff-cli setup`, the CLI will attempt local discovery via mDNS (`_ff1._tcp`). If devices are found, you can pick one and the host will be filled in automatically. If discovery returns nothing, setup falls back to manual entry.
|
|
116
123
|
|
|
124
|
+
> **mDNS is best-effort.** It frequently does not cross subnets — a common case with mesh routers (e.g. eero) that put wired and Wi-Fi clients on different `/24`s. If discovery comes up empty even though the device is reachable by IP, skip it entirely and add the device directly:
|
|
125
|
+
>
|
|
126
|
+
> ```bash
|
|
127
|
+
> ff-cli device add --host http://<device-ip>:1111 --name <name>
|
|
128
|
+
> ```
|
|
129
|
+
|
|
117
130
|
You can also manage devices independently with:
|
|
118
131
|
|
|
119
132
|
- `ff-cli device add` – Add a device interactively (with mDNS discovery), or non-interactively with `--host` and `--name`.
|
|
@@ -128,6 +141,28 @@ Selection rules when sending:
|
|
|
128
141
|
- If you omit `-d`, the first configured device is used.
|
|
129
142
|
- If you pass `-d <name>`, the CLI matches the device by `name` (exact match). If not found, you’ll see an error listing available devices.
|
|
130
143
|
|
|
144
|
+
Device cast contract:
|
|
145
|
+
|
|
146
|
+
The device exposes a single HTTP endpoint, `POST http://<device>:1111/api/cast`. `ff-cli play` casts a DP-1 playlist with:
|
|
147
|
+
|
|
148
|
+
```jsonc
|
|
149
|
+
POST http://<device>:1111/api/cast
|
|
150
|
+
Content-Type: application/json
|
|
151
|
+
// API-KEY: <optional, only if the device requires one>
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
"command": "displayPlaylist",
|
|
155
|
+
"request": {
|
|
156
|
+
"dp1_call": { /* the full signed DP-1 playlist object */ },
|
|
157
|
+
"intent": { "action": "now_display" }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The `intent` field is **required** — without it the device returns `{"message":{"ok":false}}` with no further explanation. `dp1_call` may be the playlist inline (as above) or a hosted playlist URL string; ff-cli sends the resolved playlist object.
|
|
163
|
+
|
|
164
|
+
On the first cast after boot the device may return an empty body. ff-cli retries this automatically and, if it persists, reports a clear "accepted the request but returned no usable response" error rather than crashing on a JSON parse.
|
|
165
|
+
|
|
131
166
|
Compatibility checks:
|
|
132
167
|
|
|
133
168
|
- `play` and `ssh` perform a compatibility preflight before sending commands to FF1. The CLI gets the device version by calling `POST /api/cast` with `{ "command": "getDeviceStatus", "request": {} }` and reads `message.installedVersion` from the response.
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -261,6 +261,8 @@ npm run dev -- play "https://example.com/video.mp4"
|
|
|
261
261
|
npm run dev -- play playlist.json --skip-verify
|
|
262
262
|
```
|
|
263
263
|
|
|
264
|
+
> **No feed server is required to cast.** A device can play any static, signed DP-1 playlist hosted at a public URL — sign it (`sign`), upload the JSON anywhere that serves it over HTTPS (S3, Supabase Storage, a static host, etc.), then `ff-cli play "<url>"`. The DP-1 Feed server ([Publish to Feed Server](#publish-to-feed-server) below) adds discovery and curation, but is optional for simply playing your own playlists.
|
|
265
|
+
|
|
264
266
|
## Publish to Feed Server
|
|
265
267
|
|
|
266
268
|
Publish validated playlists to a DP-1 feed server for sharing and discovery.
|