@brutalsystems/muster 0.7.18 → 0.7.19

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.
@@ -1,7 +1,7 @@
1
1
  import { identityAuthState } from "./identity-auth.js";
2
2
  import { IdentityError, identityDirExists, identityMetaSchema, identityNameSchema, identityPath, listIdentities, readIdentity, writeIdentity, } from "./identity-store.js";
3
3
  import { removeStoredToken, writeStoredToken, } from "./identity-token.js";
4
- import { captureClaudeToken, redactToken, } from "./identity-login.js";
4
+ import { captureClaudeToken, redactToken, resolveOnPath, } from "./identity-login.js";
5
5
  /**
6
6
  * The one-time login for each agent. Muster never performs it: each is an
7
7
  * interactive OAuth flow, and a command that appeared to do it for you would be
@@ -25,7 +25,12 @@ export async function setupIdentity(opts) {
25
25
  // a codex or opencode identity, where the credential is an auth file the
26
26
  // agent's own login writes into the template, it has exactly one effect: a new
27
27
  // way to fail. Refused where the operator types it.
28
- if (opts.tokenEnv && opts.agent !== "claude")
28
+ // Checked against undefined, not truthiness: `--token-env ""` is falsy, so a
29
+ // truthiness test let an empty name past every guard and then dropped it,
30
+ // leaving the identity on neither credential route without saying so (#69).
31
+ if (opts.tokenEnv !== undefined && opts.tokenEnv.trim() === "")
32
+ throw new IdentityError(opts.name, "--token-env takes the name of an environment variable holding the token, not the token and not an empty value");
33
+ if (opts.tokenEnv !== undefined && opts.agent !== "claude")
29
34
  throw new IdentityError(name, `--token-env applies only to a claude identity; ${opts.agent} authenticates from an auth file in the template, and a token variable here would only add a way for the launch to fail`);
30
35
  // Distinguish "no such identity" from "an identity that is there but unusable".
31
36
  // Overwriting the second would silently repurpose a directory that still holds
@@ -150,24 +155,30 @@ export async function interactiveSetup(opts) {
150
155
  stored: false,
151
156
  note: `Run this once, then check with \`muster identities\`:\n\n ${created.login}\n`,
152
157
  };
158
+ // Asked before anything is spawned, so "could not be started" is a fact about
159
+ // the filesystem rather than an inference from what the child printed. That
160
+ // inference was platform-dependent: a missing executable emits nothing under
161
+ // macOS and an error into the pty under Linux (#69).
162
+ if (!(await resolveOnPath("claude", opts.env ?? process.env)))
163
+ throw new IdentityError(opts.name, "`claude` is not on PATH, so the login could not be started; nothing was changed");
153
164
  write(`Running: claude setup-token\n${"─".repeat(50)}\n`);
154
165
  const result = await captureClaudeToken({
155
166
  dir: created.path,
156
167
  env: opts.env ?? process.env,
157
168
  onData: write,
158
- ...(opts.stdin ? { stdin: opts.stdin } : {}),
169
+ stdin: opts.stdin,
159
170
  });
160
171
  write(`${"─".repeat(50)}\n`);
161
172
  // Checked BEFORE the token branch, deliberately: a login that failed writes
162
173
  // no credential even if something token-shaped went past on the way down.
163
174
  //
164
- // This is also the case where the child never started at all. node-pty does
165
- // not throw for a missing executable — it exits 1 having emitted zero bytes —
166
- // so an operator with no `claude` on PATH used to be told Muster could not
167
- // parse output that never existed, with no identity name and no hint that
168
- // nothing had run. The child's own output is the error here, whatever it was.
175
+ // Two different events land here and they need different explanations. node-pty
176
+ // does not throw for a missing executable — it exits 1 having emitted zero
177
+ // bytes — which is exactly the exit shape of a login that failed on its own or
178
+ // that the operator interrupted. The exit code cannot tell them apart; whether
179
+ // anything was ever printed can (#69).
169
180
  if (result.exitCode !== 0)
170
- throw new IdentityError(opts.name, `\`claude setup-token\` failed (exit ${result.exitCode}) and no credential was stored; its own output above is the error. If nothing was printed above it could not be started at all — check that \`claude\` is on PATH.`);
181
+ throw new IdentityError(opts.name, `\`claude setup-token\` ended without storing a credential (exit ${result.exitCode}); its own output above is the error. If you interrupted it, nothing was stored and nothing was changed.`);
171
182
  if (result.token) {
172
183
  const { clearedRoute } = await storeIdentityToken({
173
184
  home: opts.home,
@@ -1,3 +1,28 @@
1
+ import { access, constants } from "node:fs/promises";
2
+ import { delimiter, join } from "node:path";
3
+ /**
4
+ * Where `name` is on PATH, or undefined. The same lookup `tmuxExecutable` does,
5
+ * and the reason the login asks it: whether a child "could not be started" has
6
+ * to be decided before spawning, not inferred from what it printed. node-pty
7
+ * does not throw for a missing executable — it exits 1, emitting nothing on
8
+ * macOS and an error into the pty on Linux, so output was never the discriminator
9
+ * it appeared to be (#69).
10
+ */
11
+ export async function resolveOnPath(name, env) {
12
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
13
+ if (!dir)
14
+ continue;
15
+ const candidate = join(dir, name);
16
+ try {
17
+ await access(candidate, constants.X_OK);
18
+ return candidate;
19
+ }
20
+ catch {
21
+ // Not here, or not executable. Keep looking.
22
+ }
23
+ }
24
+ return undefined;
25
+ }
1
26
  /**
2
27
  * A Claude long-lived OAuth token, as `claude setup-token` prints it. The length
3
28
  * floor is what separates a real token from the literal word in help text.
@@ -257,8 +282,8 @@ function attachInput(pty, input, signals) {
257
282
  * status quo.
258
283
  */
