agmsg-cloud 0.1.0-rc.8 → 0.1.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.
@@ -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
@@ -310,24 +309,20 @@ export function promptRecoveryKey(prompt) {
310
309
  /**
311
310
  * How this tool names the command that creates a vault and backs teams up.
312
311
  *
313
- * One derivation, because the argument convention has already moved once: the
314
- * team used to be required and is now optional, and the no-argument form is the
315
- * ordinary one — it covers every team this machine reports rather than the one
316
- * that happened to be in hand. Every place that prints this remedy asks here,
317
- * so the next change to the convention moves one line rather than being grepped
318
- * 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).
319
316
  */
320
- export function setupCommand(team) {
321
- if (team === undefined)
322
- return 'agmsg-cloud recovery setup';
323
- return `agmsg-cloud recovery setup ${shellArg(team)}`;
317
+ export function setupCommand() {
318
+ return 'agmsg-cloud recovery setup';
324
319
  }
325
320
  export class NeedsTerminalError extends Error {
326
- constructor(team) {
321
+ constructor() {
327
322
  super('this needs a terminal, and stdin is not one.\n\n' +
328
323
  'Nothing was created and no recovery key exists yet. Run this yourself,\n' +
329
324
  'in your own terminal:\n\n' +
330
- ` ${setupCommand(team)}\n\n` +
325
+ ` ${setupCommand()}\n\n` +
331
326
  'It is refused here on purpose: the recovery key is shown once and stored\n' +
332
327
  'nowhere, so writing it into a redirected or captured stream would be this\n' +
333
328
  'tool breaking that promise itself.');
@@ -364,17 +359,17 @@ export function recoveryKeyNotice(key) {
364
359
  'machine, not the server, not support.\n' +
365
360
  '\n');
366
361
  }
367
- export async function showRecoveryKey(key, team) {
362
+ export async function showRecoveryKey(key) {
368
363
  // Checked BEFORE the key reaches stdout, which is the whole point. Showing
369
364
  // first and then discovering there is nobody to acknowledge means the only
370
365
  // copy of the key is already in whatever the output was redirected to —
371
- // `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
372
367
  // success, which is the opposite of "shown once, stored nowhere".
373
368
  //
374
369
  // Nothing has been created at this point, so refusing here leaves no vault
375
370
  // and no key in use. The message says what to run instead.
376
371
  if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
377
- throw new NeedsTerminalError(team);
372
+ throw new NeedsTerminalError();
378
373
  }
379
374
  process.stdout.write(recoveryKeyNotice(key));
380
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,7 +7,7 @@ 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) {
@@ -45,7 +44,7 @@ export function adviseOnSlot(result, ctx) {
45
44
  tone: 'note',
46
45
  lines: [
47
46
  'this machine has a secure store but it refused: ' + result.detail,
48
- `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`,
49
48
  'slot, so later backups do not ask for the recovery key. This run continues without one.',
50
49
  ],
51
50
  };
@@ -93,24 +92,13 @@ export function adviseOnSlot(result, ctx) {
93
92
  * `recovery setup` once and this stops happening" would be a route that does
94
93
  * not exist on Windows or Linux.
95
94
  */
96
- export function adviseOnSlotForSilentFiling(result, ctx) {
95
+ export function adviseOnSlotForSilentFiling(result) {
97
96
  if (result.ok)
98
97
  return { tone: 'silent', lines: [] };
99
- // NAMED, and the argument is the point rather than a detail.
100
- //
101
- // `recovery setup` takes the team optionally now, and the no-argument form is
102
- // the one its own documentation calls ordinary — which is why this printed it
103
- // at first. But the broad form's success depends on every OTHER team the
104
- // machine reports: one pass, no per-team isolation, so a `key.sh handoff`
105
- // that fails for an unrelated team throws out of the loop and nothing is
106
- // written. And if `remote.sh status --json` comes back short (agmsg#650) the
107
- // target list can be empty, which refuses outright.
108
- //
109
- // Either way the person is sent to a command that can fail for a reason that
110
- // has nothing to do with the team they just connected — and this is the one
111
- // route they are given. Naming the team makes the remedy reach exactly the
112
- // thing the sentence above it is about (raised in review).
113
- 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()}\``;
114
102
  switch (result.reason) {
115
103
  case 'no-slot':
116
104
  return {
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Join the container against what the server calls each team.
3
+ *
4
+ * BOTH SETS COME OUT OF ONE RESPONSE. `listTeams` answers with the ids it
5
+ * returned and which of those carry a null name, so "in this org without a
6
+ * name" and "not in this org" are derived from the same read rather than from
7
+ * two lookups that could disagree. Deriving them separately is how two
8
+ * existence checks get mistaken for a relation.
9
+ */
10
+ export function inventoryOf(container, names) {
11
+ const total = container.teams.length;
12
+ if (!names.asked)
13
+ return { namesKnown: false, total, entries: [...container.teams] };
14
+ const named = [];
15
+ const unnamed = [];
16
+ const unknown = [];
17
+ const ambiguous = [];
18
+ // The ids the org answered with — the set membership test for `unknown`.
19
+ const returned = new Set(names.teams.map((t) => t.teamId));
20
+ const byId = new Map(names.teams.map((t) => [t.teamId, t.teamName]));
21
+ // How many entries each id accounts for. Counted over the CONTAINER, not the
22
+ // response: the response cannot hold the same id twice, which is the whole
23
+ // reason a name from it cannot resolve a repeat here.
24
+ const perId = new Map();
25
+ for (const entry of container.teams) {
26
+ perId.set(entry.team_id, (perId.get(entry.team_id) ?? 0) + 1);
27
+ }
28
+ for (const entry of container.teams) {
29
+ // Before the name is consulted at all. An id the vault holds twice has no
30
+ // name that means one thing, so asking for one and then discarding it would
31
+ // be the same mistake with an extra step.
32
+ if ((perId.get(entry.team_id) ?? 0) > 1) {
33
+ ambiguous.push(entry);
34
+ continue;
35
+ }
36
+ if (!returned.has(entry.team_id)) {
37
+ unknown.push(entry);
38
+ continue;
39
+ }
40
+ const team = byId.get(entry.team_id);
41
+ // An empty string is not a name: it fails `agmsg_validate_team_name` ("must
42
+ // not be empty") and would reach the OSS side as a missing argument. It is
43
+ // the null case wearing a different type, so it lands in the same bucket.
44
+ if (team !== null && team !== undefined && team !== '')
45
+ named.push({ entry, team });
46
+ else
47
+ unnamed.push(entry);
48
+ }
49
+ // A NAME can repeat too, and a repeat is just as unplaceable. Team names are
50
+ // not unique on the server — `resolveTeamByName` refuses a name that matches
51
+ // more than one team for exactly this reason — so two entries with different
52
+ // ids can both come back called `alpha`. Only one local team can carry that
53
+ // name, and nothing in the response chooses between them: placing the first
54
+ // and letting the second collide is not a resolution, it is a race decided by
55
+ // the order the vault happens to store them in.
56
+ const perName = new Map();
57
+ for (const n of named)
58
+ perName.set(n.team, (perName.get(n.team) ?? 0) + 1);
59
+ const uniquelyNamed = [];
60
+ for (const n of named) {
61
+ if ((perName.get(n.team) ?? 0) > 1)
62
+ ambiguous.push(n.entry);
63
+ else
64
+ uniquelyNamed.push(n);
65
+ }
66
+ return { namesKnown: true, total, named: uniquelyNamed, unnamed, unknown, ambiguous };
67
+ }
68
+ /**
69
+ * The report, as lines.
70
+ *
71
+ * Built as data rather than written to stdout so a test can read it without a
72
+ * process, and so the one rule that matters is checkable: **the count of what
73
+ * could not be done is always printed when it is not zero**, with the ids.
74
+ */
75
+ export function inventoryLines(inv, revision) {
76
+ const lines = [`opened the account vault, revision ${revision}`];
77
+ if (inv.total === 0) {
78
+ lines.push('it holds no team backups yet — `agmsg-cloud recovery setup` creates them');
79
+ return lines;
80
+ }
81
+ lines.push(`${inv.total} team backup(s) in it`);
82
+ if (!inv.namesKnown) {
83
+ // Every id, and no verdict about any of them. The entries are here; what
84
+ // failed is the question that would say which are placeable, and answering
85
+ // it anyway is how a network error becomes "your backups are unreachable".
86
+ lines.push('the server could not be asked for names, so none of these can be sorted yet:');
87
+ for (const entry of inv.entries)
88
+ lines.push(` ${entry.team_id}`);
89
+ return lines;
90
+ }
91
+ for (const { team, entry } of inv.named) {
92
+ lines.push(` ${team} (${entry.team_id})`);
93
+ }
94
+ // Never folded together, and never folded into the count above. "4 of 6" that
95
+ // does not name the other two is the shape of a partial success printed as a
96
+ // success — and these two lists have different ways out, so a reader who is
97
+ // shown one line takes the worse of the two readings for all of them.
98
+ if (inv.unnamed.length > 0) {
99
+ lines.push(`${inv.unnamed.length} of ${inv.total} are in this org but the server has no name recorded` +
100
+ ` for them. If you know the name, it can be placed:`);
101
+ for (const entry of inv.unnamed)
102
+ lines.push(` ${entry.team_id}`);
103
+ }
104
+ if (inv.ambiguous.length > 0) {
105
+ lines.push(`${inv.ambiguous.length} of ${inv.total} share a team id with another backup on a` +
106
+ ` different server, and the server's team list cannot say which name belongs to which:`);
107
+ for (const entry of inv.ambiguous) {
108
+ lines.push(` ${entry.team_id} on ${entry.server_instance_id}`);
109
+ }
110
+ }
111
+ if (inv.unknown.length > 0) {
112
+ lines.push(`${inv.unknown.length} of ${inv.total} did not appear in this org's teams at all —` +
113
+ ` this machine's credential cannot reach them:`);
114
+ for (const entry of inv.unknown)
115
+ lines.push(` ${entry.team_id}`);
116
+ }
117
+ return lines;
118
+ }