@feralfile/cli 2.7.0 → 2.8.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/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 +19 -31
- package/dist/src/commands/sign.js +22 -1
- package/dist/src/commands/unpublish.js +103 -0
- package/dist/src/utilities/feed-mutation.js +371 -0
- package/dist/src/utilities/playlist-publisher.js +382 -148
- package/dist/src/utilities/playlist-signer.js +30 -4
- package/dist/src/utilities/playlist-unpublisher.js +159 -0
- package/docs/CONFIGURATION.md +19 -1
- package/docs/EXAMPLES.md +199 -0
- package/docs/PROJECT_SPEC.md +33 -1
- package/docs/README.md +48 -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
|
@@ -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,35 @@ 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')
|
|
14
15
|
.action(async (file, options) => {
|
|
15
16
|
try {
|
|
16
|
-
console.log(chalk_1.default.blue('\nPublish playlist\n'));
|
|
17
|
+
console.log(chalk_1.default.blue(options.replace ? '\nReplace playlist\n' : '\nPublish playlist\n'));
|
|
17
18
|
const { getFeedConfig } = await import('../config.js');
|
|
18
|
-
const { publishPlaylist } = await import('../utilities/playlist-publisher.js');
|
|
19
|
+
const { publishPlaylist, replacePlaylist } = await import('../utilities/playlist-publisher.js');
|
|
19
20
|
const feedConfig = getFeedConfig();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
console.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
let serverUrl = feedConfig.baseURLs[0];
|
|
26
|
-
if (feedConfig.baseURLs.length > 1) {
|
|
27
|
-
if (!options.server) {
|
|
28
|
-
console.log(chalk_1.default.yellow('Multiple feed servers found. Select one:'));
|
|
29
|
-
console.log();
|
|
30
|
-
feedConfig.baseURLs.forEach((url, index) => {
|
|
31
|
-
console.log(chalk_1.default.cyan(` ${index}: ${url}`));
|
|
32
|
-
});
|
|
33
|
-
console.log();
|
|
34
|
-
const prompt = (0, prompt_1.createPrompt)();
|
|
35
|
-
const selection = await prompt.ask('Select server (0-based index): ');
|
|
36
|
-
prompt.close();
|
|
37
|
-
console.log();
|
|
38
|
-
options.server = selection;
|
|
39
|
-
}
|
|
40
|
-
const serverIndex = parseInt(options.server || '0', 10);
|
|
41
|
-
if (isNaN(serverIndex) || serverIndex < 0 || serverIndex >= feedConfig.baseURLs.length) {
|
|
42
|
-
console.error(chalk_1.default.red('\nInvalid server index'));
|
|
43
|
-
process.exit(1);
|
|
21
|
+
const selection = await (0, feed_server_1.selectFeedServer)(feedConfig.baseURLs, { serverArg: options.server });
|
|
22
|
+
if (!selection.ok) {
|
|
23
|
+
console.error(chalk_1.default.red(`\n${selection.error}`));
|
|
24
|
+
if (selection.detail) {
|
|
25
|
+
console.log(chalk_1.default.yellow(selection.detail));
|
|
44
26
|
}
|
|
45
|
-
|
|
27
|
+
console.log();
|
|
28
|
+
process.exit(1);
|
|
46
29
|
}
|
|
47
|
-
|
|
30
|
+
// A create is never silently upgraded to a replace. They are different writes — a create fails
|
|
31
|
+
// loudly on a duplicate id, a replace overwrites a published document — and an operator who typed
|
|
32
|
+
// neither flag meant the safe one.
|
|
33
|
+
const result = options.replace
|
|
34
|
+
? await replacePlaylist(file, selection.url)
|
|
35
|
+
: await publishPlaylist(file, selection.url);
|
|
48
36
|
if (result.success) {
|
|
49
|
-
console.log(chalk_1.default.green('Published'));
|
|
37
|
+
console.log(chalk_1.default.green(options.replace ? 'Replaced' : 'Published'));
|
|
50
38
|
if (result.playlistId) {
|
|
51
39
|
console.log(chalk_1.default.dim(` Playlist ID: ${result.playlistId}`));
|
|
52
40
|
}
|
|
@@ -57,7 +45,7 @@ exports.publishCommand = new commander_1.Command('publish')
|
|
|
57
45
|
console.log();
|
|
58
46
|
}
|
|
59
47
|
else {
|
|
60
|
-
console.error(chalk_1.default.red('\nPublish failed'));
|
|
48
|
+
console.error(chalk_1.default.red(options.replace ? '\nReplace failed' : '\nPublish failed'));
|
|
61
49
|
if (result.error) {
|
|
62
50
|
console.error(chalk_1.default.red(` ${result.error}`));
|
|
63
51
|
}
|
|
@@ -15,12 +15,22 @@ 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)')
|
|
18
19
|
.action(async (file, options) => {
|
|
19
20
|
try {
|
|
20
21
|
console.log(chalk_1.default.blue('\nSign playlist\n'));
|
|
21
|
-
const result = await signPlaylistFile(file, options.key, options.output, options.role
|
|
22
|
+
const result = await signPlaylistFile(file, options.key, options.output, options.role, {
|
|
23
|
+
replaceSignatures: !!options.replaceSignatures,
|
|
24
|
+
});
|
|
22
25
|
if (result.success) {
|
|
23
26
|
console.log(chalk_1.default.green('\nPlaylist signed'));
|
|
27
|
+
// Report the discard explicitly. Dropping an entry is a real change to the document —
|
|
28
|
+
// someone else's endorsement may have been in it — and staying silent would make a fresh
|
|
29
|
+
// sign indistinguishable from an appending one.
|
|
30
|
+
if (result.droppedSignatures > 0) {
|
|
31
|
+
const plural = result.droppedSignatures === 1 ? '' : 's';
|
|
32
|
+
console.log(chalk_1.default.dim(` Replaced ${result.droppedSignatures} existing signature${plural}`));
|
|
33
|
+
}
|
|
24
34
|
if (Array.isArray(result.playlist?.signatures)) {
|
|
25
35
|
console.log(chalk_1.default.dim(` Signatures: ${result.playlist.signatures.length}`));
|
|
26
36
|
}
|
|
@@ -31,6 +41,17 @@ exports.signCommand = new commander_1.Command('sign')
|
|
|
31
41
|
}
|
|
32
42
|
else {
|
|
33
43
|
console.error(chalk_1.default.red('\nSign failed:'), result.error);
|
|
44
|
+
// The one failure whose remedy is not obvious: appending to an edited document can never
|
|
45
|
+
// verify, because the earlier entries cover bytes that moved. Say so where it happens,
|
|
46
|
+
// rather than leaving the operator to conclude their key is wrong.
|
|
47
|
+
if (!options.replaceSignatures &&
|
|
48
|
+
/verification failed|not verifiable/i.test(String(result.error))) {
|
|
49
|
+
console.log(chalk_1.default.yellow('\nIf you edited this playlist after it was signed, the existing signatures no longer\n' +
|
|
50
|
+
' cover it, and signing again cannot repair them — signing appends. Sign fresh instead:\n' +
|
|
51
|
+
` ff-cli sign ${file} -r curator --replace-signatures\n` +
|
|
52
|
+
' That discards every existing entry, including any feed signature, and signs the\n' +
|
|
53
|
+
' document as it stands now. It is the path a feed replace expects.'));
|
|
54
|
+
}
|
|
34
55
|
process.exit(1);
|
|
35
56
|
}
|
|
36
57
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
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.unpublishCommand = 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
|
+
const prompt_1 = require("./helpers/prompt");
|
|
11
|
+
/**
|
|
12
|
+
* `ff-cli unpublish <id-or-url>` — delete a playlist this key owns from a feed.
|
|
13
|
+
*
|
|
14
|
+
* The delete is irreversible in a way most deletes are not: the feed tombstones the id, so the playlist
|
|
15
|
+
* cannot be restored and the id can never be reused. Hence the confirmation, which shows the title and
|
|
16
|
+
* server first, and defaults to no. `-y` skips it for scripts; without a terminal and without `-y` the
|
|
17
|
+
* command refuses rather than assuming consent.
|
|
18
|
+
*/
|
|
19
|
+
exports.unpublishCommand = new commander_1.Command('unpublish')
|
|
20
|
+
.description('Delete a playlist from a feed server (signed delete; owner key required)')
|
|
21
|
+
.argument('<id-or-url>', 'Playlist id, slug, or feed URL')
|
|
22
|
+
.option('-s, --server <index>', 'Feed server index (use this if multiple servers configured)')
|
|
23
|
+
.option('-y, --yes', 'Skip the confirmation prompt')
|
|
24
|
+
.action(async (idOrUrl, options) => {
|
|
25
|
+
try {
|
|
26
|
+
console.log(chalk_1.default.blue('\nUnpublish playlist\n'));
|
|
27
|
+
const { getFeedConfig } = await import('../config.js');
|
|
28
|
+
const { unpublishPlaylist, fetchPlaylistForUnpublish } = await import('../utilities/playlist-unpublisher.js');
|
|
29
|
+
const feedConfig = getFeedConfig();
|
|
30
|
+
const selection = await (0, feed_server_1.selectFeedServer)(feedConfig.baseURLs, {
|
|
31
|
+
serverArg: options.server,
|
|
32
|
+
nonInteractive: !!options.yes,
|
|
33
|
+
});
|
|
34
|
+
if (!selection.ok) {
|
|
35
|
+
console.error(chalk_1.default.red(`\n${selection.error}`));
|
|
36
|
+
if (selection.detail) {
|
|
37
|
+
console.log(chalk_1.default.yellow(selection.detail));
|
|
38
|
+
}
|
|
39
|
+
console.log();
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
if (!options.yes) {
|
|
43
|
+
if (!process.stdin.isTTY) {
|
|
44
|
+
console.error(chalk_1.default.red('\nRefusing to delete without confirmation'));
|
|
45
|
+
console.log(chalk_1.default.yellow(' This session has no terminal to confirm on. Pass -y to proceed.\n'));
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
// Show what is about to go before asking. A bare id says nothing about which playlist it is, and
|
|
49
|
+
// the answer cannot be taken back. A lookup failure is not fatal here: the delete path repeats
|
|
50
|
+
// the fetch and reports it properly, so the prompt just falls back to the identifier.
|
|
51
|
+
let label = idOrUrl;
|
|
52
|
+
try {
|
|
53
|
+
const stored = await fetchPlaylistForUnpublish(idOrUrl, selection.url);
|
|
54
|
+
const title = typeof stored.title === 'string' ? stored.title : '';
|
|
55
|
+
label = title ? `"${title}" (${stored.id ?? idOrUrl})` : String(stored.id ?? idOrUrl);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
label = idOrUrl;
|
|
59
|
+
}
|
|
60
|
+
console.log(chalk_1.default.yellow(` ${label}`));
|
|
61
|
+
console.log(chalk_1.default.dim(` Server: ${selection.url}`));
|
|
62
|
+
console.log(chalk_1.default.dim(' This cannot be undone; the id is tombstoned and cannot be reused.'));
|
|
63
|
+
console.log();
|
|
64
|
+
const prompt = (0, prompt_1.createPrompt)();
|
|
65
|
+
const confirmed = await (0, prompt_1.promptYesNo)(prompt.ask, 'Delete this playlist?', false);
|
|
66
|
+
prompt.close();
|
|
67
|
+
console.log();
|
|
68
|
+
if (!confirmed) {
|
|
69
|
+
console.log(chalk_1.default.dim('Cancelled.\n'));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const result = await unpublishPlaylist(idOrUrl, selection.url);
|
|
74
|
+
if (result.success) {
|
|
75
|
+
console.log(chalk_1.default.green('Unpublished'));
|
|
76
|
+
if (result.playlistId) {
|
|
77
|
+
console.log(chalk_1.default.dim(` Playlist ID: ${result.playlistId}`));
|
|
78
|
+
}
|
|
79
|
+
if (result.slug) {
|
|
80
|
+
console.log(chalk_1.default.dim(` Slug: ${result.slug}`));
|
|
81
|
+
}
|
|
82
|
+
console.log(chalk_1.default.dim(` Server: ${result.feedServer}`));
|
|
83
|
+
if (result.message) {
|
|
84
|
+
console.log(chalk_1.default.dim(` Status: ${result.message}`));
|
|
85
|
+
}
|
|
86
|
+
console.log();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.error(chalk_1.default.red('\nUnpublish failed'));
|
|
90
|
+
if (result.error) {
|
|
91
|
+
console.error(chalk_1.default.red(` ${result.error}`));
|
|
92
|
+
}
|
|
93
|
+
if (result.message) {
|
|
94
|
+
console.log(chalk_1.default.yellow(`\n${result.message}`));
|
|
95
|
+
}
|
|
96
|
+
console.log();
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
console.error(chalk_1.default.red('\nError:'), error.message);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
});
|