agmsg-cloud 0.1.0-rc.6 → 0.1.0-rc.7

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.
@@ -360,7 +360,21 @@ args, deps = {}) {
360
360
  done();
361
361
  // Closed, not refunded: §4.1 keeps a success counted in the window.
362
362
  closeAttempt(scope, requestId, 'succeeded', undefined, env);
363
- out(`approved; registered device ${device_id}\n`);
363
+ // The LABEL, not the id.
364
+ //
365
+ // `device_id` is a UUID the approver cannot use: no subcommand of this
366
+ // CLI accepts one (derived from index.ts — approve, connect, fetch,
367
+ // login, logout, pull, recovery, request, sync, watch, whoami, and none
368
+ // take a device). So it fails all three tests for reaching a person: it
369
+ // decides nothing for them, it cannot help them repair anything, and the
370
+ // label they just compared eight digits against is what actually names
371
+ // the machine they approved (#275 — the operator gets the minimum, the
372
+ // rest belongs in a log).
373
+ //
374
+ // Not routed to a log instead, because this CLI has no log to route it
375
+ // to. Building one for a single value would be the wrong size; when a
376
+ // second diagnostic needs a home, that is the moment to give it one.
377
+ out(`approved; "${start.label}" can now read this team\n`);
364
378
  }
365
379
  finally {
366
380
  rmSync(scratch, { recursive: true, force: true });
@@ -63,6 +63,7 @@ export async function cmdLogin(opts) {
63
63
  const fetchImpl = opts.fetchImpl ?? fetch;
64
64
  const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
65
65
  const now = opts.now ?? (() => Date.now());
66
+ const out = opts.out ?? ((text) => void process.stdout.write(text));
66
67
  const endpoint = (opts.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, '');
67
68
  // The flag, validated, before anything is asked. `settleMachineName` returns
68
69
  // it verbatim when it is given, so nothing below needs the prompt to know
@@ -73,7 +74,7 @@ export async function cmdLogin(opts) {
73
74
  // the thing it takes away is the moment the operator typed the destination —
74
75
  // so the destination is printed instead. Nobody should have to guess which
75
76
  // server their machine is about to be registered with.
76
- process.stdout.write(`Connecting to ${new URL(endpoint).host}\n`);
77
+ out(`Connecting to ${new URL(endpoint).host}\n`);
77
78
  const post = async (path, body, bearer) => {
78
79
  try {
79
80
  return await fetchImpl(`${endpoint}${path}`, {
@@ -115,7 +116,7 @@ export async function cmdLogin(opts) {
115
116
  if (stored && (asked === undefined || stored.machineName === asked)) {
116
117
  const res = await post('/v1/device/activate', {}, stored.secret);
117
118
  if (res.ok) {
118
- process.stdout.write(`Already signed in as machine "${stored.machineName}".\n`);
119
+ out(`Already signed in as machine "${stored.machineName}".\n`);
119
120
  return;
120
121
  }
121
122
  const code = await errorCode(res);
@@ -148,16 +149,32 @@ export async function cmdLogin(opts) {
148
149
  throw new Error(`could not start login: ${codeRes.status} ${code}`);
149
150
  }
150
151
  const grant = (await codeRes.json());
151
- process.stdout.write(`\nOpen this page and check the code matches:\n\n`);
152
- process.stdout.write(` ${grant.verification_uri_complete}\n\n`);
153
- process.stdout.write(` code: ${grant.user_code}\n`);
154
- process.stdout.write(` machine: ${machineName}\n\n`);
152
+ out(`\nOpen this page and check the code matches:\n\n`);
153
+ out(` ${grant.verification_uri_complete}\n\n`);
154
+ out(` code: ${grant.user_code}\n`);
155
+ out(` machine: ${machineName}\n\n`);
155
156
  // Armed, never awaited: an approval done from a phone, or from a URL typed by
156
157
  // hand, must still be noticed — so the poll below runs whether or not any key
157
158
  // is ever pressed. `disarm` is bound to every exit path, because a live stdin
158
159
  // listener would hold the process open after login has already finished.
159
160
  const opener = (opts.armOpener ?? armEnterToOpen)(grant.verification_uri_complete, endpoint);
160
- process.stdout.write(`Waiting for approval — nothing is granted until you approve it.\n`);
161
+ // SAYS THAT THE BLOCKING IS THE DESIGN, and that the two lines above are
162
+ // already usable.
163
+ //
164
+ // An agent held the URL and the code for 42 seconds waiting for this command
165
+ // to return before relaying them — and it cannot return until someone
166
+ // approves, which nobody can do without seeing what it has already printed.
167
+ // The one step that needs a person was the step the person was locked out of
168
+ // (issue 279).
169
+ //
170
+ // The prompt handed to an agent carries four sentences about this. They are a
171
+ // description of behaviour this output can state itself, and prose in a
172
+ // prompt does not reach anyone who did not read that prompt — someone running
173
+ // the command by hand gets nothing.
174
+ out(`Waiting for approval — nothing is granted until you approve it.\n`);
175
+ out(`This command will not return until then. That is expected, not a failure:\n` +
176
+ `the page and the code above are ready to use now — open them, do not wait\n` +
177
+ `for this to finish.\n`);
161
178
  try {
162
179
  return await pollUntilDecided(grant, {
163
180
  post,
@@ -165,6 +182,7 @@ export async function cmdLogin(opts) {
165
182
  now,
166
183
  endpoint,
167
184
  armOpener: opts.armOpener ?? armEnterToOpen,
185
+ out,
168
186
  });
169
187
  }
170
188
  finally {
@@ -172,7 +190,7 @@ export async function cmdLogin(opts) {
172
190
  }
173
191
  }
174
192
  async function pollUntilDecided(grant, ctx) {
175
- const { post, sleep, now, endpoint, armOpener } = ctx;
193
+ const { post, sleep, now, endpoint, armOpener, out } = ctx;
176
194
  // The poll is single-flight by construction: one loop, one request in flight.
177
195
  // A concurrent second poll on the same grant would revoke the credential the
178
196
  // first poll received.
@@ -198,7 +216,7 @@ async function pollUntilDecided(grant, ctx) {
198
216
  if (err instanceof Unreachable) {
199
217
  // Said out loud rather than swallowed: an operator watching a long wait
200
218
  // should see that contact was lost and regained, not silence.
201
- process.stdout.write(` (lost contact with the server, still waiting)\n`);
219
+ out(` (lost contact with the server, still waiting)\n`);
202
220
  continue;
203
221
  }
204
222
  throw err;
@@ -227,7 +245,7 @@ async function pollUntilDecided(grant, ctx) {
227
245
  if (!isOrgAddress(issued.org)) {
228
246
  throw new Error('the server answered with an org address this build does not recognise — nothing was stored');
229
247
  }
230
- return finish(issued, endpoint, post);
248
+ return finish(issued, endpoint, post, out);
231
249
  }
232
250
  const body = await errorBody(res);
233
251
  const code = body.code;
@@ -248,7 +266,9 @@ async function pollUntilDecided(grant, ctx) {
248
266
  throw new Error(explained ? `login stopped: ${explained}` : `login failed: ${res.status} ${code}`);
249
267
  }
250
268
  }
251
- async function finish(issued, endpoint, post) {
269
+ async function finish(issued, endpoint, post,
270
+ /** The caller's sink, so every line this command prints goes one place. */
271
+ out) {
252
272
  const secret = secretFromCapabilityUrl(issued.capability_url);
253
273
  // Durable write FIRST. If the process dies between here and activate, the
254
274
  // credential is on disk and the machine can be activated by running login
@@ -270,8 +290,8 @@ async function finish(issued, endpoint, post) {
270
290
  const code = await errorCode(res);
271
291
  throw new Error(`the credential was saved but could not be activated (${res.status} ${code}) — run login again`);
272
292
  }
273
- process.stdout.write(`\nSigned in as machine "${issued.machine_name}".\n`);
274
- process.stdout.write(`Its sync address is saved on this machine; no token to copy.\n`);
293
+ out(`\nSigned in as machine "${issued.machine_name}".\n`);
294
+ out(`Its sync address is saved on this machine; no token to copy.\n`);
275
295
  }
276
296
  async function errorCode(res) {
277
297
  return (await errorBody(res)).code;
@@ -87,13 +87,33 @@ export async function cmdSync(config, opts) {
87
87
  // this machine may join, and a scary line about an unrelated call would
88
88
  // compete with the instructions below.
89
89
  }
90
- out(' On a machine that already has the team, run:\n\n');
91
- // The real command, with the team quoted for a shell. A placeholder makes
92
- // the reader do the substitution, and the whole point of this arc is to stop
93
- // carrying values by hand between machines — an instruction that ends in
94
- // `<team>` hands the work straight back (raised in review).
95
- out(` agmsg-cloud approve ${shellArg(opts.team)}\n\n`);
96
- out(' Compare the eight digits on both screens before answering there.\n\n');
90
+ // PROSE, NOT A BLOCK TO COPY — and what changes is what this command claims
91
+ // at this moment, not what it knows (#307).
92
+ //
93
+ // Nothing here has established that the team exists, and nothing can. The
94
+ // local store is what `connect` and `fetch` fail closed on, and a second
95
+ // machine does not have one — that absence is the premise of `sync`. The name
96
+ // does not decide it either: `agmsg_validate_team_name` bars `.` `..`, `/`,
97
+ // `\`, a leading `-` and control characters, so `<team>` is a VALID team name.
98
+ // The ceremony below is scoped to the org rather than the team (#148). The one
99
+ // remaining source, asking the service by name, answers nothing for every team
100
+ // today (#250).
101
+ //
102
+ // So this is not a fix, and the issue stays open. What it removes is a
103
+ // misreading: an indented, copyable command reads as "this is the step", and
104
+ // when the person had not filled the placeholder it put a literal `'<team>'`
105
+ // on the other machine, quoted and ready to paste. As a sentence it reads as
106
+ // what it is — something to ask for, on a machine that has the team, which is
107
+ // a condition the reader can check and this process cannot.
108
+ //
109
+ // The command still goes through `shellArg`: the name reaching it is arbitrary
110
+ // text either way.
111
+ out(` Ask a machine that already has "${opts.team}" to run ` +
112
+ `\`agmsg-cloud approve ${shellArg(opts.team)}\`, and compare the eight digits on ` +
113
+ 'both screens before answering there.\n\n');
114
+ out(` This machine has not confirmed that "${opts.team}" is on the service — it has no\n` +
115
+ ' way to, before the steps below. If that machine has no such team, stop here and\n' +
116
+ ' connect it there first.\n\n');
97
117
  // Everything the ceremony guarantees happens inside here: the commitment is
98
118
  // pinned before anything opens, nothing is sealed or uploaded until the
99
119
  // digits match, and this side refuses a transcript it cannot verify.
@@ -23,7 +23,48 @@ function fromEnv(env) {
23
23
  // `connect --preflight` is a dry run whose whole purpose is to be usable before
24
24
  // anything is set up, so it must not be refused for not being signed in.
25
25
  export function resolveScriptsDir(env = process.env) {
26
- return env.AGMSG_SCRIPTS_DIR ?? join(homedir(), '.agents', 'skills', 'agmsg', 'scripts');
26
+ return scriptsDirChoice(env).dir;
27
+ }
28
+ /**
29
+ * The resolution, with its PROVENANCE — because the path alone does not answer
30
+ * the question an operator is actually asking (issue 282).
31
+ *
32
+ * An empty value is treated as unset. `??` passes the empty string through, so
33
+ * `export AGMSG_SCRIPTS_DIR=` resolved to `""` and every script path became a
34
+ * bare filename — measured, not supposed:
35
+ *
36
+ * resolveScriptsDir({ AGMSG_SCRIPTS_DIR: '' }) -> ""
37
+ *
38
+ * That is the shape someone reaches for when they are trying to undo the
39
+ * override, and it left them further from the default rather than back at it.
40
+ */
41
+ export function scriptsDirChoice(env = process.env) {
42
+ const given = env.AGMSG_SCRIPTS_DIR;
43
+ if (given !== undefined && given !== '')
44
+ return { dir: given, from: 'env' };
45
+ return { dir: defaultScriptsDir(), from: 'default' };
46
+ }
47
+ /**
48
+ * The install used when `AGMSG_SCRIPTS_DIR` says nothing.
49
+ *
50
+ * Split out because it is also the answer to "what is the OTHER install"
51
+ * (issue 279), and a second copy of the path would be free to drift from the
52
+ * one the resolver uses — which is exactly the disagreement being reported.
53
+ *
54
+ * `home` is a PARAMETER, not `env.HOME`. The first version read
55
+ * `env.HOME ?? homedir()` and called it injectable-but-equivalent, on the
56
+ * grounds that `homedir()` reads `$HOME` on POSIX. That does not close: this
57
+ * CLI targets Windows, where a POSIX-compatible shell sets `HOME` to something
58
+ * like `/c/Users/x` while `homedir()` returns the native path — so the
59
+ * "equivalent" version would have moved the default install location for every
60
+ * Git Bash user (raised in review).
61
+ *
62
+ * Production never passes it. A seam a caller must opt into cannot change what
63
+ * anyone runs; an environment variable that production already has is not a
64
+ * seam at all.
65
+ */
66
+ export function defaultScriptsDir(home = homedir()) {
67
+ return join(home, '.agents', 'skills', 'agmsg', 'scripts');
27
68
  }
28
69
  /**
29
70
  * THE credential decision. Every caller that needs to know whether this machine
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
- import { hasCredential } from './config.js';
3
+ import { defaultScriptsDir, hasCredential, scriptsDirChoice } from './config.js';
4
4
  import { join } from 'node:path';
5
5
  import { platform } from 'node:process';
6
6
  // What `connect` needs before it starts, checked all at once.
@@ -151,7 +151,17 @@ function toolsFor(scripts) {
151
151
  python3: scripts.includes('remote.sh'),
152
152
  };
153
153
  }
154
- export function preflight(scriptsDir, needs, env = process.env) {
154
+ export function preflight(scriptsDir, needs, env = process.env,
155
+ /**
156
+ * The home directory the default install would be under.
157
+ *
158
+ * Only ever passed by a check that needs to put a second install somewhere it
159
+ * owns. It is a parameter rather than `env.HOME` because this CLI runs on
160
+ * Windows too, where a POSIX-compatible shell sets `HOME` to a path
161
+ * `homedir()` does not return — reading it would have moved the default
162
+ * install for every Git Bash user in exchange for a testable line.
163
+ */
164
+ home) {
155
165
  const { command, scripts } = needs;
156
166
  const requireConnect = needs.requireConnect ?? false;
157
167
  const requirements = [];
@@ -229,8 +239,43 @@ export function preflight(scriptsDir, needs, env = process.env) {
229
239
  command,
230
240
  scriptsDir,
231
241
  agmsgVersion: canConnect ? installedVersion(scriptsDir) : null,
242
+ scriptsDirFrom: provenance(env, home),
243
+ otherInstalls: otherInstalls(scriptsDir, env, home),
232
244
  };
233
245
  }
246
+ /**
247
+ * The install that exists at the location this command is NOT using.
248
+ *
249
+ * Only ever one, and only when `AGMSG_SCRIPTS_DIR` points somewhere other than
250
+ * the default: those are the two places `resolveScriptsDir` can name, so they
251
+ * are the two places worth reporting. `version.sh` is the marker rather than
252
+ * the directory itself — an empty `~/.agents/skills/agmsg/scripts` is not a
253
+ * second install, and calling it one would send someone to look at nothing.
254
+ */
255
+ /**
256
+ * Which of the three states the resolution is in.
257
+ *
258
+ * Three, not two: the variable can be set AND point at the default, which is
259
+ * what someone has on the way to undoing an override. Collapsing it into "env"
260
+ * makes the output say "not the default location" about the default location.
261
+ */
262
+ function provenance(env, home) {
263
+ const choice = scriptsDirChoice(env);
264
+ if (choice.from === 'default')
265
+ return 'default';
266
+ return choice.dir === defaultScriptsDir(home) ? 'env-at-default' : 'env';
267
+ }
268
+ function otherInstalls(scriptsDir, env, home) {
269
+ // Through the resolver's own accessor, so the two can never disagree about
270
+ // where "the default" is — a disagreement here would report the wrong
271
+ // directory as the one not in use.
272
+ const fallback = defaultScriptsDir(home);
273
+ if (scriptsDir === fallback)
274
+ return [];
275
+ if (!env.AGMSG_SCRIPTS_DIR)
276
+ return [];
277
+ return existsSync(join(fallback, 'version.sh')) ? [fallback] : [];
278
+ }
234
279
  // The refusal in front of every command that shells out: one place decides how
235
280
  // a missing prerequisite reads, so it reads the same whichever command found
236
281
  // it.
@@ -262,7 +307,39 @@ export function formatPreflight(result) {
262
307
  // decoration on a label; it is the answer to "which one did you look at".
263
308
  lines.push('');
264
309
  lines.push(` scripts directory: ${result.scriptsDir}`);
265
- lines.push(` set AGMSG_SCRIPTS_DIR to check a different install.`);
310
+ // WHAT PUT IT THERE, when something did.
311
+ //
312
+ // The hint below is advice to redirect the install. Printed while
313
+ // AGMSG_SCRIPTS_DIR is already set, it invites the reader to do the thing
314
+ // that is currently confusing them — "you could redirect this" when the truth
315
+ // is "this is redirected". So the two are alternatives, not a pair.
316
+ //
317
+ // Nothing extra when the default is in force: "this is the default" on every
318
+ // ordinary run is noise, and the sentence is only missing when something
319
+ // non-default is deciding.
320
+ if (result.scriptsDirFrom === 'env') {
321
+ lines.push(` chosen by AGMSG_SCRIPTS_DIR, not the default location.`);
322
+ }
323
+ else if (result.scriptsDirFrom === 'env-at-default') {
324
+ // The variable is set AND points at the default. Saying "not the default
325
+ // location" here would be false, and it is the state someone reaches while
326
+ // trying to undo an override — the one moment the sentence has to be exact.
327
+ lines.push(` chosen by AGMSG_SCRIPTS_DIR, which points at the default location.`);
328
+ }
329
+ else {
330
+ lines.push(` set AGMSG_SCRIPTS_DIR to check a different install.`);
331
+ }
332
+ // NAMES THE OTHER ONE, and says which of the two is in force.
333
+ //
334
+ // Without this the reader has to decide, and an agent that finds two installs
335
+ // stops and asks — the incident four lines of the handed-over prompt exist to
336
+ // prevent (issue 279). The answer is not a preference to be settled; it is
337
+ // already settled by the resolver, and this is it being said out loud.
338
+ for (const other of result.otherInstalls) {
339
+ lines.push('');
340
+ lines.push(` another install is here: ${other}`);
341
+ lines.push(` it is NOT the one in use — AGMSG_SCRIPTS_DIR decides, and it points above.`);
342
+ }
266
343
  if (result.agmsgVersion !== null) {
267
344
  // Named as what it is. Calling it "agmsg 1.1.11" would invite the reader to
268
345
  // compare it with a release number, which is the thing it cannot be
@@ -274,6 +351,15 @@ export function formatPreflight(result) {
274
351
  if (missing.length === 0) {
275
352
  lines.push('');
276
353
  lines.push(`Everything ${result.command} needs is here.`);
354
+ // SAYS THAT THIS IS THE CHECK.
355
+ //
356
+ // An agent told "make sure the prerequisites are met" checks them again
357
+ // unless something says not to — it has no way to know this output was the
358
+ // check rather than a summary of one. The prompt carries a sentence for
359
+ // exactly that ("do not check for tools yourself"), which is a property
360
+ // this output should have rather than a warning the reader must be given
361
+ // separately (issue 279).
362
+ lines.push(`This was the check. Nothing else needs testing before you run it.`);
277
363
  return `${lines.join('\n')}\n`;
278
364
  }
279
365
  for (const r of missing) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agmsg-cloud",
3
- "version": "0.1.0-rc.6",
3
+ "version": "0.1.0-rc.7",
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",
@@ -33,7 +33,8 @@
33
33
  "pretypecheck": "pnpm run build:deps",
34
34
  "typecheck": "tsc -p tsconfig.json --noEmit",
35
35
  "lint": "tsx scripts/check-printed-commands.ts && tsx scripts/check-dist-is-current.ts && tsx scripts/check-readme-commands.ts && tsx scripts/check-handles.ts",
36
- "prepack": "pnpm run build && tsx scripts/check-dist-is-current.ts && tsx scripts/check-readme-commands.ts && tsx scripts/check-handles.ts"
36
+ "prepack": "pnpm run build && tsx scripts/check-dist-is-current.ts && tsx scripts/check-readme-commands.ts && tsx scripts/check-handles.ts",
37
+ "verify:published": "tsx scripts/verify-published.ts"
37
38
  },
38
39
  "dependencies": {
39
40
  "@agmsg-cloud/sas-core": "workspace:*",