@feralfile/cli 2.10.0 → 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.
- package/README.md +16 -11
- package/config.json.example +5 -0
- package/dist/package.json +2 -2
- package/dist/src/commands/build.js +40 -0
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/device.js +1 -1
- package/dist/src/commands/enrich.js +16 -21
- package/dist/src/commands/find.js +94 -81
- package/dist/src/commands/helpers/prompt.js +85 -10
- package/dist/src/commands/play.js +138 -73
- package/dist/src/commands/setup.js +81 -6
- package/dist/src/commands/status.js +4 -1
- package/dist/src/config.js +94 -0
- package/dist/src/main.js +14 -5
- package/dist/src/utilities/device-lookup.js +36 -11
- package/dist/src/utilities/feral-file-artwork.js +2 -2
- package/dist/src/utilities/index.js +270 -259
- package/dist/src/utilities/media-url.js +117 -0
- package/dist/src/utilities/nft-indexer.js +12 -376
- package/dist/src/utilities/playlist-builder.js +10 -1
- package/dist/src/utilities/playlist-source.js +222 -33
- package/dist/src/utilities/raster-client.js +100 -13
- package/dist/src/utilities/token-item.js +428 -0
- package/dist/src/utilities/token-source-contract.js +55 -0
- package/dist/src/utilities/token-source-indexer.js +99 -0
- package/dist/src/utilities/token-source-raster.js +378 -0
- package/dist/src/utilities/token-source.js +48 -0
- package/docs/CONFIGURATION.md +63 -2
- package/docs/EXAMPLES.md +5 -2
- package/docs/PROJECT_SPEC.md +23 -17
- package/docs/README.md +41 -16
- package/package.json +2 -2
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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)(
|
|
95
|
-
|
|
157
|
+
if (options.generateKey || (0, config_files_1.isMissingConfigValue)(effectiveKey)) {
|
|
158
|
+
const generated = generateSigningKeyBase64();
|
|
159
|
+
assertKeyReplacementAllowed(effectiveKey, generated, '--generate-key', force, keySource);
|
|
160
|
+
signingKey = generated;
|
|
96
161
|
console.log(chalk_1.default.dim('Generated a new Ed25519 signing key.'));
|
|
97
162
|
}
|
|
163
|
+
else if (keyFromEnv) {
|
|
164
|
+
console.log(chalk_1.default.dim('Signing with PLAYLIST_PRIVATE_KEY; config.json keeps no key.'));
|
|
165
|
+
}
|
|
98
166
|
}
|
|
99
167
|
else {
|
|
100
|
-
if ((0, config_files_1.isMissingConfigValue)(
|
|
168
|
+
if ((0, config_files_1.isMissingConfigValue)(effectiveKey)) {
|
|
101
169
|
signingKey = generateSigningKeyBase64();
|
|
102
170
|
}
|
|
171
|
+
else if (keyFromEnv) {
|
|
172
|
+
console.log(chalk_1.default.dim('Signing with PLAYLIST_PRIVATE_KEY; config.json keeps no key.'));
|
|
173
|
+
}
|
|
103
174
|
else {
|
|
104
175
|
const ask = getAsk();
|
|
105
176
|
const keepKey = await (0, prompt_1.promptYesNo)(ask, 'Keep existing signing key?', true);
|
|
@@ -109,7 +180,9 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
109
180
|
}
|
|
110
181
|
}
|
|
111
182
|
}
|
|
112
|
-
|
|
183
|
+
// The role applies to whichever identity signs, including PLAYLIST_PRIVATE_KEY when
|
|
184
|
+
// config keeps no key; skipping it there silently dropped --role.
|
|
185
|
+
if (signingKey || keyFromEnv) {
|
|
113
186
|
if (nonInteractive) {
|
|
114
187
|
// Role precedence: --role, then existing role, then 'agent'.
|
|
115
188
|
try {
|
|
@@ -139,7 +212,9 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
139
212
|
}
|
|
140
213
|
config.playlist = {
|
|
141
214
|
...(config.playlist || {}),
|
|
142
|
-
|
|
215
|
+
// With the identity in PLAYLIST_PRIVATE_KEY, keep whatever config held (empty or a
|
|
216
|
+
// placeholder) so the environment key keeps signing.
|
|
217
|
+
privateKey: signingKey || (config.playlist?.privateKey ?? ''),
|
|
143
218
|
role: signingRole,
|
|
144
219
|
};
|
|
145
220
|
}
|
|
@@ -61,7 +61,10 @@ exports.statusCommand = new commander_1.Command('status')
|
|
|
61
61
|
}
|
|
62
62
|
const configPath = await (0, config_files_1.resolveExistingConfigPath)();
|
|
63
63
|
if (!configPath) {
|
|
64
|
-
|
|
64
|
+
// Name both places looked: when a caller's environment points the user config somewhere
|
|
65
|
+
// unexpected (a sandboxed HOME with XDG_CONFIG_HOME still set), the path is the clue.
|
|
66
|
+
const { localPath, userPath } = (0, config_1.getConfigPaths)();
|
|
67
|
+
console.log(chalk_1.default.red(`config.json not found (looked in ${localPath} and ${userPath})`));
|
|
65
68
|
console.log(chalk_1.default.dim('Run: ff-cli setup'));
|
|
66
69
|
// PLAYLIST_PRIVATE_KEY is a supported signing source on its own, and the did:key it implies is
|
|
67
70
|
// the one value a user must know before they can write a publishable playlist — it has to be
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
}
|