@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feralfile/cli",
3
- "version": "2.9.0",
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.3.1",
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
- const feedServers = config.feedServers || [];
30
- if (feedServers.length > 0) {
31
- console.log(chalk_1.default.bold('\nFeed servers:\n'));
32
- feedServers.forEach((server) => {
33
- console.log(` ${chalk_1.default.dim(server.baseUrl)}`);
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 fs_1.promises.lstat(destination).catch(() => null);
48
+ const existing = await filesystem.lstat(destination).catch(() => null);
49
49
  if (existing?.isSymbolicLink()) {
50
- target = await fs_1.promises.realpath(destination);
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 fs_1.promises.stat(target).catch(() => null);
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 fs_1.promises.open(temporary, 'wx', mode);
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 fs_1.promises.rm(temporary, { force: true }).catch(() => undefined);
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 fs_1.promises.readFile(target).catch(() => null);
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 fs_1.promises.rm(temporary, { force: true }).catch(() => undefined);
183
+ await filesystem.rm(temporary, { force: true }).catch(() => undefined);
121
184
  return false;
122
185
  }
123
186
  }
124
- await fs_1.promises.rename(temporary, target);
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 fs_1.promises.rm(temporary, { force: true }).catch(() => undefined);
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
- constructor(target) {
141
- super(`cannot preserve ownership of ${target}`);
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 fs_1.promises.open(directory, 'r');
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
- console.error(chalk_1.default.red('\nThat playlist belongs to another user or group.'));
317
- console.error(chalk_1.default.dim(` Replacing it in place would hand it to you and could lock others out,\n` +
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; defaults to Play unless --output or --publish is set')
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
- if (options.output) {
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 --key would do nothing here. Refusing beats accepting it
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
- if (options.key !== undefined && !options.replace) {
27
- console.error(chalk_1.default.red('\n--key has no effect on a plain publish'));
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
- ' ff-cli sign <file> -r curator --key <private key>\n' +
31
- ' --key applies to "publish --replace", which signs an authorization intent.\n'));
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
- replaceKey = mutationSignerIdentity(options.key, 'replace').privateKey;
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: options.key !== undefined ? 'supplied' : 'configured',
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
- if (nonInteractive) {
53
- // Key precedence: explicit --key, then --generate-key, then keep an
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.key) {
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, options.key, options.output, options.role, {
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
- const overrideKey = options.key?.trim();
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(`--key unusable: ${error.message}`));
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
- ({ privateKey: signingKey, didKey: signerDidKey } = mutationSignerIdentity(options.key, 'delete'));
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: options.key !== undefined ? 'supplied' : 'configured',
115
+ keySource,
115
116
  });
116
117
  if (result.success) {
117
118
  console.log(chalk_1.default.green('Unpublished'));