@feralfile/cli 2.9.0 → 2.10.1
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/package.json +2 -2
- package/dist/src/commands/config.js +12 -5
- package/dist/src/commands/enrich.js +128 -19
- package/dist/src/commands/find.js +11 -2
- package/dist/src/commands/publish.js +22 -7
- package/dist/src/commands/setup.js +42 -15
- package/dist/src/commands/sign.js +8 -1
- package/dist/src/commands/status.js +28 -2
- package/dist/src/commands/unpublish.js +11 -10
- package/dist/src/utilities/feed-mutation.js +29 -16
- package/dist/src/utilities/playlist-publisher.js +1 -1
- package/dist/src/utilities/playlist-unpublisher.js +1 -1
- package/dist/src/utilities/signing-key-source.js +230 -0
- package/docs/CONFIGURATION.md +58 -4
- package/docs/EXAMPLES.md +39 -7
- package/docs/PROJECT_SPEC.md +14 -5
- package/docs/README.md +36 -10
- package/package.json +2 -2
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feralfile/cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.1",
|
|
4
4
|
"description": "CLI for building, signing, and playing DP-1 playlists of digital art on Feral File devices",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"chalk": "^4.1.2",
|
|
66
66
|
"commander": "^11.1.0",
|
|
67
67
|
"dotenv": "^16.3.1",
|
|
68
|
-
"dp1-js": "2.
|
|
68
|
+
"dp1-js": "2.4.1",
|
|
69
69
|
"ff1": "^1.0.0",
|
|
70
70
|
"fuzzysort": "^3.1.0",
|
|
71
71
|
"viem": "^2.56.3"
|
|
@@ -26,11 +26,18 @@ exports.configCommand = new commander_1.Command('config')
|
|
|
26
26
|
const config = (0, config_1.getConfig)();
|
|
27
27
|
console.log(chalk_1.default.blue('\nCurrent configuration\n'));
|
|
28
28
|
console.log(chalk_1.default.bold('Default duration:'), chalk_1.default.white(config.defaultDuration + 's'));
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
// The effective list, numbered. `-s <index>` on publish, fetch, replace
|
|
30
|
+
// and unpublish is an index into exactly this list — whichever of
|
|
31
|
+
// config.feedServers, the legacy feed.baseURLs, FEED_BASE_URLS, or the
|
|
32
|
+
// built-in default supplied it — so this is the one place an operator
|
|
33
|
+
// or an agent can read the number off before a write. Printing only
|
|
34
|
+
// config.feedServers, unnumbered, left the index undiscoverable for
|
|
35
|
+
// every other source.
|
|
36
|
+
const { baseURLs } = (0, config_1.getFeedConfig)();
|
|
37
|
+
if (baseURLs.length > 0) {
|
|
38
|
+
console.log(chalk_1.default.bold('\nFeed servers (index for -s):\n'));
|
|
39
|
+
baseURLs.forEach((url, index) => {
|
|
40
|
+
console.log(` ${chalk_1.default.bold(String(index))}: ${chalk_1.default.dim(url)}`);
|
|
34
41
|
});
|
|
35
42
|
}
|
|
36
43
|
const devices = config.ff1Devices?.devices || [];
|
|
@@ -40,31 +40,62 @@ const { resolveTokenBatch } = require('../utilities/nft-indexer');
|
|
|
40
40
|
* function's rename semantics, and pairing it with the wrong predicate is a
|
|
41
41
|
* mistake that has already been made once.
|
|
42
42
|
*/
|
|
43
|
-
async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
43
|
+
async function writePlaylistAtomically(destination, contents, expectedDigest, filesystem = fs_1.promises) {
|
|
44
44
|
// Follow a symlink to its target before replacing anything. rename() would
|
|
45
45
|
// replace the link itself, silently detaching a playlist that other paths
|
|
46
46
|
// reach through that name while reporting success.
|
|
47
47
|
let target = destination;
|
|
48
|
-
const existing = await
|
|
48
|
+
const existing = await filesystem.lstat(destination).catch(() => null);
|
|
49
49
|
if (existing?.isSymbolicLink()) {
|
|
50
|
-
target = await
|
|
50
|
+
target = await filesystem.realpath(destination);
|
|
51
51
|
}
|
|
52
52
|
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`);
|
|
53
|
+
// What a replacement preserves, and what it cannot.
|
|
54
|
+
//
|
|
55
|
+
// Preserved: the permission mode — applied before the file holds anything,
|
|
56
|
+
// then restored and verified against the inode after each of the two things
|
|
57
|
+
// that clear set-user-ID and set-group-ID, which are the chown and the write
|
|
58
|
+
// itself — and the owner and group, where a failed chown refuses the
|
|
59
|
+
// replacement outright. Those are what decide access for a file whose
|
|
60
|
+
// permissions are described by mode bits alone, which is the common case.
|
|
61
|
+
// Where any of it cannot be reproduced the write is refused rather than
|
|
62
|
+
// completed with different access.
|
|
63
|
+
//
|
|
64
|
+
// NOT preserved: access-control lists. A replacement is a new inode, and a
|
|
65
|
+
// new inode's ACL comes from the directory's default ACL, not from the file
|
|
66
|
+
// being replaced — so named entries granting or denying specific users and
|
|
67
|
+
// groups are gone, and whatever the directory hands out is there instead.
|
|
68
|
+
// Mode bits cannot carry them across, because mode bits cannot describe a
|
|
69
|
+
// named entry at all: on Linux the group bits are the POSIX mask rather than
|
|
70
|
+
// the group's own permissions, macOS extended ACLs are evaluated ahead of the
|
|
71
|
+
// mode and ignore the mask entirely, and Windows ACLs are not mode bits in
|
|
72
|
+
// any sense. Node exposes no portable way to read an ACL, let alone reapply
|
|
73
|
+
// one, so this is a limit of the replacement strategy and not an oversight
|
|
74
|
+
// that a few more syscalls would close. Reading the mode and finding it
|
|
75
|
+
// unchanged proves nothing about whether access widened or narrowed.
|
|
76
|
+
//
|
|
77
|
+
// What to do when it matters: enrich to a fresh name with `--output`, in a
|
|
78
|
+
// directory whose access is what you want the result to have. That is a
|
|
79
|
+
// create rather than a replace, so nothing is silently reassigned — the new
|
|
80
|
+
// file simply has the access its directory gives it, visibly.
|
|
81
|
+
//
|
|
53
82
|
// The destination's mode has to be known before the file exists, not after
|
|
54
83
|
// it holds the contents. Creating at the default 0o666-minus-umask and
|
|
55
84
|
// chmod'ing afterwards leaves the enriched playlist readable by other local
|
|
56
85
|
// users for the length of the write — short, but a disclosure window on a
|
|
57
86
|
// file the curator deliberately restricted.
|
|
58
|
-
const current = await
|
|
87
|
+
const current = await filesystem.stat(target).catch(() => null);
|
|
59
88
|
const mode = current ? current.mode & 0o7777 : undefined;
|
|
60
89
|
try {
|
|
61
90
|
// 'wx' fails rather than truncating if the name somehow exists, so a
|
|
62
91
|
// collision can never destroy another process's work in progress.
|
|
63
|
-
const handle = await
|
|
92
|
+
const handle = await filesystem.open(temporary, 'wx', mode);
|
|
64
93
|
try {
|
|
65
94
|
// open() applies the mode through umask, which can strip bits the
|
|
66
95
|
// destination had. chmod is not subject to umask, so this restores the
|
|
67
|
-
// exact mode — still before any bytes are written.
|
|
96
|
+
// exact mode — still before any bytes are written. It runs here, before
|
|
97
|
+
// the chown below, so the disclosure window stays closed even though
|
|
98
|
+
// chown will undo part of it.
|
|
68
99
|
if (mode !== undefined) {
|
|
69
100
|
await handle.chmod(mode);
|
|
70
101
|
}
|
|
@@ -88,11 +119,43 @@ async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
|
88
119
|
await handle.chown(current.uid, current.gid);
|
|
89
120
|
}
|
|
90
121
|
catch {
|
|
91
|
-
await
|
|
92
|
-
throw new OwnershipError(target);
|
|
122
|
+
await filesystem.rm(temporary, { force: true }).catch(() => undefined);
|
|
123
|
+
throw new OwnershipError(target, 'ownership');
|
|
93
124
|
}
|
|
125
|
+
// chown clears set-user-ID and set-group-ID. POSIX requires it of any
|
|
126
|
+
// chown by a process without appropriate privileges, and Linux does it
|
|
127
|
+
// for an executable regardless — the whole point is that a setuid file
|
|
128
|
+
// must not survive changing hands, since it would then run as its new
|
|
129
|
+
// owner. Correct in general, and wrong for this one case: the file is
|
|
130
|
+
// not changing hands, it is being restored to the hands it was already
|
|
131
|
+
// in. The restore happens just below, in the same step that verifies
|
|
132
|
+
// it.
|
|
94
133
|
}
|
|
134
|
+
// Verify against the inode rather than trusting the calls above.
|
|
135
|
+
//
|
|
136
|
+
// Both chmod calls can report success and still leave a different mode:
|
|
137
|
+
// restoring set-group-ID needs the caller to be in the file's group, and
|
|
138
|
+
// a filesystem may decline the bit outright. Every one of those endings
|
|
139
|
+
// is the same fact — this replacement would not carry the access the
|
|
140
|
+
// original had — and the whole reason the ownership branch refuses is
|
|
141
|
+
// that completing such a write silently is worse than not writing.
|
|
142
|
+
//
|
|
143
|
+
// Checked here so a refusal costs nothing already on disk, and again
|
|
144
|
+
// after the write below, which can undo it.
|
|
145
|
+
await requirePreservedMode(handle, mode, temporary, target, filesystem);
|
|
95
146
|
await handle.writeFile(contents);
|
|
147
|
+
// Write clears set-user-ID and set-group-ID too, for the same reason
|
|
148
|
+
// chown does: POSIX permits it on any write to a regular file by a
|
|
149
|
+
// process without appropriate privileges, and Linux does it. So the
|
|
150
|
+
// check above was necessary and not sufficient — a mode that passed it
|
|
151
|
+
// could still be renamed into place as 0o0750 where the original was
|
|
152
|
+
// 0o6750. Restore and verify once more, with nothing left that can
|
|
153
|
+
// change it: after this point the file is only synced and renamed.
|
|
154
|
+
//
|
|
155
|
+
// The earlier chmod still cannot move here. It is what keeps the
|
|
156
|
+
// enriched contents from being briefly world-readable, and that window
|
|
157
|
+
// opens the moment bytes exist.
|
|
158
|
+
await requirePreservedMode(handle, mode, temporary, target, filesystem);
|
|
96
159
|
// rename() is atomic against concurrent readers but says nothing about
|
|
97
160
|
// power loss: the directory entry can reach disk before the data it
|
|
98
161
|
// points at. Without this a crash mid-write can leave the playlist
|
|
@@ -114,19 +177,19 @@ async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
|
114
177
|
// out entirely, which is the safe path when a file is being actively
|
|
115
178
|
// edited.
|
|
116
179
|
if (expectedDigest !== undefined) {
|
|
117
|
-
const current = await
|
|
180
|
+
const current = await filesystem.readFile(target).catch(() => null);
|
|
118
181
|
const digest = current && (0, crypto_1.createHash)('sha256').update(current).digest('hex');
|
|
119
182
|
if (digest !== expectedDigest) {
|
|
120
|
-
await
|
|
183
|
+
await filesystem.rm(temporary, { force: true }).catch(() => undefined);
|
|
121
184
|
return false;
|
|
122
185
|
}
|
|
123
186
|
}
|
|
124
|
-
await
|
|
125
|
-
await syncDirectory((0, path_1.dirname)(target));
|
|
187
|
+
await filesystem.rename(temporary, target);
|
|
188
|
+
await syncDirectory((0, path_1.dirname)(target), filesystem);
|
|
126
189
|
return true;
|
|
127
190
|
}
|
|
128
191
|
catch (error) {
|
|
129
|
-
await
|
|
192
|
+
await filesystem.rm(temporary, { force: true }).catch(() => undefined);
|
|
130
193
|
throw error;
|
|
131
194
|
}
|
|
132
195
|
}
|
|
@@ -137,12 +200,51 @@ async function writePlaylistAtomically(destination, contents, expectedDigest) {
|
|
|
137
200
|
*/
|
|
138
201
|
class OwnershipError extends Error {
|
|
139
202
|
target;
|
|
140
|
-
|
|
141
|
-
|
|
203
|
+
kind;
|
|
204
|
+
constructor(target, kind = 'ownership') {
|
|
205
|
+
super(`cannot preserve ${kind} of ${target}`);
|
|
142
206
|
this.target = target;
|
|
207
|
+
this.kind = kind;
|
|
143
208
|
this.name = 'OwnershipError';
|
|
144
209
|
}
|
|
145
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* requirePreservedMode re-applies the destination's mode to the replacement and
|
|
213
|
+
* proves it took.
|
|
214
|
+
*
|
|
215
|
+
* Called twice, because two different operations clear set-user-ID and
|
|
216
|
+
* set-group-ID and both sit between the first chmod and the rename: chown does
|
|
217
|
+
* it, and so does an unprivileged write to a regular file. POSIX permits the
|
|
218
|
+
* clearing in both cases and Linux performs it, so a mode verified before the
|
|
219
|
+
* write can still reach the destination as something narrower.
|
|
220
|
+
*
|
|
221
|
+
* Verified against the inode rather than inferred from the calls, because a
|
|
222
|
+
* chmod can report success and still not take: restoring set-group-ID needs the
|
|
223
|
+
* caller to be in the file's group, and a filesystem may decline the bit
|
|
224
|
+
* outright. Every one of those endings is the same fact — this replacement
|
|
225
|
+
* would not carry the access the original had — and completing such a write
|
|
226
|
+
* silently is precisely what the ownership refusal exists to prevent.
|
|
227
|
+
*
|
|
228
|
+
* A destination that did not exist has no mode to preserve, so this is a no-op.
|
|
229
|
+
*
|
|
230
|
+
* @param handle - Open temporary file that will be renamed over the destination
|
|
231
|
+
* @param mode - Permission bits the destination carried, or undefined
|
|
232
|
+
* @param temporary - Path of the temporary file, removed on refusal
|
|
233
|
+
* @param target - Destination, named in the refusal
|
|
234
|
+
* @param filesystem - Filesystem in use
|
|
235
|
+
* @throws OwnershipError when the mode cannot be reproduced
|
|
236
|
+
*/
|
|
237
|
+
async function requirePreservedMode(handle, mode, temporary, target, filesystem) {
|
|
238
|
+
if (mode === undefined) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
await handle.chmod(mode);
|
|
242
|
+
const written = await handle.stat();
|
|
243
|
+
if ((written.mode & 0o7777) !== mode) {
|
|
244
|
+
await filesystem.rm(temporary, { force: true }).catch(() => undefined);
|
|
245
|
+
throw new OwnershipError(target, 'permissions');
|
|
246
|
+
}
|
|
247
|
+
}
|
|
146
248
|
/**
|
|
147
249
|
* syncDirectory flushes a directory entry so a rename survives power loss.
|
|
148
250
|
*
|
|
@@ -152,9 +254,9 @@ class OwnershipError extends Error {
|
|
|
152
254
|
* the rename, so the worst case here is the old name surviving a crash, not a
|
|
153
255
|
* corrupt file.
|
|
154
256
|
*/
|
|
155
|
-
async function syncDirectory(directory) {
|
|
257
|
+
async function syncDirectory(directory, filesystem) {
|
|
156
258
|
try {
|
|
157
|
-
const handle = await
|
|
259
|
+
const handle = await filesystem.open(directory, 'r');
|
|
158
260
|
try {
|
|
159
261
|
await handle.sync();
|
|
160
262
|
}
|
|
@@ -313,8 +415,15 @@ exports.enrichCommand = new commander_1.Command('enrich')
|
|
|
313
415
|
}
|
|
314
416
|
catch (error) {
|
|
315
417
|
if (error instanceof OwnershipError) {
|
|
316
|
-
|
|
317
|
-
|
|
418
|
+
// Two endings, one remedy. Say which one it was: "belongs to another user" sends someone to
|
|
419
|
+
// check ownership, and that is the wrong place to look when the ownership carried across fine
|
|
420
|
+
// and a set-group-ID bit is what could not be reproduced.
|
|
421
|
+
console.error(chalk_1.default.red(error.kind === 'ownership'
|
|
422
|
+
? '\nThat playlist belongs to another user or group.'
|
|
423
|
+
: '\nThat playlist has permissions this replacement cannot reproduce.'));
|
|
424
|
+
console.error(chalk_1.default.dim((error.kind === 'ownership'
|
|
425
|
+
? ` Replacing it in place would hand it to you and could lock others out,\n`
|
|
426
|
+
: ` Replacing it in place would give it access the original did not have,\n`) +
|
|
318
427
|
` so nothing was written. Write elsewhere instead:\n` +
|
|
319
428
|
` ff-cli enrich ${error.target} -o enriched.json\n`));
|
|
320
429
|
process.exit(1);
|
|
@@ -41,10 +41,11 @@ exports.findCommand = new commander_1.Command('find')
|
|
|
41
41
|
.option('-o, --output <path>', 'Save the playlist to this file (default: ./<slug>.json)')
|
|
42
42
|
.option('-l, --limit <n>', 'Max tokens to include from the series (default: all)')
|
|
43
43
|
.option('-p, --play', 'Play the playlist on an FF1 device after building')
|
|
44
|
+
.option('--no-play', 'Never cast to a device; combine with -y to build and save without playing')
|
|
44
45
|
.option('-d, --device <name>', 'Device to play on (used with --play; default: first configured)')
|
|
45
46
|
.option('--publish', 'Publish the playlist to a configured feed server')
|
|
46
47
|
.option('-s, --server <index>', 'Feed server index (used with --publish)')
|
|
47
|
-
.option('-y, --yes', 'Skip interactive prompts
|
|
48
|
+
.option('-y, --yes', 'Skip interactive prompts. Without --output or --publish, -y plays the playlist on a device; add --no-play to skip casting')
|
|
48
49
|
.option('--skip-verify', 'Skip signature verification before --play (mirrors `ff-cli play --skip-verify`)')
|
|
49
50
|
.action(async (input, options) => {
|
|
50
51
|
try {
|
|
@@ -478,9 +479,16 @@ async function confirmMakePlaylist(count, hasMore) {
|
|
|
478
479
|
* Precedence: explicit flags (--play / --publish) win and may combine. If no
|
|
479
480
|
* flag is set, fall back to --yes default (Play) or an interactive 3-way
|
|
480
481
|
* prompt. Save is unconditional and already done before this is called.
|
|
482
|
+
*
|
|
483
|
+
* `--no-play` (commander sets `play === false`) is an opt-out that removes
|
|
484
|
+
* casting from every path, including the `--yes` default and the interactive
|
|
485
|
+
* prompt. It is additive: `--yes` alone keeps its deliberate "max-satisfaction
|
|
486
|
+
* default for scripted use" of Play, and this must not change that. Because
|
|
487
|
+
* `--no-play` already states the user's intent, it never opens the prompt.
|
|
481
488
|
*/
|
|
482
489
|
async function decideActions(options) {
|
|
483
490
|
const flagActions = [];
|
|
491
|
+
const noPlay = options.play === false;
|
|
484
492
|
if (options.play) {
|
|
485
493
|
flagActions.push('play');
|
|
486
494
|
}
|
|
@@ -491,7 +499,8 @@ async function decideActions(options) {
|
|
|
491
499
|
return flagActions;
|
|
492
500
|
}
|
|
493
501
|
// --output alone (with or without --yes) means "save mode" — the save was the action.
|
|
494
|
-
|
|
502
|
+
// --no-play likewise means the save was the action; never prompt or default to Play.
|
|
503
|
+
if (options.output || noPlay) {
|
|
495
504
|
return [];
|
|
496
505
|
}
|
|
497
506
|
// --yes without --output defaults to Play (max-satisfaction default for scripted use).
|
|
@@ -7,28 +7,35 @@ exports.publishCommand = void 0;
|
|
|
7
7
|
const commander_1 = require("commander");
|
|
8
8
|
const chalk_1 = __importDefault(require("chalk"));
|
|
9
9
|
const feed_server_1 = require("./helpers/feed-server");
|
|
10
|
+
const signing_key_source_1 = require("../utilities/signing-key-source");
|
|
10
11
|
exports.publishCommand = new commander_1.Command('publish')
|
|
11
12
|
.description('Publish a playlist to a feed server')
|
|
12
13
|
.argument('<file>', 'Path to the playlist file')
|
|
13
14
|
.option('-s, --server <index>', 'Feed server index (use this if multiple servers configured)')
|
|
14
15
|
.option('--replace', 'Replace the playlist already stored under this document id, instead of creating a new one')
|
|
15
16
|
.option('-k, --key <privateKey>', 'Ed25519 private key that signs the replace authorization (overrides config; --replace only)')
|
|
17
|
+
.option('--key-file <path>', 'Read the key that signs the replace authorization from this file, not the command line')
|
|
16
18
|
.action(async (file, options) => {
|
|
17
19
|
try {
|
|
18
20
|
// A plain publish signs nothing at request time — it uploads the signatures[] envelope the
|
|
19
|
-
// document already carries — so
|
|
21
|
+
// document already carries — so a key flag would do nothing here. Refusing beats accepting it
|
|
20
22
|
// silently: someone passing a key believes it is being used, and a no-op flag on a command
|
|
21
23
|
// that writes to a feed is the kind of quiet lie this CLI has been removing.
|
|
22
24
|
//
|
|
23
25
|
// Tested for PRESENCE, not truthiness. `--key ""` is what an unset shell variable expands to,
|
|
24
26
|
// and it is still a key the user meant to supply; skipping the refusal for it published the
|
|
25
27
|
// document while leaving them believing a key had been checked.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
//
|
|
29
|
+
// The flag is resolved to a NAME here, not to key material: this branch is about to refuse, and
|
|
30
|
+
// opening someone's key file only to tell them the flag does not apply reads their private key
|
|
31
|
+
// for no reason at all.
|
|
32
|
+
const keyFlag = (0, signing_key_source_1.explicitSigningKeyFlag)(options);
|
|
33
|
+
if (keyFlag !== undefined && !options.replace) {
|
|
34
|
+
console.error(chalk_1.default.red(`\n${keyFlag} has no effect on a plain publish`));
|
|
28
35
|
console.log(chalk_1.default.yellow(' A publish is authorized by the signatures already inside the document; the command\n' +
|
|
29
36
|
' signs nothing. Sign the file first, with the key you meant:\n' +
|
|
30
|
-
|
|
31
|
-
|
|
37
|
+
` ff-cli sign <file> -r curator ${keyFlag} <${keyFlag === '--key-file' ? 'file holding the private key' : 'private key'}>\n` +
|
|
38
|
+
` ${keyFlag} applies to "publish --replace", which signs an authorization intent.\n`));
|
|
32
39
|
process.exit(1);
|
|
33
40
|
}
|
|
34
41
|
console.log(chalk_1.default.blue(options.replace ? '\nReplace playlist\n' : '\nPublish playlist\n'));
|
|
@@ -42,11 +49,19 @@ exports.publishCommand = new commander_1.Command('publish')
|
|
|
42
49
|
//
|
|
43
50
|
// Only for --replace: a plain publish signs nothing at request time and must keep working with
|
|
44
51
|
// no key configured at all, so requiring one here would break it.
|
|
52
|
+
//
|
|
53
|
+
// A `--key-file` is read as part of the same step: a missing, unreadable, or empty key file is a
|
|
54
|
+
// credential failure like any other, and it belongs before the server question for the same
|
|
55
|
+
// reason a malformed key does.
|
|
45
56
|
let replaceKey;
|
|
57
|
+
let keySource = 'configured';
|
|
46
58
|
if (options.replace) {
|
|
47
59
|
const { mutationSignerIdentity } = await import('../utilities/feed-mutation.js');
|
|
60
|
+
const { resolveExplicitSigningKey } = await import('../utilities/signing-key-source.js');
|
|
48
61
|
try {
|
|
49
|
-
|
|
62
|
+
const explicitKey = resolveExplicitSigningKey(options);
|
|
63
|
+
keySource = explicitKey?.flag ?? 'configured';
|
|
64
|
+
replaceKey = mutationSignerIdentity(explicitKey?.material, 'replace').privateKey;
|
|
50
65
|
}
|
|
51
66
|
catch (error) {
|
|
52
67
|
console.error(chalk_1.default.red('\nCannot sign the replacement'));
|
|
@@ -72,7 +87,7 @@ exports.publishCommand = new commander_1.Command('publish')
|
|
|
72
87
|
? await replacePlaylist(file, selection.url, {
|
|
73
88
|
// The material resolved above, so the identity validated here is the one that signs.
|
|
74
89
|
privateKey: replaceKey,
|
|
75
|
-
keySource
|
|
90
|
+
keySource,
|
|
76
91
|
})
|
|
77
92
|
: await publishPlaylist(file, selection.url);
|
|
78
93
|
if (result.success) {
|
|
@@ -16,6 +16,7 @@ const device_discovery_1 = require("./helpers/device-discovery");
|
|
|
16
16
|
const prompt_1 = require("./helpers/prompt");
|
|
17
17
|
const config_placeholders_1 = require("../utilities/config-placeholders");
|
|
18
18
|
const ed25519_key_derive_1 = require("../utilities/ed25519-key-derive");
|
|
19
|
+
const signing_key_source_1 = require("../utilities/signing-key-source");
|
|
19
20
|
const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
|
|
20
21
|
/** Generate a fresh Ed25519 signing key as base64 PKCS#8 DER (the recommended form). */
|
|
21
22
|
function generateSigningKeyBase64() {
|
|
@@ -26,6 +27,7 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
26
27
|
.description('Guided setup for config, signing key, and device')
|
|
27
28
|
.option('-y, --non-interactive', 'Run without prompts (for agents/CI); uses flags and safe defaults. Auto-enabled when stdin is not a TTY.')
|
|
28
29
|
.option('--key <privateKey>', 'Signing key material (base64 PKCS#8 DER, 32-byte seed as hex/base64, or PEM)')
|
|
30
|
+
.option('--key-file <path>', 'Read the signing key from this file instead of the command line (preferred for provisioning)')
|
|
29
31
|
.option('--generate-key', 'Generate a new Ed25519 signing key')
|
|
30
32
|
.option('--role <role>', `Signing role (${playlist_signing_role_1.DP1_PLAYLIST_SIGNING_ROLES.join(', ')})`)
|
|
31
33
|
.option('--device-host <host>', 'FF1 device host, e.g. http://192.168.1.50:1111 (skips mDNS discovery)')
|
|
@@ -39,6 +41,39 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
39
41
|
// Lazily open the prompt so non-interactive runs never touch stdin.
|
|
40
42
|
const getAsk = () => (prompt ??= (0, prompt_1.createPrompt)()).ask;
|
|
41
43
|
try {
|
|
44
|
+
// Resolve and validate an explicit key BEFORE anything touches disk.
|
|
45
|
+
//
|
|
46
|
+
// ensureConfigFile() writes a sample config when none exists, so resolving afterwards meant a
|
|
47
|
+
// run that could never succeed — a missing key file, an unreadable one, both flags at once, a
|
|
48
|
+
// key that does not parse — still left a config.json behind. On a machine being provisioned
|
|
49
|
+
// that is the difference between "nothing happened, fix the flag" and a half-configured host
|
|
50
|
+
// whose state the next run has to reason about.
|
|
51
|
+
//
|
|
52
|
+
// Provisioning is exactly where `--key` hurts most: an unattended run puts the key in the
|
|
53
|
+
// process list of a machine nobody is watching, and in whatever shell or CI log recorded the
|
|
54
|
+
// command. `--key-file` reads it from a file the provisioner already had to place.
|
|
55
|
+
//
|
|
56
|
+
// It is honoured on the interactive path too, not only under --non-interactive. A key on the
|
|
57
|
+
// command line is an instruction; prompting past it — "Keep existing signing key?" — would ask
|
|
58
|
+
// a question the operator has already answered, and answering it for them by ignoring the flag
|
|
59
|
+
// is the quiet lie this CLI keeps removing.
|
|
60
|
+
const explicitKey = (0, signing_key_source_1.resolveExplicitSigningKey)(options);
|
|
61
|
+
if (explicitKey !== undefined) {
|
|
62
|
+
// Validate eagerly so a bad key fails here with a clear message rather
|
|
63
|
+
// than later inside dp1-js during signing.
|
|
64
|
+
//
|
|
65
|
+
// Presence, not truthiness: `--key ""` is what an unset shell variable expands to, and it
|
|
66
|
+
// used to fall through to "generate a new key" — provisioning a machine with an identity
|
|
67
|
+
// nobody chose, silently, which is the whole hazard this flag family exists around. It now
|
|
68
|
+
// fails the same validation any other unusable value does.
|
|
69
|
+
try {
|
|
70
|
+
(0, ed25519_key_derive_1.parsePlaylistPrivateKeyToKeyObject)(explicitKey.material);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
throw new Error(`Invalid ${explicitKey.flag}: ${error.message}. ` +
|
|
74
|
+
'Expected base64 PKCS#8 DER, a 32-byte raw seed as hex/base64, or PEM.');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
42
77
|
const { path: configPath, created } = await (0, config_files_1.ensureConfigFile)();
|
|
43
78
|
if (created) {
|
|
44
79
|
console.log(chalk_1.default.green(`Created ${configPath}`));
|
|
@@ -49,22 +84,14 @@ exports.setupCommand = new commander_1.Command('setup')
|
|
|
49
84
|
const currentRole = config.playlist?.role || '';
|
|
50
85
|
let signingKey = currentKey;
|
|
51
86
|
let signingRole = currentRole;
|
|
52
|
-
|
|
53
|
-
|
|
87
|
+
// Already resolved and validated above, before any file was created.
|
|
88
|
+
if (explicitKey !== undefined) {
|
|
89
|
+
signingKey = explicitKey.material;
|
|
90
|
+
}
|
|
91
|
+
else if (nonInteractive) {
|
|
92
|
+
// Key precedence with no explicit key: --generate-key, then keep an
|
|
54
93
|
// existing usable key, otherwise generate one so signing works.
|
|
55
|
-
if (options.
|
|
56
|
-
// Validate eagerly so a bad key fails here with a clear message rather
|
|
57
|
-
// than later inside dp1-js during signing.
|
|
58
|
-
try {
|
|
59
|
-
(0, ed25519_key_derive_1.parsePlaylistPrivateKeyToKeyObject)(options.key);
|
|
60
|
-
}
|
|
61
|
-
catch (error) {
|
|
62
|
-
throw new Error(`Invalid --key: ${error.message}. ` +
|
|
63
|
-
'Expected base64 PKCS#8 DER, a 32-byte raw seed as hex/base64, or PEM.');
|
|
64
|
-
}
|
|
65
|
-
signingKey = options.key;
|
|
66
|
-
}
|
|
67
|
-
else if (options.generateKey || (0, config_files_1.isMissingConfigValue)(currentKey)) {
|
|
94
|
+
if (options.generateKey || (0, config_files_1.isMissingConfigValue)(currentKey)) {
|
|
68
95
|
signingKey = generateSigningKeyBase64();
|
|
69
96
|
console.log(chalk_1.default.dim('Generated a new Ed25519 signing key.'));
|
|
70
97
|
}
|
|
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.signCommand = void 0;
|
|
7
7
|
const commander_1 = require("commander");
|
|
8
8
|
const chalk_1 = __importDefault(require("chalk"));
|
|
9
|
+
const signing_key_source_1 = require("../utilities/signing-key-source");
|
|
9
10
|
// playlist-signer is still CommonJS; require keeps the interop simple.
|
|
10
11
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
11
12
|
const { signPlaylistFile } = require('../utilities/playlist-signer');
|
|
@@ -13,14 +14,20 @@ exports.signCommand = new commander_1.Command('sign')
|
|
|
13
14
|
.description('Sign a DP-1 playlist file with a DP-1 signature envelope')
|
|
14
15
|
.argument('<file>', 'Path to the playlist file to sign')
|
|
15
16
|
.option('-k, --key <privateKey>', 'Ed25519 private key in base64 format (overrides config)')
|
|
17
|
+
.option('--key-file <path>', 'Read the Ed25519 private key from this file instead of the command line')
|
|
16
18
|
.option('-r, --role <role>', 'DP-1 signing role (overrides config)')
|
|
17
19
|
.option('-o, --output <file>', 'Output file path (defaults to overwriting input file)')
|
|
18
20
|
.option('--replace-signatures', 'Discard the existing signatures and sign fresh (use after editing a signed playlist)')
|
|
19
21
|
.option('--force', 'Overwrite an output file that holds a different document (refused without this)')
|
|
20
22
|
.action(async (file, options) => {
|
|
21
23
|
try {
|
|
24
|
+
// Resolve the explicit key before the playlist is opened, so an unreadable or empty key file
|
|
25
|
+
// costs nothing but the run. `--key` material is passed through exactly as typed, including
|
|
26
|
+
// when it is empty: signPlaylistFile refuses that with the shell-expansion message, which is
|
|
27
|
+
// the right diagnosis for a value from the command line and the wrong one for a file.
|
|
28
|
+
const explicitKey = (0, signing_key_source_1.resolveExplicitSigningKey)(options);
|
|
22
29
|
console.log(chalk_1.default.blue('\nSign playlist\n'));
|
|
23
|
-
const result = await signPlaylistFile(file,
|
|
30
|
+
const result = await signPlaylistFile(file, explicitKey?.material, options.output, options.role, {
|
|
24
31
|
replaceSignatures: !!options.replaceSignatures,
|
|
25
32
|
force: !!options.force,
|
|
26
33
|
});
|
|
@@ -12,6 +12,7 @@ const ed25519_key_derive_1 = require("../utilities/ed25519-key-derive");
|
|
|
12
12
|
const playlist_signing_role_1 = require("../utilities/playlist-signing-role");
|
|
13
13
|
const signing_identity_1 = require("../utilities/signing-identity");
|
|
14
14
|
const config_placeholders_1 = require("../utilities/config-placeholders");
|
|
15
|
+
const signing_key_source_1 = require("../utilities/signing-key-source");
|
|
15
16
|
exports.statusCommand = new commander_1.Command('status')
|
|
16
17
|
.description('Show configuration status')
|
|
17
18
|
// Identity discovery must cover every key `sign` accepts, not just the configured one. `sign --key`
|
|
@@ -19,9 +20,34 @@ exports.statusCommand = new commander_1.Command('status')
|
|
|
19
20
|
// user the wrong did:key to declare in curators[] — and a wrong declaration fails exactly like a
|
|
20
21
|
// missing one.
|
|
21
22
|
.option('-k, --key <privateKey>', 'Report the signing identity for this key instead of the configured one')
|
|
23
|
+
.option('--key-file <path>', 'Read the key to report on from this file instead of the command line')
|
|
22
24
|
.action(async (options) => {
|
|
23
25
|
try {
|
|
24
|
-
|
|
26
|
+
// An unusable key file is reported here rather than allowed to fall through. This command's whole
|
|
27
|
+
// job is to answer "which identity is this?", so silently answering about the configured key
|
|
28
|
+
// instead — which an empty file would do, since the check below is truthiness — would hand back a
|
|
29
|
+
// did:key that belongs to something else and looks exactly like the right answer.
|
|
30
|
+
let explicitKey;
|
|
31
|
+
try {
|
|
32
|
+
explicitKey = (0, signing_key_source_1.resolveExplicitSigningKey)(options);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
console.log(chalk_1.default.red(error.message));
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const overrideKey = explicitKey?.material.trim();
|
|
39
|
+
// Presence, not truthiness — the same rule `sign` and the feed mutations enforce, and the reason
|
|
40
|
+
// it is enforced here too. `--key ""` is what `--key "$SIGNING_KEY"` becomes when the variable is
|
|
41
|
+
// unset, and falling through to the configured identity answers "whose key is this?" with a
|
|
42
|
+
// different key's did:key. Nothing about that answer looks wrong, so it would be copied into
|
|
43
|
+
// curators[], and a wrong declaration fails exactly like a missing one.
|
|
44
|
+
if (explicitKey !== undefined && !overrideKey) {
|
|
45
|
+
console.log(chalk_1.default.red(`The ${explicitKey.flag} value is empty. This usually means a shell variable did not ` +
|
|
46
|
+
`expand (for example ${explicitKey.flag} "$SIGNING_KEY" with SIGNING_KEY unset).`));
|
|
47
|
+
console.log(chalk_1.default.yellow(' Refusing rather than reporting the configured identity, which belongs to a different\n' +
|
|
48
|
+
' key. Run "ff-cli status" with no key flag if that is the identity you wanted.'));
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
25
51
|
if (overrideKey) {
|
|
26
52
|
try {
|
|
27
53
|
console.log(chalk_1.default.green(`Signing identity (did:key) ${(0, signing_identity_1.playlistSigningDidKey)(overrideKey)}`));
|
|
@@ -29,7 +55,7 @@ exports.statusCommand = new commander_1.Command('status')
|
|
|
29
55
|
return;
|
|
30
56
|
}
|
|
31
57
|
catch (error) {
|
|
32
|
-
console.log(chalk_1.default.red(
|
|
58
|
+
console.log(chalk_1.default.red(`${explicitKey?.flag ?? '--key'} unusable: ${error.message}`));
|
|
33
59
|
process.exit(1);
|
|
34
60
|
}
|
|
35
61
|
}
|
|
@@ -8,20 +8,13 @@ const commander_1 = require("commander");
|
|
|
8
8
|
const chalk_1 = __importDefault(require("chalk"));
|
|
9
9
|
const feed_server_1 = require("./helpers/feed-server");
|
|
10
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
11
|
exports.unpublishCommand = new commander_1.Command('unpublish')
|
|
20
12
|
.description('Delete a playlist from a feed server (signed delete; owner key required)')
|
|
21
13
|
.argument('<id-or-url>', 'Playlist id, slug, or feed URL')
|
|
22
14
|
.option('-s, --server <index>', 'Feed server index (use this if multiple servers configured)')
|
|
23
15
|
.option('-y, --yes', 'Skip the confirmation prompt')
|
|
24
16
|
.option('-k, --key <privateKey>', 'Ed25519 private key that signs the delete authorization (overrides config)')
|
|
17
|
+
.option('--key-file <path>', 'Read the key that signs the delete authorization from this file, not the command line')
|
|
25
18
|
.action(async (idOrUrl, options) => {
|
|
26
19
|
try {
|
|
27
20
|
console.log(chalk_1.default.blue('\nUnpublish playlist\n'));
|
|
@@ -46,11 +39,19 @@ exports.unpublishCommand = new commander_1.Command('unpublish')
|
|
|
46
39
|
// The DID displayed below and the key that signs are now the same value.
|
|
47
40
|
//
|
|
48
41
|
// Only the did:key is ever printed; the material never reaches the output from this path.
|
|
42
|
+
//
|
|
43
|
+
// `--key-file` is read here too, in the same step and before the same milestones: a key file that
|
|
44
|
+
// is missing, unreadable, or empty is a credential failure like any other, and it must not be
|
|
45
|
+
// discovered after the operator has approved a delete.
|
|
49
46
|
const { mutationSignerIdentity } = await import('../utilities/feed-mutation.js');
|
|
47
|
+
const { resolveExplicitSigningKey } = await import('../utilities/signing-key-source.js');
|
|
50
48
|
let signerDidKey;
|
|
51
49
|
let signingKey;
|
|
50
|
+
let keySource = 'configured';
|
|
52
51
|
try {
|
|
53
|
-
|
|
52
|
+
const explicitKey = resolveExplicitSigningKey(options);
|
|
53
|
+
keySource = explicitKey?.flag ?? 'configured';
|
|
54
|
+
({ privateKey: signingKey, didKey: signerDidKey } = mutationSignerIdentity(explicitKey?.material, 'delete'));
|
|
54
55
|
}
|
|
55
56
|
catch (error) {
|
|
56
57
|
console.error(chalk_1.default.red('\nCannot sign the delete'));
|
|
@@ -111,7 +112,7 @@ exports.unpublishCommand = new commander_1.Command('unpublish')
|
|
|
111
112
|
}
|
|
112
113
|
const result = await unpublishPlaylist(idOrUrl, selection.url, {
|
|
113
114
|
privateKey: signingKey,
|
|
114
|
-
keySource
|
|
115
|
+
keySource,
|
|
115
116
|
});
|
|
116
117
|
if (result.success) {
|
|
117
118
|
console.log(chalk_1.default.green('Unpublished'));
|