agmsg-cloud 0.1.1 → 0.1.3

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.
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from 'node:crypto';
2
2
  import { closeSync, constants, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, chmodSync, rmSync, writeSync, } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
+ import { syncDirectoryEntryIfSupported } from './durable-dir.js';
5
6
  /**
6
7
  * Identify the (credential, device identity) pair without storing either.
7
8
  *
@@ -138,13 +139,10 @@ export function recordAuthenticatedDigest(input, env = process.env) {
138
139
  throw err;
139
140
  }
140
141
  enforceMode(path, 0o600);
141
- const dirFd = openSync(dirname(path), constants.O_RDONLY);
142
- try {
143
- fsyncSync(dirFd);
144
- }
145
- finally {
146
- closeSync(dirFd);
147
- }
142
+ // The same POSIX-only directory flush as `credentials.ts`, and the same
143
+ // reason it is now probed rather than assumed (#512). Fixing only the other
144
+ // site would have moved the EPERM here instead of removing it.
145
+ syncDirectoryEntryIfSupported(dirname(path));
148
146
  }
149
147
  /**
150
148
  * Every settled ceremony recorded for this origin.
@@ -0,0 +1,50 @@
1
+ import { existsSync } from 'node:fs';
2
+ /**
3
+ * Git for Windows' usual homes, most specific first.
4
+ *
5
+ * `System32\bash.exe` is deliberately NOT on this list and is never chosen
6
+ * explicitly: it reaches PATH on its own, and if it is the only thing there
7
+ * then that is what gets reported rather than silently substituted.
8
+ */
9
+ function gitBashCandidates(env) {
10
+ const local = env['LOCALAPPDATA'];
11
+ const programFiles = env['ProgramFiles'] ?? 'C:\\Program Files';
12
+ const programFilesX86 = env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)';
13
+ return [
14
+ ...(local ? [`${local}\\Programs\\Git\\bin\\bash.exe`] : []),
15
+ `${programFiles}\\Git\\bin\\bash.exe`,
16
+ `${programFilesX86}\\Git\\bin\\bash.exe`,
17
+ ];
18
+ }
19
+ /**
20
+ * The interpreter to run the OSS shell scripts with.
21
+ *
22
+ * `AGMSG_BASH` wins outright and is not checked for existence — an operator
23
+ * naming a path is making a statement, and silently ignoring it when the path
24
+ * is wrong would put us back to guessing. A bad value fails at the spawn, with
25
+ * that value named.
26
+ *
27
+ * `deps` exists so a test can drive the Windows branches from any platform.
28
+ */
29
+ export function resolveBash(env = process.env, deps = {}) {
30
+ const override = env['AGMSG_BASH'];
31
+ if (override)
32
+ return { command: override, source: 'AGMSG_BASH' };
33
+ const platform = deps.platform ?? process.platform;
34
+ if (platform === 'win32') {
35
+ const exists = deps.exists ?? existsSync;
36
+ for (const candidate of gitBashCandidates(env)) {
37
+ if (exists(candidate))
38
+ return { command: candidate, source: 'git-bash' };
39
+ }
40
+ }
41
+ return { command: 'bash', source: 'PATH' };
42
+ }
43
+ /** One clause naming the interpreter, for a message a person has to act on. */
44
+ export function describeBash(r) {
45
+ if (r.source === 'AGMSG_BASH')
46
+ return `${r.command} (from AGMSG_BASH)`;
47
+ if (r.source === 'git-bash')
48
+ return `${r.command} (Git for Windows)`;
49
+ return `${r.command} (first on PATH)`;
50
+ }
@@ -90,6 +90,30 @@ export function verificationUrlIsSafe(url, endpoint) {
90
90
  // operator who named their own endpoint owns their own topology.
91
91
  return true;
92
92
  }
