@lumi.ai/runner 0.6.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -4
- package/dist/cli.js +213 -51
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,12 +50,31 @@ It prints the approval link and an 8-character code. Open the link anywhere you
|
|
|
50
50
|
approve this Ship, and the server finishes on its own — nothing is pasted back.
|
|
51
51
|
|
|
52
52
|
For CI or unattended provisioning, where nobody is at a terminal at all, paste a Ship key from the
|
|
53
|
-
Daemons page instead:
|
|
53
|
+
Daemons page instead — that page prints this command with the two values already filled in:
|
|
54
54
|
|
|
55
55
|
```bash
|
|
56
56
|
lumi-runner login --key <shipKey> --api-key <firebaseWebApiKey> --project <projectId>
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
A key exists only once a captain has approved the machine on that Ship's Daemons page, and it only
|
|
60
|
+
appears in that list after one of the two flows above. So this is the **second** visit to a machine,
|
|
61
|
+
not the first.
|
|
62
|
+
|
|
63
|
+
### More than one account
|
|
64
|
+
|
|
65
|
+
One machine can serve Ships belonging to **different people**. Run `setup` again and choose *Add
|
|
66
|
+
Ships, from this or another account*; the second person approves in their own browser, with their
|
|
67
|
+
own sign-in.
|
|
68
|
+
|
|
69
|
+
Nothing is taken away by that. Approving only ever ADDS Ships, so the machine keeps every key and
|
|
70
|
+
every Ship it already had — the `/connect` page can only see the Ships of whoever is signed in to
|
|
71
|
+
it, so it is never allowed to decide that the others should go. To stop serving one, say so
|
|
72
|
+
explicitly: `lumi-runner ship remove <shipId>`.
|
|
73
|
+
|
|
74
|
+
A machine ends up with one identity per account that approved it — `status` lists them — because a
|
|
75
|
+
runner id belongs to an account rather than to hardware. Only `setup`'s *Disconnect and start over*
|
|
76
|
+
removes anything, and it asks twice.
|
|
77
|
+
|
|
59
78
|
## Commands
|
|
60
79
|
|
|
61
80
|
| Command | What it does |
|
|
@@ -246,9 +265,10 @@ npm i -g @lumi.ai/runner && lumi-runner uninstall && npm rm -g @lumi.ai/runner
|
|
|
246
265
|
|
|
247
266
|
`lumi-runner doctor` reports a machine that is already in this state, naming the missing path.
|
|
248
267
|
|
|
249
|
-
Neither command touches `~/.lumi-runner`. That directory is this machine's **identity** —
|
|
250
|
-
runner id and
|
|
251
|
-
already granted. Add `--purge` to delete it too, and expect to be approved
|
|
268
|
+
Neither command touches `~/.lumi-runner`. That directory is this machine's **identity** — the
|
|
269
|
+
runner id each account knows it by, and its Ship keys — so uninstalling and reinstalling keeps the
|
|
270
|
+
approvals your captains already granted. Add `--purge` to delete it too, and expect to be approved
|
|
271
|
+
again from scratch, by every account.
|
|
252
272
|
|
|
253
273
|
## Upgrading from `crew-runner`
|
|
254
274
|
|
package/dist/cli.js
CHANGED
|
@@ -756,8 +756,35 @@ function forgetShip(config2, shipId) {
|
|
|
756
756
|
if (next.allowLocalMcp) {
|
|
757
757
|
next.allowLocalMcp = next.allowLocalMcp.filter((id) => id !== shipId);
|
|
758
758
|
}
|
|
759
|
+
if (next.shipRunnerIds) {
|
|
760
|
+
const ids = { ...next.shipRunnerIds };
|
|
761
|
+
delete ids[shipId];
|
|
762
|
+
next.shipRunnerIds = ids;
|
|
763
|
+
}
|
|
759
764
|
return next;
|
|
760
765
|
}
|
|
766
|
+
function forgetAllShips(config2) {
|
|
767
|
+
const everything = [.../* @__PURE__ */ new Set([...config2.ships, ...Object.keys(config2.shipKeys ?? {})])];
|
|
768
|
+
return everything.reduce(forgetShip, config2);
|
|
769
|
+
}
|
|
770
|
+
function shipRunnerId(config2, shipId) {
|
|
771
|
+
return config2.shipRunnerIds?.[shipId] || config2.runnerId || "";
|
|
772
|
+
}
|
|
773
|
+
function runnerIdentities(primary, pairs) {
|
|
774
|
+
const byId = /* @__PURE__ */ new Map();
|
|
775
|
+
if (primary) byId.set(primary, []);
|
|
776
|
+
for (const { shipId, runnerId } of pairs) {
|
|
777
|
+
if (!runnerId) continue;
|
|
778
|
+
byId.set(runnerId, [...byId.get(runnerId) ?? [], shipId]);
|
|
779
|
+
}
|
|
780
|
+
return [...byId.entries()].map(([runnerId, shipIds]) => ({ runnerId, shipIds, primary: runnerId === primary })).sort((a, b) => Number(b.primary) - Number(a.primary) || a.runnerId.localeCompare(b.runnerId));
|
|
781
|
+
}
|
|
782
|
+
function configRunnerIdentities(config2) {
|
|
783
|
+
return runnerIdentities(
|
|
784
|
+
config2.runnerId,
|
|
785
|
+
config2.ships.map((shipId) => ({ shipId, runnerId: shipRunnerId(config2, shipId) }))
|
|
786
|
+
);
|
|
787
|
+
}
|
|
761
788
|
function allowsLocalMcp(config2, shipId) {
|
|
762
789
|
return Array.isArray(config2.allowLocalMcp) && config2.allowLocalMcp.includes(shipId);
|
|
763
790
|
}
|
|
@@ -774,7 +801,7 @@ function mcpUrl(config2) {
|
|
|
774
801
|
}
|
|
775
802
|
|
|
776
803
|
// src/version.ts
|
|
777
|
-
var RUNNER_VERSION = true ? "0.
|
|
804
|
+
var RUNNER_VERSION = true ? "0.7.0" : "0.0.0-dev";
|
|
778
805
|
|
|
779
806
|
// src/auth.ts
|
|
780
807
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -869,7 +896,7 @@ async function signInToShip(fb, config2, shipId) {
|
|
|
869
896
|
);
|
|
870
897
|
}
|
|
871
898
|
const cred = await signInWithCustomToken(fb.auth, session.customToken);
|
|
872
|
-
return cred.user;
|
|
899
|
+
return { user: cred.user, runnerId: session.runnerId || shipRunnerId(config2, shipId) };
|
|
873
900
|
}
|
|
874
901
|
async function openShipSessions(config2, build) {
|
|
875
902
|
const sessions = [];
|
|
@@ -877,8 +904,7 @@ async function openShipSessions(config2, build) {
|
|
|
877
904
|
for (const shipId of config2.ships) {
|
|
878
905
|
const fb = build(shipId);
|
|
879
906
|
try {
|
|
880
|
-
|
|
881
|
-
sessions.push({ shipId, fb, user });
|
|
907
|
+
sessions.push({ shipId, fb, ...await signInToShip(fb, config2, shipId) });
|
|
882
908
|
} catch (e) {
|
|
883
909
|
failures.push({
|
|
884
910
|
shipId,
|
|
@@ -3461,8 +3487,9 @@ async function startDaemon() {
|
|
|
3461
3487
|
return s;
|
|
3462
3488
|
};
|
|
3463
3489
|
const serving = new Set(sessions.map((s) => s.shipId));
|
|
3490
|
+
const bannerIds = [...new Set(sessions.map((s) => s.runnerId))];
|
|
3464
3491
|
console.log(
|
|
3465
|
-
`Runner ${config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}`
|
|
3492
|
+
bannerIds.length <= 1 ? `Runner ${bannerIds[0] ?? config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}` : `Runner ${bannerIds.length} identities serving ${serving.size} Ship(s): ` + sessions.map((s) => `${s.shipId} as ${s.runnerId}`).join(", ")
|
|
3466
3493
|
);
|
|
3467
3494
|
const logLines = [];
|
|
3468
3495
|
const log2 = (line) => {
|
|
@@ -3483,7 +3510,10 @@ async function startDaemon() {
|
|
|
3483
3510
|
const liveJobsOn = (shipId) => [...running.values()].filter((r) => r.shipId === shipId && r.mirror).map((r) => r.mirror).sort((a, b) => a.startedAt - b.startedAt);
|
|
3484
3511
|
const approved = /* @__PURE__ */ new Map();
|
|
3485
3512
|
const warnedUnapproved = /* @__PURE__ */ new Set();
|
|
3486
|
-
const shipRunnerRef = (shipId) =>
|
|
3513
|
+
const shipRunnerRef = (shipId) => {
|
|
3514
|
+
const session = sess(shipId);
|
|
3515
|
+
return doc7(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId);
|
|
3516
|
+
};
|
|
3487
3517
|
const needsRefill = /* @__PURE__ */ new Set();
|
|
3488
3518
|
let beating = false;
|
|
3489
3519
|
async function heartbeat() {
|
|
@@ -3848,8 +3878,9 @@ async function startDaemon() {
|
|
|
3848
3878
|
return;
|
|
3849
3879
|
}
|
|
3850
3880
|
const startedAt2 = Date.now();
|
|
3851
|
-
|
|
3852
|
-
|
|
3881
|
+
const runnerId = sess(shipId).runnerId;
|
|
3882
|
+
tx.update(jobRef, { status: "running", runnerId, startedAt: startedAt2 });
|
|
3883
|
+
claimed = { id: snap.id, ...snap.data(), status: "running", runnerId, startedAt: startedAt2 };
|
|
3853
3884
|
});
|
|
3854
3885
|
return claimed;
|
|
3855
3886
|
} catch (e) {
|
|
@@ -3966,7 +3997,7 @@ async function startDaemon() {
|
|
|
3966
3997
|
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
|
|
3967
3998
|
);
|
|
3968
3999
|
const fresh = snap.data();
|
|
3969
|
-
if (!fresh || fresh.status !== "running" || fresh.runnerId !==
|
|
4000
|
+
if (!fresh || fresh.status !== "running" || fresh.runnerId !== sess(shipId).runnerId) {
|
|
3970
4001
|
log2(`Job ${job.id} is no longer this machine's to run \u2014 ending the session.`);
|
|
3971
4002
|
slot.abort.abort();
|
|
3972
4003
|
}
|
|
@@ -4115,7 +4146,13 @@ async function startDaemon() {
|
|
|
4115
4146
|
} else if (sessionLimit) {
|
|
4116
4147
|
const engineLabel = getEngine(engineId).label;
|
|
4117
4148
|
const resetsAt = new Date(sessionLimit.resetsAt).toISOString();
|
|
4118
|
-
await noteEngineLimit(
|
|
4149
|
+
await noteEngineLimit(
|
|
4150
|
+
sess(shipId).fb.db,
|
|
4151
|
+
shipId,
|
|
4152
|
+
engineId,
|
|
4153
|
+
sessionLimit,
|
|
4154
|
+
sess(shipId).runnerId
|
|
4155
|
+
);
|
|
4119
4156
|
await releaseJob(
|
|
4120
4157
|
sess(shipId).fb.db,
|
|
4121
4158
|
shipId,
|
|
@@ -4252,7 +4289,7 @@ async function startDaemon() {
|
|
|
4252
4289
|
try {
|
|
4253
4290
|
for (const shipId of serving) {
|
|
4254
4291
|
await setDoc2(
|
|
4255
|
-
|
|
4292
|
+
shipRunnerRef(shipId),
|
|
4256
4293
|
{
|
|
4257
4294
|
status: "offline",
|
|
4258
4295
|
lastSeenAt: now,
|
|
@@ -4520,6 +4557,16 @@ async function promptMultiSelect(options) {
|
|
|
4520
4557
|
})
|
|
4521
4558
|
);
|
|
4522
4559
|
}
|
|
4560
|
+
async function promptSelect(options) {
|
|
4561
|
+
requireInteractive(options.message, options.flagHint);
|
|
4562
|
+
return unwrap(
|
|
4563
|
+
await clack.select({
|
|
4564
|
+
message: options.message,
|
|
4565
|
+
options: options.choices,
|
|
4566
|
+
initialValue: options.initialValue
|
|
4567
|
+
})
|
|
4568
|
+
);
|
|
4569
|
+
}
|
|
4523
4570
|
async function promptConfirm(options) {
|
|
4524
4571
|
if (assumeYes && options.yesFlagApplies !== false) return true;
|
|
4525
4572
|
requireInteractive(options.message, "--yes");
|
|
@@ -4750,8 +4797,7 @@ import { collection as collection6, doc as doc8, getDoc as getDoc7, getDocs as g
|
|
|
4750
4797
|
async function openShipSession(shipId) {
|
|
4751
4798
|
const config2 = requireConfig();
|
|
4752
4799
|
const fb = initFirebase(config2, `cli-${shipId}`);
|
|
4753
|
-
|
|
4754
|
-
return { shipId, config: config2, fb, user };
|
|
4800
|
+
return { shipId, config: config2, fb, ...await signInToShip(fb, config2, shipId) };
|
|
4755
4801
|
}
|
|
4756
4802
|
|
|
4757
4803
|
// src/cli/commands/doctor.ts
|
|
@@ -4890,6 +4936,7 @@ async function checkShips(config2) {
|
|
|
4890
4936
|
const checks = [];
|
|
4891
4937
|
const engines = /* @__PURE__ */ new Set();
|
|
4892
4938
|
let needsGithub = false;
|
|
4939
|
+
const manyIdentities = configRunnerIdentities(config2).length > 1;
|
|
4893
4940
|
if (config2.ships.length === 0) {
|
|
4894
4941
|
checks.push(
|
|
4895
4942
|
fail("ships", "Ships", "This machine serves no Ships.", "Run `lumi-runner ship add` to pick one.")
|
|
@@ -4897,9 +4944,9 @@ async function checkShips(config2) {
|
|
|
4897
4944
|
return { checks, engines, needsGithub };
|
|
4898
4945
|
}
|
|
4899
4946
|
for (const shipId of config2.ships) {
|
|
4900
|
-
let
|
|
4947
|
+
let session;
|
|
4901
4948
|
try {
|
|
4902
|
-
|
|
4949
|
+
session = await openShipSession(shipId);
|
|
4903
4950
|
checks.push(ok(`key:${shipId}`, `Ship ${shipId} \u2014 key`, "Runner key accepted."));
|
|
4904
4951
|
} catch (e) {
|
|
4905
4952
|
checks.push(
|
|
@@ -4914,7 +4961,7 @@ async function checkShips(config2) {
|
|
|
4914
4961
|
}
|
|
4915
4962
|
try {
|
|
4916
4963
|
const snap = await getDoc7(
|
|
4917
|
-
doc8(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners,
|
|
4964
|
+
doc8(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId)
|
|
4918
4965
|
);
|
|
4919
4966
|
if (!snap.exists()) {
|
|
4920
4967
|
checks.push(
|
|
@@ -4935,7 +4982,13 @@ async function checkShips(config2) {
|
|
|
4935
4982
|
)
|
|
4936
4983
|
);
|
|
4937
4984
|
} else {
|
|
4938
|
-
checks.push(
|
|
4985
|
+
checks.push(
|
|
4986
|
+
ok(
|
|
4987
|
+
`approval:${shipId}`,
|
|
4988
|
+
`Ship ${shipId} \u2014 approval`,
|
|
4989
|
+
manyIdentities ? `Approved by a captain (as ${session.runnerId}).` : "Approved by a captain."
|
|
4990
|
+
)
|
|
4991
|
+
);
|
|
4939
4992
|
}
|
|
4940
4993
|
} catch (e) {
|
|
4941
4994
|
checks.push(
|
|
@@ -4949,7 +5002,7 @@ async function checkShips(config2) {
|
|
|
4949
5002
|
}
|
|
4950
5003
|
let agents = [];
|
|
4951
5004
|
try {
|
|
4952
|
-
const snap = await getDocs5(collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
|
|
5005
|
+
const snap = await getDocs5(collection6(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
|
|
4953
5006
|
agents = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
|
|
4954
5007
|
} catch {
|
|
4955
5008
|
}
|
|
@@ -4958,7 +5011,7 @@ async function checkShips(config2) {
|
|
|
4958
5011
|
for (const id of shipEngines) engines.add(id);
|
|
4959
5012
|
if (agents.some((agent) => effectiveAgentTools(agent).github.enabled)) needsGithub = true;
|
|
4960
5013
|
try {
|
|
4961
|
-
const secrets = await loadRunnerSecrets(fb.db, shipId);
|
|
5014
|
+
const secrets = await loadRunnerSecrets(session.fb.db, shipId);
|
|
4962
5015
|
const missing = [...new Set([...shipEngines].flatMap((id) => missingSecretsFor(id, secrets)))];
|
|
4963
5016
|
checks.push(
|
|
4964
5017
|
missing.length === 0 ? ok(`secrets:${shipId}`, `Ship ${shipId} \u2014 credentials`, "All required secrets are saved.") : fail(
|
|
@@ -4980,7 +5033,7 @@ async function checkShips(config2) {
|
|
|
4980
5033
|
}
|
|
4981
5034
|
try {
|
|
4982
5035
|
const snap = await getDocs5(
|
|
4983
|
-
collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
|
|
5036
|
+
collection6(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
|
|
4984
5037
|
);
|
|
4985
5038
|
const servers = snap.docs.map(
|
|
4986
5039
|
(d) => ({ id: d.id, ...d.data() })
|
|
@@ -5001,7 +5054,14 @@ async function runDoctor() {
|
|
|
5001
5054
|
);
|
|
5002
5055
|
return report2(checks);
|
|
5003
5056
|
}
|
|
5004
|
-
|
|
5057
|
+
const identities = configRunnerIdentities(config2);
|
|
5058
|
+
checks.push(
|
|
5059
|
+
ok(
|
|
5060
|
+
"config",
|
|
5061
|
+
"Configuration",
|
|
5062
|
+
identities.length <= 1 ? `Runner ${config2.runnerId} on project ${config2.projectId}` : `${identities.length} runner identities on project ${config2.projectId} \u2014 ` + identities.map((i) => i.runnerId).join(", ")
|
|
5063
|
+
)
|
|
5064
|
+
);
|
|
5005
5065
|
const progress = spinner2();
|
|
5006
5066
|
progress.start("Running checks\u2026");
|
|
5007
5067
|
const engines = /* @__PURE__ */ new Set([DEFAULT_ENGINE_ID]);
|
|
@@ -5081,21 +5141,37 @@ import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
|
|
|
5081
5141
|
function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
|
|
5082
5142
|
const approvedShips = approved.approvedShips ?? Object.keys(approved.shipKeys ?? {});
|
|
5083
5143
|
const selectedShips = approved.selectedShips ?? [];
|
|
5144
|
+
const stampId = approved.runnerId || existing?.runnerId || "";
|
|
5145
|
+
const shipRunnerIds = { ...existing?.shipRunnerIds ?? {} };
|
|
5146
|
+
if (stampId) {
|
|
5147
|
+
for (const shipId of Object.keys(approved.shipKeys ?? {})) shipRunnerIds[shipId] = stampId;
|
|
5148
|
+
}
|
|
5084
5149
|
const config2 = {
|
|
5085
5150
|
...existing,
|
|
5086
5151
|
apiKey: approved.apiKey || existing?.apiKey || "",
|
|
5087
5152
|
projectId: approved.projectId || fallbackProjectId,
|
|
5088
|
-
runnerId:
|
|
5153
|
+
runnerId: existing?.runnerId || approved.runnerId || "",
|
|
5089
5154
|
shipKeys: { ...existing?.shipKeys ?? {}, ...approved.shipKeys ?? {} },
|
|
5090
|
-
ships:
|
|
5155
|
+
ships: [.../* @__PURE__ */ new Set([...existing?.ships ?? [], ...selectedShips])],
|
|
5156
|
+
// Only when there is something to say, following `shipParallelJobs`: writing an empty map into
|
|
5157
|
+
// every config would be a key nobody asked for.
|
|
5158
|
+
...Object.keys(shipRunnerIds).length > 0 ? { shipRunnerIds } : {},
|
|
5091
5159
|
...mcpUrl2 ? { mcpUrl: mcpUrl2 } : {}
|
|
5092
5160
|
};
|
|
5161
|
+
const occupiedShips = (approved.occupiedShips ?? []).filter((id) => config2.ships.includes(id));
|
|
5093
5162
|
return {
|
|
5094
5163
|
config: config2,
|
|
5095
5164
|
approvedShips,
|
|
5096
5165
|
// Fall back to deriving it, so an older backend that omits the field reports accurately
|
|
5097
|
-
// rather than crashing or claiming nothing is pending.
|
|
5098
|
-
|
|
5166
|
+
// rather than crashing or claiming nothing is pending. Occupied Ships are subtracted for the
|
|
5167
|
+
// web page's reason: they come back in BOTH sets, and printing both tells a captain they are
|
|
5168
|
+
// waiting on a captain.
|
|
5169
|
+
pendingShips: (approved.pendingShips ?? selectedShips.filter((id) => !approvedShips.includes(id))).filter((id) => config2.ships.includes(id) && !occupiedShips.includes(id)),
|
|
5170
|
+
occupiedShips,
|
|
5171
|
+
newIdentity: Boolean(
|
|
5172
|
+
approved.runnerId && existing?.runnerId && approved.runnerId !== existing.runnerId
|
|
5173
|
+
),
|
|
5174
|
+
otherShips: (existing?.ships ?? []).filter((id) => !selectedShips.includes(id))
|
|
5099
5175
|
};
|
|
5100
5176
|
}
|
|
5101
5177
|
function openBrowser(url) {
|
|
@@ -5118,11 +5194,13 @@ async function loginWithDeviceFlow(options) {
|
|
|
5118
5194
|
const existingConfig = loadConfig();
|
|
5119
5195
|
const projectId = options.project || existingConfig?.projectId || DEFAULT_PROJECT_ID;
|
|
5120
5196
|
const baseUrl = functionsBaseUrlFor(projectId);
|
|
5197
|
+
const candidates = existingConfig ? configRunnerIdentities(existingConfig).map((i) => i.runnerId) : [];
|
|
5121
5198
|
const start = await callPublicFunction(baseUrl, "startRunnerLogin", {
|
|
5122
5199
|
hostname: os5.hostname(),
|
|
5123
|
-
//
|
|
5124
|
-
//
|
|
5125
|
-
...existingConfig?.runnerId ? { runnerId: existingConfig.runnerId } : {}
|
|
5200
|
+
// The singular field stays, always: it is what an older deployment reads, and dropping it
|
|
5201
|
+
// would silently turn every re-login into a new enrolment for the length of a rollout.
|
|
5202
|
+
...existingConfig?.runnerId ? { runnerId: existingConfig.runnerId } : {},
|
|
5203
|
+
...candidates.length > 0 ? { runnerIds: candidates } : {}
|
|
5126
5204
|
});
|
|
5127
5205
|
if (!options.noBrowser) openBrowser(start.verificationUrl);
|
|
5128
5206
|
say.note(
|
|
@@ -5160,7 +5238,7 @@ Code: ${pc.bold(start.displayCode)}`,
|
|
|
5160
5238
|
throw new CliError("The approval window expired. Run `lumi-runner login` again.");
|
|
5161
5239
|
}
|
|
5162
5240
|
progress.stop("Approved.");
|
|
5163
|
-
const { config: config2, approvedShips, pendingShips } = buildLoginResult(
|
|
5241
|
+
const { config: config2, approvedShips, pendingShips, occupiedShips, newIdentity, otherShips } = buildLoginResult(
|
|
5164
5242
|
approved,
|
|
5165
5243
|
existingConfig,
|
|
5166
5244
|
projectId,
|
|
@@ -5178,7 +5256,18 @@ Code: ${pc.bold(start.displayCode)}`,
|
|
|
5178
5256
|
const first = approvedShips[0];
|
|
5179
5257
|
const uid2 = first ? (await signInWithCustomToken2(fb.auth, (await exchange(config2, first)).customToken)).user.uid : "(no Ship approved yet)";
|
|
5180
5258
|
saveConfig(config2);
|
|
5181
|
-
return report3({
|
|
5259
|
+
return report3({
|
|
5260
|
+
// What THIS login did, which on a second account is not the machine's primary.
|
|
5261
|
+
runnerId: approved.runnerId || config2.runnerId,
|
|
5262
|
+
primaryRunnerId: config2.runnerId,
|
|
5263
|
+
newIdentity,
|
|
5264
|
+
uid: uid2,
|
|
5265
|
+
ships: config2.ships,
|
|
5266
|
+
approvedShips,
|
|
5267
|
+
pendingShips,
|
|
5268
|
+
occupiedShips,
|
|
5269
|
+
otherShips
|
|
5270
|
+
});
|
|
5182
5271
|
}
|
|
5183
5272
|
async function exchange(config2, shipId) {
|
|
5184
5273
|
return callPublicFunction(
|
|
@@ -5196,30 +5285,37 @@ async function loginWithKey(options) {
|
|
|
5196
5285
|
throw new CliError("That does not look like a runner key. Expected `crewrunner_<shipId>_<secret>`.");
|
|
5197
5286
|
}
|
|
5198
5287
|
const existing = loadConfig();
|
|
5288
|
+
const session = await callPublicFunction(
|
|
5289
|
+
functionsBaseUrlFor(options.project),
|
|
5290
|
+
"exchangeRunnerKey",
|
|
5291
|
+
{ key: options.key }
|
|
5292
|
+
);
|
|
5293
|
+
const runnerId = session.runnerId || options.runnerId || existing?.runnerId || "";
|
|
5199
5294
|
const config2 = {
|
|
5200
5295
|
...existing,
|
|
5201
5296
|
apiKey: options.apiKey,
|
|
5202
5297
|
projectId: options.project,
|
|
5203
|
-
|
|
5298
|
+
// The FIRST identity keeps the machine's name, exactly as in `buildLoginResult`: a key issued
|
|
5299
|
+
// by another account names another machine, and adopting it would strand every Ship this one
|
|
5300
|
+
// already serves.
|
|
5301
|
+
runnerId: existing?.runnerId || runnerId,
|
|
5204
5302
|
shipKeys: { ...existing?.shipKeys ?? {}, [parsed]: options.key },
|
|
5205
5303
|
ships: [.../* @__PURE__ */ new Set([...existing?.ships ?? [], parsed])],
|
|
5304
|
+
...runnerId ? { shipRunnerIds: { ...existing?.shipRunnerIds ?? {}, [parsed]: runnerId } } : {},
|
|
5206
5305
|
...options.mcpUrl ? { mcpUrl: options.mcpUrl } : {}
|
|
5207
5306
|
};
|
|
5208
|
-
const session = await callPublicFunction(
|
|
5209
|
-
functionsBaseUrlFor(config2.projectId),
|
|
5210
|
-
"exchangeRunnerKey",
|
|
5211
|
-
{ key: options.key }
|
|
5212
|
-
);
|
|
5213
|
-
config2.runnerId = session.runnerId;
|
|
5214
5307
|
const fb = initFirebase(config2);
|
|
5215
5308
|
const cred = await signInWithCustomToken2(fb.auth, session.customToken);
|
|
5216
5309
|
saveConfig(config2);
|
|
5217
5310
|
return report3({
|
|
5218
|
-
runnerId
|
|
5311
|
+
runnerId,
|
|
5312
|
+
primaryRunnerId: config2.runnerId,
|
|
5313
|
+
newIdentity: Boolean(runnerId && config2.runnerId && runnerId !== config2.runnerId),
|
|
5219
5314
|
uid: cred.user.uid,
|
|
5220
5315
|
ships: config2.ships,
|
|
5221
5316
|
approvedShips: [parsed],
|
|
5222
|
-
pendingShips: []
|
|
5317
|
+
pendingShips: [],
|
|
5318
|
+
otherShips: (existing?.ships ?? []).filter((id) => id !== parsed)
|
|
5223
5319
|
});
|
|
5224
5320
|
}
|
|
5225
5321
|
function parseKeyShipId(key) {
|
|
@@ -5232,12 +5328,27 @@ function report3(result) {
|
|
|
5232
5328
|
return 0;
|
|
5233
5329
|
}
|
|
5234
5330
|
say.success(`Connected as ${result.uid} \u2014 runner ${result.runnerId}`);
|
|
5331
|
+
if (result.newIdentity && result.primaryRunnerId) {
|
|
5332
|
+
say.info(
|
|
5333
|
+
`That is a new identity for this machine \u2014 it is still runner ${result.primaryRunnerId} on the Ships it already served.`
|
|
5334
|
+
);
|
|
5335
|
+
}
|
|
5235
5336
|
if (result.ships.length > 0) say.info(`Serving: ${result.ships.join(", ")}`);
|
|
5236
5337
|
if (result.pendingShips && result.pendingShips.length > 0) {
|
|
5237
5338
|
say.warn(
|
|
5238
5339
|
`Awaiting captain approval on: ${result.pendingShips.join(", ")} \u2014 a captain approves this machine on the Ship's Daemons page.`
|
|
5239
5340
|
);
|
|
5240
5341
|
}
|
|
5342
|
+
if (result.occupiedShips && result.occupiedShips.length > 0) {
|
|
5343
|
+
say.warn(
|
|
5344
|
+
`Already running another daemon: ${result.occupiedShips.join(", ")} \u2014 a Ship runs one. Remove the old machine on that Ship's Daemons page, then approve this one there.`
|
|
5345
|
+
);
|
|
5346
|
+
}
|
|
5347
|
+
if (result.otherShips && result.otherShips.length > 0) {
|
|
5348
|
+
say.info(
|
|
5349
|
+
`Still serving ${result.otherShips.join(", ")} \u2014 this approval did not mention them. \`lumi-runner ship remove <shipId>\` to stop.`
|
|
5350
|
+
);
|
|
5351
|
+
}
|
|
5241
5352
|
if (serviceStatus().state === "running") {
|
|
5242
5353
|
say.info("Restart the daemon for this to take effect: `lumi-runner service restart`.");
|
|
5243
5354
|
}
|
|
@@ -5455,13 +5566,42 @@ async function runSetup(options) {
|
|
|
5455
5566
|
const moved = migrateLegacyDir();
|
|
5456
5567
|
if (moved?.migrated) say.info(`Moved your runner config from ${moved.from} to ${moved.to}.`);
|
|
5457
5568
|
const existing = loadConfig();
|
|
5458
|
-
|
|
5459
|
-
message: `This machine is already connected as ${existing?.runnerId}. Keep its Ship keys?`,
|
|
5460
|
-
initialValue: true,
|
|
5461
|
-
yesFlagApplies: false
|
|
5462
|
-
});
|
|
5463
|
-
if (!reuse) {
|
|
5569
|
+
if (!existing || Object.keys(existing.shipKeys ?? {}).length === 0) {
|
|
5464
5570
|
await runLogin(options);
|
|
5571
|
+
} else {
|
|
5572
|
+
const identities = configRunnerIdentities(existing);
|
|
5573
|
+
const who = identities.length === 1 ? `as ${identities[0].runnerId}` : `under ${identities.length} accounts (${identities.map((i) => i.runnerId).join(", ")})`;
|
|
5574
|
+
const action2 = await promptSelect({
|
|
5575
|
+
message: `This machine is already connected ${who}, serving ${existing.ships.join(", ") || "no Ships"}. What now?`,
|
|
5576
|
+
choices: [
|
|
5577
|
+
{ value: "keep", label: "Nothing \u2014 keep what it has", hint: "re-check it and restart the daemon" },
|
|
5578
|
+
{
|
|
5579
|
+
value: "add",
|
|
5580
|
+
label: "Add Ships, from this or another account",
|
|
5581
|
+
hint: "opens the browser; everything it already serves is kept"
|
|
5582
|
+
},
|
|
5583
|
+
{
|
|
5584
|
+
value: "reset",
|
|
5585
|
+
label: "Disconnect and start over",
|
|
5586
|
+
hint: "forgets every Ship key on this machine"
|
|
5587
|
+
}
|
|
5588
|
+
],
|
|
5589
|
+
initialValue: "keep",
|
|
5590
|
+
flagHint: "lumi-runner login"
|
|
5591
|
+
});
|
|
5592
|
+
if (action2 === "add") {
|
|
5593
|
+
await runLogin(options);
|
|
5594
|
+
} else if (action2 === "reset") {
|
|
5595
|
+
const sure = await promptConfirm({
|
|
5596
|
+
message: `Forget all ${Object.keys(existing.shipKeys ?? {}).length} Ship key(s) on this machine and connect from scratch?`,
|
|
5597
|
+
initialValue: false,
|
|
5598
|
+
yesFlagApplies: false
|
|
5599
|
+
// destructive: `--yes` must not answer this one
|
|
5600
|
+
});
|
|
5601
|
+
if (!sure) throw new CliError("Cancelled.", 130);
|
|
5602
|
+
saveConfig(forgetAllShips(existing));
|
|
5603
|
+
await runLogin(options);
|
|
5604
|
+
}
|
|
5465
5605
|
}
|
|
5466
5606
|
const config2 = loadConfig();
|
|
5467
5607
|
if (!config2) throw new CliError("Login did not complete.");
|
|
@@ -5532,12 +5672,13 @@ async function runStatus() {
|
|
|
5532
5672
|
progress.start("Reading Ship state\u2026");
|
|
5533
5673
|
const ships = [];
|
|
5534
5674
|
for (const shipId of config2.ships) {
|
|
5535
|
-
let
|
|
5675
|
+
let session;
|
|
5536
5676
|
try {
|
|
5537
|
-
|
|
5677
|
+
session = await openShipSession(shipId);
|
|
5538
5678
|
} catch {
|
|
5539
5679
|
ships.push({
|
|
5540
5680
|
shipId,
|
|
5681
|
+
runnerId: shipRunnerId(config2, shipId),
|
|
5541
5682
|
enrolled: false,
|
|
5542
5683
|
approved: false,
|
|
5543
5684
|
online: false,
|
|
@@ -5550,8 +5691,8 @@ async function runStatus() {
|
|
|
5550
5691
|
});
|
|
5551
5692
|
continue;
|
|
5552
5693
|
}
|
|
5553
|
-
const shipRef = doc10(fb.db, COLLECTIONS.ships, shipId);
|
|
5554
|
-
const mirrorSnap = await getDoc9(doc10(shipRef, COLLECTIONS.runners,
|
|
5694
|
+
const shipRef = doc10(session.fb.db, COLLECTIONS.ships, shipId);
|
|
5695
|
+
const mirrorSnap = await getDoc9(doc10(shipRef, COLLECTIONS.runners, session.runnerId));
|
|
5555
5696
|
const mirror = mirrorSnap.data();
|
|
5556
5697
|
let queued = 0;
|
|
5557
5698
|
try {
|
|
@@ -5569,6 +5710,7 @@ async function runStatus() {
|
|
|
5569
5710
|
const engineLimits = limitsSnap.docs.map((d) => ({ id: d.id, ...d.data() })).filter((l) => isEngineLimited(l, now));
|
|
5570
5711
|
ships.push({
|
|
5571
5712
|
shipId,
|
|
5713
|
+
runnerId: session.runnerId,
|
|
5572
5714
|
enrolled: mirrorSnap.exists(),
|
|
5573
5715
|
approved: mirror?.approved === true,
|
|
5574
5716
|
online: mirror?.status === "online" && Date.now() - (mirror?.lastSeenAt ?? 0) < RUNNER_OFFLINE_AFTER_MS,
|
|
@@ -5581,11 +5723,19 @@ async function runStatus() {
|
|
|
5581
5723
|
}
|
|
5582
5724
|
progress.stop("");
|
|
5583
5725
|
const service2 = serviceStatus();
|
|
5726
|
+
const identities = runnerIdentities(
|
|
5727
|
+
config2.runnerId,
|
|
5728
|
+
ships.map((s) => ({ shipId: s.shipId, runnerId: s.runnerId }))
|
|
5729
|
+
);
|
|
5584
5730
|
if (isJson()) {
|
|
5585
5731
|
emitJson({
|
|
5586
5732
|
version: RUNNER_VERSION,
|
|
5587
5733
|
connected: true,
|
|
5734
|
+
// KEPT, and kept meaning the same thing: this is a published contract, and on the machine
|
|
5735
|
+
// that has one identity — every machine before this release — the primary IS that identity.
|
|
5588
5736
|
runnerId: config2.runnerId,
|
|
5737
|
+
/** Every name this machine answers to, and the Ships each one covers. */
|
|
5738
|
+
runnerIds: identities,
|
|
5589
5739
|
projectId: config2.projectId,
|
|
5590
5740
|
service: { state: service2.state, detail: service2.detail },
|
|
5591
5741
|
ships
|
|
@@ -5593,7 +5743,16 @@ async function runStatus() {
|
|
|
5593
5743
|
return 0;
|
|
5594
5744
|
}
|
|
5595
5745
|
say.line("");
|
|
5596
|
-
|
|
5746
|
+
if (identities.length <= 1) {
|
|
5747
|
+
say.line(` ${pc.bold("Runner")} ${config2.runnerId} ${pc.dim(`v${RUNNER_VERSION}`)}`);
|
|
5748
|
+
} else {
|
|
5749
|
+
say.line(` ${pc.bold("Runner")} ${identities.length} identities ${pc.dim(`v${RUNNER_VERSION}`)}`);
|
|
5750
|
+
for (const identity of identities) {
|
|
5751
|
+
say.line(
|
|
5752
|
+
` ${identity.runnerId} ${pc.dim(identity.shipIds.join(", ") || "no Ships")}`
|
|
5753
|
+
);
|
|
5754
|
+
}
|
|
5755
|
+
}
|
|
5597
5756
|
say.line(` ${pc.bold("Project")} ${config2.projectId}`);
|
|
5598
5757
|
say.line(` ${pc.bold("Service")} ${service2.detail}`);
|
|
5599
5758
|
say.line("");
|
|
@@ -5604,6 +5763,9 @@ async function runStatus() {
|
|
|
5604
5763
|
for (const ship2 of ships) {
|
|
5605
5764
|
const state = ship2.unreachable ? pc.red("no access") : !ship2.enrolled ? pc.dim("not enrolled") : !ship2.approved ? pc.yellow("awaiting approval") : ship2.online ? pc.green("online") : pc.red("offline");
|
|
5606
5765
|
say.line(` ${pc.bold(ship2.shipId)} ${state}`);
|
|
5766
|
+
if (identities.length > 1 && ship2.runnerId) {
|
|
5767
|
+
say.line(` ${pc.dim(`runner ${ship2.runnerId}`)}`);
|
|
5768
|
+
}
|
|
5607
5769
|
if (ship2.unreachable) {
|
|
5608
5770
|
say.line(` ${pc.dim("this machine\u2019s key for that Ship was revoked \u2014 re-approve it on the Daemons page")}`);
|
|
5609
5771
|
continue;
|
|
@@ -5750,7 +5912,7 @@ function action(handler) {
|
|
|
5750
5912
|
program.command("setup").description("Connect this machine and set it up to run jobs (interactive)").option("--project <projectId>", "Firebase project to connect to").option("--no-browser", "print the approval URL instead of opening a browser").action(action(
|
|
5751
5913
|
async (options) => runSetup({ project: options.project, noBrowser: options.browser === false })
|
|
5752
5914
|
));
|
|
5753
|
-
program.command("login").description("Connect this machine (opens a browser to approve it)").option("--project <projectId>", "Firebase project to connect to").option("--no-browser", "print the approval URL instead of opening a browser").option("--key <shipKey>", "skip the browser: paste a Ship key from the Daemons page (CI)").option("--api-key <key>", "Firebase web API key (only with --key)").option("--runner-id <id>", "
|
|
5915
|
+
program.command("login").description("Connect this machine (opens a browser to approve it)").option("--project <projectId>", "Firebase project to connect to").option("--no-browser", "print the approval URL instead of opening a browser").option("--key <shipKey>", "skip the browser: paste a Ship key from the Daemons page (CI)").option("--api-key <key>", "Firebase web API key (only with --key)").option("--runner-id <id>", "runner id to fall back on if the backend does not name one (only with --key)").option("--mcp-url <url>", "override the Workspace MCP endpoint").action(
|
|
5754
5916
|
action(
|
|
5755
5917
|
async (options) => runLogin({
|
|
5756
5918
|
project: options.project,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumi.ai/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Lumi Crew runner daemon — claims jobs from your Ships and executes them as headless Claude sessions on your own machine.",
|
|
6
6
|
"//name": "The ONLY package in this monorepo published to the public registry, so it is the one that does not follow the internal @lumi/crew-* convention: `@lumi` is not a scope we own, `@lumi.ai` is (the npm org). The workspace DIRECTORY stays packages/crew/runner — renaming the package is not renaming the folder.",
|