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.
- package/dist/src/api.js +85 -3
- package/dist/src/commands/connect.js +14 -7
- package/dist/src/commands/login.js +119 -2
- package/dist/src/commands/logout.js +38 -7
- package/dist/src/commands/request.js +142 -4
- package/dist/src/commands/sync.js +51 -5
- 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 +63 -27
- package/dist/src/oss.js +30 -1
- package/dist/src/preflight.js +140 -15
- package/dist/src/recovery-key.js +24 -21
- package/dist/src/self-install.js +152 -0
- package/dist/src/slot-advice.js +16 -22
- package/dist/src/vault-inventory.js +118 -0
- package/dist/src/vault-placement.js +94 -0
- package/dist/src/version.js +21 -22
- 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
|
|
@@ -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
|
-
|
|
74
|
-
|
|
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
|
|
306
|
-
* team used to be required
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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(
|
|
313
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
+
}
|
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,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
|
|
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
|
|
18
|
-
//
|
|
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
|
|
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
|
|
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
|
|
95
|
+
export function adviseOnSlotForSilentFiling(result) {
|
|
91
96
|
if (result.ok)
|
|
92
97
|
return { tone: 'silent', lines: [] };
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
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 {
|