93
+ /**
94
+ * The console to send someone to, or null when we do not know one.
95
+ *
96
+ * Null is the answer for a self-hosted endpoint, and it is the whole reason
97
+ * this is a function rather than a constant anyone can print. The pin above
98
+ * exists because the official console's origin is a name we ship; a
99
+ * self-hosted deployment's is "not ours to know", and guessing `app.<their
100
+ * domain>` sends an operator to a service that may not be theirs — or may be
101
+ * someone else's.
102
+ *
103
+ * So a caller that wants to end a message with "and manage it at …" has to
104
+ * handle the null. That is deliberate: a message with no URL is worse than one
105
+ * with a wrong URL only until the wrong URL is followed.
106
+ */
107
+ export function officialConsoleFor(endpoint) {
108
+ try {
109
+ return new URL(endpoint).origin === new URL(DEFAULT_ENDPOINT).origin
110
+ ? OFFICIAL_CONSOLE_ORIGIN
111
+ : null;
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
93
117
  export function openUrl(url) {
94
118
  const opener = openerFor(url);
95
119
  return new Promise((resolve) => {
@@ -1,4 +1,5 @@
1
1
  import { spawnOssInherit } from '../oss-env.js';
2
+ import { resolveBash } from '../bash-path.js';
2
3
  import { existsSync, mkdirSync } from 'node:fs';
3
4
  import { dirname, join } from 'node:path';
4
5
  import { CourierClient, CourierError } from '../api.js';
@@ -35,7 +36,7 @@ export async function cmdConnect(config, opts) {
35
36
  }
36
37
  process.stdout.write(`\nConnecting "${opts.team}" as machine "${credential.machineName}".\n`);
37
38
  const run = opts.runner ?? runInherit;
38
- const code = await run('bash', [
39
+ const code = await run(resolveBash().command, [
39
40
  join(config.scriptsDir, 'remote.sh'),
40
41
  'connect',
41
42
  '--endpoint',
@@ -3,6 +3,8 @@ import { DEFAULT_ENDPOINT, armEnterToOpen, verificationUrlIsSafe, } from '../bro
3
3
  import { credentialsForOrigin, isOrgAddress, originOf, writeCredential } from '../credentials.js';
4
4
  import { settleMachineName, validateMachineName } from '../machine-name.js';
5
5
  import { resolveScriptsDir } from '../config.js';
6
+ import { packageInstall } from '../version.js';
7
+ import { pathLookupCommand } from '../preflight.js';
6
8
  import { teamsBoundTo } from '../oss.js';
7
9
  // `login` — the device-authorization flow, from this machine's side.
8
10
  //
@@ -360,7 +362,43 @@ async function pollUntilDecided(grant, ctx) {
360
362
  // will look up again. Checked here so nothing durable happens first, and
361
363
  // checked again in `keyFor` so a future caller cannot route around this.
362
364
  if (!isOrgAddress(issued.org)) {
363
- throw new Error('the server answered with an org address this build does not recognise — nothing was stored');
365
+ // WHAT THIS SENTENCE HAS TO GET RIGHT: whose fault it is, and what to
366
+ // do next. The previous wording — "the server answered with an org
367
+ // address this build does not recognise" — put the server first, and
368
+ // the person who hit it concluded the service was broken and stopped
369
+ // (#476). It was not broken: their machine was running a build from
370
+ // before `isOrgAddress` learned the second form.
371
+ //
372
+ // `credentials.ts` predicts this window in as many words: the server
373
+ // cutover "does not land until `npm view agmsg-cloud version` resolves
374
+ // to a build whose `isOrgAddress` accepts the new form". The window was
375
+ // designed for. What was never written was the sentence someone reads
376
+ // from inside it.
377
+ //
378
+ // The version AND the directory, from `packageInstall()`, which walks
379
+ // once and returns both. That pairing is the point here rather than a
380
+ // detail: the machine that hit this had TWO `agmsg-cloud` on PATH, and
381
+ // the one that answered `-v` was not the one that ran. A version alone
382
+ // would have been the same ambiguity in a new place. Reading the
383
+ // directory back against the shell's own lookup is what settles it.
384
+ //
385
+ // The lookup command comes from `preflight.ts`, which already had to
386
+ // decide it: `where` on Windows, `which` elsewhere. Printing `which`
387
+ // unconditionally would hand a Windows reader a broken command at the
388
+ // exact moment they have hit the PATH confusion this sentence exists to
389
+ // resolve. Raised in review, and taken from the module that RUNS it so
390
+ // the printed advice and the executed lookup cannot drift apart.
391
+ //
392
+ // No version number is named as the one to upgrade TO. Naming it needs
393
+ // a claim about which release first accepted this form, that claim
394
+ // decays every release, and a reader on that exact version is told to
395
+ // install what they already have. "Update, then run this again" is
396
+ // true whatever the answer is.
397
+ const { version, directory } = packageInstall();
398
+ throw new Error(`this build (${version}, from ${directory}) does not recognise the org address ` +
399
+ 'the server sent — nothing was stored. Update with `npm i -g agmsg-cloud` and run ' +
400
+ 'this command again. If the version above is not the one you expected, another ' +
401
+ `\`agmsg-cloud\` is ahead of it on PATH — compare it with \`${pathLookupCommand()} agmsg-cloud\`.`);
364
402
  }
365
403
  return finish(issued, endpoint, post, out);
366
404
  }
@@ -1,6 +1,7 @@
1
1
  import { spawnOssInherit } from '../oss-env.js';
2
2
  import { shellArg } from '../shell-arg.js';
3
3
  import { join } from 'node:path';
4
+ import { resolveBash } from '../bash-path.js';
4
5
  import { CourierClient, isUuid } from '../api.js';
5
6
  import { originOf, readCredential } from '../credentials.js';
6
7
  import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
@@ -30,7 +31,7 @@ export async function cmdPull(config, opts) {
30
31
  const teamId = opts.teamId ?? (await resolveTeamId(config, opts));
31
32
  process.stdout.write(`\nPulling "${opts.team}" onto machine "${credential.machineName}".\n`);
32
33
  const run = opts.runner ?? runInherit;
33
- const code = await run('bash', [
34
+ const code = await run(resolveBash().command, [
34
35
  join(config.scriptsDir, 'remote.sh'),
35
36
  'pull',
36
37
  '--endpoint',
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { hostname } from 'node:os';
3
3
  import { CourierClient } from '../api.js';
4
4
  import { originOf, readCredential } from '../credentials.js';
5
+ import { officialConsoleFor } from '../browser.js';
5
6
  import { publicKeyOf } from '../oss.js';
6
7
  import { deviceIdentityPath } from '../paths.js';
7
8
  import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
@@ -217,8 +218,22 @@ export async function cmdSync(config, opts) {
217
218
  // authenticated, by machine.
218
219
  await fetch(config, { team: opts.team });
219
220
  // The last thing said, because "what now" is the question the screen leaves
220
- // otherwise — and here the answer is that there is no next command. Saying
221
- // so is the point: someone who has just run five steps and watched a code
221
+ // otherwise: someone who has just run five steps and watched a code
222
222
  // comparison has every reason to expect a sixth.
223
- out(`\n"${opts.team}" is on this machine, unlocked and syncing. Nothing further to run.\n`);
223
+ //
224
+ // "Nothing further to run" was true and was read as "nothing further to do",
225
+ // and the walk stopped there (#479). The two are different sentences. There
226
+ // is no sixth COMMAND, and there is somewhere to go — the console is where a
227
+ // team is managed once it syncs, and the CLI was the only thing that knew
228
+ // that and did not say it.
229
+ //
230
+ // The console is named only for the official endpoint. `browser.ts` pins that
231
+ // origin because it is a name we ship, and refuses to guess a self-hosted
232
+ // one — "its layout is not ours to know". Printing `app.<their domain>` would
233
+ // send an operator to a service that need not be theirs, so a self-hosted
234
+ // deployment gets the true half of the sentence and no invented URL.
235
+ const console_ = officialConsoleFor(config.baseUrl);
236
+ out(`\n"${opts.team}" is on this machine, unlocked and syncing. No further command to run` +
237
+ (console_ ? ` — manage it at ${console_}` : '') +
238
+ '.\n');
224
239
  }
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
  import { closeSync, constants, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, chmodSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
+ import { syncDirectoryEntryIfSupported } from './durable-dir.js';
5
6
  /**
6
7
  * A server-minted org address, in either of the two forms the server mints.
7
8
  *
@@ -489,14 +490,20 @@ function commit(path, dir, next) {
489
490
  enforceFileMode(path);
490
491
  // Rename is atomic but not durable until the DIRECTORY entry is flushed. The
491
492
  // whole point of writing before activating is that a crash here still leaves
492
- // the credential on disk, so this fsync is load-bearing, not hygiene.
493
- const dirFd = openSync(dir, constants.O_RDONLY);
494
- try {
495
- fsyncSync(dirFd);
496
- }
497
- finally {
498
- closeSync(dirFd);
499
- }
493
+ // the credential on disk, so this fsync is load-bearing, not hygiene —
494
+ // ON POSIX. That qualifier is new and it is the whole of #512: Windows has
495
+ // no directory fsync, the call failed with EPERM, and `login` died after the
496
+ // server exchange had already succeeded. The comment as it stood said
497
+ // "load-bearing" without a platform and would have told the next reader that
498
+ // Windows was getting a guarantee it never had.
499
+ //
500
+ // Where the operation does not exist, the credential is still written and
501
+ // renamed; what is missing is the flush. THIS CALL DOES NOT TELL US WHICH
502
+ // HAPPENED, and the comment here used to say it did — the helper returned an
503
+ // outcome and this line dropped it, so "the caller can tell the difference"
504
+ // was true of the test suite and false of the product. It is best-effort now
505
+ // and says so. Anything that is not a capability answer still throws.
506
+ syncDirectoryEntryIfSupported(dir);
500
507
  }
501
508
  // Used only by the tests that need a file on disk without going through a
502
509
  // login; kept here so the 0600/0700 rules live in exactly one place.
@@ -0,0 +1,129 @@
1
+ import { closeSync, constants, fsyncSync, openSync } from 'node:fs';
2
+ // FLUSHING A DIRECTORY ENTRY, WHERE THAT IS A THING (#512).
3
+ //
4
+ // After `rename`, POSIX gives atomicity but not durability: the entry is not on
5
+ // disk until the containing DIRECTORY is fsynced. Both call sites write a file,
6
+ // rename it into place, and then flush the directory, and the comment at one of
7
+ // them called that "load-bearing, not hygiene". On POSIX it is.
8
+ //
9
+ // Windows has no such operation, and a user hit it: `agmsg-cloud login` printed
10
+ // its device code, completed the exchange, and then died with
11
+ //
12
+ // EPERM: operation not permitted, fsync
13
+ //
14
+ // on the last line of storing the credential — after the part that talks to the
15
+ // server, before the part that makes it usable.
16
+ //
17
+ // READ THE SYSCALL IN THAT MESSAGE. It says `fsync`, so on that machine the
18
+ // open SUCCEEDED and the flush was refused. An earlier version of this file
19
+ // asserted the opposite in a comment — "Windows refuses at the OPEN, not at the
20
+ // fsync" — two paragraphs below the error text that contradicts it. Nobody
21
+ // measured it; it was inferred from how directories open elsewhere and then
22
+ // written down as fact. The open is guarded too, but as a precaution, and it is
23
+ // labelled as one below.
24
+ //
25
+ // THE FIX IS NOT AN EQUIVALENT. There is no Windows call that buys the same
26
+ // guarantee here; `MoveFileEx` reasons about durability differently. So this
27
+ // does not "do the same thing another way" — where the guarantee is
28
+ // unavailable, the entry is written and renamed and the flush does not happen,
29
+ // and that is a downgrade we accept rather than a success we claim.
30
+ /**
31
+ * Refusals that mean "this filesystem does not offer the operation", whatever
32
+ * the platform.
33
+ *
34
+ * Deliberately small. A directory fsync is refused with `EINVAL` or `ENOTSUP`
35
+ * by filesystems that do not implement it — some network and FUSE mounts — and
36
+ * those answers are not ambiguous. Adding an errno here needs the same
37
+ * argument: that it CANNOT also be a real failure of this particular write.
38
+ */
39
+ const NOT_OFFERED_ANYWHERE = new Set(['EINVAL', 'ENOTSUP']);
40
+ /**
41
+ * Refusals that mean "unsupported" ONLY on Windows, because on POSIX they are
42
+ * how a real failure of a real operation arrives.
43
+ *
44
+ * `EPERM` is the one the reported crash produced. On POSIX the same errno is a
45
+ * genuine refusal, so reading it as a capability answer everywhere would turn
46
+ * real failures into silent successes. It is gated on the platform for exactly
47
+ * that reason — see the note on `deps.platform`.
48
+ */
49
+ const NOT_OFFERED_ON_WINDOWS = new Set(['EPERM', 'EISDIR']);
50
+ /**
51
+ * `EACCES` is in NEITHER set, and this is the line most likely to be
52
+ * "simplified" later.
53
+ *
54
+ * It is a permission answer, not a capability answer: a directory whose search
55
+ * permission was dropped, an ACL, a sandbox policy. Swallowing it would report
56
+ * a successful sign-in over a filesystem that is refusing us — which is the
57
+ * failure this whole file exists to remove, pointing the other way. Same for
58
+ * `ENOENT`: the directory the caller just renamed into being gone is not a
59
+ * portability question.
60
+ */
61
+ function isUnsupported(err, phase, platform) {
62
+ const code = err.code ?? '';
63
+ if (phase === 'fsync' && NOT_OFFERED_ANYWHERE.has(code))
64
+ return true;
65
+ return platform === 'win32' && NOT_OFFERED_ON_WINDOWS.has(code);
66
+ }
67
+ /**
68
+ * Flush a directory entry where the platform offers it, and do nothing where it
69
+ * does not.
70
+ *
71
+ * Returns `void`, and the contract is the honest version of that: **this is
72
+ * best-effort durability.** An earlier draft returned `'flushed' | 'unsupported'`
73
+ * so that a caller "could tell the difference" — and neither caller looked at
74
+ * it. A union nobody reads is not a report; it only makes the prose around it
75
+ * sound like one. If we ever need to tell a user that their filesystem cannot
76
+ * promise this, that is a change at the call sites, and the return type comes
77
+ * back with it.
78
+ *
79
+ * Everything that is not a capability answer still throws.
80
+ *
81
+ * `deps.platform` exists because two errnos are ambiguous, not because the
82
+ * platform decides whether the call works. The call itself still answers that —
83
+ * probed, never assumed — and a filesystem refusing with `EINVAL` is honoured on
84
+ * any platform. What the platform gates is how to READ `EPERM`, which means one
85
+ * thing on Windows and another on Linux.
86
+ *
87
+ * The rest of `deps` is there so a test can drive these branches from any
88
+ * platform: there is no way to make a real POSIX kernel refuse this on demand.
89
+ */
90
+ export function syncDirectoryEntryIfSupported(dir, deps = {}) {
91
+ const open = deps.open ?? openSync;
92
+ const fsync = deps.fsync ?? fsyncSync;
93
+ const close = deps.close ?? closeSync;
94
+ const platform = deps.platform ?? process.platform;
95
+ let dirFd;
96
+ try {
97
+ dirFd = open(dir, constants.O_RDONLY);
98
+ }
99
+ catch (err) {
100
+ // PRECAUTION, NOT THE REPORTED CRASH. The machine that reported this got
101
+ // past the open; guarding here covers a Windows build or a mount that
102
+ // refuses earlier, and costs nothing when it does not.
103
+ if (isUnsupported(err, 'open', platform))
104
+ return;
105
+ throw err;
106
+ }
107
+ try {
108
+ fsync(dirFd);
109
+ }
110
+ catch (err) {
111
+ // Close before deciding, and do not let the close's own failure become the
112
+ // error the caller sees: it would replace a diagnosis with a symptom.
113
+ try {
114
+ close(dirFd);
115
+ }
116
+ catch {
117
+ // Intentionally dropped. The fsync error below is the one that explains
118
+ // what happened; a close error on a descriptor we are abandoning is not.
119
+ }
120
+ if (isUnsupported(err, 'fsync', platform))
121
+ return;
122
+ throw err;
123
+ }
124
+ // Success path only, so this close is the sole thing that can still fail —
125
+ // and a close reporting a deferred write error is a real failure of this
126
+ // write, not a portability answer. It throws, matching how both call sites
127
+ // already treat `closeSync` on the file itself.
128
+ close(dirFd);
129
+ }
package/dist/src/oss.js CHANGED
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { closeSync, constants, openSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { spawnOssPiped } from './oss-env.js';
5
+ import { describeBash, resolveBash } from './bash-path.js';
5
6
  // Thin wrappers over the OSS tooling. The proprietary courier never touches key
6
7
  // material: sealing/opening the bundle and the handoff/unlock steps all run
7
8
  // through `age` and the OSS `key.sh` / `remote.sh` on the operator's machine.
@@ -66,13 +67,32 @@ export function runAllowingFailure(cmd, args) {
66
67
  * not get presented to the operator as a mistyped name.
67
68
  */
68
69
  export async function localTeamLookup(scriptsDir, team) {
69
- const r = await runAllowingFailure('bash', [
70
+ const bash = resolveBash();
71
+ const r = await runAllowingFailure(bash.command, [
70
72
  join(scriptsDir, 'remote.sh'),
71
73
  'status',
72
74
  team,
73
75
  '--json',
74
76
  ]);
75
- return r.code === 0 ? { known: true, said: '' } : { known: false, said: r.stderr };
77
+ if (r.code === 0)
78
+ return { known: true, said: '' };
79
+ // THE DEFENCE IS EMPTY EXACTLY WHEN IT IS NEEDED (#507).
80
+ //
81
+ // `fetch.ts` prints `said` so the operator can tell "no such team" from "the
82
+ // store could not be read". That works while the script runs and complains.
83
+ // A process that never started writes nothing to stderr, so `said` is empty
84
+ // in the one case the two readings are hardest to tell apart — and the empty
85
+ // string is dropped from the message, leaving only the ambiguity with no
86
+ // evidence attached.
87
+ //
88
+ // So when the store said nothing, name the interpreter instead. It is not a
89
+ // diagnosis: it is the one fact this layer holds that the operator does not,
90
+ // and on Windows it is usually the whole answer (`System32\bash.exe` is the
91
+ // WSL launcher, not a shell that can run these scripts — #505).
92
+ return {
93
+ known: false,
94
+ said: r.stderr === '' ? `nothing. Interpreter tried: ${describeBash(bash)}` : r.stderr,
95
+ };
76
96
  }
77
97
  // Generate a device age identity at `identityPath`; returns its public recipient.
78
98
  export async function generateDeviceIdentity(identityPath) {
@@ -95,7 +115,7 @@ export function decryptWithIdentity(identityPath, ciphertext) {
95
115
  // OSS `key.sh handoff <team> --out <file>` — export the one secret handoff bundle
96
116
  // (confirmed snapshot chain + every epoch identity) for `team`.
97
117
  export async function keyHandoff(scriptsDir, team, outFile) {
98
- await run('bash', [join(scriptsDir, 'key.sh'), 'handoff', team, '--out', outFile]);
118
+ await run(resolveBash().command, [join(scriptsDir, 'key.sh'), 'handoff', team, '--out', outFile]);
99
119
  }
100
120
  // OSS `remote-sync.sh verify-age-handoff` — the digest the joiner's `unlock`
101
121
  // will compare the human-carried value against.
@@ -112,7 +132,7 @@ export async function keyHandoff(scriptsDir, team, outFile) {
112
132
  // and is already on disk beside it — but the directory holds private key
113
133
  // material and must be treated exactly like the bundle.
114
134
  export async function verifyHandoffDigest(scriptsDir, team, bundleFile, outDir) {
115
- const out = await run('bash', [
135
+ const out = await run(resolveBash().command, [
116
136
  join(scriptsDir, 'remote-sync.sh'),
117
137
  'verify-age-handoff',
118
138
  '--team',
@@ -153,7 +173,7 @@ export async function verifyHandoffDigest(scriptsDir, team, bundleFile, outDir)
153
173
  // material came from. `remoteTeamId` stays for callers that only address the
154
174
  // team, so neither has to know about the other's fields.
155
175
  export async function remoteBinding(scriptsDir, team) {
156
- const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', team, '--json']);
176
+ const out = await run(resolveBash().command, [join(scriptsDir, 'remote.sh'), 'status', team, '--json']);
157
177
  const text = out.toString().trim();
158
178
  if (!text)
159
179
  throw new Error(`team '${team}' has never been connected to a remote`);
@@ -173,7 +193,7 @@ export async function remoteBinding(scriptsDir, team) {
173
193
  return { teamId: status.remote_team_id, serverInstanceId: status.server_instance_id };
174
194
  }
175
195
  export async function connectedTeams(scriptsDir) {
176
- const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', '--json']);
196
+ const out = await run(resolveBash().command, [join(scriptsDir, 'remote.sh'), 'status', '--json']);
177
197
  const teams = [];
178
198
  for (const line of out.toString().split('\n')) {
179
199
  const text = line.trim();
@@ -245,7 +265,7 @@ export async function teamsBoundTo(scriptsDir, origin) {
245
265
  return { matched: matched.sort(), unreadable: unreadable.sort() };
246
266
  }
247
267
  export async function remoteTeamId(scriptsDir, team) {
248
- const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', team, '--json']);
268
+ const out = await run(resolveBash().command, [join(scriptsDir, 'remote.sh'), 'status', team, '--json']);
249
269
  const text = out.toString().trim();
250
270
  if (!text)
251
271
  throw new Error(`team '${team}' has never been connected to a remote`);
@@ -275,7 +295,7 @@ export async function remoteTeamId(scriptsDir, team) {
275
295
  // courier path must keep using unlockBundle with an out-of-band digest, because
276
296
  // age's recipient encryption does not authenticate the sender.
277
297
  export async function unlockAuthenticatedBundle(scriptsDir, team, bundle) {
278
- await run('bash', [join(scriptsDir, 'remote.sh'), 'unlock', team, '--authenticated-bundle-stdin'], bundle);
298
+ await run(resolveBash().command, [join(scriptsDir, 'remote.sh'), 'unlock', team, '--authenticated-bundle-stdin'], bundle);
279
299
  }
280
300
  // OSS `remote.sh unlock <team> --bundle <file> [--confirm-digest <digest>]`.
281
301
  //
@@ -296,7 +316,7 @@ export async function unlockBundle(scriptsDir, team, bundleFile, confirmDigest)
296
316
  const args = [join(scriptsDir, 'remote.sh'), 'unlock', team, '--bundle', bundleFile];
297
317
  if (confirmDigest)
298
318
  args.push('--confirm-digest', confirmDigest);
299
- return (await run('bash', args)).toString();
319
+ return (await run(resolveBash().command, args)).toString();
300
320
  }
301
321
  // The canonical age snapshot, exported locally, and its digest (§3.2, §3.2.1).
302
322
  //
@@ -311,7 +331,7 @@ export async function unlockBundle(scriptsDir, team, bundleFile, confirmDigest)
311
331
  // without anything failing. Reading the definition rather than the message
312
332
  // also means A and B compute the same value from the same rule.
313
333
  export async function exportSnapshotDigest(scriptsDir, team, outPath) {
314
- await run('bash', [
334
+ await run(resolveBash().command, [
315
335
  join(scriptsDir, 'remote-sync.sh'),
316
336
  'export-age-snapshot',
317
337
  '--team',
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { defaultScriptsDir, hasCredential, scriptsDirChoice } from './config.js';
4
4
  import { join } from 'node:path';
5
+ import { describeBash, resolveBash } from './bash-path.js';
5
6
  import { platform } from 'node:process';
6
7
  import { selfInstall } from './self-install.js';
7
8
  // What `connect` needs before it starts, checked all at once.
@@ -33,7 +34,7 @@ import { selfInstall } from './self-install.js';
33
34
  // cannot establish that a silently short team list is impossible.
34
35
  export function installedVersion(scriptsDir) {
35
36
  try {
36
- const out = execFileSync('bash', [join(scriptsDir, 'version.sh')], {
37
+ const out = execFileSync(resolveBash().command, [join(scriptsDir, 'version.sh')], {
37
38
  encoding: 'utf8',
38
39
  stdio: ['ignore', 'pipe', 'ignore'],
39
40
  }).trim();
@@ -70,38 +71,53 @@ export function supportsFailClosedTeamStatus(version) {
70
71
  return patch > 0;
71
72
  return rc === null || rc >= 4;
72
73
  }
73
- // Does the installed remote.sh offer `connect`? Asked of the script itself: run
74
- // with no arguments it prints its usage, which names the subcommands it has.
75
- //
76
- // This is capability detection, not version detection, and the difference is
77
- // the point — see the note above `installedVersion`.
78
- //
79
- // What is deliberately NOT probed: whether this install carries the fix for the
80
- // store-migration data-loss path. There is no way to ask a script "do you have
81
- // this guard" without reading its innards and guessing, and a check that cannot
82
- // really answer is worse than none — it reads as a guarantee. The release plan
83
- // covers it instead: the fix ships before remote sync is public.
84
- function remoteSupportsConnect(scriptsDir) {
74
+ export function remoteSupportsConnect(scriptsDir, deps = {}) {
75
+ const bash = deps.bash ?? resolveBash();
76
+ const run = deps.run ?? execFileSync;
85
77
  try {
86
- const out = execFileSync('bash', [join(scriptsDir, 'remote.sh')], {
78
+ const out = run(bash.command, [join(scriptsDir, 'remote.sh')], {
87
79
  encoding: 'utf8',
88
80
  stdio: ['ignore', 'pipe', 'pipe'],
89
81
  });
90
- return /\bconnect\b/.test(out);
82
+ return /\bconnect\b/.test(out) ? 'yes' : 'no';
91
83
  }
92
84
  catch (err) {
93
- // Usage goes to stderr and exits non-zero in some versions; that output is
94
- // just as good an answer as a clean exit.
95
85
  const e = err;
96
86
  const text = `${String(e.stdout ?? '')}${String(e.stderr ?? '')}`;
97
- return /\bconnect\b/.test(text);
87
+ // Usage on stderr with a non-zero exit is a real answer — the script ran.
88
+ if (/\bconnect\b/.test(text))
89
+ return 'yes';
90
+ // It ran and said nothing about connect: that IS evidence about the script.
91
+ // Distinguished from never having run by whether either stream produced
92
+ // anything at all. A spawn failure has neither, and ENOENT names itself.
93
+ if (e.code === 'ENOENT' || text.length === 0)
94
+ return 'unknown';
95
+ return 'no';
98
96
  }
99
97
  }
98
+ /**
99
+ * What a person types to ask their shell which executable would run.
100
+ *
101
+ * `where` on Windows, `which` everywhere else. Exported and taking its platform
102
+ * as an argument because this answer is now needed in two places and both of
103
+ * them are wrong if they disagree: this module RUNS it, and `login` PRINTS it
104
+ * for an operator to run themselves (#476). Telling a Windows reader to run
105
+ * `which` hands them a broken command at the exact moment they have hit the
106
+ * PATH confusion the message exists to resolve — raised in review.
107
+ *
108
+ * A parameter rather than a module-level constant so both branches are
109
+ * reachable from a test on one machine. A constant bound at import time can
110
+ * only ever be checked on the platform the suite happens to run on, which is
111
+ * the branch nobody needed to check.
112
+ */
113
+ export function pathLookupCommand(on = platform) {
114
+ return on === 'win32' ? 'where' : 'which';
115
+ }
100
116
  function onPath(command) {
101
117
  try {
102
118
  // `command -v` through the shell would re-interpret the name; execFile with
103
119
  // a fixed argv does not.
104
- execFileSync(platform === 'win32' ? 'where' : 'which', [command], { stdio: 'ignore' });
120
+ execFileSync(pathLookupCommand(), [command], { stdio: 'ignore' });
105
121
  return true;
106
122
  }
107
123
  catch {
@@ -238,7 +254,9 @@ versionRunner) {
238
254
  // than inferred from a version; account-wide recovery separately applies the
239
255
  // released producer floor described above `installedVersion`.
240
256
  const scriptsPresent = missingScripts.length === 0;
241
- const canConnect = scriptsPresent && (!requireConnect || remoteSupportsConnect(scriptsDir));
257
+ const bash = resolveBash();
258
+ const connectSupport = scriptsPresent && requireConnect ? remoteSupportsConnect(scriptsDir, { bash }) : 'yes';
259
+ const canConnect = scriptsPresent && connectSupport === 'yes';
242
260
  const agmsgVersion = scriptsPresent ? installedVersion(scriptsDir) : null;
243
261
  requirements.push({
244
262
  name: requireConnect
@@ -247,11 +265,20 @@ versionRunner) {
247
265
  ok: canConnect,
248
266
  why: !scriptsPresent
249
267
  ? `${command} runs ${missingScripts.join(' and ')}, and this machine has no copy there.`
250
- : 'the agmsg installed here does not offer `remote.sh connect`, so it predates remote sync.',
251
- install: [
252
- 'Install or update: npx agmsg install',
253
- 'Elsewhere? point AGMSG_SCRIPTS_DIR at that install\'s scripts directory.',
254
- ],
268
+ : connectSupport === 'unknown'
269
+ ? // NOT a claim about the install (#505). Nothing was learned about it.
270
+ `could not run ${describeBash(bash)}, so nothing here was checked — this says nothing about your agmsg install.`
271
+ : 'the agmsg installed here does not offer `remote.sh connect`, so it predates remote sync.',
272
+ install: connectSupport === 'unknown'
273
+ ? [
274
+ `Interpreter tried: ${describeBash(bash)}`,
275
+ 'On Windows the first `bash` on PATH is usually WSL, which cannot run these scripts.',
276
+ 'Point at Git Bash: set AGMSG_BASH to ...\\Git\\bin\\bash.exe',
277
+ ]
278
+ : [
279
+ 'Install or update: npx agmsg install',
280
+ 'Elsewhere? point AGMSG_SCRIPTS_DIR at that install\'s scripts directory.',
281
+ ],
255
282
  });
256
283
  if (requireFailClosedTeamStatus) {
257
284
  requirements.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agmsg-cloud",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Companion CLI for the agmsg cloud service: connect a team, join from another machine, and back up its keys.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",