agmsg-cloud 0.1.0-rc.7 → 0.1.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.
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  import { defaultScriptsDir, hasCredential, scriptsDirChoice } from './config.js';
4
4
  import { join } from 'node:path';
5
5
  import { platform } from 'node:process';
6
+ import { selfInstall } from './self-install.js';
6
7
  // What `connect` needs before it starts, checked all at once.
7
8
  //
8
9
  // Everything here is reported together. Finding one missing thing, installing
@@ -23,13 +24,13 @@ import { platform } from 'node:process';
23
24
  // documented answer to "what is installed here", and reaching past it would
24
25
  // bind us to a layout that is not ours.
25
26
  //
26
- // Reported, not compared. The string is a git-describe (`v1.1.11-320-gfbc90db`)
27
- // for an install taken from a branch, and a plain tag for a released one — and
28
- // those two do not order against each other. The dogfood install measures as
29
- // `v1.1.11-320-…` while the released `v1.1.13` has no remote sync at all, so a
30
- // numeric floor would reject the only install that can currently connect.
31
- // Until there is a released version to compare against, this is shown for
32
- // diagnosis and nothing is gated on it.
27
+ // Connect capability is still probed directly rather than inferred from this
28
+ // string: branch describe strings and older stable releases do not order by
29
+ // the remote-sync features they contain. Account-wide recovery setup has one
30
+ // narrower exception. fujibee/agmsg#650 first shipped in v1.2.0-rc.4, so that
31
+ // command uses the release as a floor for the fail-closed status producer.
32
+ // Unknown or unparseable provenance is rejected for that command because it
33
+ // cannot establish that a silently short team list is impossible.
33
34
  export function installedVersion(scriptsDir) {
34
35
  try {
35
36
  const out = execFileSync('bash', [join(scriptsDir, 'version.sh')], {
@@ -42,6 +43,33 @@ export function installedVersion(scriptsDir) {
42
43
  return null;
43
44
  }
44
45
  }
46
+ /**
47
+ * Whether the installed OSS status producer refuses a silently short team set.
48
+ *
49
+ * fujibee/agmsg#650 first shipped in v1.2.0-rc.4. Before that,
50
+ * `remote.sh status --json` could omit an unreadable team and exit 0, which is
51
+ * unsafe for account-wide recovery setup. Unknown and non-release provenance
52
+ * strings fail closed: rejecting an install we cannot order is cheaper than
53
+ * presenting a partial backup as complete.
54
+ */
55
+ export function supportsFailClosedTeamStatus(version) {
56
+ if (version === null)
57
+ return false;
58
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+)(?:-\d+-g[0-9a-f]+)?|-\d+-g[0-9a-f]+)?$/.exec(version);
59
+ if (!match)
60
+ return false;
61
+ const major = Number(match[1]);
62
+ const minor = Number(match[2]);
63
+ const patch = Number(match[3]);
64
+ const rc = match[4] === undefined ? null : Number(match[4]);
65
+ if (major !== 1)
66
+ return major > 1;
67
+ if (minor !== 2)
68
+ return minor > 2;
69
+ if (patch !== 0)
70
+ return patch > 0;
71
+ return rc === null || rc >= 4;
72
+ }
45
73
  // Does the installed remote.sh offer `connect`? Asked of the script itself: run
46
74
  // with no arguments it prints its usage, which names the subcommands it has.
47
75
  //
@@ -117,7 +145,11 @@ const PYTHON_INSTALL = [
117
145
  // recovery restore
118
146
  export const NEEDS = {
119
147
  connect: { command: 'connect', scripts: ['remote.sh'], requireConnect: true },
120
- vaultPut: { command: 'recovery setup', scripts: ['remote.sh', 'key.sh'] },
148
+ vaultPut: {
149
+ command: 'recovery setup',
150
+ scripts: ['remote.sh', 'key.sh'],
151
+ requireFailClosedTeamStatus: true,
152
+ },
121
153
  vaultRestore: { command: 'recovery restore', scripts: ['remote.sh'] },
122
154
  fetch: { command: 'fetch', scripts: ['remote.sh', 'remote-sync.sh'] },
123
155
  approve: { command: 'approve', scripts: ['key.sh', 'remote-sync.sh'] },
@@ -161,9 +193,21 @@ export function preflight(scriptsDir, needs, env = process.env,
161
193
  * `homedir()` does not return — reading it would have moved the default
162
194
  * install for every Git Bash user in exchange for a testable line.
163
195
  */
164
- home) {
196
+ home,
197
+ /**
198
+ * How the `agmsg-cloud` on PATH is asked which copy it is.
199
+ *
200
+ * A SEAM, and it earned itself immediately: the real one SPAWNS that binary,
201
+ * and the tests below call `preflight()` with the machine's own environment.
202
+ * Six of them started timing out at 5000ms the moment this block existed,
203
+ * because they were waiting on whatever `agmsg-cloud` happens to be installed
204
+ * on the machine running the suite. A check about tool prerequisites should
205
+ * not depend on the tester's PATH.
206
+ */
207
+ versionRunner) {
165
208
  const { command, scripts } = needs;
166
209
  const requireConnect = needs.requireConnect ?? false;
210
+ const requireFailClosedTeamStatus = needs.requireFailClosedTeamStatus ?? false;
167
211
  const requirements = [];
168
212
  // BEING SIGNED IN IS A PREREQUISITE, so it is one of the things checked.
169
213
  //
@@ -190,13 +234,12 @@ home) {
190
234
  // a different kind of thing (install agmsg, or point AGMSG_SCRIPTS_DIR at it)
191
235
  // than installing a binary.
192
236
  const missingScripts = scripts.filter((s) => !existsSync(join(scriptsDir, s)));
193
- // Present AND able to do the thing. A version number would be the obvious
194
- // check and is the wrong one: `version.sh` returns a git-describe string, so
195
- // the install that can connect measures as v1.1.11-320-g… while a released
196
- // v1.1.13 without remote sync measures as newer. A numeric floor would reject
197
- // the only install that works. So the capability is asked for directly.
237
+ // Present AND able to do the thing. Connect is asked as a capability rather
238
+ // than inferred from a version; account-wide recovery separately applies the
239
+ // released producer floor described above `installedVersion`.
198
240
  const scriptsPresent = missingScripts.length === 0;
199
241
  const canConnect = scriptsPresent && (!requireConnect || remoteSupportsConnect(scriptsDir));
242
+ const agmsgVersion = scriptsPresent ? installedVersion(scriptsDir) : null;
200
243
  requirements.push({
201
244
  name: requireConnect
202
245
  ? `agmsg with remote sync (in ${scriptsDir})`
@@ -210,6 +253,14 @@ home) {
210
253
  'Elsewhere? point AGMSG_SCRIPTS_DIR at that install\'s scripts directory.',
211
254
  ],
212
255
  });
256
+ if (requireFailClosedTeamStatus) {
257
+ requirements.push({
258
+ name: 'agmsg with fail-closed team status',
259
+ ok: scriptsPresent && supportsFailClosedTeamStatus(agmsgVersion),
260
+ why: 'recovery setup backs up the account-wide set. This install cannot be confirmed as v1.2.0-rc.4 or newer, where an unreadable team stopped disappearing from `remote.sh status --json` as a successful short list.',
261
+ install: ['Install or update: npx agmsg install'],
262
+ });
263
+ }
213
264
  const tools = toolsFor(scripts);
214
265
  requirements.push({
215
266
  name: 'age and age-keygen',
@@ -238,9 +289,20 @@ home) {
238
289
  ok: requirements.every((r) => r.ok),
239
290
  command,
240
291
  scriptsDir,
241
- agmsgVersion: canConnect ? installedVersion(scriptsDir) : null,
292
+ agmsgVersion: canConnect ? agmsgVersion : null,
242
293
  scriptsDirFrom: provenance(env, home),
243
294
  otherInstalls: otherInstalls(scriptsDir, env, home),
295
+ // Never fatal. A preflight that refused to run because it could not
296
+ // identify ITSELF would withhold the tool checks — the thing the operator
297
+ // actually came for — over a diagnostic about the tool.
298
+ self: (() => {
299
+ try {
300
+ return selfInstall(env, undefined, versionRunner ?? (() => null));
301
+ }
302
+ catch {
303
+ return null;
304
+ }
305
+ })(),
244
306
  };
245
307
  }
246
308
  /**
@@ -347,6 +409,69 @@ export function formatPreflight(result) {
347
409
  lines.push('');
348
410
  lines.push(` agmsg reports itself as: ${result.agmsgVersion}`);
349
411
  }
412
+ // AND WHICH `agmsg-cloud` IS SAYING ALL THIS (#341).
413
+ //
414
+ // Everything above is this binary reporting on another install. The binary
415
+ // itself went unreported, and that is the expensive gap: on a machine holding
416
+ // both an npm and a pnpm global copy, the install succeeds, the version the
417
+ // operator gets is whichever package manager put its bin directory earlier on
418
+ // PATH, and nothing errors.
419
+ //
420
+ // Printed on EVERY run, not only when there is a conflict. The version line
421
+ // is what makes an operator's report usable by anyone else, and the shadowing
422
+ // case is invisible precisely because nothing looks wrong — a section that
423
+ // appeared only on trouble would be missing from every report written before
424
+ // anyone suspected trouble.
425
+ if (result.self !== null) {
426
+ const self = result.self;
427
+ lines.push('');
428
+ // NOT `agmsg-cloud ${version}`, which was the first spelling and which the
429
+ // printed-command checker refused: a line that opens with the binary name
430
+ // and interpolates a value is command-shaped, and a command-shaped line in
431
+ // output is one someone pastes. The label form says the same thing and
432
+ // cannot be run.
433
+ lines.push(` this agmsg-cloud is: ${self.version}`);
434
+ lines.push(` running from: ${self.directory}`);
435
+ if (self.winner === 'none') {
436
+ // Reachable: an npx run, or a direct `node dist/src/index.js`. Worth
437
+ // saying rather than omitting, because "typing the name runs this" is the
438
+ // assumption the console's instruction creates, and here it is false in
439
+ // the other direction — typing the name runs nothing.
440
+ lines.push(` no agmsg-cloud on PATH — this was not started by typing its name.`);
441
+ }
442
+ else {
443
+ const first = self.onPath[0];
444
+ lines.push(` typing the name runs: ${first}`);
445
+ if (self.winner === 'elsewhere') {
446
+ // THE ONE THIS SECTION EXISTS FOR, and the claim is now a measurement
447
+ // rather than an inference: that binary was RUN, and it named a
448
+ // different directory as the one it came from. Still not "you are
449
+ // running an old version" — the version it reported is its own business
450
+ // and this line does not repeat it; what the operator needs is that the
451
+ // name does not reach this build.
452
+ lines.push(` THAT IS NOT THIS COPY. Another agmsg-cloud is earlier on PATH and wins.`);
453
+ lines.push(` remove the one you do not want — a second install does not error.`);
454
+ }
455
+ else if (self.winner === 'unresolved') {
456
+ // It could not be run, or could not be read: a spawn failure, a
457
+ // timeout, or a release old enough to print one record instead of two —
458
+ // which is every published version at the time of writing. None of
459
+ // those is evidence of a different copy, so no claim is made and the
460
+ // reader is handed the question.
461
+ lines.push(` whether that is this copy could not be determined from here.`);
462
+ lines.push(` run agmsg-cloud -v in a new shell and compare it with the version above.`);
463
+ }
464
+ if (self.onPath.length > 1) {
465
+ // The rest of the list, because "another one is earlier" prompts "where
466
+ // are they all", and a PATH search is the thing the operator would do
467
+ // next by hand. First already named above, so the label says which.
468
+ lines.push('');
469
+ for (const other of self.onPath.slice(1)) {
470
+ lines.push(` also on PATH, later: ${other}`);
471
+ }
472
+ }
473
+ }
474
+ }
350
475
  const missing = result.requirements.filter((r) => !r.ok);
351
476
  if (missing.length === 0) {
352
477
  lines.push('');
@@ -1,5 +1,4 @@
1
1
  import { randomInt } from 'node:crypto';
2
- import { shellArg } from './shell-arg.js';
3
2
  // The recovery key (K7) is the only thing that opens the vault. It is generated
4
3
  // here, shown once, and never stored — not by us and not by the server. Losing it
5
4
  // means the vault is unopenable, which is the property that makes the vault worth
@@ -70,8 +69,8 @@ export function generateRecoveryKey() {
70
69
  // error rather than a silent drop, so a wrong key fails as a wrong key and not
71
70
  // as a mangled one.
72
71
  export function normalizeRecoveryKey(input) {
73
- let cleaned = input
74
- .trim()
72
+ const entered = input.trim();
73
+ let cleaned = entered
75
74
  .toUpperCase()
76
75
  .replace(/[\s-]/g, '');
77
76
  // The prefix is a label, not key material, and it is stripped by LENGTH
@@ -87,6 +86,17 @@ export function normalizeRecoveryKey(input) {
87
86
  if (cleaned.length === PREFIX_SYMBOLS + TOTAL_SYMBOLS && cleaned.startsWith('AGMSG')) {
88
87
  cleaned = cleaned.slice(PREFIX_SYMBOLS);
89
88
  }
89
+ // Locate a pasted label before diagnosing one character inside it. The
90
+ // prompt masks the input, so "unusable character: :" after a 50-character
91
+ // paste does not tell the operator where the colon was or what a key should
92
+ // look like. Length is the larger fact and can be checked without exposing
93
+ // the secret: the canonical form is 35 characters, while accepted spacing
94
+ // and grouping still reduce to exactly 25 key symbols (or 30 with AGMSG).
95
+ if (cleaned.length !== TOTAL_SYMBOLS) {
96
+ throw new Error(`that entry is ${entered.length} characters; a recovery key is 35 characters ` +
97
+ `in the form ${PREFIX}XXXXX-XXXXX-XXXXX-XXXXX-XXXXX ` +
98
+ `(${TOTAL_SYMBOLS} symbols without the prefix and separators)`);
99
+ }
90
100
  cleaned = cleaned
91
101
  .replace(/[IL]/g, '1')
92
102
  .replace(/O/g, '0')
@@ -95,9 +105,6 @@ export function normalizeRecoveryKey(input) {
95
105
  if (!ALPHABET.includes(ch))
96
106
  throw new Error(`recovery key contains an unusable character: ${ch}`);
97
107
  }
98
- if (cleaned.length !== TOTAL_SYMBOLS) {
99
- throw new Error(`recovery key must be ${TOTAL_SYMBOLS} symbols, got ${cleaned.length}`);
100
- }
101
108
  // The check symbol, verified here rather than at the vault. Without it, a
102
109
  // mistyped key is indistinguishable from a wrong one until the AEAD refuses
103
110
  // it — which is at restore time, the moment there is no way back. Naming the
@@ -302,24 +309,20 @@ export function promptRecoveryKey(prompt) {
302
309
  /**
303
310
  * How this tool names the command that creates a vault and backs teams up.
304
311
  *
305
- * One derivation, because the argument convention has already moved once: the
306
- * team used to be required and is now optional, and the no-argument form is the
307
- * ordinary one — it covers every team this machine reports rather than the one
308
- * that happened to be in hand. Every place that prints this remedy asks here,
309
- * so the next change to the convention moves one line rather than being grepped
310
- * for (#181).
312
+ * One derivation, because the argument convention has already moved twice: the
313
+ * team used to be required, then optional while agmsg#650 made the broad
314
+ * enumeration unsafe, and now absent. Recovery belongs to the account, and
315
+ * every printed remedy uses the account-wide command (#182).
311
316
  */
312
- export function setupCommand(team) {
313
- if (team === undefined)
314
- return 'agmsg-cloud recovery setup';
315
- return `agmsg-cloud recovery setup ${shellArg(team)}`;
317
+ export function setupCommand() {
318
+ return 'agmsg-cloud recovery setup';
316
319
  }
317
320
  export class NeedsTerminalError extends Error {
318
- constructor(team) {
321
+ constructor() {
319
322
  super('this needs a terminal, and stdin is not one.\n\n' +
320
323
  'Nothing was created and no recovery key exists yet. Run this yourself,\n' +
321
324
  'in your own terminal:\n\n' +
322
- ` ${setupCommand(team)}\n\n` +
325
+ ` ${setupCommand()}\n\n` +
323
326
  'It is refused here on purpose: the recovery key is shown once and stored\n' +
324
327
  'nowhere, so writing it into a redirected or captured stream would be this\n' +
325
328
  'tool breaking that promise itself.');
@@ -356,17 +359,17 @@ export function recoveryKeyNotice(key) {
356
359
  'machine, not the server, not support.\n' +
357
360
  '\n');
358
361
  }
359
- export async function showRecoveryKey(key, team) {
362
+ export async function showRecoveryKey(key) {
360
363
  // Checked BEFORE the key reaches stdout, which is the whole point. Showing
361
364
  // first and then discovering there is nobody to acknowledge means the only
362
365
  // copy of the key is already in whatever the output was redirected to —
363
- // `recovery setup <team> > log.txt` would write it to a file and report
366
+ // `recovery setup > log.txt` would write it to a file and report
364
367
  // success, which is the opposite of "shown once, stored nowhere".
365
368
  //
366
369
  // Nothing has been created at this point, so refusing here leaves no vault
367
370
  // and no key in use. The message says what to run instead.
368
371
  if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
369
- throw new NeedsTerminalError(team);
372
+ throw new NeedsTerminalError();
370
373
  }
371
374
  process.stdout.write(recoveryKeyNotice(key));
372
375
  await confirmSaved();
@@ -0,0 +1,152 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs';
3
+ import { delimiter, join } from 'node:path';
4
+ import { platform } from 'node:process';
5
+ import { packageInstall } from './version.js';
6
+ import { parseVersionOutput } from '../scripts/version-output.js';
7
+ // WHICH `agmsg-cloud` ANSWERS WHEN SOMEONE TYPES IT (#341).
8
+ //
9
+ // The console tells everyone to run `npm i -g agmsg-cloud`. On a machine that
10
+ // also has a pnpm global install, that succeeds and a different binary answers:
11
+ // measured on a real machine, `npm i -g` reported success, `npm ls -g` showed
12
+ // rc.7, and `agmsg-cloud -v` said rc.5, because the pnpm bin directory sits
13
+ // earlier on PATH. Nothing errored. The operator then spent a walk meeting two
14
+ // defects that were fixed in the version they had installed and were not
15
+ // running.
16
+ //
17
+ // This is #282 one level up. #317 made `--preflight` name the `agmsg` install
18
+ // and what chose it; nothing reported on the `agmsg-cloud` binary doing the
19
+ // reporting. The expensive failure mode is the same one: THE WRONG INSTALL
20
+ // ANSWERS CORRECTLY.
21
+ //
22
+ // TWO DIFFERENT QUESTIONS, and `version.ts` already says so in as many words:
23
+ // `which agmsg-cloud` answers from PATH, and a manifest answers from the module
24
+ // graph. This module is where the two are asked side by side, because the whole
25
+ // defect is that they can disagree while everything reports success.
26
+ //
27
+ // DERIVING THE CANDIDATES MATTERS MORE HERE than it did for `agmsg`, and the
28
+ // issue says why: `resolveScriptsDir` takes an env var or a default and never
29
+ // searches, so there were exactly two places to look. PATH is a list, and the
30
+ // answer is whichever comes first.
31
+ /** The names a PATH entry can carry on this platform. */
32
+ function executableNames() {
33
+ // On Windows a bare `agmsg-cloud` is not executable; npm writes `.cmd` (and
34
+ // pnpm a `.CMD`/`.ps1` pair) beside it. PATHEXT is the general rule and this
35
+ // is not it — it is the two names this package's installers actually write.
36
+ // Stated as a limit rather than implied: a Windows install under some other
37
+ // extension is not found here, and `pathScanComplete` reports that this scan
38
+ // is name-based so a reader is not told a bounded list is an exhaustive one.
39
+ return platform === 'win32' ? ['agmsg-cloud.cmd', 'agmsg-cloud.exe', 'agmsg-cloud'] : ['agmsg-cloud'];
40
+ }
41
+ /**
42
+ * Would a shell run this file, or merely find it?
43
+ *
44
+ * The scan asked `isFile()` alone in the first version, so a PATH directory
45
+ * holding a non-executable `agmsg-cloud` — a leftover, a half-finished copy, a
46
+ * file someone saved there — was reported as the thing `typing the name runs`
47
+ * (raised in review). It is not: a shell skips it and keeps walking. This whole
48
+ * block's claim is about what RUNS, not about which directories contain a
49
+ * matching name, so finding is the wrong question to stop at.
50
+ *
51
+ * `X_OK` rather than a mode-bit test, because "executable" is a question about
52
+ * this process's effective user and its groups, not about `0o111` being set
53
+ * somewhere in the mode. On Windows `access` reports every existing file as
54
+ * executable, which is why the name and extension list above carries that
55
+ * platform's rule instead.
56
+ */
57
+ function runnable(candidate) {
58
+ if (platform === 'win32')
59
+ return true;
60
+ try {
61
+ accessSync(candidate, constants.X_OK);
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ /**
69
+ * Every `agmsg-cloud` on PATH, in PATH order.
70
+ *
71
+ * FIRST WINS, and that is the whole reason the order is preserved rather than
72
+ * sorted or deduplicated into a set. The list is what a shell would walk, and
73
+ * the answer to "what runs when I type the name" is its first element.
74
+ *
75
+ * A directory that appears twice on PATH yields its entry twice; that is left
76
+ * as it is, because a PATH with duplicates is itself worth seeing when someone
77
+ * is trying to work out why the wrong copy answers.
78
+ */
79
+ export function agmsgCloudOnPath(env = process.env) {
80
+ const raw = env['PATH'] ?? env['Path'] ?? '';
81
+ const found = [];
82
+ for (const dir of raw.split(delimiter)) {
83
+ if (dir === '')
84
+ continue;
85
+ for (const name of executableNames()) {
86
+ const candidate = join(dir, name);
87
+ try {
88
+ if (existsSync(candidate) && statSync(candidate).isFile() && runnable(candidate)) {
89
+ found.push(candidate);
90
+ break;
91
+ }
92
+ }
93
+ catch {
94
+ // A PATH entry that cannot be stat'ed — a dead mount, a permission
95
+ // wall — is not a candidate and is not an error worth ending the scan
96
+ // for. The next entry may well be the one that answers.
97
+ }
98
+ }
99
+ }
100
+ return found;
101
+ }
102
+ export function selfInstall(env = process.env, moduleUrl,
103
+ /**
104
+ * How to ask the binary which copy it is. Defaults to NOT ASKING.
105
+ *
106
+ * A default that spawns would put an extra process in front of every
107
+ * `connect`, and this is a diagnostic for the screen someone opens when they
108
+ * are confused — not a tax on the path that is working. `cmdConnectPreflight`
109
+ * passes `runVersion`; the check that runs before a real connect does not.
110
+ */
111
+ run = () => null) {
112
+ const install = moduleUrl === undefined ? packageInstall() : packageInstall(moduleUrl);
113
+ const onPath = agmsgCloudOnPath(env);
114
+ return { ...install, onPath, winner: winnerOf(onPath[0], install.directory, run) };
115
+ }
116
+ export const runVersion = (binary) => {
117
+ try {
118
+ return execFileSync(binary, ['--version'], {
119
+ encoding: 'utf8',
120
+ stdio: ['ignore', 'pipe', 'ignore'],
121
+ // Bounded, because this runs inside a preflight someone is waiting on and
122
+ // the thing being spawned is by definition a binary this build knows
123
+ // nothing about.
124
+ timeout: 5000,
125
+ });
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ };
131
+ function winnerOf(first, directory, run) {
132
+ if (first === undefined)
133
+ return 'none';
134
+ const printed = run(first);
135
+ if (printed === null)
136
+ return 'unresolved';
137
+ const parsed = parseVersionOutput(printed.trim());
138
+ if (!parsed.ok)
139
+ return 'unresolved';
140
+ const same = (a, b) => {
141
+ const real = (p) => {
142
+ try {
143
+ return realpathSync(p);
144
+ }
145
+ catch {
146
+ return p;
147
+ }
148
+ };
149
+ return real(a) === real(b);
150
+ };
151
+ return same(parsed.directory, directory) ? 'this' : 'elsewhere';
152
+ }
@@ -1,5 +1,4 @@
1
1
  import { setupCommand } from './recovery-key.js';
2
- import { shellArg } from './shell-arg.js';
3
2
  /**
4
3
  * Explain a slot that did not open, given the command that will now ask for the
5
4
  * recovery key anyway.
@@ -8,18 +7,24 @@ import { shellArg } from './shell-arg.js';
8
7
  * recovery key is typed. None of them is a dead end, which is the point: a
9
8
  * machine with no secure store is not a machine that cannot back up.
10
9
  */
11
- export function adviseOnSlot(result, ctx) {
10
+ export function adviseOnSlot(result) {
12
11
  if (result.ok)
13
12
  return { tone: 'silent', lines: [] };
14
13
  switch (result.reason) {
15
14
  case 'no-slot':
16
15
  // The first backup on this machine, or the first after a re-issuance.
17
- // Worth one line, because the next sentence asks for the recovery key and
18
- // the person deserves to know why this time and not next time.
16
+ // Worth saying before the next sentence asks for the recovery key. Do
17
+ // not promise "once": knowing that the slot is absent is not knowing
18
+ // that this session may write its replacement. A headless macOS session
19
+ // can read the default keychain name and still have add-generic-password
20
+ // refused with errSecInteractionNotAllowed. That fact is learned only
21
+ // after the vault has opened, when saveDeviceSlot performs the write.
19
22
  return {
20
23
  tone: 'note',
21
24
  lines: [
22
- 'this machine has no key slot for this vault yet, so the recovery key is needed once.',
25
+ 'this machine has no key slot for this vault yet, so the recovery key is needed now.',
26
+ 'After this succeeds, the CLI will try to keep a slot in the OS secure store. If this',
27
+ 'session cannot use that store, the recovery key will be needed every time.',
23
28
  ],
24
29
  };
25
30
  case 'no-store':
@@ -39,7 +44,7 @@ export function adviseOnSlot(result, ctx) {
39
44
  tone: 'note',
40
45
  lines: [
41
46
  'this machine has a secure store but it refused: ' + result.detail,
42
- `unlock it and run \`agmsg-cloud recovery setup ${shellArg(ctx.team)}\` again to keep a key`,
47
+ `unlock it and run \`${setupCommand()}\` again to keep a key`,
43
48
  'slot, so later backups do not ask for the recovery key. This run continues without one.',
44
49
  ],
45
50
  };
@@ -87,24 +92,13 @@ export function adviseOnSlot(result, ctx) {
87
92
  * `recovery setup` once and this stops happening" would be a route that does
88
93
  * not exist on Windows or Linux.
89
94
  */
90
- export function adviseOnSlotForSilentFiling(result, ctx) {
95
+ export function adviseOnSlotForSilentFiling(result) {
91
96
  if (result.ok)
92
97
  return { tone: 'silent', lines: [] };
93
- // NAMED, and the argument is the point rather than a detail.
94
- //
95
- // `recovery setup` takes the team optionally now, and the no-argument form is
96
- // the one its own documentation calls ordinary — which is why this printed it
97
- // at first. But the broad form's success depends on every OTHER team the
98
- // machine reports: one pass, no per-team isolation, so a `key.sh handoff`
99
- // that fails for an unrelated team throws out of the loop and nothing is
100
- // written. And if `remote.sh status --json` comes back short (agmsg#650) the
101
- // target list can be empty, which refuses outright.
102
- //
103
- // Either way the person is sent to a command that can fail for a reason that
104
- // has nothing to do with the team they just connected — and this is the one
105
- // route they are given. Naming the team makes the remedy reach exactly the
106
- // thing the sentence above it is about (raised in review).
107
- const setup = `\`${setupCommand(ctx.team)}\``;
98
+ // The narrow form existed only while agmsg#650 could silently omit another
99
+ // team. That producer contract is now fail-closed, so recovery setup has one
100
+ // account-wide form everywhere it is printed (#182).
101
+ const setup = `\`${setupCommand()}\``;
108
102
  switch (result.reason) {
109
103
  case 'no-slot':
110
104
  return {