@feralfile/cli 2.10.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,23 +36,98 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.NonInteractiveError = exports.NON_TTY_ANSWER_TIMEOUT_MS = void 0;
40
+ exports.isNonInteractiveError = isNonInteractiveError;
39
41
  exports.createPrompt = createPrompt;
40
42
  exports.promptYesNo = promptYesNo;
41
43
  const readline = __importStar(require("readline"));
42
44
  const chalk_1 = __importDefault(require("chalk"));
43
- function createPrompt() {
44
- const rl = readline.createInterface({
45
- input: process.stdin,
46
- output: process.stdout,
47
- });
48
- const ask = async (question) => new Promise((resolve) => {
49
- rl.question(chalk_1.default.yellow(question), (answer) => {
50
- resolve(answer.trim());
45
+ /**
46
+ * NON_TTY_ANSWER_TIMEOUT_MS bounds how long a question waits for an answer on
47
+ * a stdin that is not a terminal.
48
+ *
49
+ * Piped answers (`echo n | ff-cli find …`) arrive at once and still work. An
50
+ * agent harness that leaves stdin open but never writes to it would otherwise
51
+ * wait forever, which reads as a hang the agent cannot diagnose.
52
+ */
53
+ exports.NON_TTY_ANSWER_TIMEOUT_MS = 10_000;
54
+ /**
55
+ * NonInteractiveError reports a question that got no answer because stdin is
56
+ * not a terminal: input ended first, or nothing arrived in time.
57
+ *
58
+ * Before this error, end-of-input left readline's callback uncalled and the
59
+ * process exited 0 having done nothing (`find <url>` printed "Build playlist?
60
+ * [Y/n]" and quit). That looks like success to a caller that trusts exit
61
+ * codes, so the question fails loudly instead, naming what would answer it.
62
+ *
63
+ * Callers match on `name` (isNonInteractiveError), not `instanceof`: tests
64
+ * replace this module with a stub that does not export the class.
65
+ */
66
+ class NonInteractiveError extends Error {
67
+ constructor(question, hint) {
68
+ const asked = question.trim();
69
+ super(`"${asked}" needs an answer, and none came: stdin is not a terminal.${hint ? ` ${hint}` : ''}`);
70
+ this.name = 'NonInteractiveError';
71
+ }
72
+ }
73
+ exports.NonInteractiveError = NonInteractiveError;
74
+ /**
75
+ * isNonInteractiveError reports whether an error came from a question that
76
+ * got no answer on a non-terminal stdin.
77
+ *
78
+ * @param {unknown} error - Any caught value
79
+ * @returns {boolean} True for a NonInteractiveError
80
+ */
81
+ function isNonInteractiveError(error) {
82
+ return error instanceof Error && error.name === 'NonInteractiveError';
83
+ }
84
+ /**
85
+ * createPrompt returns a prompt that asks on stdin and never waits silently.
86
+ *
87
+ * The readline interface opens on the first question, not at creation, so a
88
+ * command that never asks never attaches to stdin. A question rejects when
89
+ * input ends before an answer (on a terminal that is Ctrl-D; otherwise a
90
+ * NonInteractiveError), and, when stdin is not a terminal, when no answer
91
+ * arrives within NON_TTY_ANSWER_TIMEOUT_MS.
92
+ *
93
+ * @param {string} [nonInteractiveHint] - What answers the question without a
94
+ * terminal (usually a flag); appended to the NonInteractiveError message
95
+ * @param {Object} [options] - Test seams
96
+ * @param {PromptInput} [options.input] - Stream to read answers from (default stdin)
97
+ * @param {number} [options.answerTimeoutMs] - Non-terminal wait bound
98
+ * @returns {Prompt} ask/close pair
99
+ */
100
+ function createPrompt(nonInteractiveHint, options = {}) {
101
+ const input = options.input ?? process.stdin;
102
+ const timeoutMs = options.answerTimeoutMs ?? exports.NON_TTY_ANSWER_TIMEOUT_MS;
103
+ let rl = null;
104
+ const ask = async (question) => {
105
+ rl ??= readline.createInterface({ input, output: process.stdout });
106
+ const iface = rl;
107
+ return new Promise((resolve, reject) => {
108
+ let timer;
109
+ const settle = (finish) => {
110
+ clearTimeout(timer);
111
+ iface.off('close', onClose);
112
+ finish();
113
+ };
114
+ const noAnswer = () => input.isTTY
115
+ ? new Error('Input closed before an answer was given.')
116
+ : new NonInteractiveError(question, nonInteractiveHint);
117
+ const onClose = () => settle(() => reject(noAnswer()));
118
+ iface.once('close', onClose);
119
+ if (!input.isTTY) {
120
+ timer = setTimeout(() => settle(() => {
121
+ reject(noAnswer());
122
+ iface.close();
123
+ }), timeoutMs);
124
+ }
125
+ iface.question(chalk_1.default.yellow(question), (answer) => settle(() => resolve(answer.trim())));
51
126
  });
52
- });
127
+ };
53
128
  return {
54
129
  ask,
55
- close: () => rl.close(),
130
+ close: () => rl?.close(),
56
131
  };
57
132
  }
58
133
  async function promptYesNo(ask, question, defaultYes = true) {
@@ -81,6 +81,12 @@ function describeDiscoveryInput(source) {
81
81
  * redirects to `find` instead of being wrapped as a web item. The probe
82
82
  * costs one fetch before a redirect, only for discovery inputs that are
83
83
  * http(s) URLs; coordinates and wallet addresses never hit the network.
84
+ *
85
+ * A URL that does not exist (404/410, no such host, a broken `.json` link)
86
+ * is refused here rather than redirected: the redirect also suggests
87
+ * `play --web`, which would cast the error page.
88
+ *
89
+ * @throws {Error} When the URL does not exist (refusalForLoadFailure)
84
90
  */
85
91
  async function probeDiscoveryUrlAsPlaylist(source) {
86
92
  if (!(0, playlist_source_1.isPlaylistSourceUrl)(source)) {
@@ -99,89 +105,51 @@ async function probeDiscoveryUrlAsPlaylist(source) {
99
105
  source: loaded.source,
100
106
  };
101
107
  }
102
- catch {
108
+ catch (error) {
109
+ const refusal = (0, playlist_source_1.refusalForLoadFailure)(source.trim(), error);
110
+ if (refusal) {
111
+ throw refusal;
112
+ }
103
113
  return null;
104
114
  }
105
115
  }
116
+ /**
117
+ * resolveWebPageSource wraps `source` as a web page for `play --web`, without
118
+ * the discovery redirect or the playlist probe.
119
+ *
120
+ * `--web` exists because the discovery parser claims whole hosts: any page on
121
+ * feralfile.com, opensea.io, objkt.com and the like is redirected to `find`,
122
+ * and `find` rejects every page that is not an artwork it can resolve (a
123
+ * homepage, an exhibition overview, a profile). Before `--web` there was no
124
+ * way to put such a page on a wall; the two commands pointed at each other.
125
+ *
126
+ * @param {string} source - The URL the caller passed
127
+ * @returns {Promise<PlaySource>} A `media` source with a `text/html` item
128
+ * @throws {Error} When `source` is not an http(s) URL
129
+ */
130
+ async function resolveWebPageSource(source) {
131
+ const url = source.trim();
132
+ if (!(0, playlist_source_1.isPlaylistSourceUrl)(url)) {
133
+ throw new Error(`--web takes an http(s) URL; got "${source}".`);
134
+ }
135
+ const note = await (0, playlist_source_1.checkWebPageUrl)(url);
136
+ return (0, playlist_source_1.buildMediaPlaySource)(url, { asWebPage: true, note });
137
+ }
106
138
  exports.playCommand = new commander_1.Command('play')
107
- .description('Play a playlist or media URL on an FF1 device')
108
- .argument('<source>', 'Playlist file, playlist URL, or media URL')
139
+ .description('Play a playlist file, playlist URL, media URL, or web page on an FF1 device')
140
+ .argument('<source>', 'Playlist file, playlist URL, or any media or web page URL')
109
141
  .option('-d, --device <name>', 'Device name (uses first device if not specified)')
142
+ .option('--web', 'Play the URL as a web page, even on a marketplace site where play would otherwise send you to find')
110
143
  .option('--skip-verify', 'Skip signature verification and auto-sign gating before playing; use only when you accept malformed envelopes')
111
144
  .action(async (source, options) => {
112
145
  try {
113
- // `play` casts an exact playable source. A marketplace URL / on-chain
114
- // coords / wallet must be resolved through the indexer first — redirect
115
- // to `find` rather than silently wrapping the *page* as a web item.
116
- // Exception: a discovery-classified URL that actually serves a DP-1
117
- // playlist document (the discovery parser claims whole hosts, catching
118
- // feed playlist URLs) plays directly — see probeDiscoveryUrlAsPlaylist.
119
- const discovery = describeDiscoveryInput(source);
120
- let probed = null;
121
- if (discovery) {
122
- probed = await probeDiscoveryUrlAsPlaylist(source);
123
- }
124
- if (discovery && !probed) {
125
- console.error(chalk_1.default.red('\n`play` casts an exact playlist or media URL — it does not search marketplaces.'));
126
- console.error(chalk_1.default.dim(` "${source}" looks like ${discovery}, which needs to be resolved first.`));
127
- const deviceFlag = options.device ? ` -d "${options.device}"` : '';
128
- console.log(chalk_1.default.yellow(`\nTry: ff-cli find "${source}" --play${deviceFlag}`));
129
- process.exit(1);
130
- }
131
- // No explicit duration: a direct video/audio URL plays its natural
132
- // length (DP-1 §4.1); static media uses config.defaultDuration.
133
- const resolved = probed ?? (await (0, playlist_source_1.resolvePlaySource)(source));
134
- const isPlaylistSource = resolved.kind === 'playlist';
135
- const sourceLabel = isPlaylistSource
136
- ? `${resolved.sourceType}: ${resolved.source}`
137
- : resolved.source;
138
- console.log(chalk_1.default.blue('\nPlay on FF1\n'));
139
- if (!options.skipVerify && isPlaylistSource) {
140
- console.log(chalk_1.default.cyan(`Verify playlist (${sourceLabel})`));
141
- }
142
- // A direct media/web URL is wrapped in an unsigned single-item playlist,
143
- // so it must be signed before the verify gate; a loaded playlist is sent
144
- // as-is. castPlaylist owns the sign → verify → send sequence.
145
- // Resolve through getPlaylistConfig rather than reading config.playlist directly: it is the one
146
- // place that knows a config value may still be the config.json.example placeholder, in which case a
147
- // valid PLAYLIST_PRIVATE_KEY must win. Duplicating the `config || env` precedence here is what made
148
- // this path sign with the template literal while every other signing path used the real key.
149
- const privateKey = (0, config_1.getPlaylistConfig)().privateKey || undefined;
150
- const result = await (0, playlist_cast_1.castPlaylist)(resolved.playlist, {
151
- deviceName: options.device,
152
- skipVerify: options.skipVerify,
153
- requireSignature: resolved.kind === 'media',
154
- signingKey: privateKey,
155
- });
156
- if (!result.success) {
157
- if (result.stage === 'verify') {
158
- (0, playlist_display_1.printPlaylistVerificationFailure)({ valid: false, error: result.error, details: result.details }, isPlaylistSource ? `source: ${sourceLabel}` : undefined);
159
- }
160
- else if (result.stage === 'sign') {
161
- console.error(chalk_1.default.red(`\n${result.error}`));
162
- }
163
- else {
164
- console.error(chalk_1.default.red('\nPlay failed:'), result.error);
165
- if (result.deviceDetails) {
166
- console.error(chalk_1.default.dim(` Details: ${result.deviceDetails}`));
167
- }
168
- }
169
- process.exit(1);
170
- }
171
- if (result.verified) {
172
- console.log(chalk_1.default.green(result.signed ? '✓ Signed and verified\n' : '✓ Verified\n'));
173
- }
174
- console.log(chalk_1.default.green('✓ Playing'));
175
- if (result.deviceName) {
176
- console.log(chalk_1.default.dim(` Device: ${result.deviceName}`));
177
- }
178
- if (result.device) {
179
- console.log(chalk_1.default.dim(` Host: ${result.device}`));
180
- }
181
- console.log();
146
+ const resolved = options.web
147
+ ? await resolveWebPageSource(source)
148
+ : await resolveDefaultSource(source, options.device);
149
+ await castResolved(resolved, options);
182
150
  }
183
151
  catch (error) {
184
- if ((0, playlist_source_1.isPlaylistSourceUrl)(source)) {
152
+ if (!options.web && (0, playlist_source_1.isPlaylistSourceUrl)(source)) {
185
153
  (0, playlist_display_1.printPlaylistSourceLoadFailure)(source, error);
186
154
  }
187
155
  else {
@@ -190,3 +158,100 @@ exports.playCommand = new commander_1.Command('play')
190
158
  process.exit(1);
191
159
  }
192
160
  });
161
+ /**
162
+ * resolveDefaultSource resolves a `play` argument without `--web`: discovery
163
+ * inputs go to `find`, everything else loads as a playlist or wraps as media.
164
+ *
165
+ * `play` casts an exact playable source. A marketplace URL / on-chain coords /
166
+ * wallet must be resolved through the indexer first, so it redirects to `find`
167
+ * rather than silently wrapping the *page* as a web item. Exception: a
168
+ * discovery-classified URL that actually serves a DP-1 playlist document (the
169
+ * discovery parser claims whole hosts, catching feed playlist URLs) plays
170
+ * directly; see probeDiscoveryUrlAsPlaylist. The redirect names `--web` too,
171
+ * for the caller who did mean the page.
172
+ *
173
+ * @param {string} source - The URL, path, or reference the caller passed
174
+ * @param {string} [device] - Device name, carried into the suggested command
175
+ * @returns {Promise<PlaySource>} What to cast; exits 1 on a discovery input
176
+ */
177
+ async function resolveDefaultSource(source, device) {
178
+ const discovery = describeDiscoveryInput(source);
179
+ let probed = null;
180
+ if (discovery) {
181
+ probed = await probeDiscoveryUrlAsPlaylist(source);
182
+ }
183
+ if (discovery && !probed) {
184
+ console.error(chalk_1.default.red('\n`play` casts an exact playlist or media URL — it does not search marketplaces.'));
185
+ console.error(chalk_1.default.dim(` "${source}" looks like ${discovery}, which needs to be resolved first.`));
186
+ const deviceFlag = device ? ` -d "${device}"` : '';
187
+ console.log(chalk_1.default.yellow(`\nTry: ff-cli find "${source}" --play -y${deviceFlag}`));
188
+ if ((0, playlist_source_1.isPlaylistSourceUrl)(source)) {
189
+ console.log(chalk_1.default.dim(`To show the page itself: ff-cli play --web "${source}"${deviceFlag}`));
190
+ }
191
+ process.exit(1);
192
+ }
193
+ // No explicit duration: a direct video/audio URL plays its natural
194
+ // length (DP-1 §4.1); static media uses config.defaultDuration.
195
+ return probed ?? (await (0, playlist_source_1.resolvePlaySource)(source));
196
+ }
197
+ /**
198
+ * castResolved signs (for a synthesized media playlist), verifies, and sends
199
+ * a resolved source, then reports the outcome. Exits 1 on any failure.
200
+ *
201
+ * @param {PlaySource} resolved - What to cast
202
+ * @param {Object} options - The `play` options
203
+ * @returns {Promise<void>}
204
+ */
205
+ async function castResolved(resolved, options) {
206
+ const isPlaylistSource = resolved.kind === 'playlist';
207
+ const sourceLabel = isPlaylistSource
208
+ ? `${resolved.sourceType}: ${resolved.source}`
209
+ : resolved.source;
210
+ console.log(chalk_1.default.blue('\nPlay on FF1\n'));
211
+ if (resolved.kind === 'media' && resolved.note) {
212
+ console.log(chalk_1.default.yellow(resolved.note));
213
+ }
214
+ if (!options.skipVerify && isPlaylistSource) {
215
+ console.log(chalk_1.default.cyan(`Verify playlist (${sourceLabel})`));
216
+ }
217
+ // A direct media/web URL is wrapped in an unsigned single-item playlist,
218
+ // so it must be signed before the verify gate; a loaded playlist is sent
219
+ // as-is. castPlaylist owns the sign → verify → send sequence.
220
+ // Resolve through getPlaylistConfig rather than reading config.playlist directly: it is the one
221
+ // place that knows a config value may still be the config.json.example placeholder, in which case a
222
+ // valid PLAYLIST_PRIVATE_KEY must win. Duplicating the `config || env` precedence here is what made
223
+ // this path sign with the template literal while every other signing path used the real key.
224
+ const privateKey = (0, config_1.getPlaylistConfig)().privateKey || undefined;
225
+ const result = await (0, playlist_cast_1.castPlaylist)(resolved.playlist, {
226
+ deviceName: options.device,
227
+ skipVerify: options.skipVerify,
228
+ requireSignature: resolved.kind === 'media',
229
+ signingKey: privateKey,
230
+ });
231
+ if (!result.success) {
232
+ if (result.stage === 'verify') {
233
+ (0, playlist_display_1.printPlaylistVerificationFailure)({ valid: false, error: result.error, details: result.details }, isPlaylistSource ? `source: ${sourceLabel}` : undefined);
234
+ }
235
+ else if (result.stage === 'sign') {
236
+ console.error(chalk_1.default.red(`\n${result.error}`));
237
+ }
238
+ else {
239
+ console.error(chalk_1.default.red('\nPlay failed:'), result.error);
240
+ if (result.deviceDetails) {
241
+ console.error(chalk_1.default.dim(` Details: ${result.deviceDetails}`));
242
+ }
243
+ }
244
+ process.exit(1);
245
+ }
246
+ if (result.verified) {
247
+ console.log(chalk_1.default.green(result.signed ? '✓ Signed and verified\n' : '✓ Verified\n'));
248
+ }
249
+ console.log(chalk_1.default.green('✓ Playing'));
250
+ if (result.deviceName) {
251
+ console.log(chalk_1.default.dim(` Device: ${result.deviceName}`));
252
+ }
253
+ if (result.device) {
254
+ console.log(chalk_1.default.dim(` Host: ${result.device}`));
255
+ }
256
+ console.log();
257
+ }
@@ -17,7 +17,61 @@ const prompt_1 = require("./helpers/prompt");
17
17
  const config_placeholders_1 = require("../utilities/config-placeholders");
18
18
  const ed25519_key_derive_1 = require("../utilities/ed25519-key-derive");
19
19
  const signing_key_source_1 = require("../utilities/signing-key-source");
20
+ const signing_identity_1 = require("../utilities/signing-identity");
20
21
  const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
22
+ /**
23
+ * didKeyOrNull derives the signing identity for key material, or null when the
24
+ * material does not parse (a placeholder or corrupt value is not an identity
25
+ * anyone owns playlists with).
26
+ *
27
+ * @param {string} keyMaterial - Configured or supplied key material
28
+ * @returns {string | null} The did:key, or null
29
+ */
30
+ function didKeyOrNull(keyMaterial) {
31
+ try {
32
+ return (0, signing_identity_1.playlistSigningDidKey)(keyMaterial);
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /**
39
+ * assertKeyReplacementAllowed refuses to replace a configured signing key with
40
+ * a different one unless the caller passed --force.
41
+ *
42
+ * The configured key is the curator identity that owns every playlist it
43
+ * published: `publish --replace` and `unpublish` only accept that key. setup
44
+ * keeps no copy of the old key, so replacing it silently is permanent. This
45
+ * happened in practice: an agent ran the documented `setup --non-interactive
46
+ * --generate-key` on a machine that was already configured and swapped its
47
+ * owner key without a word. The interactive "Keep existing signing key?"
48
+ * question is its own confirmation and does not come through here.
49
+ *
50
+ * "Configured" means the identity signing uses today: the config key, or
51
+ * PLAYLIST_PRIVATE_KEY when config holds none. A config key always wins over
52
+ * the environment (getPlaylistConfig), so writing one would mask an
53
+ * environment identity just as surely as overwriting a config key.
54
+ *
55
+ * @param {string} currentKey - Effective key material (may be a placeholder or empty)
56
+ * @param {string} nextKey - Key material setup is about to write
57
+ * @param {string} flag - The flag that asked for the change, named in the error
58
+ * @param {boolean} force - Whether --force was passed
59
+ * @param {string} [source] - Where currentKey came from, when not config.json
60
+ * @throws {Error} When an existing, different identity would be replaced without --force
61
+ */
62
+ function assertKeyReplacementAllowed(currentKey, nextKey, flag, force, source) {
63
+ if (force || (0, config_files_1.isMissingConfigValue)(currentKey)) {
64
+ return;
65
+ }
66
+ const currentDid = didKeyOrNull(currentKey);
67
+ if (currentDid === null || currentDid === didKeyOrNull(nextKey)) {
68
+ return;
69
+ }
70
+ throw new Error(`A signing key is already configured (${currentDid}${source ? `, from ${source}` : ''}). ` +
71
+ 'It owns the playlists you published; ' +
72
+ 'replacing it means you can no longer replace or unpublish them. ' +
73
+ `Re-run without ${flag} to keep it, or add --force to replace it.`);
74
+ }
21
75
  /** Generate a fresh Ed25519 signing key as base64 PKCS#8 DER (the recommended form). */
22
76
  function generateSigningKeyBase64() {
23
77
  const keyPair = crypto_1.default.generateKeyPairSync('ed25519');
@@ -28,7 +82,8 @@ exports.setupCommand = new commander_1.Command('setup')
28
82
  .option('-y, --non-interactive', 'Run without prompts (for agents/CI); uses flags and safe defaults. Auto-enabled when stdin is not a TTY.')
29
83
  .option('--key <privateKey>', 'Signing key material (base64 PKCS#8 DER, 32-byte seed as hex/base64, or PEM)')
30
84
  .option('--key-file <path>', 'Read the signing key from this file instead of the command line (preferred for provisioning)')
31
- .option('--generate-key', 'Generate a new Ed25519 signing key')
85
+ .option('--generate-key', 'Generate a new Ed25519 signing key (only when none is configured)')
86
+ .option('--force', 'Allow --generate-key, --key, or --key-file to replace a configured signing key (it owns your published playlists)')
32
87
  .option('--role <role>', `Signing role (${playlist_signing_role_1.DP1_PLAYLIST_SIGNING_ROLES.join(', ')})`)
33
88
  .option('--device-host <host>', 'FF1 device host, e.g. http://192.168.1.50:1111 (skips mDNS discovery)')
34
89
  .option('--device-name <name>', 'Friendly name for the device')
@@ -84,22 +139,38 @@ exports.setupCommand = new commander_1.Command('setup')
84
139
  const currentRole = config.playlist?.role || '';
85
140
  let signingKey = currentKey;
86
141
  let signingRole = currentRole;
142
+ // The identity signing uses today. With no usable key in config, signing falls back to
143
+ // PLAYLIST_PRIVATE_KEY, and a config key written now would silently take its place.
144
+ const envKey = process.env.PLAYLIST_PRIVATE_KEY?.trim() || '';
145
+ const keyFromEnv = (0, config_files_1.isMissingConfigValue)(currentKey) && !(0, config_files_1.isMissingConfigValue)(envKey);
146
+ const effectiveKey = keyFromEnv ? envKey : currentKey;
147
+ const keySource = keyFromEnv ? 'PLAYLIST_PRIVATE_KEY' : undefined;
148
+ const force = Boolean(options.force);
87
149
  // Already resolved and validated above, before any file was created.
88
150
  if (explicitKey !== undefined) {
151
+ assertKeyReplacementAllowed(effectiveKey, explicitKey.material, explicitKey.flag, force, keySource);
89
152
  signingKey = explicitKey.material;
90
153
  }
91
154
  else if (nonInteractive) {
92
155
  // Key precedence with no explicit key: --generate-key, then keep an
93
156
  // existing usable key, otherwise generate one so signing works.
94
- if (options.generateKey || (0, config_files_1.isMissingConfigValue)(currentKey)) {
95
- signingKey = generateSigningKeyBase64();
157
+ if (options.generateKey || (0, config_files_1.isMissingConfigValue)(effectiveKey)) {
158
+ const generated = generateSigningKeyBase64();
159
+ assertKeyReplacementAllowed(effectiveKey, generated, '--generate-key', force, keySource);
160
+ signingKey = generated;
96
161
  console.log(chalk_1.default.dim('Generated a new Ed25519 signing key.'));
97
162
  }
163
+ else if (keyFromEnv) {
164
+ console.log(chalk_1.default.dim('Signing with PLAYLIST_PRIVATE_KEY; config.json keeps no key.'));
165
+ }
98
166
  }
99
167
  else {
100
- if ((0, config_files_1.isMissingConfigValue)(currentKey)) {
168
+ if ((0, config_files_1.isMissingConfigValue)(effectiveKey)) {
101
169
  signingKey = generateSigningKeyBase64();
102
170
  }
171
+ else if (keyFromEnv) {
172
+ console.log(chalk_1.default.dim('Signing with PLAYLIST_PRIVATE_KEY; config.json keeps no key.'));
173
+ }
103
174
  else {
104
175
  const ask = getAsk();
105
176
  const keepKey = await (0, prompt_1.promptYesNo)(ask, 'Keep existing signing key?', true);
@@ -109,7 +180,9 @@ exports.setupCommand = new commander_1.Command('setup')
109
180
  }
110
181
  }
111
182
  }
112
- if (signingKey) {
183
+ // The role applies to whichever identity signs, including PLAYLIST_PRIVATE_KEY when
184
+ // config keeps no key; skipping it there silently dropped --role.
185
+ if (signingKey || keyFromEnv) {
113
186
  if (nonInteractive) {
114
187
  // Role precedence: --role, then existing role, then 'agent'.
115
188
  try {
@@ -139,7 +212,9 @@ exports.setupCommand = new commander_1.Command('setup')
139
212
  }
140
213
  config.playlist = {
141
214
  ...(config.playlist || {}),
142
- privateKey: signingKey,
215
+ // With the identity in PLAYLIST_PRIVATE_KEY, keep whatever config held (empty or a
216
+ // placeholder) so the environment key keeps signing.
217
+ privateKey: signingKey || (config.playlist?.privateKey ?? ''),
143
218
  role: signingRole,
144
219
  };
145
220
  }
@@ -61,7 +61,10 @@ exports.statusCommand = new commander_1.Command('status')
61
61
  }
62
62
  const configPath = await (0, config_files_1.resolveExistingConfigPath)();
63
63
  if (!configPath) {
64
- console.log(chalk_1.default.red('config.json not found'));
64
+ // Name both places looked: when a caller's environment points the user config somewhere
65
+ // unexpected (a sandboxed HOME with XDG_CONFIG_HOME still set), the path is the clue.
66
+ const { localPath, userPath } = (0, config_1.getConfigPaths)();
67
+ console.log(chalk_1.default.red(`config.json not found (looked in ${localPath} and ${userPath})`));
65
68
  console.log(chalk_1.default.dim('Run: ff-cli setup'));
66
69
  // PLAYLIST_PRIVATE_KEY is a supported signing source on its own, and the did:key it implies is
67
70
  // the one value a user must know before they can write a publishable playlist — it has to be
@@ -11,6 +11,7 @@ exports.getPlaylistConfig = getPlaylistConfig;
11
11
  exports.getFeedConfig = getFeedConfig;
12
12
  exports.getFF1DeviceConfig = getFF1DeviceConfig;
13
13
  exports.getFF1RelayerConfig = getFF1RelayerConfig;
14
+ exports.getTokenSourceConfig = getTokenSourceConfig;
14
15
  exports.validateConfig = validateConfig;
15
16
  exports.createSampleConfig = createSampleConfig;
16
17
  const fs_1 = __importDefault(require("fs"));
@@ -276,6 +277,93 @@ function resolveFF1RelayerConfig(config) {
276
277
  apiKey: apiKey || undefined,
277
278
  };
278
279
  }
280
+ const TOKEN_SOURCES = ['raster', 'indexer'];
281
+ const DEFAULT_TOKEN_SOURCE = 'indexer';
282
+ const DEFAULT_IPFS_GATEWAY_FERAL_FILE = 'https://ipfs.feralfile.com/ipfs/';
283
+ const DEFAULT_IPFS_GATEWAY = 'https://ipfs.filebase.io/ipfs/';
284
+ /**
285
+ * Get the token source selection using repository config priority.
286
+ *
287
+ * `config.json` wins over environment variables, which win over the defaults.
288
+ * The Feral File indexer is the default vendor; `raster` is opt-in while
289
+ * Raster's data bugs (feral-file#3546) are open. There is no fallback between
290
+ * the two at runtime: one vendor answers a whole run.
291
+ */
292
+ function getTokenSourceConfig() {
293
+ return resolveTokenSourceConfig(getConfig());
294
+ }
295
+ /** Resolve and validate the effective token source configuration. */
296
+ function resolveTokenSourceConfig(config) {
297
+ const source = resolveTokenSourceName(config);
298
+ const gateways = resolveIpfsGatewayFields(config);
299
+ return {
300
+ source,
301
+ ipfsGateways: {
302
+ feralFile: normalizeIpfsGateway(gateways.feralFile ||
303
+ process.env.IPFS_GATEWAY_FERAL_FILE ||
304
+ DEFAULT_IPFS_GATEWAY_FERAL_FILE, 'ipfsGateways.feralFile'),
305
+ default: normalizeIpfsGateway(gateways.default || process.env.IPFS_GATEWAY_DEFAULT || DEFAULT_IPFS_GATEWAY, 'ipfsGateways.default'),
306
+ },
307
+ };
308
+ }
309
+ /** resolveTokenSourceName picks the vendor name, rejecting anything outside TOKEN_SOURCES. */
310
+ function resolveTokenSourceName(config) {
311
+ const configured = config.tokenSource;
312
+ if (configured !== undefined && typeof configured !== 'string') {
313
+ throw new Error(`tokenSource must be one of: ${TOKEN_SOURCES.join(', ')}`);
314
+ }
315
+ const candidate = (configured || process.env.FF_TOKEN_SOURCE || '')
316
+ .trim()
317
+ .toLowerCase();
318
+ if (!candidate) {
319
+ return DEFAULT_TOKEN_SOURCE;
320
+ }
321
+ if (!TOKEN_SOURCES.includes(candidate)) {
322
+ throw new Error(`tokenSource must be one of: ${TOKEN_SOURCES.join(', ')}`);
323
+ }
324
+ return candidate;
325
+ }
326
+ /** resolveIpfsGatewayFields type-checks the `ipfsGateways` block without resolving defaults. */
327
+ function resolveIpfsGatewayFields(config) {
328
+ const configured = config.ipfsGateways;
329
+ if (configured === undefined) {
330
+ return {};
331
+ }
332
+ if (configured === null || typeof configured !== 'object' || Array.isArray(configured)) {
333
+ throw new Error('ipfsGateways must be an object');
334
+ }
335
+ const fields = configured;
336
+ for (const key of ['feralFile', 'default']) {
337
+ if (fields[key] !== undefined && typeof fields[key] !== 'string') {
338
+ throw new Error(`ipfsGateways.${key} must be a string`);
339
+ }
340
+ }
341
+ return { feralFile: fields.feralFile, default: fields.default };
342
+ }
343
+ /**
344
+ * normalizeIpfsGateway validates a gateway and returns it in `https://host/ipfs/` form.
345
+ *
346
+ * A bare origin is accepted because that is how gateways are usually quoted;
347
+ * the `/ipfs/` path is what a CID is appended to, so it is added when absent
348
+ * and a trailing slash is guaranteed so callers can concatenate.
349
+ */
350
+ function normalizeIpfsGateway(value, label) {
351
+ let parsed;
352
+ try {
353
+ parsed = new URL(value.trim());
354
+ }
355
+ catch {
356
+ throw new Error(`${label} must be a valid HTTP(S) URL`);
357
+ }
358
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
359
+ throw new Error(`${label} must be a valid HTTP(S) URL`);
360
+ }
361
+ const path = parsed.pathname.replace(/\/+$/, '');
362
+ parsed.pathname = path.endsWith('/ipfs') ? `${path}/` : `${path}/ipfs/`;
363
+ parsed.search = '';
364
+ parsed.hash = '';
365
+ return parsed.toString();
366
+ }
279
367
  /**
280
368
  * Validate configuration
281
369
  *
@@ -341,6 +429,12 @@ function validateConfig() {
341
429
  catch (error) {
342
430
  errors.push(error.message);
343
431
  }
432
+ try {
433
+ resolveTokenSourceConfig(config);
434
+ }
435
+ catch (error) {
436
+ errors.push(error.message);
437
+ }
344
438
  return {
345
439
  valid: errors.length === 0,
346
440
  errors,
package/dist/src/main.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Builds DP-1 playlists deterministically from structured parameters.
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.REQUIREMENT_TYPES = void 0;
7
8
  exports.validateRequirements = validateRequirements;
8
9
  exports.applyPlaylistDefaults = applyPlaylistDefaults;
9
10
  exports.buildPlaylistDirect = buildPlaylistDirect;
@@ -18,10 +19,20 @@ process.emitWarning = function (warning, type, ctor) {
18
19
  }
19
20
  return originalEmitWarning.apply(this, [warning, type, ctor]);
20
21
  };
21
- const config_1 = require("./config");
22
22
  // Lazy load utilities to avoid circular dependencies
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const getUtilities = () => require('./utilities');
25
+ /**
26
+ * REQUIREMENT_TYPES lists every requirement `build` accepts, in the order
27
+ * `build --help` documents them. Kept beside validateRequirements so a new
28
+ * branch there and this list change together.
29
+ */
30
+ exports.REQUIREMENT_TYPES = [
31
+ 'build_playlist',
32
+ 'query_address',
33
+ 'fetch_feed',
34
+ 'feral_file_artwork',
35
+ ];
25
36
  /**
26
37
  * Validate and apply constraints to requirements
27
38
  *
@@ -103,7 +114,8 @@ function validateRequirements(requirements) {
103
114
  };
104
115
  }
105
116
  }
106
- throw new Error(`Requirement ${index + 1}: invalid type "${req.type}"`);
117
+ throw new Error(`Requirement ${index + 1}: invalid type "${req.type}". ` +
118
+ `Valid types: ${exports.REQUIREMENT_TYPES.join(', ')} (see ff-cli build --help).`);
107
119
  });
108
120
  }
109
121
  /**
@@ -140,8 +152,5 @@ async function buildPlaylistDirect(params, options = {}) {
140
152
  const requirements = validateRequirements(params.requirements);
141
153
  const playlistSettings = applyPlaylistDefaults(params.playlistSettings);
142
154
  const utilities = getUtilities();
143
- const config = (0, config_1.getConfig)();
144
- // Initialize utilities with config (indexer endpoint, API key, etc.)
145
- utilities.initializeUtilities(config);
146
155
  return await utilities.buildPlaylistDirect({ requirements, playlistSettings }, options);
147
156
  }