@feralfile/cli 2.0.1 → 2.0.3
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 +2 -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 +32 -107
- package/dist/src/commands/setup.js +142 -71
- package/dist/src/config.js +10 -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 +33 -9
- 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 +49 -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"defaultDuration": 10,
|
|
3
|
+
"generativeDuration": 60,
|
|
3
4
|
"browser": {
|
|
4
5
|
"timeout": 90000,
|
|
5
6
|
"sanitizationLevel": "medium"
|
|
@@ -15,7 +16,7 @@
|
|
|
15
16
|
}
|
|
16
17
|
],
|
|
17
18
|
"playlist": {
|
|
18
|
-
"privateKey": "
|
|
19
|
+
"privateKey": "YOUR_ED25519_PRIVATE_KEY__base64_PKCS8_DER_recommended__or_32byte_raw_seed_as_hex_or_base64__run_ff-cli_setup_to_generate",
|
|
19
20
|
"role": "agent"
|
|
20
21
|
},
|
|
21
22
|
"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
|
*
|
|
@@ -126,83 +89,45 @@ exports.playCommand = new commander_1.Command('play')
|
|
|
126
89
|
? `${resolved.sourceType}: ${resolved.source}`
|
|
127
90
|
: resolved.source;
|
|
128
91
|
console.log(chalk_1.default.blue('\nPlay on FF1\n'));
|
|
129
|
-
if (!options.skipVerify) {
|
|
130
|
-
|
|
131
|
-
const privateKey = playlistConfig?.privateKey || process.env.PLAYLIST_PRIVATE_KEY;
|
|
132
|
-
const { verifyPlaylist } = await Promise.resolve().then(() => __importStar(require('../utilities/playlist-verifier')));
|
|
133
|
-
if (isPlaylistSource) {
|
|
134
|
-
console.log(chalk_1.default.cyan(`Verify playlist (${sourceLabel})`));
|
|
135
|
-
}
|
|
136
|
-
const verifyResult = resolved.kind === 'media'
|
|
137
|
-
? await (async () => {
|
|
138
|
-
try {
|
|
139
|
-
if (!privateKey) {
|
|
140
|
-
return {
|
|
141
|
-
valid: false,
|
|
142
|
-
error: 'Cannot sign playlist for playback: no playlist signing key is configured',
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
const signature = await signPlaylist(resolved.playlist, privateKey);
|
|
146
|
-
const signedPlaylist = {
|
|
147
|
-
...resolved.playlist,
|
|
148
|
-
signature: undefined,
|
|
149
|
-
signatures: [signature],
|
|
150
|
-
};
|
|
151
|
-
const signedVerification = await verifyPlaylist(signedPlaylist);
|
|
152
|
-
if (signedVerification.valid) {
|
|
153
|
-
resolved.playlist = signedPlaylist;
|
|
154
|
-
return { valid: true, playlist: signedPlaylist };
|
|
155
|
-
}
|
|
156
|
-
return {
|
|
157
|
-
valid: false,
|
|
158
|
-
error: signedVerification.error,
|
|
159
|
-
details: signedVerification.details,
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
catch (error) {
|
|
163
|
-
return {
|
|
164
|
-
valid: false,
|
|
165
|
-
error: `Failed to sign playlist for playback: ${error.message}`,
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
})()
|
|
169
|
-
: await verifyPlaylist(resolved.playlist);
|
|
170
|
-
if (!verifyResult.valid) {
|
|
171
|
-
(0, playlist_display_1.printPlaylistVerificationFailure)({
|
|
172
|
-
valid: false,
|
|
173
|
-
error: verifyResult.error,
|
|
174
|
-
details: verifyResult.details,
|
|
175
|
-
}, isPlaylistSource ? `source: ${sourceLabel}` : undefined);
|
|
176
|
-
process.exit(1);
|
|
177
|
-
}
|
|
178
|
-
if (resolved.kind === 'media') {
|
|
179
|
-
console.log(chalk_1.default.green('✓ Signed and verified\n'));
|
|
180
|
-
}
|
|
181
|
-
else if (isPlaylistSource) {
|
|
182
|
-
console.log(chalk_1.default.green('✓ Verified\n'));
|
|
183
|
-
}
|
|
92
|
+
if (!options.skipVerify && isPlaylistSource) {
|
|
93
|
+
console.log(chalk_1.default.cyan(`Verify playlist (${sourceLabel})`));
|
|
184
94
|
}
|
|
185
|
-
|
|
186
|
-
|
|
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, {
|
|
187
100
|
deviceName: options.device,
|
|
101
|
+
skipVerify: options.skipVerify,
|
|
102
|
+
requireSignature: resolved.kind === 'media',
|
|
103
|
+
signingKey: privateKey,
|
|
188
104
|
});
|
|
189
|
-
if (result.success) {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
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);
|
|
193
108
|
}
|
|
194
|
-
if (result.
|
|
195
|
-
console.
|
|
109
|
+
else if (result.stage === 'sign') {
|
|
110
|
+
console.error(chalk_1.default.red(`\n${result.error}`));
|
|
196
111
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
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
|
+
}
|
|
203
117
|
}
|
|
204
118
|
process.exit(1);
|
|
205
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();
|
|
206
131
|
}
|
|
207
132
|
catch (error) {
|
|
208
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
|
/**
|
|
@@ -63,6 +34,7 @@ function loadConfig() {
|
|
|
63
34
|
const { localPath, userPath } = getConfigPaths();
|
|
64
35
|
const defaultConfig = {
|
|
65
36
|
defaultDuration: parseInt(process.env.DEFAULT_DURATION || '10', 10),
|
|
37
|
+
generativeDuration: parseInt(process.env.DEFAULT_GENERATIVE_DURATION || '60', 10),
|
|
66
38
|
browser: {
|
|
67
39
|
timeout: parseInt(process.env.BROWSER_TIMEOUT || '90000', 10),
|
|
68
40
|
sanitizationLevel: process.env.SANITIZATION_LEVEL || 'medium',
|
|
@@ -301,16 +273,21 @@ async function createSampleConfig(targetPath) {
|
|
|
301
273
|
if (fs_1.default.existsSync(configPath)) {
|
|
302
274
|
throw new Error('config.json already exists');
|
|
303
275
|
}
|
|
304
|
-
// Look for config.json.example
|
|
305
|
-
//
|
|
306
|
-
//
|
|
276
|
+
// Look for config.json.example across the layouts we ship in:
|
|
277
|
+
// - cwd: running from a source checkout
|
|
278
|
+
// - __dirname/../..: npm install (this file is dist/src/config.js, template
|
|
279
|
+
// sits at the package root)
|
|
280
|
+
// - __dirname/..: single-file release bundle (this file is bundled into
|
|
281
|
+
// lib/ff-cli.js, template sits one level up at the package root)
|
|
307
282
|
const exampleCandidates = [
|
|
308
283
|
path_1.default.join(process.cwd(), 'config.json.example'),
|
|
309
284
|
path_1.default.join(__dirname, '../..', 'config.json.example'),
|
|
285
|
+
path_1.default.join(__dirname, '..', 'config.json.example'),
|
|
310
286
|
];
|
|
311
287
|
const examplePath = exampleCandidates.find((candidate) => fs_1.default.existsSync(candidate));
|
|
312
288
|
if (!examplePath) {
|
|
313
|
-
throw new Error('config.json.example not found
|
|
289
|
+
throw new Error('config.json.example not found (likely an incomplete install). ' +
|
|
290
|
+
'Reinstall with: npm i -g @feralfile/cli');
|
|
314
291
|
}
|
|
315
292
|
const exampleConfig = fs_1.default.readFileSync(examplePath, 'utf-8');
|
|
316
293
|
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 {
|
|
@@ -48,16 +48,18 @@ function isInteractiveWeb(mimeType, sourceUrl) {
|
|
|
48
48
|
* - a time-based source (video/audio) gets NO duration and
|
|
49
49
|
* `display.loop: false`, so a conformant player MUST advance at
|
|
50
50
|
* end-of-stream — the media plays its natural length;
|
|
51
|
-
* - an interactive web page (HTML)
|
|
52
|
-
*
|
|
53
|
-
*
|
|
51
|
+
* - an interactive web page (HTML) — generative art with no intrinsic
|
|
52
|
+
* runtime and no end-of-stream event — gets the configured
|
|
53
|
+
* `generativeDuration` (default 60s) so the playlist rotates. Set
|
|
54
|
+
* `generativeDuration` to 0 to omit it and have a conformant player park
|
|
55
|
+
* on the work open-ended instead;
|
|
54
56
|
* - static and code-based sources fall back to the configured
|
|
55
57
|
* `defaultDuration` because they have no intrinsic runtime to play out.
|
|
56
58
|
*
|
|
57
|
-
* Do not
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
59
|
+
* Do not stamp a duration on video/audio: it silently truncates or pads them.
|
|
60
|
+
* (Earlier this also omitted duration for HTML works, which left them with no
|
|
61
|
+
* duration field. Players that require the field — notably FF1 — then rejected
|
|
62
|
+
* the entire playlist, so generative works now carry an explicit duration.)
|
|
61
63
|
*
|
|
62
64
|
* @param {Object} item - DP1 playlist item (mutated in place)
|
|
63
65
|
* @param {Object} mediaHints - Media signals for the time-based check
|
|
@@ -76,8 +78,13 @@ function applyItemTiming(item, mediaHints = {}, duration) {
|
|
|
76
78
|
return item;
|
|
77
79
|
}
|
|
78
80
|
if (isInteractiveWeb(mediaHints.mimeType, mediaHints.sourceUrl)) {
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
+
// Generative/interactive works have no intrinsic runtime, so stamp the
|
|
82
|
+
// configured generative duration to drive playlist rotation. A configured
|
|
83
|
+
// value of 0 means "omit" — a conformant player then parks open-ended.
|
|
84
|
+
const generativeDuration = getGenerativeDuration();
|
|
85
|
+
if (generativeDuration > 0) {
|
|
86
|
+
item.duration = generativeDuration;
|
|
87
|
+
}
|
|
81
88
|
return item;
|
|
82
89
|
}
|
|
83
90
|
item.duration = getDefaultStaticDuration();
|
|
@@ -98,6 +105,23 @@ function getDefaultStaticDuration() {
|
|
|
98
105
|
return 10;
|
|
99
106
|
}
|
|
100
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* getGenerativeDuration returns the configured display seconds for
|
|
110
|
+
* generative/interactive (HTML) works, which have no intrinsic runtime.
|
|
111
|
+
* Defaults to 60 when config is unavailable. A configured 0 is honored (the
|
|
112
|
+
* caller omits the duration so the work plays open-ended).
|
|
113
|
+
*
|
|
114
|
+
* @returns {number} Display duration in seconds (0 means "omit / open-ended")
|
|
115
|
+
*/
|
|
116
|
+
function getGenerativeDuration() {
|
|
117
|
+
try {
|
|
118
|
+
const configured = getConfig().generativeDuration;
|
|
119
|
+
return typeof configured === 'number' ? configured : 60;
|
|
120
|
+
}
|
|
121
|
+
catch (_error) {
|
|
122
|
+
return 60;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
101
125
|
/**
|
|
102
126
|
* Convert a string to a URL-friendly slug
|
|
103
127
|
*
|
|
@@ -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
|
@@ -22,8 +22,11 @@ npm run dev -- config show
|
|
|
22
22
|
## Top‑level fields
|
|
23
23
|
|
|
24
24
|
- **defaultDuration** (number, seconds)
|
|
25
|
-
- Default per‑item display duration for media without an intrinsic runtime (images
|
|
25
|
+
- Default per‑item display duration for static media without an intrinsic runtime (images). Falls back to 10s when unset.
|
|
26
26
|
- Does NOT apply to video/audio items: with no explicit duration those are emitted without `duration` and with `display.loop: false`, so a DP-1 player advances at end-of-stream (§4.1) — the media plays its natural length.
|
|
27
|
+
- **generativeDuration** (number, seconds)
|
|
28
|
+
- Display duration stamped on generative/interactive (HTML) works, which have no intrinsic runtime and no end-of-stream event. Defaults to **60s** (override via `DEFAULT_GENERATIVE_DURATION`).
|
|
29
|
+
- Set to **0** to omit the duration entirely, so a conformant player parks on the work open-ended instead of rotating. Note: some players (including FF1) require every item to carry a `duration` and will reject a playlist whose items omit it — keep this non-zero unless you know your player tolerates an absent duration.
|
|
27
30
|
|
|
28
31
|
## browser
|
|
29
32
|
|
|
@@ -38,30 +41,37 @@ Used for signing DP‑1 playlists.
|
|
|
38
41
|
|
|
39
42
|
- `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
43
|
|
|
41
|
-
**Signing and key encoding:** Signing paths (`sign`, deterministic `build` when configured, and `-k/--key` overrides)
|
|
44
|
+
**Signing and key encoding:** Signing paths (`sign`, deterministic `build` when configured, and `-k/--key` overrides) accept the private key in any of these encodings:
|
|
45
|
+
|
|
46
|
+
- **base64 PKCS#8 DER** — recommended; what `ff-cli setup` generates.
|
|
47
|
+
- **32-byte raw Ed25519 seed** as **hex** (optional `0x`) or **base64**.
|
|
48
|
+
- **PEM** (`-----BEGIN PRIVATE KEY-----`).
|
|
49
|
+
- **PKCS#8 DER as hex**.
|
|
50
|
+
|
|
51
|
+
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
52
|
|
|
43
53
|
- `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
54
|
|
|
45
55
|
### Generate an Ed25519 private key
|
|
46
56
|
|
|
47
|
-
|
|
57
|
+
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
58
|
|
|
49
|
-
OpenSSL (recommended):
|
|
59
|
+
OpenSSL (recommended — produces a PKCS#8 DER key):
|
|
50
60
|
|
|
51
61
|
```bash
|
|
52
|
-
# Base64 (
|
|
62
|
+
# Base64 PKCS#8 DER (recommended)
|
|
53
63
|
openssl genpkey -algorithm ED25519 -outform DER | base64 | tr -d '\n'
|
|
54
64
|
|
|
55
|
-
# Hex (alternative)
|
|
65
|
+
# Hex of the same PKCS#8 DER (alternative)
|
|
56
66
|
openssl genpkey -algorithm ED25519 -outform DER | xxd -p -c 256
|
|
57
67
|
```
|
|
58
68
|
|
|
59
|
-
Paste
|
|
69
|
+
Paste the value into `playlist.privateKey`. Any of these are accepted:
|
|
60
70
|
|
|
61
|
-
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
-
|
|
71
|
+
- **base64 PKCS#8 DER** (recommended): `MC4CAQAwBQYDK2Vw...`
|
|
72
|
+
- **32-byte raw seed** as hex (`0x`-prefixed or not) or base64 — e.g. the bare seed, not the full PKCS#8 blob.
|
|
73
|
+
- **PEM**: a `-----BEGIN PRIVATE KEY-----` block.
|
|
74
|
+
- **PKCS#8 DER as hex**.
|
|
65
75
|
|
|
66
76
|
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
77
|
|
|
@@ -114,6 +124,12 @@ Configure devices you want to play content on.
|
|
|
114
124
|
|
|
115
125
|
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
126
|
|
|
127
|
+
> **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:
|
|
128
|
+
>
|
|
129
|
+
> ```bash
|
|
130
|
+
> ff-cli device add --host http://<device-ip>:1111 --name <name>
|
|
131
|
+
> ```
|
|
132
|
+
|
|
117
133
|
You can also manage devices independently with:
|
|
118
134
|
|
|
119
135
|
- `ff-cli device add` – Add a device interactively (with mDNS discovery), or non-interactively with `--host` and `--name`.
|
|
@@ -128,6 +144,28 @@ Selection rules when sending:
|
|
|
128
144
|
- If you omit `-d`, the first configured device is used.
|
|
129
145
|
- 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
146
|
|
|
147
|
+
Device cast contract:
|
|
148
|
+
|
|
149
|
+
The device exposes a single HTTP endpoint, `POST http://<device>:1111/api/cast`. `ff-cli play` casts a DP-1 playlist with:
|
|
150
|
+
|
|
151
|
+
```jsonc
|
|
152
|
+
POST http://<device>:1111/api/cast
|
|
153
|
+
Content-Type: application/json
|
|
154
|
+
// API-KEY: <optional, only if the device requires one>
|
|
155
|
+
|
|
156
|
+
{
|
|
157
|
+
"command": "displayPlaylist",
|
|
158
|
+
"request": {
|
|
159
|
+
"dp1_call": { /* the full signed DP-1 playlist object */ },
|
|
160
|
+
"intent": { "action": "now_display" }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
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.
|
|
166
|
+
|
|
167
|
+
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.
|
|
168
|
+
|
|
131
169
|
Compatibility checks:
|
|
132
170
|
|
|
133
171
|
- `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.
|