@feralfile/cli 2.7.0 → 2.9.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/dist/index.js +4 -0
- package/dist/package.json +1 -1
- package/dist/src/commands/enrich.js +12 -18
- package/dist/src/commands/fetch.js +89 -0
- package/dist/src/commands/find.js +13 -40
- package/dist/src/commands/helpers/feed-server.js +105 -0
- package/dist/src/commands/publish.js +59 -29
- package/dist/src/commands/sign.js +90 -1
- package/dist/src/commands/unpublish.js +145 -0
- package/dist/src/utilities/feed-mutation.js +454 -0
- package/dist/src/utilities/playlist-publisher.js +371 -148
- package/dist/src/utilities/playlist-signer.js +391 -20
- package/dist/src/utilities/playlist-unpublisher.js +140 -0
- package/dist/src/utilities/same-file.js +52 -0
- package/docs/CONFIGURATION.md +22 -2
- package/docs/EXAMPLES.md +370 -0
- package/docs/PROJECT_SPEC.md +63 -1
- package/docs/README.md +63 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -26,6 +26,8 @@ const validate_1 = require("./src/commands/validate");
|
|
|
26
26
|
const sign_1 = require("./src/commands/sign");
|
|
27
27
|
const play_1 = require("./src/commands/play");
|
|
28
28
|
const publish_1 = require("./src/commands/publish");
|
|
29
|
+
const unpublish_1 = require("./src/commands/unpublish");
|
|
30
|
+
const fetch_1 = require("./src/commands/fetch");
|
|
29
31
|
const find_1 = require("./src/commands/find");
|
|
30
32
|
const build_1 = require("./src/commands/build");
|
|
31
33
|
const enrich_1 = require("./src/commands/enrich");
|
|
@@ -55,6 +57,8 @@ program.addCommand(validate_1.validateCommand);
|
|
|
55
57
|
program.addCommand(sign_1.signCommand);
|
|
56
58
|
program.addCommand(play_1.playCommand);
|
|
57
59
|
program.addCommand(publish_1.publishCommand);
|
|
60
|
+
program.addCommand(unpublish_1.unpublishCommand);
|
|
61
|
+
program.addCommand(fetch_1.fetchCommand);
|
|
58
62
|
program.addCommand(find_1.findCommand);
|
|
59
63
|
program.addCommand(enrich_1.enrichCommand);
|
|
60
64
|
program.addCommand(build_1.buildCommand);
|
package/dist/package.json
CHANGED
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.enrichCommand = void 0;
|
|
7
|
+
exports.writePlaylistAtomically = writePlaylistAtomically;
|
|
7
8
|
const commander_1 = require("commander");
|
|
8
9
|
const chalk_1 = __importDefault(require("chalk"));
|
|
9
10
|
const fs_1 = require("fs");
|
|
@@ -11,6 +12,12 @@ const crypto_1 = require("crypto");
|
|
|
11
12
|
const path_1 = require("path");
|
|
12
13
|
const enrich_playlist_1 = require("../utilities/enrich-playlist");
|
|
13
14
|
const playlist_verifier_1 = require("../utilities/playlist-verifier");
|
|
15
|
+
// Rename semantics, not inode identity. This command writes a temp file and renames it over the
|
|
16
|
+
// destination, and rename() replaces a directory ENTRY: onto a hard link it repoints that one name and
|
|
17
|
+
// leaves the input's own name on the untouched original inode. So a hard-link -o is a genuinely
|
|
18
|
+
// distinct output here, and treating it as in-place makes the stale-input guard below refuse a write
|
|
19
|
+
// that was never unsafe. The signer's direct-write path needs the opposite predicate; see same-file.ts.
|
|
20
|
+
const same_file_1 = require("../utilities/same-file");
|
|
14
21
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
15
22
|
const { resolveTokenBatch } = require('../utilities/nft-indexer');
|
|
16
23
|
/**
|
|
@@ -28,6 +35,10 @@ const { resolveTokenBatch } = require('../utilities/nft-indexer');
|
|
|
28
35
|
* The temporary file is created alongside the destination rather than in the
|
|
29
36
|
* system temp directory: rename is only atomic within a filesystem, and those
|
|
30
37
|
* are not guaranteed to be the same one.
|
|
38
|
+
*
|
|
39
|
+
* Exported for tests: whether a destination counts as the input depends on this
|
|
40
|
+
* function's rename semantics, and pairing it with the wrong predicate is a
|
|
41
|
+
* mistake that has already been made once.
|
|
31
42
|
*/
|
|
32
43
|
async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
33
44
|
// Follow a symlink to its target before replacing anything. rename() would
|
|
@@ -132,23 +143,6 @@ class OwnershipError extends Error {
|
|
|
132
143
|
this.name = 'OwnershipError';
|
|
133
144
|
}
|
|
134
145
|
}
|
|
135
|
-
/**
|
|
136
|
-
* isSameFile reports whether two paths name the same file on disk.
|
|
137
|
-
*
|
|
138
|
-
* Compares resolved paths rather than the strings the caller typed, so
|
|
139
|
-
* `-o ./playlist.json` against `playlist.json`, and a symlink against its
|
|
140
|
-
* target, are recognized as the in-place case they are.
|
|
141
|
-
*/
|
|
142
|
-
async function isSameFile(a, b) {
|
|
143
|
-
if (a === b) {
|
|
144
|
-
return true;
|
|
145
|
-
}
|
|
146
|
-
const [left, right] = await Promise.all([
|
|
147
|
-
fs_1.promises.realpath(a).catch(() => null),
|
|
148
|
-
fs_1.promises.realpath(b).catch(() => null),
|
|
149
|
-
]);
|
|
150
|
-
return left !== null && left === right;
|
|
151
|
-
}
|
|
152
146
|
/**
|
|
153
147
|
* syncDirectory flushes a directory entry so a rename survives power loss.
|
|
154
148
|
*
|
|
@@ -284,7 +278,7 @@ exports.enrichCommand = new commander_1.Command('enrich')
|
|
|
284
278
|
if (!after.valid) {
|
|
285
279
|
reportInvalid('Enrichment produced an invalid playlist:', after);
|
|
286
280
|
}
|
|
287
|
-
const replaced = await writePlaylistAtomically(destination, JSON.stringify(result.playlist, null, 2), (await
|
|
281
|
+
const replaced = await writePlaylistAtomically(destination, JSON.stringify(result.playlist, null, 2), (await (0, same_file_1.isSameEntryForRename)(destination, file)) ? originalDigest : undefined);
|
|
288
282
|
if (!replaced) {
|
|
289
283
|
console.error(chalk_1.default.red('\nThat playlist changed while the lookup ran.'));
|
|
290
284
|
console.error(chalk_1.default.dim(' Enrichment was computed from the earlier version, so writing it\n' +
|
|
@@ -0,0 +1,89 @@
|
|
|
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.fetchCommand = void 0;
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const feed_server_1 = require("./helpers/feed-server");
|
|
10
|
+
/**
|
|
11
|
+
* `ff-cli fetch <id-or-url>` — save a published playlist as the document to edit.
|
|
12
|
+
*
|
|
13
|
+
* Editing a published playlist has to start from the stored document: `--replace` requires the `id`,
|
|
14
|
+
* `slug`, and `created` to equal the stored row's, and rebuilding with `find` or `build` mints new ones.
|
|
15
|
+
* Nothing in the CLI produced that starting file, so the recovery advice pointed at `verify`, which only
|
|
16
|
+
* validates and prints a summary — it neither prints nor saves the JSON. That left the documented
|
|
17
|
+
* edit → re-sign → replace loop starting with a `curl` the CLI could do itself.
|
|
18
|
+
*
|
|
19
|
+
* Writing the file is the point, so `-o` is the normal form; without it the document goes to stdout for
|
|
20
|
+
* piping, and every status line goes to stderr so the two never mix.
|
|
21
|
+
*/
|
|
22
|
+
exports.fetchCommand = new commander_1.Command('fetch')
|
|
23
|
+
.description('Save a published playlist from a feed server (the starting point for --replace)')
|
|
24
|
+
.argument('<id-or-url>', 'Playlist id, slug, or feed URL')
|
|
25
|
+
.option('-s, --server <index>', 'Feed server index (use this if multiple servers configured)')
|
|
26
|
+
.option('-o, --output <file>', 'Write the playlist here (defaults to stdout)')
|
|
27
|
+
.action(async (idOrUrl, options) => {
|
|
28
|
+
try {
|
|
29
|
+
console.error(chalk_1.default.blue('\nFetch playlist\n'));
|
|
30
|
+
const { getFeedConfig } = await import('../config.js');
|
|
31
|
+
const { fetchStoredPlaylist, resolvePlaylistIdentifier } = await import('../utilities/feed-mutation.js');
|
|
32
|
+
const feedConfig = getFeedConfig();
|
|
33
|
+
const selection = await (0, feed_server_1.selectFeedServer)(feedConfig.baseURLs, { serverArg: options.server });
|
|
34
|
+
if (!selection.ok) {
|
|
35
|
+
console.error(chalk_1.default.red(selection.error));
|
|
36
|
+
if (selection.detail) {
|
|
37
|
+
console.error(chalk_1.default.yellow(selection.detail));
|
|
38
|
+
}
|
|
39
|
+
console.error();
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
const identifier = resolvePlaylistIdentifier(idOrUrl);
|
|
43
|
+
if (!identifier) {
|
|
44
|
+
console.error(chalk_1.default.red('No playlist id or URL was given\n'));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
let stored;
|
|
48
|
+
try {
|
|
49
|
+
stored = await fetchStoredPlaylist(selection.url, identifier);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
const status = error.response?.status;
|
|
53
|
+
if (status === 404) {
|
|
54
|
+
console.error(chalk_1.default.red(`\nNo playlist ${identifier} on ${selection.url}`));
|
|
55
|
+
console.error(chalk_1.default.yellow(' Either the id or slug is wrong for this feed, or the playlist was deleted.\n' +
|
|
56
|
+
' Deleted ids are tombstoned and are never served again.\n'));
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
console.error(chalk_1.default.red('\nFetch failed:'), error.message);
|
|
60
|
+
console.error();
|
|
61
|
+
}
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
// Two spaces, matching what `sign` and `build` write, so a fetched document and a locally built
|
|
65
|
+
// one produce the same diff shape when both are edited by hand.
|
|
66
|
+
const document = `${JSON.stringify(stored, null, 2)}\n`;
|
|
67
|
+
if (!options.output) {
|
|
68
|
+
process.stdout.write(document);
|
|
69
|
+
console.error(chalk_1.default.green('Fetched'));
|
|
70
|
+
console.error(chalk_1.default.dim(` Server: ${selection.url}`));
|
|
71
|
+
console.error();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const fs = await import('fs');
|
|
75
|
+
fs.writeFileSync(options.output, document, 'utf-8');
|
|
76
|
+
console.error(chalk_1.default.green('Fetched'));
|
|
77
|
+
console.error(chalk_1.default.dim(` Playlist ID: ${stored.id ?? identifier}`));
|
|
78
|
+
if (typeof stored.title === 'string') {
|
|
79
|
+
console.error(chalk_1.default.dim(` Title: ${stored.title}`));
|
|
80
|
+
}
|
|
81
|
+
console.error(chalk_1.default.dim(` Server: ${selection.url}`));
|
|
82
|
+
console.error(chalk_1.default.dim(` Saved to: ${options.output}`));
|
|
83
|
+
console.error();
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
console.error(chalk_1.default.red('\nError:'), error.message);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
@@ -12,6 +12,7 @@ const chalk_1 = __importDefault(require("chalk"));
|
|
|
12
12
|
const fs_1 = require("fs");
|
|
13
13
|
const source_resolver_1 = require("@feralfile/source-resolver");
|
|
14
14
|
const prompt_1 = require("./helpers/prompt");
|
|
15
|
+
const feed_server_1 = require("./helpers/feed-server");
|
|
15
16
|
const ff_marketplace_1 = require("../utilities/ff-marketplace");
|
|
16
17
|
const objkt_marketplace_1 = require("../utilities/objkt-marketplace");
|
|
17
18
|
const ab_marketplace_1 = require("../utilities/ab-marketplace");
|
|
@@ -565,49 +566,21 @@ async function doPublish(savedPath, serverArg, nonInteractive) {
|
|
|
565
566
|
const { getFeedConfig } = await import('../config.js');
|
|
566
567
|
const { publishPlaylist } = await import('../utilities/playlist-publisher.js');
|
|
567
568
|
const feedConfig = getFeedConfig();
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
569
|
+
// One implementation of the server rule for every write path (see helpers/feed-server.ts): --server is
|
|
570
|
+
// validated whatever the server count, and an ambiguous choice fails loudly rather than defaulting.
|
|
571
|
+
const selection = await (0, feed_server_1.selectFeedServer)(feedConfig.baseURLs, {
|
|
572
|
+
serverArg,
|
|
573
|
+
nonInteractive,
|
|
574
|
+
});
|
|
575
|
+
if (!selection.ok) {
|
|
576
|
+
console.error(chalk_1.default.red(selection.error));
|
|
577
|
+
if (selection.detail) {
|
|
578
|
+
console.error(selection.detail);
|
|
579
|
+
}
|
|
571
580
|
process.exitCode = 1;
|
|
572
581
|
return;
|
|
573
582
|
}
|
|
574
|
-
|
|
575
|
-
// silently truncate to 0 and route to a different server; with a single
|
|
576
|
-
// server configured the old code skipped --server validation entirely and
|
|
577
|
-
// accepted any garbage value while publishing to baseURLs[0].
|
|
578
|
-
let serverIndex = 0;
|
|
579
|
-
if (serverArg !== undefined) {
|
|
580
|
-
const n = Number(serverArg);
|
|
581
|
-
if (!Number.isInteger(n) || n < 0 || n >= feedConfig.baseURLs.length) {
|
|
582
|
-
console.error(chalk_1.default.red(`Invalid --server value: ${serverArg} (expected integer in 0..${feedConfig.baseURLs.length - 1})`));
|
|
583
|
-
process.exitCode = 1;
|
|
584
|
-
return;
|
|
585
|
-
}
|
|
586
|
-
serverIndex = n;
|
|
587
|
-
}
|
|
588
|
-
else if (feedConfig.baseURLs.length > 1) {
|
|
589
|
-
if (nonInteractive) {
|
|
590
|
-
console.error(chalk_1.default.red(`Multiple feed servers configured (${feedConfig.baseURLs.length}); pass --server <index> when running with --yes.`));
|
|
591
|
-
process.exitCode = 1;
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
console.log(chalk_1.default.yellow(`Multiple feed servers configured:`));
|
|
595
|
-
feedConfig.baseURLs.forEach((url, i) => {
|
|
596
|
-
console.log(chalk_1.default.cyan(` ${i}: ${url}`));
|
|
597
|
-
});
|
|
598
|
-
const prompt = (0, prompt_1.createPrompt)();
|
|
599
|
-
const answer = await prompt.ask('Select server (0-based index): ');
|
|
600
|
-
prompt.close();
|
|
601
|
-
console.log();
|
|
602
|
-
const n = Number(answer);
|
|
603
|
-
if (!Number.isInteger(n) || n < 0 || n >= feedConfig.baseURLs.length) {
|
|
604
|
-
console.error(chalk_1.default.red(`Invalid selection: ${answer} (expected integer in 0..${feedConfig.baseURLs.length - 1})`));
|
|
605
|
-
process.exitCode = 1;
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
serverIndex = n;
|
|
609
|
-
}
|
|
610
|
-
const serverUrl = feedConfig.baseURLs[serverIndex];
|
|
583
|
+
const serverUrl = selection.url;
|
|
611
584
|
const result = await publishPlaylist(savedPath, serverUrl);
|
|
612
585
|
if (result.success) {
|
|
613
586
|
console.log(chalk_1.default.green('✓ Published'));
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Chooses which configured feed server a command writes to.
|
|
4
|
+
*
|
|
5
|
+
* The rule this file exists to fix: with more than one server configured and no `--server`, the old
|
|
6
|
+
* `publish` printed "Select server" and read from stdin. Under a pipe, cron job, or CI step, stdin is
|
|
7
|
+
* closed, readline resolves immediately with an empty answer, `parseInt('')` yields `NaN`, and the
|
|
8
|
+
* command exited **0** having published nothing. A silent success is the worst possible answer for a
|
|
9
|
+
* write, so ambiguity in a non-interactive session is now a non-zero exit that lists the servers.
|
|
10
|
+
*
|
|
11
|
+
* Failing rather than defaulting to index 0 is deliberate: the first configured server is usually
|
|
12
|
+
* production, and a script that meant the other one would otherwise write to the wrong feed and report
|
|
13
|
+
* success. `find --publish` already answered this way for `--yes`; this generalizes it to any session
|
|
14
|
+
* with no terminal, and to the delete path, where a wrong-feed write is not undoable.
|
|
15
|
+
*/
|
|
16
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
17
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.selectFeedServer = selectFeedServer;
|
|
21
|
+
exports.describeServers = describeServers;
|
|
22
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
23
|
+
const prompt_1 = require("./prompt");
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the feed server URL for a command.
|
|
26
|
+
*
|
|
27
|
+
* `--server` is validated whatever the server count: `parseInt('0abc')` truncates to 0 and would route
|
|
28
|
+
* to a different server than the user named.
|
|
29
|
+
*
|
|
30
|
+
* @param baseURLs - Configured feed server base URLs, in config order
|
|
31
|
+
* @param options - `--server` value and whether the session may prompt
|
|
32
|
+
* @returns The chosen URL and its index, or the error to report
|
|
33
|
+
*/
|
|
34
|
+
async function selectFeedServer(baseURLs, options = {}) {
|
|
35
|
+
if (!baseURLs || baseURLs.length === 0) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
error: 'No feed servers configured',
|
|
39
|
+
detail: 'Add feed server URLs to config.json: feed.baseURLs',
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const upperBound = baseURLs.length - 1;
|
|
43
|
+
if (options.serverArg !== undefined) {
|
|
44
|
+
// `Number('')` and `Number(' ')` are both 0, so an empty `--server=` would silently select the
|
|
45
|
+
// first server — a typo answering as if it were a choice.
|
|
46
|
+
const raw = options.serverArg.trim();
|
|
47
|
+
const index = raw.length === 0 ? Number.NaN : Number(raw);
|
|
48
|
+
if (!Number.isInteger(index) || index < 0 || index >= baseURLs.length) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: `Invalid --server value: ${options.serverArg} (expected integer in 0..${upperBound})`,
|
|
52
|
+
detail: describeServers(baseURLs),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, url: baseURLs[index], index };
|
|
56
|
+
}
|
|
57
|
+
if (baseURLs.length === 1) {
|
|
58
|
+
return { ok: true, url: baseURLs[0], index: 0 };
|
|
59
|
+
}
|
|
60
|
+
if (options.nonInteractive || !process.stdin.isTTY) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: `Multiple feed servers configured (${baseURLs.length}); pass --server <index>`,
|
|
64
|
+
detail: describeServers(baseURLs),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
console.log(chalk_1.default.yellow('Multiple feed servers configured:'));
|
|
68
|
+
console.log(describeServers(baseURLs));
|
|
69
|
+
console.log();
|
|
70
|
+
const answer = await askForSelection(options.ask);
|
|
71
|
+
console.log();
|
|
72
|
+
// A bare Enter is not a choice. `Number('')` is 0, so accepting it would send the write to whichever
|
|
73
|
+
// feed happens to be listed first — usually production — for someone who was hesitating, or who
|
|
74
|
+
// reflexively took a default that was never offered. This prompt deliberately has no default: the
|
|
75
|
+
// whole point of asking is that the CLI cannot tell which feed was meant.
|
|
76
|
+
const selected = answer.trim();
|
|
77
|
+
const index = selected.length === 0 ? Number.NaN : Number(selected);
|
|
78
|
+
if (!Number.isInteger(index) || index < 0 || index >= baseURLs.length) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: selected.length === 0
|
|
82
|
+
? `No server selected (expected integer in 0..${upperBound})`
|
|
83
|
+
: `Invalid selection: ${selected} (expected integer in 0..${upperBound})`,
|
|
84
|
+
detail: describeServers(baseURLs),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, url: baseURLs[index], index };
|
|
88
|
+
}
|
|
89
|
+
/** Ask which server to use, through the injected prompt when a caller supplied one. */
|
|
90
|
+
async function askForSelection(ask) {
|
|
91
|
+
if (ask) {
|
|
92
|
+
return ask('Select server (0-based index): ');
|
|
93
|
+
}
|
|
94
|
+
const prompt = (0, prompt_1.createPrompt)();
|
|
95
|
+
try {
|
|
96
|
+
return await prompt.ask('Select server (0-based index): ');
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
prompt.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Numbered listing of the configured servers, used in both the prompt and the failure detail. */
|
|
103
|
+
function describeServers(baseURLs) {
|
|
104
|
+
return baseURLs.map((url, index) => chalk_1.default.cyan(` ${index}: ${url}`)).join('\n');
|
|
105
|
+
}
|
|
@@ -6,47 +6,77 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.publishCommand = void 0;
|
|
7
7
|
const commander_1 = require("commander");
|
|
8
8
|
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
-
const
|
|
9
|
+
const feed_server_1 = require("./helpers/feed-server");
|
|
10
10
|
exports.publishCommand = new commander_1.Command('publish')
|
|
11
11
|
.description('Publish a playlist to a feed server')
|
|
12
12
|
.argument('<file>', 'Path to the playlist file')
|
|
13
13
|
.option('-s, --server <index>', 'Feed server index (use this if multiple servers configured)')
|
|
14
|
+
.option('--replace', 'Replace the playlist already stored under this document id, instead of creating a new one')
|
|
15
|
+
.option('-k, --key <privateKey>', 'Ed25519 private key that signs the replace authorization (overrides config; --replace only)')
|
|
14
16
|
.action(async (file, options) => {
|
|
15
17
|
try {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
// A plain publish signs nothing at request time — it uploads the signatures[] envelope the
|
|
19
|
+
// document already carries — so --key would do nothing here. Refusing beats accepting it
|
|
20
|
+
// silently: someone passing a key believes it is being used, and a no-op flag on a command
|
|
21
|
+
// that writes to a feed is the kind of quiet lie this CLI has been removing.
|
|
22
|
+
//
|
|
23
|
+
// Tested for PRESENCE, not truthiness. `--key ""` is what an unset shell variable expands to,
|
|
24
|
+
// and it is still a key the user meant to supply; skipping the refusal for it published the
|
|
25
|
+
// document while leaving them believing a key had been checked.
|
|
26
|
+
if (options.key !== undefined && !options.replace) {
|
|
27
|
+
console.error(chalk_1.default.red('\n--key has no effect on a plain publish'));
|
|
28
|
+
console.log(chalk_1.default.yellow(' A publish is authorized by the signatures already inside the document; the command\n' +
|
|
29
|
+
' signs nothing. Sign the file first, with the key you meant:\n' +
|
|
30
|
+
' ff-cli sign <file> -r curator --key <private key>\n' +
|
|
31
|
+
' --key applies to "publish --replace", which signs an authorization intent.\n'));
|
|
23
32
|
process.exit(1);
|
|
24
33
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
console.log(chalk_1.default.blue(options.replace ? '\nReplace playlist\n' : '\nPublish playlist\n'));
|
|
35
|
+
const { getFeedConfig } = await import('../config.js');
|
|
36
|
+
const { publishPlaylist, replacePlaylist } = await import('../utilities/playlist-publisher.js');
|
|
37
|
+
// Validate the signing credential before the server is chosen, for a replace.
|
|
38
|
+
//
|
|
39
|
+
// Selecting between several configured feeds is a question put to the operator; asking it and
|
|
40
|
+
// only then rejecting their key spends their attention on a run that could never have completed.
|
|
41
|
+
// The credential is already in hand, so it is checked without asking anybody anything.
|
|
42
|
+
//
|
|
43
|
+
// Only for --replace: a plain publish signs nothing at request time and must keep working with
|
|
44
|
+
// no key configured at all, so requiring one here would break it.
|
|
45
|
+
let replaceKey;
|
|
46
|
+
if (options.replace) {
|
|
47
|
+
const { mutationSignerIdentity } = await import('../utilities/feed-mutation.js');
|
|
48
|
+
try {
|
|
49
|
+
replaceKey = mutationSignerIdentity(options.key, 'replace').privateKey;
|
|
39
50
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
console.error(chalk_1.default.red(
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.error(chalk_1.default.red('\nCannot sign the replacement'));
|
|
53
|
+
console.error(chalk_1.default.red(` ${error.message}`));
|
|
54
|
+
console.log();
|
|
43
55
|
process.exit(1);
|
|
44
56
|
}
|
|
45
|
-
serverUrl = feedConfig.baseURLs[serverIndex];
|
|
46
57
|
}
|
|
47
|
-
const
|
|
58
|
+
const feedConfig = getFeedConfig();
|
|
59
|
+
const selection = await (0, feed_server_1.selectFeedServer)(feedConfig.baseURLs, { serverArg: options.server });
|
|
60
|
+
if (!selection.ok) {
|
|
61
|
+
console.error(chalk_1.default.red(`\n${selection.error}`));
|
|
62
|
+
if (selection.detail) {
|
|
63
|
+
console.log(chalk_1.default.yellow(selection.detail));
|
|
64
|
+
}
|
|
65
|
+
console.log();
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
// A create is never silently upgraded to a replace. They are different writes — a create fails
|
|
69
|
+
// loudly on a duplicate id, a replace overwrites a published document — and an operator who typed
|
|
70
|
+
// neither flag meant the safe one.
|
|
71
|
+
const result = options.replace
|
|
72
|
+
? await replacePlaylist(file, selection.url, {
|
|
73
|
+
// The material resolved above, so the identity validated here is the one that signs.
|
|
74
|
+
privateKey: replaceKey,
|
|
75
|
+
keySource: options.key !== undefined ? 'supplied' : 'configured',
|
|
76
|
+
})
|
|
77
|
+
: await publishPlaylist(file, selection.url);
|
|
48
78
|
if (result.success) {
|
|
49
|
-
console.log(chalk_1.default.green('Published'));
|
|
79
|
+
console.log(chalk_1.default.green(options.replace ? 'Replaced' : 'Published'));
|
|
50
80
|
if (result.playlistId) {
|
|
51
81
|
console.log(chalk_1.default.dim(` Playlist ID: ${result.playlistId}`));
|
|
52
82
|
}
|
|
@@ -57,7 +87,7 @@ exports.publishCommand = new commander_1.Command('publish')
|
|
|
57
87
|
console.log();
|
|
58
88
|
}
|
|
59
89
|
else {
|
|
60
|
-
console.error(chalk_1.default.red('\nPublish failed'));
|
|
90
|
+
console.error(chalk_1.default.red(options.replace ? '\nReplace failed' : '\nPublish failed'));
|
|
61
91
|
if (result.error) {
|
|
62
92
|
console.error(chalk_1.default.red(` ${result.error}`));
|
|
63
93
|
}
|
|
@@ -15,12 +15,90 @@ exports.signCommand = new commander_1.Command('sign')
|
|
|
15
15
|
.option('-k, --key <privateKey>', 'Ed25519 private key in base64 format (overrides config)')
|
|
16
16
|
.option('-r, --role <role>', 'DP-1 signing role (overrides config)')
|
|
17
17
|
.option('-o, --output <file>', 'Output file path (defaults to overwriting input file)')
|
|
18
|
+
.option('--replace-signatures', 'Discard the existing signatures and sign fresh (use after editing a signed playlist)')
|
|
19
|
+
.option('--force', 'Overwrite an output file that holds a different document (refused without this)')
|
|
18
20
|
.action(async (file, options) => {
|
|
19
21
|
try {
|
|
20
22
|
console.log(chalk_1.default.blue('\nSign playlist\n'));
|
|
21
|
-
const result = await signPlaylistFile(file, options.key, options.output, options.role
|
|
23
|
+
const result = await signPlaylistFile(file, options.key, options.output, options.role, {
|
|
24
|
+
replaceSignatures: !!options.replaceSignatures,
|
|
25
|
+
force: !!options.force,
|
|
26
|
+
});
|
|
22
27
|
if (result.success) {
|
|
23
28
|
console.log(chalk_1.default.green('\nPlaylist signed'));
|
|
29
|
+
// Name the casualty. --force overwrote a document this command never read, and the only
|
|
30
|
+
// record that it existed is now this line.
|
|
31
|
+
if (result.overwroteAnother) {
|
|
32
|
+
console.log(chalk_1.default.yellow(` Overwrote a different document at ${result.outputPath} (--force)`));
|
|
33
|
+
}
|
|
34
|
+
// Name every discarded entry, and say only what was actually checked.
|
|
35
|
+
//
|
|
36
|
+
// Your own earlier signature is free: this command replaces it. For the rest the only
|
|
37
|
+
// establishable fact is whether the entry verifies against the document as it stands —
|
|
38
|
+
// "void" would assert it verified against the PREVIOUS content, which was edited in place
|
|
39
|
+
// and exists nowhere by the time this runs. A failed verification is equally consistent
|
|
40
|
+
// with an edit, with a tampered entry, and with one that was never valid, so it is reported
|
|
41
|
+
// as unverified rather than diagnosed.
|
|
42
|
+
//
|
|
43
|
+
// Roles are carried through rather than assumed: a document may hold `agent`,
|
|
44
|
+
// `institution`, or `licensor` entries, and a `feed` role is not special-cased because any
|
|
45
|
+
// key can emit one and this CLI has no feed identity to check a kid against.
|
|
46
|
+
const dropped = result.dropped ?? [];
|
|
47
|
+
if (dropped.length > 0) {
|
|
48
|
+
const plural = dropped.length === 1 ? '' : 's';
|
|
49
|
+
console.log(chalk_1.default.dim(` Replaced ${dropped.length} existing signature${plural}:`));
|
|
50
|
+
for (const entry of dropped) {
|
|
51
|
+
console.log(chalk_1.default.dim(` - ${entry.label}`));
|
|
52
|
+
}
|
|
53
|
+
// Everything this run did not supersede. A same-key entry in another role belongs here:
|
|
54
|
+
// the fresh signature asserts a different role, so that entry is gone and not reinstated.
|
|
55
|
+
const others = dropped.filter((entry) => entry.kind !== 'replaced' && entry.checkable);
|
|
56
|
+
const stillValid = others.filter((entry) => entry.verified);
|
|
57
|
+
const unverified = others.filter((entry) => !entry.verified);
|
|
58
|
+
if (stillValid.length > 0) {
|
|
59
|
+
const noun = stillValid.length === 1 ? 'signature' : 'signatures';
|
|
60
|
+
const it = stillValid.length === 1 ? 'it' : 'them';
|
|
61
|
+
// Say where they still exist. Only two cases reach here: an --output run, where the
|
|
62
|
+
// input is untouched and holds them; or an in-place run whose still-valid entries are
|
|
63
|
+
// all this key's own in another role, which nobody else has to be asked for. An
|
|
64
|
+
// in-place run that would drop another key's still-valid signature never gets this far
|
|
65
|
+
// — it is refused, because that file is the only copy and its holder is the only one
|
|
66
|
+
// who could make another.
|
|
67
|
+
const where = result.inPlace
|
|
68
|
+
? ` ${it.charAt(0).toUpperCase()}${it.slice(1)} ${stillValid.length === 1 ? 'was' : 'were'} made by your own key in another role; sign again to add back any you still want.`
|
|
69
|
+
: ` Your input file is untouched, so ${it} ${stillValid.length === 1 ? 'remains' : 'remain'} valid there.`;
|
|
70
|
+
console.log(chalk_1.default.yellow(` ${stillValid.length} other ${noun} still verified over this content and ` +
|
|
71
|
+
`${stillValid.length === 1 ? 'was' : 'were'} removed anyway.\n` +
|
|
72
|
+
where));
|
|
73
|
+
}
|
|
74
|
+
if (unverified.length > 0) {
|
|
75
|
+
const noun = unverified.length === 1 ? 'signature' : 'signatures';
|
|
76
|
+
// Stated as what was observed, with both explanations, and no instruction that would
|
|
77
|
+
// only make sense under one of them.
|
|
78
|
+
console.log(chalk_1.default.yellow(` ${unverified.length} other ${noun} could not be verified against this document:`));
|
|
79
|
+
for (const entry of unverified) {
|
|
80
|
+
const who = entry.kid ? `...${entry.kid.slice(-8)}` : 'unknown key';
|
|
81
|
+
// "claims" is not decoration: an unverified entry carrying this key's kid has not
|
|
82
|
+
// established whose it is, and reading it as yours is how a forgery would hide.
|
|
83
|
+
const mine = entry.sameKey
|
|
84
|
+
? ' — your own key'
|
|
85
|
+
: entry.claimsSigningKey
|
|
86
|
+
? ' — claims your key, unverified'
|
|
87
|
+
: '';
|
|
88
|
+
console.log(chalk_1.default.yellow(` ${who}${entry.role ? ` (${entry.role})` : ''}${mine}`));
|
|
89
|
+
}
|
|
90
|
+
console.log(chalk_1.default.yellow(` That is consistent with the content having changed since they were made, and\n` +
|
|
91
|
+
` equally with their never having been valid — this command only has the document\n` +
|
|
92
|
+
` as it stands, so it cannot tell which. If you want those signatures on what you\n` +
|
|
93
|
+
` publish, their holders have to sign this document; signing appends, so they can\n` +
|
|
94
|
+
` add to this file without disturbing yours.`));
|
|
95
|
+
}
|
|
96
|
+
if (dropped.some((entry) => !entry.checkable)) {
|
|
97
|
+
console.log(chalk_1.default.yellow(` A legacy flat signature carries no kid or role, so nothing here can judge it.`));
|
|
98
|
+
}
|
|
99
|
+
// The general fact, never a claim about a specific entry above.
|
|
100
|
+
console.log(chalk_1.default.dim(` A feed appends its own signature again after it verifies a replacement.`));
|
|
101
|
+
}
|
|
24
102
|
if (Array.isArray(result.playlist?.signatures)) {
|
|
25
103
|
console.log(chalk_1.default.dim(` Signatures: ${result.playlist.signatures.length}`));
|
|
26
104
|
}
|
|
@@ -31,6 +109,17 @@ exports.signCommand = new commander_1.Command('sign')
|
|
|
31
109
|
}
|
|
32
110
|
else {
|
|
33
111
|
console.error(chalk_1.default.red('\nSign failed:'), result.error);
|
|
112
|
+
// The one failure whose remedy is not obvious: appending to an edited document can never
|
|
113
|
+
// verify, because the earlier entries cover bytes that moved. Say so where it happens,
|
|
114
|
+
// rather than leaving the operator to conclude their key is wrong.
|
|
115
|
+
if (!options.replaceSignatures &&
|
|
116
|
+
/verification failed|not verifiable/i.test(String(result.error))) {
|
|
117
|
+
console.log(chalk_1.default.yellow('\nIf you edited this playlist after it was signed, the existing signatures no longer\n' +
|
|
118
|
+
' cover it, and signing again cannot repair them — signing appends. Sign fresh instead:\n' +
|
|
119
|
+
` ff-cli sign ${file} -r curator --replace-signatures\n` +
|
|
120
|
+
' That discards every existing entry, including any feed signature, and signs the\n' +
|
|
121
|
+
' document as it stands now. It is the path a feed replace expects.'));
|
|
122
|
+
}
|
|
34
123
|
process.exit(1);
|
|
35
124
|
}
|
|
36
125
|
}
|