@stringsync/splates 0.0.52 → 0.0.53

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 (60) hide show
  1. package/artifact.ts +120 -0
  2. package/budget.ts +117 -0
  3. package/checkout.ts +8 -0
  4. package/creds.test.ts +336 -0
  5. package/creds.ts +144 -0
  6. package/dev/dev.ts +86 -0
  7. package/dev/eval.ts +21 -0
  8. package/dev/fix.ts +63 -0
  9. package/dev/index.ts +1 -0
  10. package/dev/prove.ts +38 -0
  11. package/dev/root.ts +7 -0
  12. package/dev/ship.ts +46 -0
  13. package/dev/test.ts +154 -0
  14. package/e2e.test.ts +472 -0
  15. package/flags.ts +157 -0
  16. package/hook.ts +328 -0
  17. package/index.ts +18 -0
  18. package/infra.ts +107 -0
  19. package/lane.test.ts +29 -0
  20. package/lane.ts +172 -0
  21. package/notes.ts +19 -0
  22. package/package.json +14 -12
  23. package/projects.test.ts +191 -0
  24. package/projects.ts +37 -0
  25. package/queue.ts +82 -0
  26. package/screenshots/annotate.png +0 -0
  27. package/screenshots/demo.png +0 -0
  28. package/screenshots/parallel.png +0 -0
  29. package/screenshots/questions.png +0 -0
  30. package/server.ts +287 -0
  31. package/setup.ts +233 -0
  32. package/slots.ts +65 -0
  33. package/splates.test.ts +338 -0
  34. package/splates.ts +567 -0
  35. package/task.ts +354 -0
  36. package/tunnel.ts +106 -0
  37. package/wait-journal.test.ts +96 -0
  38. package/waiting.ts +59 -0
  39. package/worker-relay.test.ts +233 -0
  40. package/worker.ts +300 -0
  41. package/artifact.d.ts +0 -3
  42. package/budget.d.ts +0 -3
  43. package/creds.d.ts +0 -3
  44. package/flags.d.ts +0 -37
  45. package/hook.d.ts +0 -3
  46. package/index.d.ts +0 -2
  47. package/index.js +0 -1776
  48. package/infra.d.ts +0 -3
  49. package/lane.d.ts +0 -3
  50. package/notes.d.ts +0 -4
  51. package/projects.d.ts +0 -3
  52. package/queue.d.ts +0 -3
  53. package/server.d.ts +0 -7
  54. package/setup.d.ts +0 -22
  55. package/slots.d.ts +0 -3
  56. package/splates.d.ts +0 -10
  57. package/task.d.ts +0 -3
  58. package/tunnel.d.ts +0 -3
  59. package/waiting.d.ts +0 -3
  60. package/worker.d.ts +0 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stringsync/splates",
3
- "version": "0.0.52",
3
+ "version": "0.0.53",
4
4
  "description": "Runs several AI coding agents on one repository at once, each in its own git worktree",
5
5
  "license": "MIT",
6
6
  "author": "stringsync LLC",
@@ -10,6 +10,14 @@
10
10
  "directory": "packages/splates"
11
11
  },
12
12
  "type": "module",
13
+ "module": "index.ts",
14
+ "bin": {
15
+ "splates": "./index.ts",
16
+ "arbor": "./index.ts"
17
+ },
18
+ "exports": {
19
+ ".": "./index.ts"
20
+ },
13
21
  "engines": {
14
22
  "bun": ">=1.3"
15
23
  },
@@ -17,20 +25,14 @@
17
25
  "access": "public"
18
26
  },
19
27
  "dependencies": {
20
- "@stringsync/splates-server": "^0.0.52",
28
+ "@stringsync/splates-server": "workspace:*",
21
29
  "webappwiz": "0.0.47",
22
30
  "zod": "^4.4.3"
23
31
  },
