@feralfile/cli 2.0.1 → 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 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 (no Node.js required).
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.
@@ -15,7 +15,7 @@
15
15
  }
16
16
  ],
17
17
  "playlist": {
18
- "privateKey": "YOUR_ED25519_PRIVATE_KEY_BASE64_OR_HEX",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 ff1_device_1 = require("../utilities/ff1-device");
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
- // Match `ff-cli play`'s verification gate: signed playlists are verified
448
- // before device delivery; the same surface that play surfaces failures.
449
- // `buildDP1Playlist` signs when a playlist private key is configured and
450
- // silently continues unsigned otherwise — so when no key is set, the user
451
- // sees an actionable error here instead of an unverifiable playlist on
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
- const verifyResult = await verifyPlaylist(playlist);
457
- if (!verifyResult.valid) {
458
- console.error(chalk_1.default.red('Playlist verification failed:'), verifyResult.error);
459
- if (verifyResult.details && verifyResult.details.length > 0) {
460
- for (const d of verifyResult.details) {
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
- const result = await (0, ff1_device_1.sendPlaylistToDevice)({ playlist, deviceName });
473
- if (result.success) {
474
- console.log(chalk_1.default.green('✓ Playing'));
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
- console.error(chalk_1.default.red('Play failed:'), result.error);
485
- if (result.details) {
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
- process.exitCode = 1;
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/ff1/config.json`). Returns null when neither exists.
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
- const playlistConfig = config.playlist;
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
- const result = await sendPlaylistToDevice({
186
- playlist: resolved.playlist,
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
- console.log(chalk_1.default.green('✓ Playing'));
191
- if (result.deviceName) {
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.device) {
195
- console.log(chalk_1.default.dim(` Host: ${result.device}`));
109
+ else if (result.stage === 'sign') {
110
+ console.error(chalk_1.default.red(`\n${result.error}`));
196
111
  }
197
- console.log();
198
- }
199
- else {
200
- console.error(chalk_1.default.red('\nPlay failed:'), result.error);
201
- if (result.details) {
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
- .action(async () => {
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('\nff-cli setup\n'));
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 ((0, config_files_1.isMissingConfigValue)(currentKey)) {
36
- const keyPair = crypto_1.default.generateKeyPairSync('ed25519');
37
- signingKey = keyPair.privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64');
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
- const keepKey = await (0, prompt_1.promptYesNo)(ask, 'Keep existing signing key?', true);
41
- if (!keepKey) {
42
- const keyAnswer = await ask('Paste signing key (base64 or hex), or leave blank to regenerate: ');
43
- if (keyAnswer) {
44
- signingKey = keyAnswer;
45
- }
46
- else {
47
- const keyPair = crypto_1.default.generateKeyPairSync('ed25519');
48
- signingKey = keyPair.privateKey
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
- const roleHint = playlist_signing_role_1.DP1_PLAYLIST_SIGNING_ROLES.join(', ');
56
- while (true) {
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)(roleAnswer, signingRole || 'agent');
60
- break;
89
+ signingRole = (0, playlist_signing_role_1.resolveDp1PlaylistSigningRole)(options.role, signingRole || 'agent');
61
90
  }
62
91
  catch (error) {
63
- console.log(chalk_1.default.red(error.message));
64
- if (!roleAnswer.trim()) {
65
- console.log(chalk_1.default.dim('Enter one of the supported roles above, or fix the stored value.'));
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
- const selection = await (0, device_discovery_1.discoverAndSelectDevice)(ask, existingDevices, { allowSkip: true });
85
- if (selection.hostValue) {
86
- // Prefer the already-stored label so re-running setup (or re-adding a device
87
- // that returned on a new IP) doesn't clobber the friendly name.
88
- const existingEntry = (0, device_lookup_1.findExistingDeviceEntry)(existingDevices, selection.hostValue, selection.discoveredName, selection.discoveredId, selection.discoveredAddresses);
89
- const existingIndex = existingEntry
90
- ? existingDevices.findIndex((d) => d === existingEntry)
91
- : -1;
92
- const existingName = existingEntry?.name || '';
93
- const defaultName = existingName || selection.discoveredName || 'art-computer';
94
- const namePrompt = defaultName !== 'art-computer'
95
- ? `Device name (kitchen, office, etc.) [${defaultName}]: `
96
- : 'Device name (kitchen, office, etc.): ';
97
- const nameAnswer = await ask(namePrompt);
98
- let deviceName = nameAnswer || defaultName || 'art-computer';
99
- // Same name-collision guard as `device add`: reject names that would
100
- // clobber a different device entry. Only fires when existingIndex !== -1
101
- // (we know the row); when existingIndex === -1, a same-name entry is the
102
- // case-3 migration path.
103
- const setupNameConflict = existingIndex !== -1
104
- ? existingDevices.find((d, i) => d.name === deviceName && i !== existingIndex)
105
- : undefined;
106
- if (setupNameConflict) {
107
- console.log(chalk_1.default.yellow(`"${deviceName}" is already used by another device. Please choose a different name.`));
108
- const retryAnswer = await ask('Device name: ');
109
- deviceName = retryAnswer || 'art-computer';
110
- const retryConflict = existingIndex !== -1
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 (retryConflict) {
114
- console.log(chalk_1.default.yellow(`"${deviceName}" is also taken. Skipping device.`));
115
- config.ff1Devices = { devices: existingDevices };
116
- await fs_1.promises.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
117
- return;
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.close();
134
- prompt = null;
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 || '');
@@ -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 in the package directory
305
- // When compiled, this file is in dist/src/config.js
306
- // The template is at the package root: ../../config.json.example
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. This is likely a package installation issue.');
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 errors.
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
- // Parse response
277
- const responseData = (await response.json());
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 {
@@ -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, privateKey, role, currentTimestamp());
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)), privateKey, role, currentTimestamp());
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
- /** Loads `dp1-js`; env overrides are not supported (see playlist-verifier). */
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
- const require = (0, module_1.createRequire)(__filename);
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 a real MIME (`contentType`) when Raster populates it; today it
230
- // is usually empty, so fall back to the category-prefixed `previewType`.
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
  }
@@ -38,30 +38,37 @@ Used for signing DP‑1 playlists.
38
38
 
39
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) pass the private key string through to **`dp1-js`** (`SignMultiEd25519`) without an extra decoding step in ff-cli. `dp1-js` recognizes **hex** (optional `0x`) or **base64** encodings of the PKCS#8 DER blob produced by the OpenSSL examples below, then loads the key for Ed25519. Use those formats; ff-cli does not add a separate normalizer ahead of the library.
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
- You can generate a key locally. The CLI accepts either base64 (preferred) or hex
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 (preferred)
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 either value into `playlist.privateKey`:
66
+ Paste the value into `playlist.privateKey`. Any of these are accepted:
60
67
 
61
- - Hex example (either is valid):
62
- - `0xabc123...` (with prefix)
63
- - `abc123...` (without prefix)
64
- - Base64 example: `uQd9m8S...==`
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
5
5
  "repository": {
6
6
  "type": "git",