@feralfile/cli 2.3.0 → 2.5.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/config.json.example +4 -5
- package/dist/index.js +2 -0
- package/dist/package.json +1 -1
- package/dist/src/commands/enrich.js +315 -0
- package/dist/src/commands/find.js +1 -2
- package/dist/src/commands/play.js +5 -2
- package/dist/src/commands/publish.js +1 -8
- package/dist/src/commands/status.js +51 -1
- package/dist/src/config.js +45 -5
- package/dist/src/utilities/enrich-playlist.js +459 -0
- package/dist/src/utilities/index.js +1 -1
- package/dist/src/utilities/nft-indexer.js +13 -4
- package/dist/src/utilities/playlist-builder.js +12 -0
- package/dist/src/utilities/playlist-publisher.js +61 -13
- package/dist/src/utilities/signing-identity.js +34 -0
- package/docs/CONFIGURATION.md +13 -0
- package/docs/EXAMPLES.md +106 -8
- package/docs/PROJECT_SPEC.md +122 -0
- package/docs/README.md +52 -5
- package/package.json +1 -1
package/config.json.example
CHANGED
|
@@ -7,17 +7,16 @@
|
|
|
7
7
|
},
|
|
8
8
|
"feedServers": [
|
|
9
9
|
{
|
|
10
|
-
"baseUrl": "https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1"
|
|
11
|
-
"apiKey": "YOUR_FEED_SERVER_API_KEY_OPTIONAL"
|
|
10
|
+
"baseUrl": "https://dp1-feed-operator-api-prod.autonomy-system.workers.dev/api/v1"
|
|
12
11
|
},
|
|
13
12
|
{
|
|
14
|
-
"baseUrl": "http://localhost:8787"
|
|
15
|
-
"apiKey": "YOUR_FEED_SERVER_API_KEY_OPTIONAL"
|
|
13
|
+
"baseUrl": "http://localhost:8787"
|
|
16
14
|
}
|
|
17
15
|
],
|
|
18
16
|
"playlist": {
|
|
19
17
|
"privateKey": "YOUR_ED25519_PRIVATE_KEY__base64_PKCS8_DER_recommended__or_32byte_raw_seed_as_hex_or_base64__run_ff-cli_setup_to_generate",
|
|
20
|
-
"role": "agent"
|
|
18
|
+
"role": "agent",
|
|
19
|
+
"curatorName": "YOUR_CURATOR_NAME"
|
|
21
20
|
},
|
|
22
21
|
"ff1Relayer": {
|
|
23
22
|
"baseUrl": "https://tv-cast-coordination.autonomy-system.workers.dev"
|
package/dist/index.js
CHANGED
|
@@ -28,6 +28,7 @@ const play_1 = require("./src/commands/play");
|
|
|
28
28
|
const publish_1 = require("./src/commands/publish");
|
|
29
29
|
const find_1 = require("./src/commands/find");
|
|
30
30
|
const build_1 = require("./src/commands/build");
|
|
31
|
+
const enrich_1 = require("./src/commands/enrich");
|
|
31
32
|
const config_1 = require("./src/commands/config");
|
|
32
33
|
const ssh_1 = require("./src/commands/ssh");
|
|
33
34
|
const device_1 = require("./src/commands/device");
|
|
@@ -55,6 +56,7 @@ program.addCommand(sign_1.signCommand);
|
|
|
55
56
|
program.addCommand(play_1.playCommand);
|
|
56
57
|
program.addCommand(publish_1.publishCommand);
|
|
57
58
|
program.addCommand(find_1.findCommand);
|
|
59
|
+
program.addCommand(enrich_1.enrichCommand);
|
|
58
60
|
program.addCommand(build_1.buildCommand);
|
|
59
61
|
program.addCommand(config_1.configCommand);
|
|
60
62
|
program.addCommand(ssh_1.sshCommand);
|
package/dist/package.json
CHANGED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.enrichCommand = void 0;
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const fs_1 = require("fs");
|
|
10
|
+
const crypto_1 = require("crypto");
|
|
11
|
+
const path_1 = require("path");
|
|
12
|
+
const enrich_playlist_1 = require("../utilities/enrich-playlist");
|
|
13
|
+
const playlist_verifier_1 = require("../utilities/playlist-verifier");
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
15
|
+
const { getNFTTokenInfoBatch } = require('../utilities/nft-indexer');
|
|
16
|
+
/**
|
|
17
|
+
* writePlaylistAtomically replaces a file only once the new bytes are safely on
|
|
18
|
+
* disk.
|
|
19
|
+
*
|
|
20
|
+
* The default destination is the input file, and a plain write truncates it
|
|
21
|
+
* first. Enrichment runs after a lookup that can take minutes while the indexer
|
|
22
|
+
* warms tokens, so the window between truncation and a complete write is a
|
|
23
|
+
* window in which an interruption, a full disk, or an I/O error leaves the
|
|
24
|
+
* curator with an empty or half-written playlist and no copy of what they had.
|
|
25
|
+
* A rename within the same directory is atomic, so the original file survives
|
|
26
|
+
* intact until the replacement is complete.
|
|
27
|
+
*
|
|
28
|
+
* The temporary file is created alongside the destination rather than in the
|
|
29
|
+
* system temp directory: rename is only atomic within a filesystem, and those
|
|
30
|
+
* are not guaranteed to be the same one.
|
|
31
|
+
*/
|
|
32
|
+
async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
33
|
+
// Follow a symlink to its target before replacing anything. rename() would
|
|
34
|
+
// replace the link itself, silently detaching a playlist that other paths
|
|
35
|
+
// reach through that name while reporting success.
|
|
36
|
+
let target = destination;
|
|
37
|
+
const existing = await fs_1.promises.lstat(destination).catch(() => null);
|
|
38
|
+
if (existing?.isSymbolicLink()) {
|
|
39
|
+
target = await fs_1.promises.realpath(destination);
|
|
40
|
+
}
|
|
41
|
+
const temporary = (0, path_1.join)((0, path_1.dirname)(target), `.${(0, path_1.basename)(target)}.${process.pid}.${(0, crypto_1.randomBytes)(4).toString('hex')}.tmp`);
|
|
42
|
+
// The destination's mode has to be known before the file exists, not after
|
|
43
|
+
// it holds the contents. Creating at the default 0o666-minus-umask and
|
|
44
|
+
// chmod'ing afterwards leaves the enriched playlist readable by other local
|
|
45
|
+
// users for the length of the write — short, but a disclosure window on a
|
|
46
|
+
// file the curator deliberately restricted.
|
|
47
|
+
const current = await fs_1.promises.stat(target).catch(() => null);
|
|
48
|
+
const mode = current ? current.mode & 0o7777 : undefined;
|
|
49
|
+
try {
|
|
50
|
+
// 'wx' fails rather than truncating if the name somehow exists, so a
|
|
51
|
+
// collision can never destroy another process's work in progress.
|
|
52
|
+
const handle = await fs_1.promises.open(temporary, 'wx', mode);
|
|
53
|
+
try {
|
|
54
|
+
// open() applies the mode through umask, which can strip bits the
|
|
55
|
+
// destination had. chmod is not subject to umask, so this restores the
|
|
56
|
+
// exact mode — still before any bytes are written.
|
|
57
|
+
if (mode !== undefined) {
|
|
58
|
+
await handle.chmod(mode);
|
|
59
|
+
}
|
|
60
|
+
// A replacement is a new inode, so it carries this process's ownership
|
|
61
|
+
// rather than the destination's. Silently reassigning a playlist that
|
|
62
|
+
// belongs to another user or a shared group can lock collaborators out
|
|
63
|
+
// of it, or widen who can reach it. Carry the ownership across, and when
|
|
64
|
+
// that is not permitted refuse the in-place replacement instead of
|
|
65
|
+
// completing it with different access than the file had.
|
|
66
|
+
if (current && (current.uid !== process.getuid?.() || current.gid !== process.getgid?.())) {
|
|
67
|
+
try {
|
|
68
|
+
await handle.chown(current.uid, current.gid);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
await fs_1.promises.rm(temporary, { force: true }).catch(() => undefined);
|
|
72
|
+
throw new OwnershipError(target);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
await handle.writeFile(contents);
|
|
76
|
+
// rename() is atomic against concurrent readers but says nothing about
|
|
77
|
+
// power loss: the directory entry can reach disk before the data it
|
|
78
|
+
// points at. Without this a crash mid-write can leave the playlist
|
|
79
|
+
// present and empty, which is the outcome the temporary file exists to
|
|
80
|
+
// prevent. Sync the contents first, then the directory entry after.
|
|
81
|
+
await handle.sync();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
await handle.close();
|
|
85
|
+
}
|
|
86
|
+
// Checked here, as late as the sequence allows: everything expensive is
|
|
87
|
+
// already done, so the interval between this read and the rename below is
|
|
88
|
+
// a few syscalls rather than the minutes a lookup takes.
|
|
89
|
+
//
|
|
90
|
+
// It narrows the race; it cannot close it. Comparing and then replacing is
|
|
91
|
+
// not atomic against an editor that does not participate in any protocol,
|
|
92
|
+
// and nothing available in userspace makes it so — every tool that edits a
|
|
93
|
+
// file in place carries this. Passing no digest (a distinct --output) opts
|
|
94
|
+
// out entirely, which is the safe path when a file is being actively
|
|
95
|
+
// edited.
|
|
96
|
+
if (expectedDigest !== undefined) {
|
|
97
|
+
const current = await fs_1.promises.readFile(target).catch(() => null);
|
|
98
|
+
const digest = current && (0, crypto_1.createHash)('sha256').update(current).digest('hex');
|
|
99
|
+
if (digest !== expectedDigest) {
|
|
100
|
+
await fs_1.promises.rm(temporary, { force: true }).catch(() => undefined);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await fs_1.promises.rename(temporary, target);
|
|
105
|
+
await syncDirectory((0, path_1.dirname)(target));
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
await fs_1.promises.rm(temporary, { force: true }).catch(() => undefined);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* OwnershipError marks a refusal to replace a file whose ownership this
|
|
115
|
+
* process cannot reproduce, so the caller can explain it rather than print a
|
|
116
|
+
* bare errno.
|
|
117
|
+
*/
|
|
118
|
+
class OwnershipError extends Error {
|
|
119
|
+
target;
|
|
120
|
+
constructor(target) {
|
|
121
|
+
super(`cannot preserve ownership of ${target}`);
|
|
122
|
+
this.target = target;
|
|
123
|
+
this.name = 'OwnershipError';
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* isSameFile reports whether two paths name the same file on disk.
|
|
128
|
+
*
|
|
129
|
+
* Compares resolved paths rather than the strings the caller typed, so
|
|
130
|
+
* `-o ./playlist.json` against `playlist.json`, and a symlink against its
|
|
131
|
+
* target, are recognized as the in-place case they are.
|
|
132
|
+
*/
|
|
133
|
+
async function isSameFile(a, b) {
|
|
134
|
+
if (a === b) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
const [left, right] = await Promise.all([
|
|
138
|
+
fs_1.promises.realpath(a).catch(() => null),
|
|
139
|
+
fs_1.promises.realpath(b).catch(() => null),
|
|
140
|
+
]);
|
|
141
|
+
return left !== null && left === right;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* syncDirectory flushes a directory entry so a rename survives power loss.
|
|
145
|
+
*
|
|
146
|
+
* Best-effort by design. Directory fsync is not portable — Windows rejects it
|
|
147
|
+
* outright — and a durability barrier that cannot be raised is not a reason to
|
|
148
|
+
* fail a write that has already succeeded. The data itself was synced before
|
|
149
|
+
* the rename, so the worst case here is the old name surviving a crash, not a
|
|
150
|
+
* corrupt file.
|
|
151
|
+
*/
|
|
152
|
+
async function syncDirectory(directory) {
|
|
153
|
+
try {
|
|
154
|
+
const handle = await fs_1.promises.open(directory, 'r');
|
|
155
|
+
try {
|
|
156
|
+
await handle.sync();
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
await handle.close();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
/* not supported on this platform or filesystem */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* reportInvalid prints structural validation diagnostics and exits non-zero.
|
|
168
|
+
*
|
|
169
|
+
* Enrichment refuses to work on a document DP-1 rejects, and refuses to write
|
|
170
|
+
* one. Writing an invalid playlist would replace the operator's file with
|
|
171
|
+
* something sign, publish, and play all reject later, having reported success.
|
|
172
|
+
*/
|
|
173
|
+
function reportInvalid(stage, result) {
|
|
174
|
+
console.error(chalk_1.default.red(`\n${stage}`));
|
|
175
|
+
if (result.error) {
|
|
176
|
+
console.error(chalk_1.default.dim(` ${result.error}`));
|
|
177
|
+
}
|
|
178
|
+
for (const detail of result.details ?? []) {
|
|
179
|
+
console.error(chalk_1.default.dim(` ${detail.path}: ${detail.message}`));
|
|
180
|
+
}
|
|
181
|
+
console.error(chalk_1.default.dim('\n The file was left unchanged.\n'));
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* SKIP_COPY explains each skip in the operator's terms rather than the code's.
|
|
186
|
+
* `no-provenance` is the one a person can act on, so it says what to add.
|
|
187
|
+
*/
|
|
188
|
+
const SKIP_COPY = {
|
|
189
|
+
'already-labelled': 'already has a manifest (use --force to replace)',
|
|
190
|
+
'external-ref': 'carries an external ref, which outranks an inline manifest',
|
|
191
|
+
'no-provenance': 'no provenance.contract chain/address/tokenId to look up',
|
|
192
|
+
'ambiguous-chain': 'chain "evm" names a family; pass --assume-ethereum to assert the network',
|
|
193
|
+
// The indexer drops unresolved tokens from its response rather than
|
|
194
|
+
// reporting why, so there is no per-item reason to relay here.
|
|
195
|
+
'not-indexed': 'the indexer returned nothing for it',
|
|
196
|
+
'no-metadata': 'the indexer resolved it but has no artist, description, or still image',
|
|
197
|
+
};
|
|
198
|
+
exports.enrichCommand = new commander_1.Command('enrich')
|
|
199
|
+
.description('Add missing artist, title, and thumbnail metadata to a playlist')
|
|
200
|
+
.argument('<file>', 'Path to the DP-1 playlist file')
|
|
201
|
+
.option('-o, --output <filename>', 'Write here instead of overwriting the input')
|
|
202
|
+
.option('--force', 'Replace manifests that already exist', false)
|
|
203
|
+
.option('--assume-ethereum', 'Treat DP-1 "evm" coordinates as Ethereum. Without this they are skipped, ' +
|
|
204
|
+
'because "evm" names a family and the wrong member yields another artwork.', false)
|
|
205
|
+
.option('-v, --verbose', 'Show detailed output', false)
|
|
206
|
+
.action(async (file, options) => {
|
|
207
|
+
try {
|
|
208
|
+
console.log(chalk_1.default.blue('\nEnrich playlist\n'));
|
|
209
|
+
// Hash the exact bytes parsed, not a stat taken afterwards: an edit
|
|
210
|
+
// between the read and the stat would leave the snapshot describing
|
|
211
|
+
// newer content while enrichment still worked from the older bytes.
|
|
212
|
+
const original = await fs_1.promises.readFile(file);
|
|
213
|
+
const originalDigest = (0, crypto_1.createHash)('sha256').update(original).digest('hex');
|
|
214
|
+
const playlist = JSON.parse(original.toString('utf-8'));
|
|
215
|
+
const total = Array.isArray(playlist.items) ? playlist.items.length : 0;
|
|
216
|
+
if (total === 0) {
|
|
217
|
+
console.error(chalk_1.default.red('That playlist has no items.'));
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
// Validate before spending minutes on indexer lookups for a document
|
|
221
|
+
// that was never going to be writable.
|
|
222
|
+
const before = await (0, playlist_verifier_1.validatePlaylist)(playlist);
|
|
223
|
+
if (!before.valid) {
|
|
224
|
+
reportInvalid('That playlist is not valid DP-1:', before);
|
|
225
|
+
}
|
|
226
|
+
// Warming previously-unseen tokens can take minutes, so a human watching
|
|
227
|
+
// this needs to see it move. Matches the progress line `find` prints.
|
|
228
|
+
let printedProgress = false;
|
|
229
|
+
const onProgress = (done, count) => {
|
|
230
|
+
printedProgress = true;
|
|
231
|
+
process.stdout.write(chalk_1.default.dim(`\r ${done}/${count} looked up...`));
|
|
232
|
+
};
|
|
233
|
+
// getNFTTokenInfoBatch is (tokens, duration, onProgress) — the second
|
|
234
|
+
// positional is DP-1 display seconds, not the callback. Enrichment never
|
|
235
|
+
// sets duration (the curator's timing is not ours to touch), so it is
|
|
236
|
+
// passed undefined and the callback goes third. find.ts carries the same
|
|
237
|
+
// warning; getting this wrong silently returns unusable results.
|
|
238
|
+
const lookup = (tokens, onProgressCallback) => getNFTTokenInfoBatch(tokens, undefined, onProgressCallback);
|
|
239
|
+
const result = await (0, enrich_playlist_1.enrichPlaylistManifests)(playlist, lookup, {
|
|
240
|
+
force: options.force,
|
|
241
|
+
assumeEthereum: options.assumeEthereum,
|
|
242
|
+
onProgress,
|
|
243
|
+
});
|
|
244
|
+
if (printedProgress) {
|
|
245
|
+
process.stdout.write('\n');
|
|
246
|
+
}
|
|
247
|
+
// Said before anything is written, so an operator who did not mean to
|
|
248
|
+
// assert the network still has the file they started with.
|
|
249
|
+
if (result.assumedEthereum > 0) {
|
|
250
|
+
console.log(chalk_1.default.yellow(`\n ${result.assumedEthereum} coordinate(s) gave chain "evm" and were looked up on` +
|
|
251
|
+
`\n Ethereum because --assume-ethereum was passed. An L2 work sharing an` +
|
|
252
|
+
`\n address and token id with an Ethereum one would take the wrong metadata.`));
|
|
253
|
+
}
|
|
254
|
+
const destination = options.output ?? file;
|
|
255
|
+
// --output names a file the caller expects to find afterwards, so it is
|
|
256
|
+
// written even when nothing was enriched. Overwriting the input on a
|
|
257
|
+
// no-op would be pure risk for no gain, so that case still writes
|
|
258
|
+
// nothing.
|
|
259
|
+
const shouldWrite = result.enriched > 0 || options.output !== undefined;
|
|
260
|
+
if (shouldWrite) {
|
|
261
|
+
// Re-validate the enriched candidate. An inline manifest is schema-
|
|
262
|
+
// checked the same way a fetched one is (playlists extension §3.6), so
|
|
263
|
+
// a malformed manifest from the indexer must not reach the file.
|
|
264
|
+
const after = await (0, playlist_verifier_1.validatePlaylist)(result.playlist);
|
|
265
|
+
if (!after.valid) {
|
|
266
|
+
reportInvalid('Enrichment produced an invalid playlist:', after);
|
|
267
|
+
}
|
|
268
|
+
const replaced = await writePlaylistAtomically(destination, JSON.stringify(result.playlist, null, 2), (await isSameFile(file, destination)) ? originalDigest : undefined);
|
|
269
|
+
if (!replaced) {
|
|
270
|
+
console.error(chalk_1.default.red('\nThat playlist changed while the lookup ran.'));
|
|
271
|
+
console.error(chalk_1.default.dim(' Enrichment was computed from the earlier version, so writing it\n' +
|
|
272
|
+
' would discard whatever changed. Nothing was written. Re-run to\n' +
|
|
273
|
+
' enrich the current file, or pass -o to write somewhere else.\n'));
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
console.log(result.enriched > 0
|
|
278
|
+
? chalk_1.default.green(`\n${result.enriched} of ${total} item(s) enriched`)
|
|
279
|
+
: chalk_1.default.yellow('\nNothing to enrich'));
|
|
280
|
+
if (shouldWrite) {
|
|
281
|
+
console.log(chalk_1.default.dim(` Output: ${destination}`));
|
|
282
|
+
}
|
|
283
|
+
if (result.skipped.length > 0) {
|
|
284
|
+
const shown = options.verbose ? result.skipped : result.skipped.slice(0, 10);
|
|
285
|
+
console.log(chalk_1.default.dim(`\n Skipped ${result.skipped.length}:`));
|
|
286
|
+
for (const skip of shown) {
|
|
287
|
+
console.log(chalk_1.default.dim(` ${skip.title} — ${SKIP_COPY[skip.reason]}`));
|
|
288
|
+
}
|
|
289
|
+
if (shown.length < result.skipped.length) {
|
|
290
|
+
console.log(chalk_1.default.dim(` ...and ${result.skipped.length - shown.length} more (-v to list)`));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Say this loudly. A signed playlist that silently lost its envelope
|
|
294
|
+
// fails at the device, which is the worst place to discover it.
|
|
295
|
+
if (result.signatureInvalidated) {
|
|
296
|
+
console.log(chalk_1.default.yellow('\n Signature removed — the document changed.'));
|
|
297
|
+
console.log(chalk_1.default.dim(` Re-sign before playing: ff-cli sign ${destination}`));
|
|
298
|
+
}
|
|
299
|
+
console.log();
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
if (error instanceof OwnershipError) {
|
|
303
|
+
console.error(chalk_1.default.red('\nThat playlist belongs to another user or group.'));
|
|
304
|
+
console.error(chalk_1.default.dim(` Replacing it in place would hand it to you and could lock others out,\n` +
|
|
305
|
+
` so nothing was written. Write elsewhere instead:\n` +
|
|
306
|
+
` ff-cli enrich ${error.target} -o enriched.json\n`));
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
console.error(chalk_1.default.red('\nError:'), error.message);
|
|
310
|
+
if (options.verbose) {
|
|
311
|
+
console.error(chalk_1.default.dim(error.stack));
|
|
312
|
+
}
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
@@ -641,8 +641,7 @@ async function doPublish(savedPath, serverArg, nonInteractive) {
|
|
|
641
641
|
serverIndex = n;
|
|
642
642
|
}
|
|
643
643
|
const serverUrl = feedConfig.baseURLs[serverIndex];
|
|
644
|
-
const
|
|
645
|
-
const result = await publishPlaylist(savedPath, serverUrl, serverApiKey);
|
|
644
|
+
const result = await publishPlaylist(savedPath, serverUrl);
|
|
646
645
|
if (result.success) {
|
|
647
646
|
console.log(chalk_1.default.green('✓ Published'));
|
|
648
647
|
if (result.playlistId) {
|
|
@@ -128,7 +128,6 @@ exports.playCommand = new commander_1.Command('play')
|
|
|
128
128
|
console.log(chalk_1.default.yellow(`\nTry: ff-cli find "${source}" --play${deviceFlag}`));
|
|
129
129
|
process.exit(1);
|
|
130
130
|
}
|
|
131
|
-
const config = (0, config_1.getConfig)();
|
|
132
131
|
// No explicit duration: a direct video/audio URL plays its natural
|
|
133
132
|
// length (DP-1 §4.1); static media uses config.defaultDuration.
|
|
134
133
|
const resolved = probed ?? (await (0, playlist_source_1.resolvePlaySource)(source));
|
|
@@ -143,7 +142,11 @@ exports.playCommand = new commander_1.Command('play')
|
|
|
143
142
|
// A direct media/web URL is wrapped in an unsigned single-item playlist,
|
|
144
143
|
// so it must be signed before the verify gate; a loaded playlist is sent
|
|
145
144
|
// as-is. castPlaylist owns the sign → verify → send sequence.
|
|
146
|
-
|
|
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;
|
|
147
150
|
const result = await (0, playlist_cast_1.castPlaylist)(resolved.playlist, {
|
|
148
151
|
deviceName: options.device,
|
|
149
152
|
skipVerify: options.skipVerify,
|
|
@@ -56,7 +56,6 @@ exports.publishCommand = new commander_1.Command('publish')
|
|
|
56
56
|
process.exit(1);
|
|
57
57
|
}
|
|
58
58
|
let serverUrl = feedConfig.baseURLs[0];
|
|
59
|
-
let serverApiKey = feedConfig.apiKey;
|
|
60
59
|
if (feedConfig.baseURLs.length > 1) {
|
|
61
60
|
if (!options.server) {
|
|
62
61
|
console.log(chalk_1.default.yellow('Multiple feed servers found. Select one:'));
|
|
@@ -77,14 +76,8 @@ exports.publishCommand = new commander_1.Command('publish')
|
|
|
77
76
|
process.exit(1);
|
|
78
77
|
}
|
|
79
78
|
serverUrl = feedConfig.baseURLs[serverIndex];
|
|
80
|
-
if (feedConfig.servers && feedConfig.servers[serverIndex]) {
|
|
81
|
-
serverApiKey = feedConfig.servers[serverIndex].apiKey;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
else if (feedConfig.servers && feedConfig.servers[0]) {
|
|
85
|
-
serverApiKey = feedConfig.servers[0].apiKey;
|
|
86
79
|
}
|
|
87
|
-
const result = await publishPlaylist(file, serverUrl
|
|
80
|
+
const result = await publishPlaylist(file, serverUrl);
|
|
88
81
|
if (result.success) {
|
|
89
82
|
console.log(chalk_1.default.green('Published'));
|
|
90
83
|
if (result.playlistId) {
|
|
@@ -10,15 +10,47 @@ const config_files_1 = require("./helpers/config-files");
|
|
|
10
10
|
const config_1 = require("../config");
|
|
11
11
|
const ed25519_key_derive_1 = require("../utilities/ed25519-key-derive");
|
|
12
12
|
const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
|
|
13
|
+
const signing_identity_1 = require("../utilities/signing-identity");
|
|
13
14
|
const config_placeholders_1 = require("../utilities/config-placeholders");
|
|
14
15
|
exports.statusCommand = new commander_1.Command('status')
|
|
15
16
|
.description('Show configuration status')
|
|
16
|
-
.
|
|
17
|
+
// Identity discovery must cover every key `sign` accepts, not just the configured one. `sign --key`
|
|
18
|
+
// signs with a key the config never mentions, so reporting only the configured identity would hand the
|
|
19
|
+
// user the wrong did:key to declare in curators[] — and a wrong declaration fails exactly like a
|
|
20
|
+
// missing one.
|
|
21
|
+
.option('-k, --key <privateKey>', 'Report the signing identity for this key instead of the configured one')
|
|
22
|
+
.action(async (options) => {
|
|
17
23
|
try {
|
|
24
|
+
const overrideKey = options.key?.trim();
|
|
25
|
+
if (overrideKey) {
|
|
26
|
+
try {
|
|
27
|
+
console.log(chalk_1.default.green(`Signing identity (did:key) ${(0, signing_identity_1.playlistSigningDidKey)(overrideKey)}`));
|
|
28
|
+
console.log(chalk_1.default.dim(" declare this in the playlist's curators[] before signing"));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
console.log(chalk_1.default.red(`--key unusable: ${error.message}`));
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
18
36
|
const configPath = await (0, config_files_1.resolveExistingConfigPath)();
|
|
19
37
|
if (!configPath) {
|
|
20
38
|
console.log(chalk_1.default.red('config.json not found'));
|
|
21
39
|
console.log(chalk_1.default.dim('Run: ff-cli setup'));
|
|
40
|
+
// PLAYLIST_PRIVATE_KEY is a supported signing source on its own, and the did:key it implies is
|
|
41
|
+
// the one value a user must know before they can write a publishable playlist — it has to be
|
|
42
|
+
// declared in curators[] before signing. Exiting silently here would leave an environment-only
|
|
43
|
+
// user unable to obtain it from anywhere. The exit status still reports "not configured".
|
|
44
|
+
const envKey = process.env.PLAYLIST_PRIVATE_KEY?.trim();
|
|
45
|
+
if (envKey) {
|
|
46
|
+
try {
|
|
47
|
+
console.log(chalk_1.default.green(`\nSigning identity (did:key) ${(0, signing_identity_1.playlistSigningDidKey)(envKey)}`));
|
|
48
|
+
console.log(chalk_1.default.dim(" from PLAYLIST_PRIVATE_KEY; declare it in the playlist's curators[]"));
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
console.log(chalk_1.default.red(`\nPLAYLIST_PRIVATE_KEY unusable: ${error.message}`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
22
54
|
process.exit(1);
|
|
23
55
|
}
|
|
24
56
|
const config = await (0, config_files_1.readConfigFile)(configPath);
|
|
@@ -38,6 +70,17 @@ exports.statusCommand = new commander_1.Command('status')
|
|
|
38
70
|
playlistRoleError = playlistRoleMaterial;
|
|
39
71
|
}
|
|
40
72
|
}
|
|
73
|
+
// The feed matches a signature's kid against the document's own curators[], so this value is not
|
|
74
|
+
// decoration: without it a user cannot write a publishable playlist, and it appears nowhere else.
|
|
75
|
+
let signingDidKey;
|
|
76
|
+
if (hasPlaylistSigningKey) {
|
|
77
|
+
try {
|
|
78
|
+
signingDidKey = (0, signing_identity_1.playlistSigningDidKey)(playlistKeyMaterial);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
signingDidKey = undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
41
84
|
const statuses = [
|
|
42
85
|
{
|
|
43
86
|
label: 'Config file',
|
|
@@ -55,6 +98,13 @@ exports.statusCommand = new commander_1.Command('status')
|
|
|
55
98
|
: undefined,
|
|
56
99
|
hint: ' (needed for signing and legacy verification)',
|
|
57
100
|
},
|
|
101
|
+
{
|
|
102
|
+
label: 'Signing identity (did:key)',
|
|
103
|
+
ok: Boolean(signingDidKey),
|
|
104
|
+
optional: false,
|
|
105
|
+
detail: signingDidKey,
|
|
106
|
+
hint: " (declare this in the playlist's curators[] so the feed accepts a publish)",
|
|
107
|
+
},
|
|
58
108
|
{
|
|
59
109
|
label: 'Playlist signing role',
|
|
60
110
|
ok: hasValidPlaylistRole,
|
package/dist/src/config.js
CHANGED
|
@@ -121,10 +121,38 @@ function getPlaylistConfig() {
|
|
|
121
121
|
const config = getConfig();
|
|
122
122
|
const playlistConfig = config.playlist || {};
|
|
123
123
|
return {
|
|
124
|
-
|
|
124
|
+
// `config init` writes the sample file through verbatim, so an untouched config carries the
|
|
125
|
+
// YOUR_ED25519_PRIVATE_KEY... literal. It is truthy, so without this it would beat a perfectly good
|
|
126
|
+
// PLAYLIST_PRIVATE_KEY and every signing path would fail on a key the user never chose.
|
|
127
|
+
privateKey: (0, config_placeholders_1.isMissingConfigValue)(playlistConfig.privateKey)
|
|
128
|
+
? process.env.PLAYLIST_PRIVATE_KEY || null
|
|
129
|
+
: playlistConfig.privateKey || null,
|
|
125
130
|
role: playlistConfig.role || process.env.PLAYLIST_ROLE || null,
|
|
131
|
+
curatorName: resolveCuratorName(playlistConfig.curatorName),
|
|
126
132
|
};
|
|
127
133
|
}
|
|
134
|
+
/** The literal shipped in config.json.example, and the only curator name treated as unset. */
|
|
135
|
+
const CURATOR_NAME_TEMPLATE = 'YOUR_CURATOR_NAME';
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the curator name recorded in a built playlist's signed `curators[]`.
|
|
138
|
+
*
|
|
139
|
+
* Two hazards meet here, and they pull in opposite directions. The sample value must not win over the
|
|
140
|
+
* environment, or a freshly initialized config publishes under a placeholder. But the generic
|
|
141
|
+
* YOUR_/your_ placeholder test is too broad for a human name — a real studio called "YOUR_STUDIO" would
|
|
142
|
+
* be silently replaced, and the wrong attribution would then be sealed inside a signature. So only the
|
|
143
|
+
* exact template literal counts as unset.
|
|
144
|
+
*
|
|
145
|
+
* A non-string value is treated as unset rather than passed through: config.json is arbitrary runtime
|
|
146
|
+
* JSON, and this value is assigned into the document *after* the builder has validated it, so a number
|
|
147
|
+
* here would be signed into a malformed playlist instead of being rejected.
|
|
148
|
+
*/
|
|
149
|
+
function resolveCuratorName(configured) {
|
|
150
|
+
const fromConfig = typeof configured === 'string' ? configured.trim() : '';
|
|
151
|
+
if (fromConfig && fromConfig !== CURATOR_NAME_TEMPLATE) {
|
|
152
|
+
return fromConfig;
|
|
153
|
+
}
|
|
154
|
+
return process.env.PLAYLIST_CURATOR_NAME?.trim() || null;
|
|
155
|
+
}
|
|
128
156
|
/**
|
|
129
157
|
* Resolve the effective playlist signing role used by runtime paths.
|
|
130
158
|
*
|
|
@@ -146,12 +174,15 @@ function resolveEffectivePlaylistRole(config) {
|
|
|
146
174
|
/**
|
|
147
175
|
* Get feed configuration for DP1 feed API
|
|
148
176
|
*
|
|
149
|
-
* Supports both legacy (feed.baseURLs
|
|
177
|
+
* Supports both legacy (feed.baseURLs) and new (feedServers array) formats.
|
|
178
|
+
*
|
|
179
|
+
* No API key is returned. The feed authorizes writes from the signatures inside the document, so a key
|
|
180
|
+
* is neither sent nor accepted; an `apiKey` left in an existing config file is simply ignored rather
|
|
181
|
+
* than being an error, so old configs keep working.
|
|
150
182
|
*
|
|
151
183
|
* @returns {Object} Feed configuration
|
|
152
184
|
* @returns {string[]} returns.baseURLs - Array of base URLs for feed APIs
|
|
153
|
-
* @returns {
|
|
154
|
-
* @returns {Array<Object>} [returns.servers] - Array of feed servers with individual API keys (new)
|
|
185
|
+
* @returns {Array<Object>} [returns.servers] - Array of configured feed servers (new format)
|
|
155
186
|
*/
|
|
156
187
|
function getFeedConfig() {
|
|
157
188
|
const config = getConfig();
|
|
@@ -179,7 +210,6 @@ function getFeedConfig() {
|
|
|
179
210
|
}
|
|
180
211
|
return {
|
|
181
212
|
baseURLs: urls,
|
|
182
|
-
apiKey: feedConfig.apiKey,
|
|
183
213
|
};
|
|
184
214
|
}
|
|
185
215
|
/**
|
|
@@ -281,6 +311,16 @@ function validateConfig() {
|
|
|
281
311
|
}
|
|
282
312
|
}
|
|
283
313
|
}
|
|
314
|
+
// curatorName is signed into curators[] as a public name, and it is written into the document after
|
|
315
|
+
// the builder has validated it — so a malformed value is not caught anywhere downstream. Reporting a
|
|
316
|
+
// clean config while `build` emits an invalid signed playlist is the failure worth preventing here.
|
|
317
|
+
const curatorNameValue = config.playlist
|
|
318
|
+
?.curatorName;
|
|
319
|
+
if (curatorNameValue !== undefined && curatorNameValue !== null) {
|
|
320
|
+
if (typeof curatorNameValue !== 'string' || curatorNameValue.trim().length === 0) {
|
|
321
|
+
errors.push('playlist.curatorName must be a non-empty string');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
284
324
|
if (config.playlist?.role !== undefined || process.env.PLAYLIST_ROLE !== undefined) {
|
|
285
325
|
try {
|
|
286
326
|
resolveEffectivePlaylistRole(config);
|