@futurelastic/muster 0.2.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +494 -0
  3. package/bin/muster.js +97 -0
  4. package/package.json +35 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 godx-jp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,494 @@
1
+ # muster
2
+
3
+ **One API for every coding-agent session you are running — on every machine you
4
+ are running them on.**
5
+
6
+ Go, standard library only, no dependencies. MIT.
7
+
8
+ You have coding agents running in terminals. Probably several. Possibly on more
9
+ than one machine. Right now the only way to know what any of them is doing is to
10
+ look at it, and the only way to answer one is to walk over to it.
11
+
12
+ `muster` is a machine-local service that **owns** those sessions and puts
13
+ them behind an HTTP API — list them, read what state each one is in, send one an
14
+ instruction, answer the permission dialog one is stuck on. Peer instances
15
+ federate, so a session on the machine in the other room is one call away and
16
+ looks exactly like a local one.
17
+
18
+ ```mermaid
19
+ flowchart TB
20
+ C["supervisors & clients<br/>dashboard · CLI · your program"]
21
+
22
+ subgraph MA["machine-a"]
23
+ S["muster<br/>HTTP API"]
24
+ DT["tmux driver"]
25
+ DO["opencode driver"]
26
+ DR["remote driver"]
27
+ CLI["agent CLI<br/>in tmux"]
28
+ SUB["agent subprocess"]
29
+ end
30
+
31
+ subgraph MB["machine-b"]
32
+ P["peer muster"]
33
+ end
34
+
35
+ C -- "HTTP + bearer token" --> S
36
+ S --> DT --> CLI
37
+ S --> DO --> SUB
38
+ S --> DR -- "HTTP · LAN / VPN" --> P
39
+
40
+ classDef hop stroke-width:2px,stroke-dasharray:6 4
41
+ class DR,P hop
42
+ ```
43
+
44
+ *A session driver is an interface. The remote peer is just another driver —
45
+ which is why federation cost nothing to add.*
46
+
47
+ ---
48
+
49
+ ## What problem this actually solves
50
+
51
+ A fleet of coding agents needs two separable things: something that decides
52
+ **what work should happen**, and something that knows **how a session is
53
+ actually run and where**. Most implementations fuse them — the supervisor shells
54
+ out to a terminal multiplexer, scrapes the screen to guess what the agent is
55
+ doing, and is thereby permanently bound to one runtime on one host. (Survey of
56
+ comparable projects, and what that split buys: [`docs/positioning.md`](docs/positioning.md).)
57
+
58
+ `muster` is the second half, extracted. Supervisors become clients.
59
+
60
+ Three properties fall out of that one decision:
61
+
62
+ - **Runtime independence.** A driver is an interface, not a hardcoded
63
+ subprocess. Three exist today: a terminal multiplexer driving an interactive
64
+ agent CLI, a third-party agent spawned as a subprocess and spoken to over
65
+ HTTP, and a peer service on another machine.
66
+ - **Machine-to-machine is free.** A remote peer is *just another driver*. There
67
+ was no separate federation feature to build.
68
+ - **Churn is contained.** When a runtime changes underneath you, the damage
69
+ lands on one driver instead of everywhere.
70
+
71
+ That last one matters more than it looks. The value of the abstraction does not
72
+ depend on any particular runtime being good; it is what makes betting on one
73
+ affordable.
74
+
75
+ ## What it will never own
76
+
77
+ Version control state, worktrees, issue trackers, work claims, planning, or any
78
+ judgement about whether work is finished.
79
+
80
+ > **muster knows a session has a working directory.
81
+ > It does not know what a worktree is.**
82
+
83
+ A fleet layer that learns what an issue is has become a second supervisor, and
84
+ now two components believe they are in charge. Every field in this API is tested
85
+ against that sentence.
86
+
87
+ What that means for a consumer — a dashboard that claims issues, spawns sessions
88
+ and merges branches, say — is that the dependency runs one way:
89
+
90
+ - **Work vocabulary is layered above, never pushed down.** A consumer that needs
91
+ repositories, issues, claims, worktrees, locks or leases keeps them itself. The
92
+ only places its vocabulary can appear here are opaque, caller-supplied fields —
93
+ `marker` on session create, and a session's `labels`, set at create or changed
94
+ later — which the service bounds in size, carries and returns without
95
+ interpreting.
96
+ - **Consumers feature-detect and degrade.** What a machine can do is read, not
97
+ assumed: `GET /v1/runtimes` says which runtimes are wired, and `build` on
98
+ `GET /v1/machines` says which version answers. A consumer that finds a
99
+ capability missing does less, rather than failing.
100
+ - **This service never calls a consumer back.** It opens connections only to its
101
+ own runtimes and its peers. A consumer that wants to hear about change
102
+ subscribes to `GET /v1/events`; nothing here holds a consumer's address.
103
+
104
+ ---
105
+
106
+ ## The parts that are unusual
107
+
108
+ Most tools in this space manage sessions on one laptop, for one vendor's agent,
109
+ behind a TUI. The properties below are the ones that turned out to be rare, and
110
+ they are all consequences of taking failure seriously rather than features that
111
+ were designed for their own sake. ["Rare" is measured, not asserted —
112
+ `docs/positioning.md`](docs/positioning.md) is the survey behind this claim,
113
+ including the combination nothing else in it had.
114
+
115
+ ### A session has a state, not a pulse
116
+
117
+ ```mermaid
118
+ stateDiagram-v2
119
+ [*] --> idle
120
+ idle --> working: send prompt
121
+ working --> idle: turn completes
122
+ working --> waiting_input: permission dialog
123
+ waiting_input --> working: respond
124
+ working --> quota_blocked: quota exhausted
125
+ quota_blocked --> idle: quota window resets
126
+
127
+ note right of waiting_input
128
+ Dead end. Only a respond call moves it:
129
+ answer by index, checked against a nonce.
130
+ This is the state that strands work.
131
+ end note
132
+
133
+ note right of quota_blocked
134
+ Not idle. On screen both are a quiet
135
+ terminal with an empty composer.
136
+ The API keeps them apart.
137
+ end note
138
+ ```
139
+
140
+ On screen, an agent that finished, an agent out of quota, an agent whose turn
141
+ died on a transient error, and an agent holding text nobody submitted all look
142
+ identical: a quiet terminal with an empty composer. Collapsing them is how
143
+ abandoned work goes unnoticed for a day.
144
+
145
+ So they are different states, each carrying its evidence, and every state says
146
+ whether it was `observed` from a structured read or `inferred` from a screen.
147
+
148
+ ### Sending a prompt is not fire-and-forget
149
+
150
+ ```mermaid
151
+ flowchart TB
152
+ A(["POST input — send a prompt"]) --> R{result}
153
+ R -->|submitted| S["delivered — the agent has it"]
154
+ R -->|queued| Q["accepted — submit registered"]
155
+ R -->|unknown| U["NOT sent — text stranded<br/>in the composer"]
156
+ U -. "retry: send resumes its own<br/>unconfirmed delivery, never a human's text" .-> A
157
+
158
+ classDef bad stroke-width:2px,stroke-dasharray:6 4
159
+ class U bad
160
+ ```
161
+
162
+ `unknown` means the text is sitting in the composer and was **not** submitted. A
163
+ system that reports success here loses instructions silently, and you find out
164
+ hours later when nothing happened. Retrying resubmits only text the service
165
+ itself placed there — never text a human typed.
166
+
167
+ There is a fourth outcome, `refused`, for when the driver actively protects the
168
+ session from input that would corrupt it. A refusal is information to act on,
169
+ not a fault to retry, so it arrives as an ordinary `200`.
170
+
171
+ ### A session blocked on a dialog can be answered remotely
172
+
173
+ The dialog is read with every option enumerated, and you answer by index —
174
+ carrying a nonce, so your answer cannot land on a question that changed while
175
+ you were deciding. A session lost to a question nobody can reach is the most
176
+ expensive failure in a fleet, and an ordinary `send` cannot fix it: the property
177
+ that makes `send` safe for messages makes it useless for control.
178
+
179
+ ### Every route is authenticated, and exposure is never accidental
180
+
181
+ There is no unauthenticated mode — not on loopback, not in development. A
182
+ service with no token refuses to start. The default bind is loopback on an
183
+ ephemeral port, and binding all interfaces logs a warning that names the risk.
184
+ Callers are named principals with per-verb grants; every mutation is audited
185
+ with an actor, not an address.
186
+
187
+ ---
188
+
189
+ ## Install
190
+
191
+ Published to npm; one install surface. Needs Node 18 or newer to *launch* the
192
+ service — the service itself is a static Go binary and needs nothing else.
193
+
194
+ ```sh
195
+ npx @futurelastic/muster serve # run it once, nothing installed
196
+ npm i -g @futurelastic/muster # or install the `muster` command
197
+ muster --version # muster <version> (...)
198
+ ```
199
+
200
+ `@futurelastic/muster` is a small launcher. npm installs exactly one of the
201
+ platform packages beside it (`@futurelastic/muster-darwin-arm64`, `-darwin-x64`,
202
+ `-linux-x64`, `-linux-arm64`) and the launcher runs that binary. Nothing is
203
+ downloaded at install time, so `--ignore-scripts` and an offline mirror both
204
+ work. Every release is published with npm provenance, and the launcher refuses
205
+ a platform package whose version differs from its own. Release candidates
206
+ publish under the `next` dist-tag (`npx @futurelastic/muster@next`); `latest`
207
+ only ever moves to a final release.
208
+
209
+ Configure it with the `FLEET_*` environment the [Quickstart](#quickstart) shows
210
+ and start it with `muster serve`. A service that survives a reboot is
211
+ [`docs/install.md`](docs/install.md). Build from source only to hack on it.
212
+
213
+ ## Quickstart
214
+
215
+ Building from source: Go 1.26 or newer. No dependencies.
216
+
217
+ ```sh
218
+ go build ./... && go test ./...
219
+ ```
220
+
221
+ ### One machine
222
+
223
+ ```sh
224
+ export FLEET_MACHINE=machine-a
225
+ export FLEET_TOKEN="$(openssl rand -hex 32)"
226
+ export FLEET_ADDR=127.0.0.1:9000
227
+ go run ./cmd/muster
228
+ ```
229
+
230
+ ```sh
231
+ curl -s -H "Authorization: Bearer $FLEET_TOKEN" http://127.0.0.1:9000/v1/health
232
+ ```
233
+
234
+ There is no default port — pick one and keep it. The default bind is loopback on
235
+ an ephemeral port, so a service you have not deliberately configured is
236
+ reachable only from its own machine.
237
+
238
+ ### Two machines, over a LAN or a VPN
239
+
240
+ Four steps. Do **machine-b first** — machine-a needs an address to point at.
241
+
242
+ **1. Give each machine its own credential.** Not one shared token: the whole
243
+ point of the next three steps is that the two machines have distinct identities.
244
+
245
+ ```sh
246
+ mkdir -p ~/.config/muster
247
+ openssl rand -hex 32 > ~/.config/muster/machine-a.token
248
+ chmod 600 ~/.config/muster/machine-a.token
249
+ ```
250
+
251
+ **2. On machine-b — the one that owns the sessions.** Bind the interface the
252
+ peer will actually reach: **a specific address, never `0.0.0.0`**. On a VPN that
253
+ is the VPN address; on a trusted LAN it is the LAN address. Loopback is added
254
+ alongside it automatically, so the service never becomes undiagnosable from its
255
+ own machine.
256
+
257
+ ```sh
258
+ export FLEET_MACHINE=machine-b
259
+ export FLEET_TOKEN="$(cat ~/.config/muster/machine-b.token)"
260
+ export FLEET_ADDR=10.8.0.12:9000 # this machine's VPN or LAN address
261
+ export FLEET_ALLOW_MUTATIONS=1 # permit writes to its own sessions
262
+ muster
263
+ ```
264
+
265
+ **3. On machine-a — the one that will call it.** A peer is statically
266
+ configured; there is no discovery. The address is one **you** have confirmed
267
+ reachable from this machine, never the peer's own idea of its name — that is how
268
+ a fleet ends up pointing at a hostname which resolves on only one side.
269
+
270
+ ```sh
271
+ export FLEET_MACHINE=machine-a
272
+ export FLEET_TOKEN="$(cat ~/.config/muster/machine-a.token)"
273
+ export FLEET_PEERS="machine-b=http://10.8.0.12:9000"
274
+ export FLEET_ALLOW_RELAY=1 # permit forwarding writes to a peer
275
+ muster
276
+ ```
277
+
278
+ **4. Verify, from machine-a.**
279
+
280
+ ```sh
281
+ curl -s -H "Authorization: Bearer $(cat ~/.config/muster/machine-a.token)" \
282
+ http://127.0.0.1:9000/v1/machines
283
+ ```
284
+
285
+ ```json
286
+ { "items": [ { "machine": "machine-a", "self": true, "status": "ok" },
287
+ { "machine": "machine-b", "self": false, "status": "ok" } ],
288
+ "complete": true }
289
+ ```
290
+
291
+ Both reading `ok` is the entire handshake. If machine-b reads `unreachable`,
292
+ the bind address or the network path is wrong — **not** the token: a bad
293
+ credential is a `401`, not a silence.
294
+
295
+ > **The two credentials are not symmetric.** The token machine-a presents for a
296
+ > peer is *machine-a's identity on machine-b*, not machine-b's identity here.
297
+ > They are different secrets, and conflating them is how a fleet quietly ends up
298
+ > back on one shared token.
299
+
300
+ You now have one API over both machines. What to do with it is below.
301
+
302
+ > **This is a demo, not an install.** Every process above ends with its shell.
303
+ > A service that survives a reboot — a principal table, a state directory, a
304
+ > service unit, and a `muster doctor` run that checks all of it — is
305
+ > [`docs/install.md`](docs/install.md).
306
+
307
+
308
+ ## One machine drives another
309
+
310
+ What the two-machine setup above actually buys you, end to end. Every call is
311
+ made against the **local** service; that it crosses a LAN or a VPN to reach the
312
+ other machine is the service's problem, not the caller's.
313
+
314
+ ```mermaid
315
+ sequenceDiagram
316
+ autonumber
317
+ actor C as client
318
+ box transparent machine-a
319
+ participant A as muster (a)
320
+ end
321
+ box transparent machine-b
322
+ participant B as muster (b)
323
+ participant T as tmux driver
324
+ participant X as agent CLI
325
+ end
326
+
327
+ C->>A: GET /v1/machines/machine-b/sessions/s42
328
+ Note over A,B: one hop · LAN / VPN<br/>HTTP + bearer token
329
+ A->>B: proxy — same API, service's own credential
330
+ B->>T: read session
331
+ T->>X: capture + classify screen
332
+ X-->>T: screen
333
+ T-->>B: state: waiting_input
334
+ B-->>A: session s42
335
+ A-->>C: session s42
336
+ Note over C,A: the caller never learns the session was remote.<br/>Fan-out stops here — peers never recurse.
337
+ ```
338
+
339
+ ### Drive it
340
+
341
+ ```sh
342
+ FLEET=http://127.0.0.1:9000
343
+ AUTH="Authorization: Bearer $(cat ~/.config/muster/machine-a.token)"
344
+ ```
345
+
346
+ **Every session on both machines, as one list:**
347
+
348
+ ```sh
349
+ curl -s -H "$AUTH" "$FLEET/v1/sessions?scope=fleet"
350
+ ```
351
+
352
+ ```json
353
+ {
354
+ "items": [
355
+ { "machine": "machine-a", "id": "s17", "state": { "status": "working", "confidence": "inferred" } },
356
+ { "machine": "machine-b", "id": "s42", "state": { "status": "waiting_input", "confidence": "inferred",
357
+ "waitingOn": "prompt",
358
+ "prompt": { "question": "Allow running tests?", "options": ["No", "Yes", "Yes, always"],
359
+ "selected": 1, "kind": "tool-permission", "nonce": "a3f1" } } }
360
+ ],
361
+ "sources": [ { "machine": "machine-a", "status": "ok" }, { "machine": "machine-b", "status": "ok" } ],
362
+ "complete": true
363
+ }
364
+ ```
365
+
366
+ Read `complete` before you trust that list. If the VPN was down, `machine-b`
367
+ would report unreachable, `complete` would be `false`, and the response would
368
+ still be a `200` — because "the machine did not answer" and "the session does
369
+ not exist" are different facts and must never look the same.
370
+
371
+ **Send an instruction to a session on the other machine:**
372
+
373
+ ```sh
374
+ curl -s -X POST -H "$AUTH" -H 'Content-Type: application/json' \
375
+ -d '{"text":"run the test suite","submit":true}' \
376
+ "$FLEET/v1/machines/machine-b/sessions/s42/input"
377
+ ```
378
+
379
+ ```json
380
+ { "outcome": "submitted" }
381
+ ```
382
+
383
+ If that comes back `"unknown"`, the text is stranded in the composer on the
384
+ other machine and was not sent. Retry the identical call with
385
+ `"resumeIfStranded": true`.
386
+
387
+ **Answer the dialog it is blocked on** — quoting the nonce from the read above:
388
+
389
+ ```sh
390
+ curl -s -X POST -H "$AUTH" -H 'Content-Type: application/json' \
391
+ -d '{"choice":2,"nonce":"a3f1"}' \
392
+ "$FLEET/v1/machines/machine-b/sessions/s42/respond"
393
+ ```
394
+
395
+ `choice` is 1-based against `prompt.options`, so this answers **"Yes"**. If the
396
+ prompt changed while you were deciding, the nonce no longer matches and the
397
+ answer is refused rather than applied to a different question.
398
+
399
+ ### What has to be true for that to work
400
+
401
+ | | machine-a (calls) | machine-b (owns the session) |
402
+ |---|---|---|
403
+ | Reachable address | — | `FLEET_ADDR` on the LAN/VPN interface |
404
+ | Peer configured | `FLEET_PEERS` | — |
405
+ | Write to own sessions | — | `FLEET_ALLOW_MUTATIONS=1` |
406
+ | Forward writes to a peer | `FLEET_ALLOW_RELAY=1` | — |
407
+
408
+ With a principal table those two booleans become per-identity grants: the caller
409
+ needs `relay` on machine-a, and the verb grant (`send`) is checked on machine-b.
410
+ They are separate refusals and you meet them one at a time. Both default to
411
+ denied, on a fresh deployment as much as an established one.
412
+
413
+ > **Exposing this is a decision, not a default.** The service reads paths and,
414
+ > when mutations are enabled, starts processes. Put it on a VPN or a trusted
415
+ > LAN segment, bind a specific interface, and give each caller its own
416
+ > credential with only the grants it needs.
417
+
418
+ ---
419
+
420
+ ## Documentation
421
+
422
+ | If you want to | Read |
423
+ |---|---|
424
+ | Call the API | [`docs/api.md`](docs/api.md) — every endpoint at a glance |
425
+ | Write a client | [`docs/client-guide.md`](docs/client-guide.md) — a walkthrough, every example taken from a running service |
426
+ | Understand the design | [`docs/spec/session-abstraction.md`](docs/spec/session-abstraction.md) — the domain model. If you read one section, read §5.7 |
427
+ | Know the wire protocol exactly | [`docs/spec/api-http.md`](docs/spec/api-http.md) — normative |
428
+ | Adopt this in an existing system | [`docs/adoption.md`](docs/adoption.md) — staged so each step is reversible; §2 is the precondition that surprised us |
429
+ | Work on the service | [`docs/internals.md`](docs/internals.md) — measurements, decided questions, known gaps |
430
+ | Check a runtime build before the fleet takes it | [`docs/compat.md`](docs/compat.md) — `muster compat`: a versioned report of whether a candidate build still behaves as this service assumes |
431
+ | Install it on a new machine | [`docs/install.md`](docs/install.md) — from nothing to a running service, ending with `muster doctor` |
432
+ | Deploy it | [`docs/deploy.md`](docs/deploy.md) — from a merged commit to a service that already runs |
433
+ | Declare a machine-wide session identity | [`docs/session-identity.md`](docs/session-identity.md) — `sessionEnv`, precedence against a caller, verification |
434
+ | See what this is positioned against | [`docs/positioning.md`](docs/positioning.md) — a survey of comparable projects: category, what turned out rare, trajectory |
435
+
436
+ The specs carry the reasoning; the code is a transcription of them, not the
437
+ other way round. The session spec is organised so you can tell current truth
438
+ from history: **§1–§13 are normative**, **§14 lists what the document requires
439
+ but cannot enforce** — read it before trusting any guarantee — and **Appendix A
440
+ is the findings log**, the measurements and bugs that produced the rules.
441
+
442
+ That appendix is kept rather than smoothed away on purpose. A reader who knows
443
+ only a rule will restate it; a reader who knows how it was violated will
444
+ recognise the next instance.
445
+
446
+ ---
447
+
448
+ ## Where this is going
449
+
450
+ Done, in order, each one having changed the specification rather than merely
451
+ implemented it:
452
+
453
+ - ✅ **A first working driver** — a terminal multiplexer running an interactive
454
+ agent CLI. It amended four sections of the spec and found one defect the spec
455
+ cannot fix on its own.
456
+ - ✅ **Event subscription** — over the substrate's own push channel, served as
457
+ SSE with cursors, epoch, retention and announced resync. No polling anywhere.
458
+ - ✅ **A second driver: a remote peer** — federation, proven. A caller asks a
459
+ service holding no local drivers, which proxies into a second service and
460
+ back with real sessions in ~30 ms. Neither service needed a special case.
461
+ - ✅ **Answering, not just observing** — enumerated options, a nonce, and
462
+ verification that the prompt actually cleared.
463
+ - ✅ **A second *local* driver** — a third-party agent spawned as a subprocess,
464
+ the first able to declare `observesState: true`. Two runtimes on one machine
465
+ at once, which is the proof the interface was worth having.
466
+ - ✅ **State that survives a restart** — idempotency keys, event epoch and
467
+ cursor, and driver session records persisted atomically, so reconciliation
468
+ can tell adopted from orphaned rather than calling everything orphaned.
469
+
470
+ Next, and this is where the value actually lands:
471
+
472
+ - ⬜ **A supervisor as a client**, replacing direct terminal-multiplexer access.
473
+ Until that happens this is a second implementation of session management
474
+ rather than a replacement for one. Planned in
475
+ [`docs/adoption.md`](docs/adoption.md) — including the one precondition that
476
+ cannot be discharged from inside this repository: nothing here prevents two
477
+ machines editing one working tree, because repository state is a non-goal.
478
+ - ⬜ **Credential lifecycle.** Grants are per principal and per verb, and
479
+ enrolment is a command. Nothing expires, a compromised token is revoked by
480
+ editing a file, and changing a principal's grants means removing and
481
+ re-adding it.
482
+ - ⬜ **Metrics.** Subprocess spawn cost is known to degrade with host load —
483
+ 8× on a machine under heavy load — and nothing measures it in production.
484
+
485
+ The honest list of what is missing is in
486
+ [`docs/internals.md`](docs/internals.md), kept current on purpose.
487
+
488
+ ---
489
+
490
+ ## License
491
+
492
+ [MIT](LICENSE). Copy it, run it, change it — the adoption path in
493
+ [`docs/adoption.md`](docs/adoption.md) exists for people who are not us, and a
494
+ documented adoption path with no licence grants them nothing.
package/bin/muster.js ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // Launcher for the muster binary (#243).
3
+ //
4
+ // This package carries no binary. npm installs exactly one of the sibling
5
+ // @futurelastic/muster-<os>-<cpu> packages (their `os`/`cpu` fields do the
6
+ // choosing, via optionalDependencies), and this file finds it and runs it.
7
+ // Nothing is downloaded at install time, so `--ignore-scripts` changes nothing.
8
+ 'use strict';
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+ const { spawn } = require('node:child_process');
13
+
14
+ const SUPPORTED = ['darwin-arm64', 'darwin-x64', 'linux-x64', 'linux-arm64'];
15
+
16
+ function fail(message) {
17
+ process.stderr.write(`muster: ${message}\n`);
18
+ process.exit(1);
19
+ }
20
+
21
+ // resolveBinary returns the absolute path of the platform package's binary, or
22
+ // throws an Error whose message says what to do. Exported through `require.main`
23
+ // so the test can drive it without spawning anything.
24
+ function resolveBinary(platform, arch, resolve, ownVersion) {
25
+ const key = `${platform}-${arch}`;
26
+ if (!SUPPORTED.includes(key)) {
27
+ throw new Error(
28
+ `no prebuilt binary for ${key} (supported: ${SUPPORTED.join(', ')}). ` +
29
+ 'Build from source: https://github.com/futurelastic/muster#install'
30
+ );
31
+ }
32
+ const pkg = `@futurelastic/muster-${key}`;
33
+ let manifest;
34
+ try {
35
+ manifest = resolve(`${pkg}/package.json`);
36
+ } catch {
37
+ throw new Error(
38
+ `${pkg} is not installed. It is an optional dependency of @futurelastic/muster, ` +
39
+ 'so an install that skipped optional dependencies (--omit=optional) or copied ' +
40
+ 'node_modules between platforms leaves it out. Reinstall on this machine.'
41
+ );
42
+ }
43
+ const theirs = JSON.parse(fs.readFileSync(manifest, 'utf8')).version;
44
+ if (theirs !== ownVersion) {
45
+ // The versions are published together and must stay together: a launcher
46
+ // running a different build than the one it was released with is a
47
+ // support problem nobody can see from `muster --version` alone.
48
+ throw new Error(
49
+ `version mismatch: @futurelastic/muster is ${ownVersion} but ${pkg} is ${theirs}. ` +
50
+ 'Reinstall so both come from the same release.'
51
+ );
52
+ }
53
+ return path.join(path.dirname(manifest), 'bin', 'muster');
54
+ }
55
+
56
+ function run() {
57
+ const own = require('../package.json').version;
58
+ let bin;
59
+ try {
60
+ bin = resolveBinary(process.platform, process.arch, require.resolve, own);
61
+ } catch (err) {
62
+ fail(err.message);
63
+ }
64
+ // A tarball extracted without the executable bit (some mirrors do this)
65
+ // would otherwise fail with an opaque EACCES.
66
+ try {
67
+ fs.accessSync(bin, fs.constants.X_OK);
68
+ } catch {
69
+ try {
70
+ fs.chmodSync(bin, 0o755);
71
+ } catch (err) {
72
+ fail(`${bin} is not executable and could not be made so: ${err.message}`);
73
+ }
74
+ }
75
+
76
+ const child = spawn(bin, process.argv.slice(2), { stdio: 'inherit' });
77
+ // The service handles these itself (a graceful stop); pass them on rather
78
+ // than dying first and orphaning it.
79
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
80
+ process.on(sig, () => child.kill(sig));
81
+ }
82
+ child.on('error', (err) => fail(`could not start ${bin}: ${err.message}`));
83
+ child.on('exit', (code, signal) => {
84
+ if (signal) {
85
+ process.removeAllListeners(signal);
86
+ process.kill(process.pid, signal);
87
+ return;
88
+ }
89
+ process.exit(code === null ? 1 : code);
90
+ });
91
+ }
92
+
93
+ if (require.main === module) {
94
+ run();
95
+ } else {
96
+ module.exports = { resolveBinary, SUPPORTED };
97
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@futurelastic/muster",
3
+ "version": "0.2.0",
4
+ "description": "muster — a machine-local service that owns session agents across a fleet. Launcher: runs the prebuilt binary for this platform.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/futurelastic/muster#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/futurelastic/muster.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/futurelastic/muster/issues"
13
+ },
14
+ "bin": {
15
+ "muster": "bin/muster.js"
16
+ },
17
+ "files": [
18
+ "bin/muster.js",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "optionalDependencies": {
23
+ "@futurelastic/muster-darwin-arm64": "0.2.0",
24
+ "@futurelastic/muster-darwin-x64": "0.2.0",
25
+ "@futurelastic/muster-linux-x64": "0.2.0",
26
+ "@futurelastic/muster-linux-arm64": "0.2.0"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "provenance": true
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ }
35
+ }