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.
- package/dist/scripts/version-output.js +65 -0
- package/dist/src/api.js +85 -3
- package/dist/src/commands/connect.js +14 -7
- package/dist/src/commands/login.js +120 -3
- package/dist/src/commands/logout.js +38 -7
- package/dist/src/commands/request.js +53 -3
- package/dist/src/commands/sync.js +12 -1
- package/dist/src/commands/vault.js +345 -78
- package/dist/src/commands/watch.js +25 -7
- package/dist/src/config.js +11 -2
- package/dist/src/credentials.js +149 -17
- package/dist/src/data-plane.js +67 -0
- package/dist/src/index.js +45 -24
- package/dist/src/oss.js +30 -1
- package/dist/src/preflight.js +140 -15
- package/dist/src/recovery-key.js +11 -16
- package/dist/src/self-install.js +152 -0
- package/dist/src/slot-advice.js +7 -19
- package/dist/src/vault-inventory.js +118 -0
- package/dist/src/vault-placement.js +94 -0
- package/package.json +3 -2
package/dist/src/preflight.js
CHANGED
|
@@ -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
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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: {
|
|
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.
|
|
194
|
-
//
|
|
195
|
-
//
|
|
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 ?
|
|
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('');
|
package/dist/src/recovery-key.js
CHANGED
|
@@ -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
|
|
314
|
-
* team used to be required
|
|
315
|
-
*
|
|
316
|
-
*
|
|
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(
|
|
321
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
+
}
|
package/dist/src/slot-advice.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
95
|
+
export function adviseOnSlotForSilentFiling(result) {
|
|
97
96
|
if (result.ok)
|
|
98
97
|
return { tone: 'silent', lines: [] };
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
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
|
+
}
|