24
- "main": "./index.js",
25
- "types": "./index.d.ts",
26
- "exports": {
27
- ".": {
28
- "types": "./index.d.ts",
29
- "default": "./index.js"
30
- }
32
+ "devDependencies": {
33
+ "@types/bun": "1.3.14"
31
34
  },
32
- "bin": {
33
- "splates": "./index.js",
34
- "arbor": "./index.js"
35
+ "scripts": {
36
+ "build": "bun run --cwd ../web build"
35
37
  }
36
38
  }
@@ -0,0 +1,191 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { basename, join } from "node:path";
3
+ import { Testing } from "@stringsync/splates-server/testing";
4
+ import { projectArgs, splates } from "./splates";
5
+
6
+ /**
7
+ * Two projects on one machine, the first's registry naming both, its agent
8
+ * running `splates` on the second with `--project`.
9
+ */
10
+ describe("splates across projects", () => {
11
+ let here: Testing;
12
+ let there: Testing;
13
+ let deps: Parameters<typeof splates.run>[0];
14
+ let hereSlug: string;
15
+ let thereSlug: string;
16
+
17
+ beforeEach(async () => {
18
+ here = await Testing.open();
19
+ there = await Testing.open();
20
+ await here.projects.register(there.root);
21
+ const listed = await here.projects.list();
22
+ const slug = (root: string) =>
23
+ listed.find((each) => each.root === root)?.slug ?? "";
24
+ hereSlug = slug(here.root);
25
+ thereSlug = slug(there.root);
26
+ here.ps.cd(here.root);
27
+ deps = {
28
+ log: here.log,
29
+ fs: here.fs,
30
+ ps: here.ps,
31
+ assets: here.assets,
32
+ secrets: here.secrets,
33
+ };
34
+ });
35
+
36
+ afterEach(async () => {
37
+ await here.disposeAsync();
38
+ await there.disposeAsync();
39
+ });
40
+
41
+ /** Runs `splates --project <there> ...args` from here. */
42
+ const onThere = (...args: string[]) =>
43
+ splates.run({ ...deps, project: thereSlug }, args);
44
+
45
+ it("splits a leading --project off the arguments", () => {
46
+ expect(projectArgs(["--project", "shop", "list"])).toEqual({
47
+ project: "shop",
48
+ argv: ["list"],
49
+ });
50
+ expect(projectArgs(["--project=shop", "list"])).toEqual({
51
+ project: "shop",
52
+ argv: ["list"],
53
+ });
54
+ expect(projectArgs(["list", "--project", "shop"])).toEqual({
55
+ argv: ["list", "--project", "shop"],
56
+ });
57
+ });
58
+
59
+ it("queues a task in its untriaged lane, saying where it came from", async () => {
60
+ await splates.run(deps, ["add", "alpha", "--goal", "Land it."]);
61
+ here.ps.cd((await here.service.find("alpha")).path);
62
+
63
+ await onThere(
64
+ "task",
65
+ "add",
66
+ "Export the parser",
67
+ "--slug",
68
+ "parser-export",
69
+ );
70
+
71
+ const thereSlug = (await here.projects.list()).find(
72
+ (each) => each.root === there.root,
73
+ )?.slug;
74
+ expect(String(here.log.entries.at(-1)?.message)).toContain(
75
+ `${thereSlug} #1 at`,
76
+ );
77
+
78
+ const [queued] = await there.tasks.all();
79
+ expect(queued?.state).toMatchObject({
80
+ subject: "Export the parser",
81
+ from: null,
82
+ lane: null,
83
+ origin: { project: here.root, task: "alpha" },
84
+ });
85
+ expect(await here.tasks.all()).toEqual([]);
86
+
87
+ await onThere("task", "show", String(queued?.id));
88
+ expect(String(here.log.entries.at(-1)?.message)).toContain(
89
+ `${thereSlug} #${queued?.id}`,
90
+ );
91
+ expect(String(here.log.entries.at(-1)?.message)).toContain(
92
+ `from: ${basename(here.root)}/alpha`,
93
+ );
94
+ });
95
+
96
+ it("refuses a lane for a queued task from another project", async () => {
97
+ await onThere(
98
+ "task",
99
+ "add",
100
+ "Export the parser",
101
+ "--slug",
102
+ "parser-export",
103
+ "--lane",
104
+ "new",
105
+ );
106
+
107
+ expect(here.out()).toContain('"reason":"not_granted"');
108
+ expect(await there.tasks.all()).toEqual([]);
109
+ });
110
+
111
+ it("refuses what the other project has not granted, until it does", async () => {
112
+ await onThere(
113
+ "task",
114
+ "add",
115
+ "Export the parser",
116
+ "--slug",
117
+ "parser-export",
118
+ );
119
+ await onThere("task", "remove", "1");
120
+ expect(here.out()).toContain('"reason":"not_granted"');
121
+ expect(here.out()).toContain(`splates projects grant ${hereSlug} tasks`);
122
+ expect(await there.tasks.all()).toHaveLength(1);
123
+
124
+ // The person, in the other project's main tree.
125
+ here.ps.cd(there.root);
126
+ await splates.run(deps, ["projects", "grant", hereSlug, "tasks"]);
127
+ expect(await here.projects.reach(here.root, there.root)).toBe("tasks");
128
+
129
+ here.ps.cd(here.root);
130
+ await onThere("task", "remove", "1");
131
+ expect(await there.tasks.all()).toEqual([]);
132
+ });
133
+
134
+ it("starts no task in another project, granted or not, so it is queued there", async () => {
135
+ await here.projects.grant(there.root, here.root, "tasks");
136
+
137
+ await onThere("add", "beta", "--goal", "Do it.", "--agent", "claude");
138
+
139
+ expect(here.out()).toContain('"reason":"not_granted"');
140
+ expect(here.out()).toContain("task add");
141
+ expect((await there.service.find("beta")).gone).toBe(true);
142
+ });
143
+
144
+ it("refuses a command only a project runs on itself", async () => {
145
+ await here.projects.grant(there.root, here.root, "tasks");
146
+
147
+ await onThere("projects", "grant", hereSlug, "tasks");
148
+
149
+ expect(here.out()).toContain('"reason":"not_granted"');
150
+ });
151
+
152
+ it("checks an agent of this project's page that changes into the other", async () => {
153
+ await here.projects.grant(there.root, here.root, "read");
154
+ await here.fs.mkdir(join(here.root, ".git/splates/conversations"));
155
+ await here.fs.write(
156
+ join(here.root, ".git/splates/conversations/c1.jsonl"),
157
+ "",
158
+ );
159
+ here.proc.env.SPLATES_CONVERSATION = "c1";
160
+ here.ps.cd(there.root);
161
+
162
+ await splates.run(deps, [
163
+ "task",
164
+ "add",
165
+ "Export the parser",
166
+ "--slug",
167
+ "parser-export",
168
+ ]);
169
+ expect(here.out()).toContain('"reason":"not_granted"');
170
+ expect(await there.tasks.all()).toEqual([]);
171
+
172
+ await splates.run(deps, ["task", "list", "--json"]);
173
+ expect(JSON.parse(String(here.log.entries.at(-1)?.message))).toEqual([]);
174
+ });
175
+
176
+ it("lists what each project may do in the other", async () => {
177
+ await here.projects.grant(there.root, here.root, "tasks");
178
+
179
+ await splates.run(deps, ["projects", "list", "--json"]);
180
+
181
+ expect(JSON.parse(String(here.log.entries.at(-1)?.message))).toEqual([
182
+ {
183
+ slug: thereSlug,
184
+ root: there.root,
185
+ gone: false,
186
+ here: "queue",
187
+ there: "tasks",
188
+ },
189
+ ]);
190
+ });
191
+ });
package/projects.ts ADDED
@@ -0,0 +1,37 @@
1
+ import {
2
+ grantReach,
3
+ listReaches,
4
+ REACHES,
5
+ } from "@stringsync/splates-server/projects";
6
+ import { repository } from "@stringsync/splates-server/records";
7
+ import type { SplatesDeps } from "@stringsync/splates-server/server";
8
+ import { cli } from "webappwiz/cmd";
9
+ import { z } from "zod";
10
+
11
+ /** `splates projects`, which the root cli mounts after `splates lane`. */
12
+ export const splatesProjects = cli<SplatesDeps>("projects")
13
+ .description(
14
+ "the other splates projects on this machine, and what agents of each may do in the others through splates: `read` their tasks and lanes; add tasks to their untriaged lane with `queue`, as every project may until granted otherwise; or run their queue, lanes and hooks with `tasks`. Any repo command runs on another project as `splates --project <slug> <command>`, refused where it needs more than that project granted",
15
+ )
16
+ .fallback("list");
17
+
18
+ splatesProjects
19
+ .command("list")
20
+ .use(repository({ needs: "read" }))
21
+ .description(
22
+ "print each other project: its slug, its root, what it may do here, and what this one may do there",
23
+ )
24
+ .option("json", z.boolean(), { default: false, description: "emit JSON" })
25
+ .action((opts, ctx) => listReaches(ctx, { json: opts.json }));
26
+
27
+ splatesProjects
28
+ .command("grant")
29
+ .use(repository({ needs: "own" }))
30
+ .description(
31
+ "let agents of another project do as much as `read`, `queue` or `tasks` here, each including those before it; the user's call, so an agent runs it only when the user asks it to",
32
+ )
33
+ .arg("project", z.string(), { description: "the other project's slug" })
34
+ .arg("reach", z.enum(REACHES), {
35
+ description: "read, queue or tasks",
36
+ })
37
+ .action((opts, ctx) => grantReach(ctx, opts.project, opts.reach));
package/queue.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { tasksNamed } from "@stringsync/splates-server/board";
2
+ import { refuseGroomer } from "@stringsync/splates-server/conversations";
3
+ import {
4
+ queueAdd,
5
+ queueList,
6
+ queueMove,
7
+ queueRemove,
8
+ refuseAgentQueue,
9
+ } from "@stringsync/splates-server/queue";
10
+ import { repository } from "@stringsync/splates-server/records";
11
+ import type { SplatesDeps } from "@stringsync/splates-server/server";
12
+ import { cli } from "webappwiz/cmd";
13
+ import { z } from "zod";
14
+ import { here } from "./flags";
15
+
16
+ /** `splates queue`, which the root cli mounts beside `splates task`. */
17
+ export const splatesQueue = cli<SplatesDeps>("queue").description(
18
+ "the queue: the tasks a worker of `splates server` starts as soon as it has a slot free, each after every task it is blocked by, otherwise in the order you set. Only you queue a task, from the backlog or groomed, and an agent asks you to with a queue card; one that names no model runs on the roster's first agent",
19
+ );
20
+
21
+ splatesQueue
22
+ .command("list")
23
+ .use(repository({ needs: "read" }))
24
+ .description(
25
+ "the queue in the order workers start it, with each task's model and what it waits on: the open tasks it is blocked by, or those removed without merging",
26
+ )
27
+ .option("json", z.boolean(), { default: false, description: "emit JSON" })
28
+ .action((opts, ctx) => queueList(ctx, { json: opts.json }));
29
+
30
+ splatesQueue
31
+ .command("add")
32
+ .use(repository({ needs: "tasks" }))
33
+ .description(
34
+ "queue tasks for a worker to start once nothing they are blocked by is open; refused for one already under way",
35
+ )
36
+ .rest("ids", z.coerce.number().int().positive(), {
37
+ description: "task numbers",
38
+ })
39
+ .action(async (opts, ctx) => {
40
+ refuseAgentQueue(ctx.service, "queue add");
41
+ await refuseGroomer(ctx.service, "queue add");
42
+ await ctx.journal.record(
43
+ "queue add",
44
+ await here(ctx),
45
+ () => queueAdd(ctx, opts.ids),
46
+ (queued) => ({ tasks: tasksNamed(queued) }),
47
+ );
48
+ });
49
+
50
+ splatesQueue
51
+ .command("remove")
52
+ .use(repository({ needs: "tasks" }))
53
+ .description(
54
+ "take tasks out of the queue, back to ready when they name a model, else the backlog; the tasks stay",
55
+ )
56
+ .rest("ids", z.coerce.number().int().positive(), {
57
+ description: "task numbers",
58
+ })
59
+ .action(async (opts, ctx) => {
60
+ refuseAgentQueue(ctx.service, "queue remove");
61
+ await ctx.journal.record(
62
+ "queue remove",
63
+ await here(ctx),
64
+ () => queueRemove(ctx, opts.ids),
65
+ (removed) => ({ tasks: tasksNamed(removed) }),
66
+ );
67
+ });
68
+
69
+ splatesQueue
70
+ .command("move")
71
+ .use(repository({ needs: "tasks" }))
72
+ .description(
73
+ "move a queued task to another place in the queue, from 1 for the next to start; what it is blocked by still goes first. Then lists the queue",
74
+ )
75
+ .arg("id", z.coerce.number().int().positive(), { description: "task number" })
76
+ .arg("position", z.coerce.number().int().positive(), {
77
+ description: "where in the queue to put it, from 1",
78
+ })
79
+ .action(async (opts, ctx) => {
80
+ refuseAgentQueue(ctx.service, "queue move");
81
+ return queueMove(ctx, opts.id, opts.position);
82
+ });
Binary file
Binary file
Binary file
Binary file
package/server.ts ADDED
@@ -0,0 +1,287 @@
1
+ import {
2
+ BoxShell,
3
+ type BoxStanding,
4
+ CoordinatorBox,
5
+ coordinatorMoved,
6
+ deployKey,
7
+ machineMove,
8
+ neededCreds,
9
+ } from "@stringsync/splates-server/coordinator";
10
+ import { SYSTEM_STORES } from "@stringsync/splates-server/creds";
11
+ import { fail } from "@stringsync/splates-server/exit";
12
+ import {
13
+ DEFAULT_PORT,
14
+ RUNNING,
15
+ type SplatesDeps,
16
+ serveDev,
17
+ } from "@stringsync/splates-server/server";
18
+ import { LOCAL_SLOTS } from "@stringsync/splates-server/workers";
19
+ import { cli } from "webappwiz/cmd";
20
+ import { z } from "zod";
21
+ import { splatesInfra } from "./infra";
22
+
23
+ /**
24
+ * `splates server`, which the root cli mounts after `splates log`: bare, it
25
+ * runs the server here; its other commands deploy it onto a box of the user's
26
+ * own and look after that box.
27
+ */
28
+ export const splatesServer = cli<SplatesDeps>("server")
29
+ .description(
30
+ "the splates server: run it on this machine, or deploy it to an always-on box in your own AWS account, which the Pulumi project `splates server infra init` writes makes, reached by SSH alone",
31
+ )
32
+ .fallback("run");
33
+
34
+ splatesServer
35
+ .command("run")
36
+ .description(
37
+ "serve every splates project on this machine as a web page, from wherever it is run: the page picks the project it shows, adds one by its path, running `splates init` there, and shows its queued and started tasks, where queued tasks can be added, updated, reordered or removed; it brings each project's records forward as it opens it. A server that crashes starts again after 2s, then 4s, 8s and 16s, and is left down after five crashes in a row that each came within a minute of its start",
38
+ )
39
+ .option("port", z.coerce.number(), {
40
+ default: DEFAULT_PORT,
41
+ description: "port to listen on, or the next open one above it",
42
+ })
43
+ .option("allow-hosts", z.string(), {
44
+ default: "",
45
+ description:
46
+ "comma-separated host names the page may also be reached by, like a tunnel's; put Cloudflare Access in front of them with --access-team and --access-aud",
47
+ })
48
+ .option("access-team", z.string(), {
49
+ default: "",
50
+ description:
51
+ "the Cloudflare Access team in front of --allow-hosts, as in <team>.cloudflareaccess.com; with --access-aud, those hosts answer only to a verified Access token, and may then use conversations and terminals",
52
+ })
53
+ .option("access-aud", z.string(), {
54
+ default: "",
55
+ description: "the AUD tag of that Access application",
56
+ })
57
+ .option("tunnel", z.string(), {
58
+ default: "",
59
+ description:
60
+ "the public host name of a tunnel to run, with cloudflared or the tunnel provider named by splates.config.ts in the repository it is run in, if any, which is allowed as a host too; refused without --access-team and --access-aud, so a tunnel never exposes the page unauthenticated. With none of the tunnel and Access flags, the tunnel `splates tunnel setup` saved runs, if any",
61
+ })
62
+ .option("tunnel-name", z.string(), {
63
+ default: "",
64
+ description:
65
+ "the named tunnel cloudflared runs for --tunnel, by default the one its own config names; not with a tunnel provider of splates.config.ts",
66
+ })
67
+ .option("ports-host", z.string(), {
68
+ default: "",
69
+ description:
70
+ "the wildcard base host to serve task ports under, saved for this machine so a bare `splates server` serves it too: each port a task's processes listen on opens at https://<task>-<port>.<base>, to whoever Cloudflare Access lets in, and is refused without Access; routing *.<base> here, its DNS and TLS, are yours. `none` forgets it, leaving each task port forwarded to a port on this machine's loopback alone",
71
+ })
72
+ .option("worker-slots", z.coerce.number().int().min(1), {
73
+ default: LOCAL_SLOTS,
74
+ description:
75
+ "how many agents that other agents started (with `splates add --agent`, or a lane's orchestrator handing out tasks) this machine's worker runs at once; past them, each new or resumed one waits in the queue, first come, first served, which `splates worker queue` shows and reorders. An agent you start runs at once on any worker up, taking no slot. Read as the server starts its worker, which a restart from the page leaves running",
76
+ })
77
+ .option("keepalive", z.boolean(), {
78
+ default: false,
79
+ description:
80
+ "keep a machine left to serve awake while the server runs: on macOS with `caffeinate`, on power or idle, though closing a laptop's lid still sleeps it; on Linux with a systemd-inhibit lock on sleep and idle, which the server serves on without, saying so, where systemd is missing or refuses it. Refused on other platforms",
81
+ })
82
+ .option("slot", z.boolean(), {
83
+ default: false,
84
+ description:
85
+ "serve this checkout's splates at https://<word>.<zone>, a slot of its own on the zone `splates tunnel setup` uses, behind the same Access policy, so several run side by side and a phone opens each by name: a task's worktree keeps its word, as fox, until the task merges or is removed, which stops its server; run again, it stops the tree's last server first. Refused in a splates repository's main tree, which every agent shares. A slot keeps its own projects in ~/.splates/slots/<word>, apart from yours, starting with a throwaway project there; not with the tunnel and Access flags",
86
+ })
87
+ .action((opts, ctx) => serveDev(ctx, opts));
88
+
89
+ splatesServer.mount("infra", splatesInfra);
90
+
91
+ splatesServer
92
+ .command("deploy")
93
+ .description(
94
+ "deploy the server to the box its Pulumi stack made (`splates server infra init`, then `pulumi up`), over SSH, with splates of this release installed for the user splates. The SSH key and the host key come from the stack and the instance's console. It checks what the deploy needs, brings the box's splates to this release, and exits `blocked`, naming each, until the box's Parameter Store holds the creds the stack should have put there; then gives each project a clone there with its deploy key from Parameter Store. Then it asks every agent of this machine's `splates server` to checkpoint and stops it, carries the server's records (no creds: the box reads its own through its instance role) to the box, starts `splates server` there for good under systemd, and joins this machine to it as a worker, kept up by a LaunchAgent, where each task carries on in its worktree here. This machine's records go aside, kept, and its `splates` reaches the box from then on. Run again once the server runs on the box, it brings the box's splates to this release and restarts it there",
95
+ )
96
+ .option("dry-run", z.boolean(), {
97
+ default: false,
98
+ description:
99
+ "stop once the box is ready, before this machine's server stops or anything here changes; once the server runs on the box, change nothing",
100
+ })
101
+ .option("dev", z.string(), {
102
+ default: `http://localhost:${DEFAULT_PORT}`,
103
+ description: "this machine's `splates server`, which the deploy stops",
104
+ })
105
+ .action(async (opts, ctx) => {
106
+ const moved = await machineMove({
107
+ ps: ctx.ps,
108
+ fs: ctx.fs,
109
+ log: ctx.log,
110
+ version: RUNNING,
111
+ dev: opts.dev,
112
+ stores: ctx.secrets ?? SYSTEM_STORES,
113
+ ...(ctx.fetch === undefined ? {} : { fetch: ctx.fetch }),
114
+ }).run({ dryRun: opts["dry-run"] });
115
+ if (moved.status === "blocked") {
116
+ fail(
117
+ "blocked",
118
+ [
119
+ "The box's Parameter Store lacks these. Set each (the Pulumi stack makes them: `pulumi up`), then deploy again:",
120
+ ...moved.needs.map((name) => ` ${name}`),
121
+ ].join("\n"),
122
+ { needs: moved.needs },
123
+ );
124
+ }
125
+ if (moved.status === "deployed") {
126
+ ctx.log.info(
127
+ `the server runs on the box at ${moved.ipv4}: deploy again without --dry-run to bring it to ${moved.version}`,
128
+ );
129
+ return;
130
+ }
131
+ if (moved.status === "updated") {
132
+ ctx.log.info(
133
+ `the server on the box at ${moved.ipv4} runs ${moved.version} now`,
134
+ );
135
+ return;
136
+ }
137
+ if (moved.status === "ready") {
138
+ ctx.log.info(
139
+ `the box is ready to serve ${moved.projects.join(", ")} through ${moved.host}: deploy again without --dry-run`,
140
+ );
141
+ return;
142
+ }
143
+ ctx.log.info(
144
+ [
145
+ `the server runs on the box: https://${moved.host}`,
146
+ `this machine is its worker ${ctx.ps.hostname}, kept up by ${moved.worker}`,
147
+ `its own records are kept aside in ${moved.aside} and each project's .git/splates.moved`,
148
+ ].join("\n"),
149
+ );
150
+ });
151
+
152
+ splatesServer
153
+ .command("status")
154
+ .description(
155
+ "print where the server runs, here or deployed to its box, and the box as its cloud has it",
156
+ )
157
+ .option("json", z.boolean(), { default: false, description: "emit JSON" })
158
+ .action(async (opts, ctx) => {
159
+ const deployed = await coordinatorMoved({
160
+ fs: ctx.fs,
161
+ ps: ctx.ps,
162
+ home: box(ctx).home,
163
+ });
164
+ const standing = await box(ctx).status();
165
+ if (opts.json) {
166
+ ctx.log.info(JSON.stringify({ deployed, box: standing }));
167
+ return;
168
+ }
169
+ ctx.log.info(
170
+ [
171
+ `runs: ${deployed ? "on the box" : "on this machine"}`,
172
+ ...(standing === null
173
+ ? [
174
+ "box: none: `splates server infra init` writes the Pulumi project, `pulumi up` there makes it",
175
+ ]
176
+ : [
177
+ `address: ${standing.ipv4}`,
178
+ `instance: ${standing.instanceId} (role ${standing.roleName})`,
179
+ `host: ${standing.host}`,
180
+ "ssh: splates server ssh",
181
+ ]),
182
+ ].join("\n"),
183
+ );
184
+ });
185
+
186
+ splatesServer
187
+ .command("ssh")
188
+ .description(
189
+ "open a root shell on the box over SSH, with the key its Pulumi stack made, or run the command given there",
190
+ )
191
+ .rest("command", z.string(), {
192
+ description: "what to run on the box; by default a shell",
193
+ })
194
+ .passThroughOptions()
195
+ .action(async (opts, ctx) => {
196
+ const standing = await box(ctx).status();
197
+ if (standing === null) {
198
+ fail(
199
+ "not_found",
200
+ "no box: `splates server infra init` writes the Pulumi project, `pulumi up` there makes it",
201
+ );
202
+ }
203
+ const { exitCode } = await ctx.ps.spawn(
204
+ shell(ctx, standing).login(opts.command),
205
+ );
206
+ ctx.ps.exit(exitCode);
207
+ });
208
+
209
+ splatesServer
210
+ .command("destroy")
211
+ .description(
212
+ "unmake the box and everything its Pulumi stack made (its role, snapshots, creds, tunnel and deploy keys), lifting the box's termination protection first: anything only on the box is lost. The stack and its config stay, so the setup's Make the box step, or `splates server deploy`, makes it again. Without --yes, says what it would destroy",
213
+ )
214
+ .option("yes", z.boolean(), {
215
+ default: false,
216
+ description: "destroy without asking",
217
+ })
218
+ .action(async (opts, ctx) => {
219
+ const infra = await box(ctx).infra();
220
+ if (infra === null) {
221
+ fail(
222
+ "not_found",
223
+ "no infra project here: nothing to destroy, since `splates server infra init` has not written one",
224
+ );
225
+ }
226
+ const standing = await box(ctx).status();
227
+ const what =
228
+ standing === null
229
+ ? `what the stack in ${infra.dir} made`
230
+ : `the box at ${standing.ipv4} (${standing.instanceId}) and everything the stack in ${infra.dir} made`;
231
+ if (!opts.yes) {
232
+ fail(
233
+ "usage",
234
+ `this destroys ${what}, and anything only on the box is lost: run again with --yes`,
235
+ { dir: infra.dir, box: standing },
236
+ );
237
+ }
238
+ ctx.log.info(`Destroying ${what}, a few minutes…`);
239
+ await box(ctx).destroy();
240
+ ctx.log.info(
241
+ "Destroyed. The stack and its config stay for making it again.",
242
+ );
243
+ });
244
+
245
+ splatesServer
246
+ .command("needs")
247
+ .description(
248
+ "kept for `splates server deploy`, which runs it on the box: print the name of each cred this box's store lacks of those the server needs, one per line",
249
+ )
250
+ .rest("roots", z.string(), {
251
+ description: "each project's clone on this machine",
252
+ })
253
+ .action(async (opts, ctx) => {
254
+ const missing = await neededCreds(ctx.secrets ?? SYSTEM_STORES, opts.roots);
255
+ for (const name of missing) {
256
+ ctx.log.info(name);
257
+ }
258
+ });
259
+
260
+ splatesServer
261
+ .command("deploy-key")
262
+ .description(
263
+ "kept for `splates server deploy`, which runs it on the box: print the deploy key of the project whose clone is <root>",
264
+ )
265
+ .arg("root", z.string(), {
266
+ description: "the project's clone on this machine",
267
+ })
268
+ .action(async (opts, ctx) => {
269
+ const key = await deployKey(ctx.secrets ?? SYSTEM_STORES, opts.root);
270
+ if (key === undefined) {
271
+ fail("not_found", `no deploy key kept for ${opts.root}`);
272
+ }
273
+ ctx.log.info(key);
274
+ });
275
+
276
+ function box(ctx: SplatesDeps): CoordinatorBox {
277
+ return new CoordinatorBox({ ps: ctx.ps, fs: ctx.fs });
278
+ }
279
+
280
+ function shell(ctx: SplatesDeps, standing: BoxStanding): BoxShell {
281
+ return new BoxShell({
282
+ ps: ctx.ps,
283
+ ipv4: standing.ipv4,
284
+ key: standing.key,
285
+ knownHosts: `${box(ctx).home}/coordinator/known_hosts`,
286
+ });
287
+ }