259
284
  export async function captureClaudeToken(opts) {
260
- const module = await import("node-pty");
261
- const pty = module.spawn("claude", ["setup-token"], {
285
+ const spawn = opts.spawn ?? (await import("node-pty")).spawn;
286
+ const pty = spawn("claude", ["setup-token"], {
262
287
  cwd: opts.dir,
263
288
  env: { ...opts.env, CLAUDE_CONFIG_DIR: opts.dir },
264
289
  name: "xterm-256color",
@@ -278,7 +303,7 @@ export async function captureClaudeToken(opts) {
278
303
  // up before rethrowing, and enters raw mode last of all.
279
304
  let detachInput = () => { };
280
305
  try {
281
- detachInput = attachInput(pty, opts.stdin ?? process.stdin, opts.signals ?? process);
306
+ detachInput = attachInput(pty, opts.stdin, opts.signals ?? process);
282
307
  const filter = createTokenFilter(opts.onData);
283
308
  const dataSub = pty.onData((chunk) => filter.push(chunk));
284
309
  const exitCode = await new Promise((resolve) => {
package/dist/muster.js CHANGED
@@ -247,13 +247,21 @@ async function main() {
247
247
  // year-long token to disk on the machine the operator explicitly asked
248
248
  // to keep no credential at rest, while never recording the variable
249
249
  // they named.
250
- if (tokenEnv)
250
+ if (tokenEnv !== undefined && agent !== "claude")
251
+ // Not the mutual-exclusion message: for codex and opencode
252
+ // `--interactive` captures nothing and `--token-env` does not apply
253
+ // at all, so describing them as two routes to choose between would
254
+ // be describing Claude's behaviour at a different agent (#69).
255
+ throw new IdentityError(identity, `--token-env applies only to a claude identity; ${agent} authenticates from an auth file in the template, and a token variable here would only add a way for the launch to fail`);
256
+ if (tokenEnv !== undefined)
251
257
  throw new IdentityError(identity, "--interactive and --token-env are the two mutually exclusive credential routes: --interactive runs the login and stores a token in the identity, --token-env records the name of a variable to read one from instead. Pass one, not both.");
252
258
  const result = await interactiveSetup({
253
259
  home: musterHome(),
254
260
  name: identity,
255
261
  agent: agent,
256
262
  isTTY: Boolean(process.stdin.isTTY),
263
+ // The one place the operator's real keyboard is named.
264
+ stdin: process.stdin,
257
265
  });
258
266
  process.stdout.write(`\n${result.note}\n`);
259
267
  return;
@@ -265,7 +273,7 @@ async function main() {
265
273
  home: musterHome(),
266
274
  name: identity,
267
275
  agent: agent,
268
- ...(tokenEnv ? { tokenEnv } : {}),
276
+ ...(tokenEnv !== undefined ? { tokenEnv } : {}),
269
277
  })) + "\n");
270
278
  }
271
279
  else if (command === "identities") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.7.18",
3
+ "version": "0.7.19",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {
@@ -12,7 +12,8 @@
12
12
  "scripts": {
13
13
  "build": "tsc && node scripts/prepare-bin.mjs",
14
14
  "test": "vitest run",
15
- "check": "npm test && node scripts/check-tarball.mjs",
15
+ "typecheck": "tsc -p tsconfig.check.json",
16
+ "check": "npm run typecheck && npm test && node scripts/check-tarball.mjs",
16
17
  "test:contract": "MUSTER_CONTRACT=1 vitest run test/contract.test.ts",
17
18
  "postinstall": "node scripts/prepare-pty.mjs",
18
19
  "pretest": "npm run build",