tapthat-server 0.1.1 → 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 (2) hide show
  1. package/dist/cli.js +1275 -28
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -1,8 +1,1236 @@
1
1
  #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/install/sh.ts
13
+ import { spawn as spawn3 } from "node:child_process";
14
+ function run2(command, args, opts = {}) {
15
+ return new Promise((done) => {
16
+ const child = spawn3(command, args, {
17
+ cwd: opts.cwd,
18
+ env: { ...process.env, ...opts.env },
19
+ stdio: opts.interactive ? "inherit" : ["pipe", "pipe", "pipe"]
20
+ });
21
+ let stdout = "";
22
+ let stderr = "";
23
+ child.stdout?.on("data", (d) => stdout += d);
24
+ child.stderr?.on("data", (d) => stderr += d);
25
+ child.on("error", (e) => done({ code: 127, stdout, stderr: stderr || String(e.message) }));
26
+ child.on("exit", (code) => done({ code: code ?? 1, stdout, stderr }));
27
+ if (!opts.interactive) child.stdin?.end(opts.input ?? "");
28
+ });
29
+ }
30
+ async function must(command, args, opts = {}) {
31
+ const result = await run2(command, args, opts);
32
+ if (result.code !== 0) throw new CommandError(`${command} ${args.filter((a) => !a.startsWith("-")).slice(0, 3).join(" ")}`, result);
33
+ return result.stdout;
34
+ }
35
+ function parseJson(text, what) {
36
+ const start = text.search(/[[{]/);
37
+ if (start < 0) throw new Error(`${what}: expected JSON, got ${JSON.stringify(text.slice(0, 200))}`);
38
+ try {
39
+ return JSON.parse(text.slice(start));
40
+ } catch {
41
+ throw new Error(`${what}: could not parse its JSON output`);
42
+ }
43
+ }
44
+ var CommandError, sleep;
45
+ var init_sh = __esm({
46
+ "src/install/sh.ts"() {
47
+ "use strict";
48
+ CommandError = class extends Error {
49
+ constructor(command, result) {
50
+ const detail = (result.stderr || result.stdout).trim().split("\n").slice(-6).join("\n");
51
+ super(`${command} failed${detail ? `:
52
+ ${detail}` : ""}`);
53
+ this.command = command;
54
+ this.result = result;
55
+ }
56
+ };
57
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms));
58
+ }
59
+ });
60
+
61
+ // src/install/github.ts
62
+ async function canPush(token2, repo) {
63
+ const base = process.env.TAPTHAT_GITHUB_URL ?? "https://github.com";
64
+ const res = await fetch(`${base}/${repo}.git/info/refs?service=git-receive-pack`, {
65
+ headers: { authorization: `Basic ${Buffer.from(`x-access-token:${token2}`).toString("base64")}`, "user-agent": "tapthat-installer" },
66
+ redirect: "manual"
67
+ });
68
+ await res.body?.cancel();
69
+ return res.status === 200;
70
+ }
71
+ var GitHub;
72
+ var init_github = __esm({
73
+ "src/install/github.ts"() {
74
+ "use strict";
75
+ init_sh();
76
+ GitHub = class {
77
+ constructor(bin = process.env.TAPTHAT_GH_BIN ?? "gh") {
78
+ this.bin = bin;
79
+ }
80
+ async available() {
81
+ return (await run2(this.bin, ["--version"])).code === 0;
82
+ }
83
+ async loggedIn() {
84
+ return (await run2(this.bin, ["auth", "status"])).code === 0;
85
+ }
86
+ async login() {
87
+ return (await run2(this.bin, ["auth", "login"], { interactive: true })).code === 0;
88
+ }
89
+ async api(path, args = []) {
90
+ const r = await run2(this.bin, ["api", path, ...args]);
91
+ if (r.code !== 0) {
92
+ if (/HTTP 404|Not Found/.test(r.stderr + r.stdout)) return null;
93
+ throw new Error(`gh api ${path} failed: ${(r.stderr || r.stdout).trim().split("\n").slice(-3).join(" ")}`);
94
+ }
95
+ return parseJson(r.stdout, `gh api ${path}`);
96
+ }
97
+ async defaultBranch(repo) {
98
+ const r = await this.api(`repos/${repo}`);
99
+ if (!r) throw new Error(`GitHub repository ${repo} not found, or your gh login cannot see it`);
100
+ return r.default_branch;
101
+ }
102
+ async branchSha(repo, branch) {
103
+ const r = await this.api(`repos/${repo}/branches/${encodeURIComponent(branch)}`);
104
+ return r?.commit.sha ?? null;
105
+ }
106
+ async createBranch(repo, branch, from) {
107
+ const sha = await this.branchSha(repo, from);
108
+ if (!sha) throw new Error(`${repo} has no branch "${from}" to create "${branch}" from`);
109
+ await must(this.bin, ["api", "-X", "POST", `repos/${repo}/git/refs`, "-f", `ref=refs/heads/${branch}`, "-f", `sha=${sha}`]);
110
+ }
111
+ async file(repo, path, ref) {
112
+ const r = await this.api(`repos/${repo}/contents/${path}?ref=${encodeURIComponent(ref)}`);
113
+ if (!r?.content) return null;
114
+ return Buffer.from(r.content, "base64").toString("utf8");
115
+ }
116
+ async putFile(repo, path, branch, content, message) {
117
+ await must(this.bin, [
118
+ "api",
119
+ "-X",
120
+ "PUT",
121
+ `repos/${repo}/contents/${path}`,
122
+ "-f",
123
+ `message=${message}`,
124
+ "-f",
125
+ `branch=${branch}`,
126
+ "-f",
127
+ `content=${Buffer.from(content).toString("base64")}`
128
+ ]);
129
+ }
130
+ async repoFacts(repo, ref) {
131
+ const pkg = await this.file(repo, "package.json", ref);
132
+ let scripts = {};
133
+ try {
134
+ scripts = (pkg ? JSON.parse(pkg).scripts : null) ?? {};
135
+ } catch {
136
+ }
137
+ const has = async (p) => !!await this.api(`repos/${repo}/contents/${p}?ref=${encodeURIComponent(ref)}`);
138
+ const lockfile = await has("package-lock.json") ? "npm" : await has("pnpm-lock.yaml") ? "pnpm" : await has("yarn.lock") ? "yarn" : null;
139
+ return { scripts, lockfile };
140
+ }
141
+ };
142
+ }
143
+ });
144
+
145
+ // src/install/model.ts
146
+ function referencesOf(s) {
147
+ const out = [];
148
+ for (const [key, value] of Object.entries(s.variables)) {
149
+ for (const m of value.matchAll(CROSS_REF)) out.push({ service: m[1], variable: m[2], key });
150
+ }
151
+ return out;
152
+ }
153
+ function environmentDeploying(envs, branch) {
154
+ let best = null;
155
+ for (const env of envs) {
156
+ const repos = env.services.filter(isRepoService);
157
+ if (!repos.length) continue;
158
+ const share = repos.filter((s) => s.source.branch === branch).length / repos.length;
159
+ if (share > 0.5 && (!best || share > best.share)) best = { name: env.name, share };
160
+ }
161
+ return best?.name ?? null;
162
+ }
163
+ function suggestSite(services, withDomains) {
164
+ const candidates = services.filter((s) => isRepoService(s) && withDomains.has(s.name));
165
+ const preferred = ["app", "web", "frontend", "site", "www", "client"];
166
+ for (const name of preferred) {
167
+ const hit = candidates.find((s) => s.name === name);
168
+ if (hit) return hit.name;
169
+ }
170
+ return candidates[0]?.name ?? null;
171
+ }
172
+ function topology(services, site) {
173
+ const byName = new Map(services.map((s) => [s.name, s]));
174
+ const siteSpec = byName.get(site);
175
+ if (!siteSpec) throw new Error(`No service named "${site}".`);
176
+ const included = [site];
177
+ for (const ref of referencesOf(siteSpec)) {
178
+ const target = byName.get(ref.service);
179
+ if (ref.variable === "RAILWAY_PRIVATE_DOMAIN" && target && isRepoService(target) && !included.includes(target.name)) {
180
+ included.push(target.name);
181
+ }
182
+ }
183
+ const kept = /* @__PURE__ */ new Set();
184
+ const queue = [...included];
185
+ while (queue.length) {
186
+ const spec = byName.get(queue.shift());
187
+ if (!spec) continue;
188
+ for (const ref of referencesOf(spec)) {
189
+ if (ref.variable === "RAILWAY_PUBLIC_DOMAIN") continue;
190
+ const target = byName.get(ref.service);
191
+ if (!target || included.includes(target.name) || kept.has(target.name)) continue;
192
+ kept.add(target.name);
193
+ queue.push(target.name);
194
+ }
195
+ }
196
+ const pgs = services.filter((s) => isDatabase(s, "postgres")).map((s) => s.name);
197
+ const redises = services.filter((s) => isDatabase(s, "redis")).map((s) => s.name);
198
+ const postgres = pgs.find((n) => kept.has(n)) ?? null;
199
+ const redis = redises.find((n) => kept.has(n)) ?? null;
200
+ return {
201
+ site,
202
+ included,
203
+ kept: [...kept].sort(),
204
+ dropped: services.map((s) => s.name).filter((n) => !included.includes(n) && !kept.has(n)).sort(),
205
+ postgres,
206
+ redis
207
+ };
208
+ }
209
+ function assignPorts(services, included) {
210
+ const ports = /* @__PURE__ */ new Map();
211
+ const taken = /* @__PURE__ */ new Set([SIDECAR_PORT]);
212
+ for (const name of included) {
213
+ const port = Number(services.find((s) => s.name === name)?.variables.PORT);
214
+ if (Number.isInteger(port) && port > 0 && !taken.has(port)) {
215
+ ports.set(name, port);
216
+ taken.add(port);
217
+ }
218
+ }
219
+ let next = 3e3;
220
+ for (const name of included) {
221
+ if (ports.has(name)) continue;
222
+ while (taken.has(next)) next++;
223
+ ports.set(name, next);
224
+ taken.add(next);
225
+ }
226
+ return ports;
227
+ }
228
+ function rewriteValue(value, owner, included, ports) {
229
+ const out = value.replace(
230
+ /(https?:\/\/)?\$\{\{\s*([A-Za-z0-9_-]+)\.RAILWAY_PRIVATE_DOMAIN\s*\}\}(:\d+)?/g,
231
+ (whole, scheme, service) => included.includes(service) ? `${scheme ?? ""}localhost:${ports.get(service)}` : whole
232
+ );
233
+ return out.replace(ANY_REF, (whole, service, variable) => {
234
+ if (service) {
235
+ if (variable === "RAILWAY_PUBLIC_DOMAIN") return "${{RAILWAY_PUBLIC_DOMAIN}}";
236
+ return included.includes(service) ? `\${{${prefixOf(service)}_${variable}}}` : whole;
237
+ }
238
+ if (variable === "PORT") return String(ports.get(owner));
239
+ if (variable.startsWith("RAILWAY_")) return whole;
240
+ return `\${{${prefixOf(owner)}_${variable}}}`;
241
+ });
242
+ }
243
+ function planWorkspace(input) {
244
+ const { services, site, branch } = input;
245
+ const topo = topology(services, site);
246
+ const ports = assignPorts(services, topo.included);
247
+ const byName = new Map(services.map((s) => [s.name, s]));
248
+ const warnings = [];
249
+ const variables = {};
250
+ const secretKeys = /* @__PURE__ */ new Set();
251
+ const repos = topo.included.map((name) => {
252
+ const spec = byName.get(name);
253
+ const facts = input.repoFacts.get(name) ?? { scripts: {}, lockfile: "npm" };
254
+ const env = {};
255
+ for (const [key, raw] of Object.entries(spec.variables)) {
256
+ if (SKIPPED.test(key)) continue;
257
+ const rewritten = rewriteValue(raw, name, topo.included, ports);
258
+ if (/^https?:\/\/localhost:\d+[^$]*$/.test(rewritten)) {
259
+ env[key] = rewritten;
260
+ continue;
261
+ }
262
+ const wsKey = `${prefixOf(name)}_${key}`;
263
+ variables[wsKey] = rewritten;
264
+ if (!rewritten.includes("${{")) secretKeys.add(wsKey);
265
+ env[key] = `\${${wsKey}}`;
266
+ }
267
+ const command = facts.scripts.dev ? "npm run dev" : facts.scripts.start ? "npm start" : null;
268
+ if (!command) warnings.push(`${name}: package.json has no "dev" or "start" script; set devServer.command by hand`);
269
+ if (facts.lockfile && facts.lockfile !== "npm") {
270
+ warnings.push(`${name}: uses ${facts.lockfile}; the image has npm only, so install with npm or adjust devServer.install`);
271
+ }
272
+ const pre = spec.deploy?.preDeployCommand;
273
+ const prepare = facts.scripts["migrate:deploy"] ? "npm run migrate:deploy" : (Array.isArray(pre) ? pre.join(" && ") : pre) || null;
274
+ return {
275
+ name,
276
+ ...name === site ? { primary: true } : { url: `https://github.com/${spec.source.repo}.git` },
277
+ description: `The ${name} service (${spec.source.repo}).`,
278
+ ...facts.scripts.typecheck ? { verifyCommand: "npm run typecheck" } : {},
279
+ devServer: {
280
+ command: command ?? "npm run dev",
281
+ url: `http://localhost:${ports.get(name)}`,
282
+ install: facts.lockfile === "npm" || !facts.lockfile ? "npm ci --no-audit --no-fund" : "npm install --no-audit --no-fund",
283
+ ...prepare ? { prepare } : {},
284
+ env
285
+ }
286
+ };
287
+ });
288
+ const deployOrder = [...topo.included.filter((n) => n !== site), site];
289
+ const session = {};
290
+ if (topo.postgres) {
291
+ const pg = topo.postgres;
292
+ variables.TAPTHAT_PLAYGROUND_DATABASE_URL = `postgresql://\${{${pg}.PGUSER}}:\${{${pg}.PGPASSWORD}}@\${{${pg}.PGHOST}}:\${{${pg}.PGPORT}}/postgres`;
293
+ const redisKey = Object.keys(variables).find((k) => k.endsWith("_REDIS_URL"));
294
+ session.snapshot = {
295
+ source: "${TAPTHAT_DEV_DATABASE_URL}",
296
+ target: "${TAPTHAT_PLAYGROUND_DATABASE_URL}",
297
+ ...redisKey ? { redis: `\${${redisKey}}` } : {},
298
+ // Dev servers that talk to the database are stopped while it is replaced.
299
+ stopServers: topo.included.filter((n) => referencesOf(byName.get(n)).some((r) => r.service === pg))
300
+ };
301
+ } else {
302
+ warnings.push("No Postgres service found among the dependencies: Start session will copy code but no data.");
303
+ }
304
+ const migrates = repos.some((r) => "prepare" in r.devServer);
305
+ const config = {
306
+ branch,
307
+ git: { mode: "session", deployOrder },
308
+ ...migrates ? {
309
+ agent: {
310
+ rules: [
311
+ "Do not change database schemas or migrations. If a request needs a database change, stop and say exactly what is needed."
312
+ ]
313
+ }
314
+ } : {},
315
+ repos,
316
+ ...Object.keys(session).length ? { session } : {}
317
+ };
318
+ return { topology: topo, ports, variables, secretKeys, config, warnings };
319
+ }
320
+ function rewriteForKept(value, topo, ports) {
321
+ const present = new Set(topo.kept);
322
+ const withPrivate = value.replace(
323
+ /\$\{\{\s*([A-Za-z0-9_-]+)\.RAILWAY_PRIVATE_DOMAIN\s*\}\}(:\d+)?/g,
324
+ (whole, service) => topo.included.includes(service) ? `\${{${WORKSPACE}.RAILWAY_PRIVATE_DOMAIN}}:${ports.get(service)}` : whole
325
+ );
326
+ return withPrivate.replace(CROSS_REF, (whole, service, variable) => {
327
+ if (present.has(service) || service === WORKSPACE) return whole;
328
+ if (variable === "RAILWAY_PUBLIC_DOMAIN") return `\${{${WORKSPACE}.RAILWAY_PUBLIC_DOMAIN}}`;
329
+ if (topo.included.includes(service)) return `\${{${WORKSPACE}.${prefixOf(service)}_${variable}}}`;
330
+ return whole;
331
+ });
332
+ }
333
+ function keptRewrites(playground, topo, ports) {
334
+ const out = /* @__PURE__ */ new Map();
335
+ for (const s of playground) {
336
+ if (!topo.kept.includes(s.name)) continue;
337
+ const changed = {};
338
+ for (const [k, v] of Object.entries(s.variables)) {
339
+ const next = rewriteForKept(v, topo, ports);
340
+ if (next !== v) changed[k] = next;
341
+ }
342
+ if (Object.keys(changed).length) out.set(s.name, changed);
343
+ }
344
+ return out;
345
+ }
346
+ function danglingReferences(playground) {
347
+ const names = new Set(playground.map((s) => s.name));
348
+ const out = [];
349
+ for (const s of playground) {
350
+ for (const ref of referencesOf(s)) {
351
+ if (!names.has(ref.service)) out.push(`${s.name}.${ref.key} \u2192 ${ref.service}`);
352
+ }
353
+ }
354
+ return out;
355
+ }
356
+ function baseVariables(siteRepo, site) {
357
+ return {
358
+ NODE_ENV: "development",
359
+ PORT: String(SIDECAR_PORT),
360
+ TAPTHAT_ENABLE: "1",
361
+ TAPTHAT_PROXY: "1",
362
+ TAPTHAT_START_DEV_SERVER: "1",
363
+ TAPTHAT_WORKSPACE_ROOT: "/workspace/repos",
364
+ TAPTHAT_REPO_ROOT: `/workspace/repos/${site}`,
365
+ TAPTHAT_REPO_URL: `https://github.com/${siteRepo}.git`,
366
+ TAPTHAT_ALLOWED_ORIGINS: "https://${{RAILWAY_PUBLIC_DOMAIN}}"
367
+ };
368
+ }
369
+ function configPlaceholders(config) {
370
+ const names = /* @__PURE__ */ new Set();
371
+ for (const m of JSON.stringify(config).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)) names.add(m[1]);
372
+ return [...names].sort();
373
+ }
374
+ function missingVariables(placeholders, base, planned, current) {
375
+ const set = {};
376
+ const unknown = [];
377
+ const handled = /* @__PURE__ */ new Set(["TAPTHAT_TOKEN", "TAPTHAT_ENCRYPTION_KEY", "TAPTHAT_GIT_TOKEN", "TAPTHAT_DEV_DATABASE_URL"]);
378
+ const seen = /* @__PURE__ */ new Set();
379
+ const queue = [...Object.keys(base), ...placeholders, ...Object.keys(planned).filter((k) => k.startsWith("TAPTHAT_"))];
380
+ while (queue.length) {
381
+ const name = queue.shift();
382
+ if (seen.has(name) || handled.has(name)) continue;
383
+ seen.add(name);
384
+ const value = current[name] ?? base[name] ?? planned[name];
385
+ if (value === void 0) {
386
+ unknown.push(name);
387
+ continue;
388
+ }
389
+ if (!(name in current)) set[name] = value;
390
+ for (const m of value.matchAll(ANY_REF)) {
391
+ if (!m[1] && !m[2].startsWith("RAILWAY_")) queue.push(m[2]);
392
+ }
393
+ }
394
+ return { set, unknown: unknown.sort() };
395
+ }
396
+ function devDatabaseUrl(v) {
397
+ const { PGUSER, PGPASSWORD, RAILWAY_TCP_PROXY_DOMAIN: host, RAILWAY_TCP_PROXY_PORT: port } = v;
398
+ if (!PGUSER || !PGPASSWORD || !host || !port) return null;
399
+ return `postgresql://${encodeURIComponent(PGUSER)}:${encodeURIComponent(PGPASSWORD)}@${host}:${port}/postgres`;
400
+ }
401
+ var WORKSPACE, IMAGE, SIDECAR_PORT, CROSS_REF, ANY_REF, isDatabase, isRepoService, prefixOf, SKIPPED, isLiteralTemplate, REDIS_TEMPLATE_VARIABLES;
402
+ var init_model = __esm({
403
+ "src/install/model.ts"() {
404
+ "use strict";
405
+ WORKSPACE = "workspace";
406
+ IMAGE = "ghcr.io/ljellevo/tapthat-server:latest";
407
+ SIDECAR_PORT = 8080;
408
+ CROSS_REF = /\$\{\{\s*([A-Za-z0-9_-]+)\.([A-Za-z0-9_]+)\s*\}\}/g;
409
+ ANY_REF = /\$\{\{\s*(?:([A-Za-z0-9_-]+)\.)?([A-Za-z0-9_]+)\s*\}\}/g;
410
+ isDatabase = (s, kind) => {
411
+ const image = s.source.image ?? "";
412
+ if (kind === "postgres") return /postgres/i.test(image);
413
+ if (kind === "redis") return /redis/i.test(image);
414
+ return /postgres|redis|mysql|mongo/i.test(image);
415
+ };
416
+ isRepoService = (s) => !!s.source.repo;
417
+ prefixOf = (name) => name.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
418
+ SKIPPED = /^(PORT|HOSTNAME|NODE_ENV|RAILWAY_[A-Z_]+)$/;
419
+ isLiteralTemplate = (value) => !!value && /^secret\(/.test(value.trim());
420
+ REDIS_TEMPLATE_VARIABLES = {
421
+ REDISUSER: "default",
422
+ REDISPORT: "6379",
423
+ REDISHOST: "${{RAILWAY_PRIVATE_DOMAIN}}",
424
+ REDISPASSWORD: "${{REDIS_PASSWORD}}",
425
+ REDIS_URL: "redis://${{REDISUSER}}:${{REDIS_PASSWORD}}@${{REDISHOST}}:${{REDISPORT}}",
426
+ // Railway mounts volumes owned by root; Railway's redis image runs as a non-root user.
427
+ RAILWAY_RUN_UID: "0"
428
+ };
429
+ }
430
+ });
431
+
432
+ // src/install/railway.ts
433
+ var Railway;
434
+ var init_railway = __esm({
435
+ "src/install/railway.ts"() {
436
+ "use strict";
437
+ init_sh();
438
+ Railway = class {
439
+ constructor(cwd, bin = process.env.TAPTHAT_RAILWAY_BIN ?? "railway") {
440
+ this.cwd = cwd;
441
+ this.bin = bin;
442
+ }
443
+ cli(args, input) {
444
+ return must(this.bin, args, { cwd: this.cwd, input });
445
+ }
446
+ async version() {
447
+ const r = await run2(this.bin, ["--version"], { cwd: this.cwd });
448
+ return r.code === 0 ? /(\d+\.\d+\.\d+)/.exec(r.stdout)?.[1] ?? null : null;
449
+ }
450
+ async loggedIn() {
451
+ return (await run2(this.bin, ["whoami"], { cwd: this.cwd })).code === 0;
452
+ }
453
+ async login() {
454
+ return (await run2(this.bin, ["login"], { cwd: this.cwd, interactive: true })).code === 0;
455
+ }
456
+ /** Lets the person pick a project in Railway's own picker. */
457
+ async link() {
458
+ return (await run2(this.bin, ["link"], { cwd: this.cwd, interactive: true })).code === 0;
459
+ }
460
+ async project() {
461
+ const r = await run2(this.bin, ["status", "--json"], { cwd: this.cwd });
462
+ if (r.code !== 0) return null;
463
+ const s = parseJson(r.stdout, "railway status");
464
+ return {
465
+ id: s.id,
466
+ name: s.name,
467
+ environments: s.environments.edges.map((e) => e.node),
468
+ services: s.services.edges.map((e) => e.node)
469
+ };
470
+ }
471
+ /** Every service in an environment with its raw (unresolved) variables. */
472
+ async services(project, env) {
473
+ const cfg = parseJson(await this.cli(["environment", "config", "-e", env, "--json"]), "railway environment config");
474
+ const names = new Map(project.services.map((s) => [s.id, s.name]));
475
+ return Object.entries(cfg.services ?? {}).map(([id, s]) => ({
476
+ id,
477
+ name: names.get(id) ?? id,
478
+ source: { repo: s.source?.repo ?? null, branch: s.source?.branch ?? null, image: s.source?.image ?? null },
479
+ deploy: {
480
+ preDeployCommand: s.deploy?.preDeployCommand ?? null,
481
+ healthcheckPath: s.deploy?.healthcheckPath ?? null,
482
+ healthcheckTimeout: s.deploy?.healthcheckTimeout ?? null
483
+ },
484
+ variables: Object.fromEntries(
485
+ Object.entries(s.variables ?? {}).flatMap(([k, v]) => v && typeof v.value === "string" ? [[k, v.value]] : [])
486
+ ),
487
+ domains: [...Object.keys(s.networking?.serviceDomains ?? {}), ...Object.keys(s.networking?.customDomains ?? {})]
488
+ })).sort((a, b) => a.name.localeCompare(b.name));
489
+ }
490
+ /** Variables as the service sees them, references resolved (includes RAILWAY_*). */
491
+ async rendered(service, env) {
492
+ const text = await this.cli(["variable", "list", "-s", service, "-e", env, "--json"]);
493
+ const listed = parseJson(text, "railway variable list");
494
+ if (!Array.isArray(listed)) return listed;
495
+ return Object.fromEntries(listed.map((v) => [v.name ?? v.key ?? "", v.value]));
496
+ }
497
+ /** Values without secrets: references and plain settings, several at once. */
498
+ async setPlain(service, env, vars) {
499
+ const pairs = Object.entries(vars).map(([k, v]) => `${k}=${v}`);
500
+ if (pairs.length) await this.cli(["variable", "set", ...pairs, "-s", service, "-e", env, "--skip-deploys"]);
501
+ }
502
+ async setSecret(service, env, key, value) {
503
+ await this.cli(["variable", "set", key, "--stdin", "-s", service, "-e", env, "--skip-deploys"], value);
504
+ }
505
+ async createEnvironment(name, duplicate) {
506
+ await this.cli(["environment", "new", name, "--duplicate", duplicate]);
507
+ }
508
+ async editServices(env, changes, message) {
509
+ if (!changes.length) return;
510
+ await this.cli(["environment", "edit", "-e", env, ...changes.flatMap((c) => ["--service-config", ...c]), "-m", message]);
511
+ }
512
+ async deleteService(service, env) {
513
+ await this.cli(["service", "delete", "-s", service, "-e", env, "--yes"]);
514
+ }
515
+ /** `railway add` and `volume add` act on the linked environment and service. */
516
+ async linkTo(project, env, service) {
517
+ await this.cli(["link", "-p", project.id, "-e", env, ...service ? ["-s", service] : []]);
518
+ }
519
+ async addImageService(name, image) {
520
+ await this.cli(["add", "-s", name, "-i", image]);
521
+ }
522
+ async addVolume(mountPath) {
523
+ await this.cli(["volume", "add", "-m", mountPath]);
524
+ }
525
+ async createDomain(service, env, port) {
526
+ const out = await this.cli(["domain", "-s", service, "-e", env, "--port", String(port)]);
527
+ return /https:\/\/\S+/.exec(out)?.[0] ?? null;
528
+ }
529
+ async createTcpProxy(service, env, port) {
530
+ await this.cli(["tcp-proxy", "create", "--port", String(port), "-s", service, "-e", env]);
531
+ }
532
+ async tcpProxyIds(service, env) {
533
+ const out = await this.cli(["tcp-proxy", "list", "-s", service, "-e", env, "--json"]);
534
+ const d = parseJson(out, "railway tcp-proxy list");
535
+ return (Array.isArray(d) ? d : d.proxies ?? []).map((p) => p.id);
536
+ }
537
+ async deleteTcpProxy(id, service, env) {
538
+ await this.cli(["tcp-proxy", "delete", id, "--yes", "-s", service, "-e", env]);
539
+ }
540
+ /** Whether this CLI has the commands the installer relies on. */
541
+ async capable() {
542
+ return (await run2(this.bin, ["environment", "config", "--help"], { cwd: this.cwd })).code === 0;
543
+ }
544
+ async redeploy(service, env, fromSource = false) {
545
+ await this.cli(["redeploy", "-s", service, "-e", env, "--yes", ...fromSource ? ["--from-source"] : []]);
546
+ }
547
+ };
548
+ }
549
+ });
550
+
551
+ // src/install/ui.ts
552
+ import { createInterface } from "node:readline";
553
+ async function readAllStdin() {
554
+ let text = "";
555
+ for await (const chunk of process.stdin) text += chunk;
556
+ return text.trim();
557
+ }
558
+ async function waitFor(label, poll, timeoutMs, everyMs = 5e3) {
559
+ const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
560
+ const started = Date.now();
561
+ let i = 0;
562
+ for (; ; ) {
563
+ const value = await poll().catch(() => null);
564
+ if (value !== null) {
565
+ if (tty) process.stdout.write("\r\x1B[2K");
566
+ return value;
567
+ }
568
+ const elapsed = Math.round((Date.now() - started) / 1e3);
569
+ if (Date.now() - started > timeoutMs * PACE) {
570
+ if (tty) process.stdout.write("\r\x1B[2K");
571
+ return null;
572
+ }
573
+ if (tty) process.stdout.write(`\r\x1B[2K ${cyan(frames[i++ % frames.length])} ${label} ${dim(`${elapsed}s`)}`);
574
+ await new Promise((r) => setTimeout(r, everyMs * PACE));
575
+ }
576
+ }
577
+ var tty, paint, bold, dim, green, yellow, red, cyan, say, heading, ok, todo, warn, fail, Prompter, PACE;
578
+ var init_ui = __esm({
579
+ "src/install/ui.ts"() {
580
+ "use strict";
581
+ tty = process.stdout.isTTY && !process.env.NO_COLOR;
582
+ paint = (code) => (s) => tty ? `\x1B[${code}m${s}\x1B[0m` : s;
583
+ bold = paint(1);
584
+ dim = paint(2);
585
+ green = paint(32);
586
+ yellow = paint(33);
587
+ red = paint(31);
588
+ cyan = paint(36);
589
+ say = (s = "") => console.log(s);
590
+ heading = (s) => say(`
591
+ ${bold(s)}`);
592
+ ok = (s) => say(` ${green("\u2713")} ${s}`);
593
+ todo = (s) => say(` ${yellow("\u2022")} ${s}`);
594
+ warn = (s) => say(` ${yellow("!")} ${s}`);
595
+ fail = (s) => say(` ${red("\u2717")} ${s}`);
596
+ Prompter = class {
597
+ constructor(assumeYes) {
598
+ this.assumeYes = assumeYes;
599
+ }
600
+ get interactive() {
601
+ return !!process.stdin.isTTY && !this.assumeYes;
602
+ }
603
+ ask(question) {
604
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
605
+ return new Promise((done) => rl.question(question, (a) => (rl.close(), done(a.trim()))));
606
+ }
607
+ noTerminal(what, fallback) {
608
+ if (this.assumeYes && fallback !== void 0) return fallback;
609
+ throw new Error(`${what}: needs an answer. Run in a terminal, or pass the flag for it (see --help) and --yes.`);
610
+ }
611
+ async input(question, fallback) {
612
+ if (!this.interactive) return this.noTerminal(question, fallback);
613
+ for (; ; ) {
614
+ const a = await this.ask(`${cyan("?")} ${question}${fallback ? dim(` (${fallback})`) : ""} `);
615
+ if (a || fallback) return a || fallback;
616
+ }
617
+ }
618
+ async confirm(question, fallback = true) {
619
+ if (!this.interactive) return this.assumeYes ? fallback : !!this.noTerminal(question, void 0);
620
+ const a = (await this.ask(`${cyan("?")} ${question} ${dim(fallback ? "(Y/n)" : "(y/N)")} `)).toLowerCase();
621
+ return a ? a.startsWith("y") : fallback;
622
+ }
623
+ async select(question, options, fallback) {
624
+ if (!options.length) throw new Error(`${question}: nothing to choose from`);
625
+ const def = options.find((o) => o.value === fallback) ?? options[0];
626
+ if (!this.interactive) return this.noTerminal(question, def.value);
627
+ say(`${cyan("?")} ${question}`);
628
+ options.forEach((o, i) => say(` ${dim(`${i + 1})`)} ${o.label}${o === def ? dim(" \u2190 default") : ""}`));
629
+ for (; ; ) {
630
+ const a = await this.ask(` ${dim(`1-${options.length}, Enter for default:`)} `);
631
+ if (!a) return def.value;
632
+ const hit = options[Number(a) - 1] ?? options.find((o) => o.value === a);
633
+ if (hit) return hit.value;
634
+ }
635
+ }
636
+ /** Reads a secret without echoing it. */
637
+ async secret(question) {
638
+ if (!process.stdin.isTTY) throw new Error(`${question}: needs a terminal (or pass it on stdin with --git-token-stdin)`);
639
+ process.stdout.write(`${cyan("?")} ${question} ${dim("(hidden)")} `);
640
+ const stdin = process.stdin;
641
+ stdin.setRawMode(true);
642
+ stdin.resume();
643
+ stdin.setEncoding("utf8");
644
+ return new Promise((done, reject) => {
645
+ let value = "";
646
+ const onData = (chunk) => {
647
+ for (const ch of chunk) {
648
+ if (ch === "\r" || ch === "\n") {
649
+ finish();
650
+ process.stdout.write("\n");
651
+ return done(value.trim());
652
+ }
653
+ if (ch === "") {
654
+ finish();
655
+ process.stdout.write("\n");
656
+ return reject(new Error("Cancelled."));
657
+ }
658
+ if (ch === "\x7F" || ch === "\b") value = value.slice(0, -1);
659
+ else value += ch;
660
+ }
661
+ };
662
+ const finish = () => {
663
+ stdin.off("data", onData);
664
+ stdin.setRawMode(false);
665
+ stdin.pause();
666
+ };
667
+ stdin.on("data", onData);
668
+ });
669
+ }
670
+ };
671
+ PACE = Number(process.env.TAPTHAT_INSTALL_PACE ?? 1);
672
+ }
673
+ });
674
+
675
+ // src/install/index.ts
676
+ var install_exports = {};
677
+ __export(install_exports, {
678
+ INSTALL_USAGE: () => INSTALL_USAGE,
679
+ install: () => install,
680
+ parseInstallArgs: () => parseInstallArgs
681
+ });
682
+ import { randomBytes as randomBytes3 } from "node:crypto";
683
+ function parseInstallArgs(args, cwd) {
684
+ const opts = { cwd, playground: "tapthat", dryRun: false, yes: false, gitTokenStdin: false };
685
+ for (let i = 0; i < args.length; i++) {
686
+ const a = args[i];
687
+ const [flag, inline] = a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : [a, void 0];
688
+ const value = () => {
689
+ const v = inline ?? args[++i];
690
+ if (!v || v.startsWith("--")) throw new Error(`${flag} needs a value`);
691
+ return v;
692
+ };
693
+ try {
694
+ if (flag === "--platform") opts.platform = value();
695
+ else if (flag === "--branch") opts.branch = value();
696
+ else if (flag === "--site") opts.site = value();
697
+ else if (flag === "--playground") opts.playground = value();
698
+ else if (flag === "--dry-run") opts.dryRun = true;
699
+ else if (flag === "--yes" || flag === "-y") opts.yes = true;
700
+ else if (flag === "--git-token-stdin") opts.gitTokenStdin = true;
701
+ else return `Unknown option: ${a}`;
702
+ } catch (e) {
703
+ return e.message;
704
+ }
705
+ }
706
+ return opts;
707
+ }
708
+ async function install(opts) {
709
+ try {
710
+ return await installInner(opts);
711
+ } catch (e) {
712
+ say();
713
+ fail(e.message);
714
+ say(dim(" Nothing is half-done in a way that matters: fix the above and run the installer again."));
715
+ return 1;
716
+ }
717
+ }
718
+ async function installInner(opts) {
719
+ const p = new Prompter(opts.yes);
720
+ const rw = new Railway(opts.cwd);
721
+ const gh = new GitHub();
722
+ say(bold("TapThat installer") + (opts.dryRun ? yellow(" (dry run: nothing will change)") : ""));
723
+ const platform = opts.platform ?? await p.select("Where do your environments run?", [{ value: "railway", label: "Railway" }], "railway");
724
+ if (platform.toLowerCase() !== "railway") throw new Error(`Only Railway is supported for now (got "${platform}").`);
725
+ heading("Tools");
726
+ if (!await rw.version()) throw new Error("The Railway CLI is not installed: https://docs.railway.com/cli (brew install railway)");
727
+ if (!await rw.capable()) throw new Error("This Railway CLI is too old for the installer. Update it: railway upgrade");
728
+ if (!await rw.loggedIn()) {
729
+ if (opts.dryRun || !process.stdin.isTTY) throw new Error("Not logged in to Railway. Run: railway login");
730
+ if (!await rw.login()) throw new Error("Railway login did not complete.");
731
+ }
732
+ ok(`Railway CLI ${await rw.version()}, logged in`);
733
+ if (!await gh.available()) throw new Error("The GitHub CLI is not installed: https://cli.github.com (brew install gh)");
734
+ if (!await gh.loggedIn()) {
735
+ if (opts.dryRun || !process.stdin.isTTY) throw new Error("Not logged in to GitHub. Run: gh auth login");
736
+ if (!await gh.login()) throw new Error("GitHub login did not complete.");
737
+ }
738
+ ok("GitHub CLI, logged in");
739
+ let project = await rw.project();
740
+ if (!project) {
741
+ if (opts.dryRun || !process.stdin.isTTY) throw new Error("This folder is not linked to a Railway project. Run: railway link");
742
+ say(" This folder is not linked to a Railway project; pick it:");
743
+ await rw.link();
744
+ project = await rw.project();
745
+ if (!project) throw new Error("No Railway project linked.");
746
+ }
747
+ ok(`Railway project ${bold(project.name)}`);
748
+ const envNames = project.environments.map((e) => e.name);
749
+ const load = async (env) => rw.services(await rw.project() ?? project, env);
750
+ const envs = await Promise.all(envNames.map(async (name) => ({ name, services: await load(name) })));
751
+ const repoServices = /* @__PURE__ */ new Map();
752
+ for (const env of envs) for (const s of env.services.filter(isRepoService)) if (!repoServices.has(s.source.repo)) repoServices.set(s.source.repo, s);
753
+ if (!repoServices.size) throw new Error(`No service in ${project.name} deploys from a GitHub repository.`);
754
+ heading("Dev branch");
755
+ const branch = opts.branch ?? await p.input("Which branch should reviewed changes go to?", "dev");
756
+ const repos = [...repoServices.keys()].sort();
757
+ const defaults2 = /* @__PURE__ */ new Map();
758
+ const missingBranch = [];
759
+ for (const repo of repos) {
760
+ defaults2.set(repo, await gh.defaultBranch(repo));
761
+ if (!await gh.branchSha(repo, branch)) missingBranch.push(repo);
762
+ }
763
+ if (!missingBranch.length) ok(`every repository has ${bold(branch)} (${repos.join(", ")})`);
764
+ else {
765
+ todo(`no ${bold(branch)} branch in ${missingBranch.join(", ")}`);
766
+ if (opts.dryRun) say(dim(` would offer to create it from each default branch`));
767
+ else if (await p.confirm(`Create ${branch} in ${missingBranch.length === 1 ? missingBranch[0] : `these ${missingBranch.length} repositories`}, from ${missingBranch.length === 1 ? defaults2.get(missingBranch[0]) : "each default branch"}?`)) {
768
+ for (const repo of missingBranch) {
769
+ await gh.createBranch(repo, branch, defaults2.get(repo));
770
+ ok(`${repo}: created ${branch} from ${defaults2.get(repo)}`);
771
+ }
772
+ } else throw new Error(`TapThat commits to ${branch}; it has to exist in every repository first.`);
773
+ }
774
+ heading("Environments");
775
+ const mostCommonDefault = mode([...defaults2.values()]) ?? "main";
776
+ const sourceEnv = environmentDeploying(envs, mostCommonDefault) ?? (envNames.includes("production") ? "production" : envs.find((e) => e.name !== opts.playground)?.name);
777
+ if (!sourceEnv) throw new Error("No environment to start from.");
778
+ let devEnv = environmentDeploying(envs.filter((e) => e.name !== opts.playground), branch);
779
+ let devCreated = false;
780
+ if (devEnv) ok(`${bold(devEnv)} deploys ${branch}`);
781
+ else {
782
+ const existing = envNames.includes(branch) ? branch : null;
783
+ todo(existing ? `environment ${bold(existing)} does not deploy ${branch}` : `no environment deploys ${branch}`);
784
+ const question = existing ? `Point every service in ${existing} at ${branch}?` : `Create environment ${bold(branch)} as a copy of ${bold(sourceEnv)}, deploying ${branch}?`;
785
+ if (opts.dryRun) say(dim(` would ask: ${question}`));
786
+ else if (await p.confirm(question)) {
787
+ const name = existing ?? branch;
788
+ if (!existing) {
789
+ await rw.createEnvironment(name, sourceEnv);
790
+ await waitForServices(rw, project, name, envs.find((e) => e.name === sourceEnv).services.length);
791
+ devCreated = true;
792
+ ok(`created ${name} from ${sourceEnv}`);
793
+ }
794
+ const services = await load(name);
795
+ await rw.editServices(name, services.filter(isRepoService).map((s) => [s.name, "source.branch", branch]), `Deploy ${branch}`);
796
+ ok(`${name}'s services deploy ${branch}`);
797
+ devEnv = name;
798
+ } else throw new Error(`The playground copies from the environment that deploys ${branch}; there has to be one.`);
799
+ }
800
+ const devName = devEnv ?? sourceEnv;
801
+ let devServices = devEnv ? await load(devEnv) : envs.find((e) => e.name === sourceEnv).services;
802
+ if (devCreated) {
803
+ const source = envs.find((e) => e.name === sourceEnv).services;
804
+ for (const s of devServices) {
805
+ const had = source.find((x) => x.name === s.name)?.domains.length;
806
+ if (had && !s.domains.length) {
807
+ const url2 = await rw.createDomain(s.name, devName, Number(s.variables.PORT) || 8080);
808
+ ok(`${s.name}: domain ${url2 ?? "created"}`);
809
+ }
810
+ }
811
+ devServices = await load(devName);
812
+ warn(`${devName} starts with empty databases. Run your project's own database setup there (roles, migrations) before the first Start session.`);
813
+ }
814
+ heading("Site");
815
+ const withDomains = new Set(devServices.filter((s) => s.domains.length).map((s) => s.name));
816
+ const candidates = devServices.filter((s) => isRepoService(s) && withDomains.has(s.name));
817
+ if (!candidates.length) throw new Error(`No service in ${devName} has a public domain to review.`);
818
+ const site = opts.site ?? await p.select(
819
+ "Which site do reviewers comment on?",
820
+ candidates.map((s) => ({ value: s.name, label: `${s.name} ${dim(s.domains[0] ?? "")}` })),
821
+ suggestSite(devServices, withDomains) ?? void 0
822
+ );
823
+ const siteSpec = devServices.find((s) => s.name === site);
824
+ if (siteSpec) ok(`${bold(site)} ${dim(siteSpec.domains[0] ?? "")}`);
825
+ if (!siteSpec?.source.repo) throw new Error(`${site} is not a service built from a GitHub repository in ${devName}.`);
826
+ heading("Plan");
827
+ const facts = /* @__PURE__ */ new Map();
828
+ const planTopology = planWorkspace({ services: devServices, site, branch, repoFacts: /* @__PURE__ */ new Map() }).topology;
829
+ for (const name of planTopology.included) {
830
+ facts.set(name, await gh.repoFacts(devServices.find((s) => s.name === name).source.repo, branch));
831
+ }
832
+ const plan = planWorkspace({ services: devServices, site, branch, repoFacts: facts });
833
+ const topo = plan.topology;
834
+ say(` The ${bold(opts.playground)} environment runs:`);
835
+ say(` ${cyan(WORKSPACE)} ${topo.included.map((n) => `${n} ${dim(`:${plan.ports.get(n)}`)}`).join(", ")} as live dev servers, and the agent`);
836
+ if (topo.kept.length) say(` ${topo.kept.join(", ")} ${dim("as they run in " + devName)}`);
837
+ if (topo.dropped.length) say(` ${dim(`not needed: ${topo.dropped.join(", ")}`)}`);
838
+ for (const w of plan.warnings) warn(w);
839
+ const actions = [];
840
+ const pgName = topo.postgres;
841
+ const redisName = topo.redis;
842
+ let devPasswordChanges = false;
843
+ if (pgName) {
844
+ const raw = devServices.find((s) => s.name === pgName).variables;
845
+ const rendered = await rw.rendered(pgName, devName);
846
+ if (isLiteralTemplate(raw.POSTGRES_PASSWORD)) {
847
+ devPasswordChanges = true;
848
+ actions.push({
849
+ label: `${devName}/${pgName}: replace the guessable superuser password (Railway stored its secret() template as text)`,
850
+ run: () => rotatePostgres(rw, devName, pgName),
851
+ outside: true
852
+ });
853
+ }
854
+ if (!rendered.RAILWAY_TCP_PROXY_DOMAIN) {
855
+ actions.push({
856
+ label: `${devName}/${pgName}: open a TCP proxy, so the playground can copy from it (strong password; the copy logs in as the superuser)`,
857
+ run: async () => {
858
+ await rw.createTcpProxy(pgName, devName, 5432);
859
+ const up2 = await waitFor("waiting for the proxy", async () => (await rw.rendered(pgName, devName)).RAILWAY_TCP_PROXY_DOMAIN ? true : null, 12e4, 3e3);
860
+ if (!up2) throw new Error(`The TCP proxy on ${devName}/${pgName} did not appear. Run the installer again in a minute.`);
861
+ },
862
+ outside: true
863
+ });
864
+ }
865
+ }
866
+ if (redisName) pushRedisFix(actions, rw, devName, devServices.find((s) => s.name === redisName), true);
867
+ const siteRepo = siteSpec.source.repo;
868
+ const existingText = await gh.file(siteRepo, "tapthat.config.json", branch);
869
+ let config = plan.config;
870
+ if (existingText) {
871
+ try {
872
+ config = JSON.parse(existingText);
873
+ } catch {
874
+ throw new Error(`${siteRepo}/tapthat.config.json on ${branch} is not valid JSON. Fix it, or delete it to have one generated.`);
875
+ }
876
+ ok(`${siteRepo} has tapthat.config.json on ${branch} ${dim("(kept as it is)")}`);
877
+ } else {
878
+ actions.push({
879
+ label: `commit a generated tapthat.config.json to ${siteRepo} on ${branch}`,
880
+ run: () => gh.putFile(siteRepo, "tapthat.config.json", branch, `${JSON.stringify(plan.config, null, 2)}
881
+ `, "Add tapthat.config.json (TapThat playground)")
882
+ });
883
+ }
884
+ const playgroundEnv = envs.find((e) => e.name === opts.playground);
885
+ let playground = playgroundEnv?.services ?? null;
886
+ const playgroundCreated = !playground;
887
+ if (!playground) {
888
+ actions.push({
889
+ label: `create environment ${bold(opts.playground)} as a copy of ${devName}, keeping ${[...topo.kept].join(", ") || "nothing else"}`,
890
+ run: async () => {
891
+ await rw.createEnvironment(opts.playground, devName);
892
+ playground = await waitForServices(rw, project, opts.playground, devServices.length);
893
+ for (const s of playground.filter((s2) => !topo.kept.includes(s2.name))) {
894
+ await rw.deleteService(s.name, opts.playground);
895
+ }
896
+ playground = await load(opts.playground);
897
+ }
898
+ });
899
+ } else {
900
+ ok(`environment ${bold(opts.playground)} exists`);
901
+ for (const name of topo.kept.filter((n) => !playground.some((s) => s.name === n))) {
902
+ const used = usesOf(config, devServices, topo.included, name);
903
+ if (used.length) warn(`${opts.playground} has no ${name}, but the config passes on ${used.join(", ")}, which ${used.length === 1 ? "uses" : "use"} it. Add ${name} to ${opts.playground}, or change the config.`);
904
+ }
905
+ const pg = pgName && playground.find((s) => s.name === pgName);
906
+ if (pg && isLiteralTemplate(pg.variables.POSTGRES_PASSWORD)) {
907
+ actions.push({
908
+ label: `${opts.playground}/${pgName}: replace the guessable superuser password`,
909
+ run: () => rotatePostgres(rw, opts.playground, pgName)
910
+ });
911
+ }
912
+ const redis = redisName && playground.find((s) => s.name === redisName);
913
+ if (redis) pushRedisFix(actions, rw, opts.playground, redis);
914
+ }
915
+ const rewriteKept = async () => {
916
+ for (const [service, vars] of keptRewrites(playground, topo, plan.ports)) {
917
+ await rw.setPlain(service, opts.playground, vars);
918
+ await rw.redeploy(service, opts.playground);
919
+ }
920
+ };
921
+ if (!playground) {
922
+ actions.push({ label: `point the kept services at the workspace where they referred to ${[...topo.included, ...topo.dropped].join("/")}`, run: rewriteKept });
923
+ } else {
924
+ const pending = keptRewrites(playground, topo, plan.ports);
925
+ if (pending.size) {
926
+ const what = [...pending].map(([svc, vars]) => `${svc} (${Object.keys(vars).join(", ")})`).join("; ");
927
+ actions.push({ label: `point ${what} at the workspace instead of services the playground leaves out`, run: rewriteKept });
928
+ }
929
+ }
930
+ const workspace = playground?.find((s) => s.source.image?.includes("tapthat-server")) ?? playground?.find((s) => s.name === WORKSPACE);
931
+ const wsName = workspace?.name ?? WORKSPACE;
932
+ if (!workspace) {
933
+ actions.push({
934
+ label: `add the ${bold(WORKSPACE)} service (${IMAGE}) with a volume at /workspace`,
935
+ run: async () => {
936
+ const linkedEnv = await linkedEnvironment(rw);
937
+ const others = (await rw.project() ?? project).environments.map((e) => e.name).filter((e) => e !== opts.playground);
938
+ const hadIt = /* @__PURE__ */ new Set();
939
+ for (const e of others) if ((await load(e)).some((s) => s.name === WORKSPACE)) hadIt.add(e);
940
+ await rw.linkTo(project, opts.playground);
941
+ try {
942
+ await rw.addImageService(WORKSPACE, IMAGE);
943
+ await waitForServices(rw, project, opts.playground, 1, (s) => s.some((x) => x.name === WORKSPACE));
944
+ await rw.linkTo(project, opts.playground, WORKSPACE);
945
+ await rw.addVolume("/workspace");
946
+ } finally {
947
+ if (linkedEnv) await rw.linkTo(project, linkedEnv).catch(() => void 0);
948
+ }
949
+ for (const e of others) {
950
+ if (!hadIt.has(e) && (await load(e)).some((s) => s.name === WORKSPACE)) {
951
+ await rw.deleteService(WORKSPACE, e);
952
+ ok(`removed the ${WORKSPACE} copy Railway also created in ${e}`);
953
+ }
954
+ }
955
+ }
956
+ });
957
+ } else ok(`${opts.playground} has the ${wsName} service`);
958
+ if (workspace?.deploy?.healthcheckPath !== "/__tapthat/healthz" || !workspace || Number(workspace.deploy?.healthcheckTimeout) < 900) {
959
+ actions.push({
960
+ label: `${wsName}: health check /__tapthat/healthz with 15 minutes to boot (the first boot installs every repo)`,
961
+ run: () => rw.editServices(
962
+ opts.playground,
963
+ [
964
+ [wsName, "deploy.healthcheckPath", "/__tapthat/healthz"],
965
+ [wsName, "deploy.healthcheckTimeout", "900"]
966
+ ],
967
+ "TapThat workspace health check"
968
+ )
969
+ });
970
+ }
971
+ const current = workspace?.variables ?? {};
972
+ const base = baseVariables(siteRepo, site);
973
+ const { set: missing, unknown } = missingVariables(configPlaceholders(config), base, plan.variables, current);
974
+ for (const name of unknown) warn(`the config reads ${name}, which the installer cannot work out: set it on ${wsName} by hand`);
975
+ const generated = {};
976
+ if (!current.TAPTHAT_TOKEN) generated.TAPTHAT_TOKEN = token;
977
+ if (!current.TAPTHAT_ENCRYPTION_KEY) generated.TAPTHAT_ENCRYPTION_KEY = key32;
978
+ const devPg = pgName ? await rw.rendered(pgName, devName) : {};
979
+ const devUrlNow = devDatabaseUrl(devPg);
980
+ const devUrlStale = !!pgName && (devPasswordChanges || !devUrlNow || current.TAPTHAT_DEV_DATABASE_URL !== devUrlNow);
981
+ const needsGitToken = !current.TAPTHAT_GIT_TOKEN;
982
+ const varCount = Object.keys(missing).length + Object.keys(generated).length + (devUrlStale ? 1 : 0);
983
+ if (varCount) {
984
+ actions.push({
985
+ label: `${wsName}: set ${varCount} variable${varCount === 1 ? "" : "s"} ${dim(`(${[...Object.keys(missing), ...Object.keys(generated), ...devUrlStale ? ["TAPTHAT_DEV_DATABASE_URL"] : []].slice(0, 6).join(", ")}${varCount > 6 ? ", \u2026" : ""})`)}`,
986
+ run: async () => {
987
+ const plain = {};
988
+ for (const [k, v] of Object.entries(missing)) {
989
+ if (plan.secretKeys.has(k)) await rw.setSecret(wsName, opts.playground, k, v);
990
+ else plain[k] = v;
991
+ }
992
+ await rw.setPlain(wsName, opts.playground, plain);
993
+ for (const [k, make] of Object.entries(generated)) await rw.setSecret(wsName, opts.playground, k, make());
994
+ if (devUrlStale) {
995
+ const url2 = devDatabaseUrl(await rw.rendered(pgName, devName));
996
+ if (!url2) throw new Error(`${devName}/${pgName} has no TCP proxy yet; run the installer again in a minute.`);
997
+ await rw.setSecret(wsName, opts.playground, "TAPTHAT_DEV_DATABASE_URL", url2);
998
+ }
999
+ }
1000
+ });
1001
+ }
1002
+ let gitToken = null;
1003
+ const includedRepos = topo.included.map((n) => devServices.find((s) => s.name === n).source.repo);
1004
+ if (needsGitToken) {
1005
+ todo(`${wsName} needs a GitHub token that can push to ${includedRepos.join(", ")}`);
1006
+ if (opts.dryRun) say(dim(" would ask for it"));
1007
+ else {
1008
+ say(dim(` Create a fine-grained token: https://github.com/settings/personal-access-tokens/new`));
1009
+ say(dim(` Resource owner: ${includedRepos[0].split("/")[0]} \xB7 Repositories: ${includedRepos.map((r) => r.split("/")[1]).join(", ")} \xB7 Contents: Read and write`));
1010
+ for (let attempt = 0; ; attempt++) {
1011
+ gitToken = opts.gitTokenStdin ? await readAllStdin() : await p.secret("Paste the token");
1012
+ const denied = [];
1013
+ for (const repo of includedRepos) if (!await canPush(gitToken, repo)) denied.push(repo);
1014
+ if (!denied.length) {
1015
+ ok(`the token can push to ${includedRepos.join(", ")}`);
1016
+ break;
1017
+ }
1018
+ fail(`the token cannot push to ${denied.join(", ")}`);
1019
+ if (opts.gitTokenStdin || attempt >= 2) throw new Error("No working GitHub token.");
1020
+ }
1021
+ const t = gitToken;
1022
+ actions.push({ label: `${wsName}: store the GitHub token`, run: () => rw.setSecret(wsName, opts.playground, "TAPTHAT_GIT_TOKEN", t) });
1023
+ }
1024
+ }
1025
+ if (!workspace?.domains.length) {
1026
+ actions.push({
1027
+ label: `${wsName}: a public domain`,
1028
+ run: async () => {
1029
+ await rw.createDomain(wsName, opts.playground, SIDECAR_PORT);
1030
+ }
1031
+ });
1032
+ }
1033
+ heading(actions.length ? "Changes" : "Everything is in place");
1034
+ actions.forEach((a, i) => say(` ${dim(`${i + 1}.`)} ${a.label}`));
1035
+ if (opts.dryRun) {
1036
+ say(`
1037
+ ${dim("Dry run: nothing was changed.")}`);
1038
+ return 0;
1039
+ }
1040
+ if (actions.length) {
1041
+ if (!await p.confirm(`Make these ${actions.length} change${actions.length === 1 ? "" : "s"}?`)) {
1042
+ say("Nothing was changed.");
1043
+ return 1;
1044
+ }
1045
+ for (const a of actions) {
1046
+ await a.run();
1047
+ ok(a.label);
1048
+ }
1049
+ }
1050
+ heading("Workspace");
1051
+ const finalServices = await load(opts.playground);
1052
+ const ws = finalServices.find((s) => s.name === wsName);
1053
+ if (!ws) throw new Error(`${opts.playground} has no ${wsName} service after setup.`);
1054
+ const url = ws.domains[0] ? `${/^(localhost|127\.0\.0\.1)[:/]/.test(ws.domains[0]) ? "http" : "https"}://${ws.domains[0]}` : null;
1055
+ if (!url) throw new Error(`${wsName} has no public domain.`);
1056
+ const wsVars = await rw.rendered(wsName, opts.playground);
1057
+ const healthy = async () => await getJson(`${url}/__tapthat/healthz`) !== null;
1058
+ if (actions.some((a) => !a.outside) || !await healthy()) {
1059
+ await rw.redeploy(wsName, opts.playground);
1060
+ say(dim(` deploying ${wsName}; the first boot clones and installs every repo`));
1061
+ await sleep(15e3 * PACE);
1062
+ }
1063
+ const up = await waitFor(`waiting for ${url}`, async () => await healthy() ? true : null, 20 * 6e4, 1e4);
1064
+ if (!up) throw new Error(`${wsName} did not become healthy in 20 minutes. Its deploy logs say why: railway logs -s ${wsName} -e ${opts.playground}`);
1065
+ ok(`${url} is up`);
1066
+ const auth = { authorization: `Bearer ${wsVars.TAPTHAT_TOKEN}` };
1067
+ const session = await getJson(`${url}/__tapthat/api/session`, auth);
1068
+ if (session && !session.session && !session.last && pgName && devCreated) {
1069
+ say(dim(` ${devName} is new and has no data yet: set its databases up, then press Start session to copy them`));
1070
+ } else if (session && !session.session && !session.last && pgName) {
1071
+ say(dim(` first data copy from ${devName}`));
1072
+ const started = await fetch(`${url}/__tapthat/api/session/start`, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: '{"reviewer":"installer"}' });
1073
+ if (started.status !== 202) throw new Error(`Start session answered ${started.status}: ${await started.text()}`);
1074
+ const done = await waitFor(
1075
+ `copying ${devName}'s data`,
1076
+ async () => {
1077
+ const s = await getJson(`${url}/__tapthat/api/session`, auth);
1078
+ return s?.session && (s.session.state === "active" || s.session.state === "failed") ? s.session : null;
1079
+ },
1080
+ 20 * 6e4
1081
+ );
1082
+ if (!done) throw new Error("The first data copy did not finish in 20 minutes; check the workspace log.");
1083
+ if (done.state === "failed") throw new Error(`The first data copy failed: ${done.error}`);
1084
+ await fetch(`${url}/__tapthat/api/session/discard`, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: '{"reviewer":"installer"}' });
1085
+ ok(`${opts.playground} has ${devName}'s data`);
1086
+ if (playgroundCreated) {
1087
+ for (const s of finalServices.filter((s2) => topo.kept.includes(s2.name) && isRepoService(s2))) await rw.redeploy(s.name, opts.playground);
1088
+ ok(`redeployed ${topo.kept.filter((n) => finalServices.find((s) => s.name === n && isRepoService(s))).join(", ")}`);
1089
+ }
1090
+ }
1091
+ const dangling = danglingReferences(finalServices);
1092
+ if (dangling.length) warn(`references to services ${opts.playground} does not have: ${dangling.join("; ")}`);
1093
+ heading("Done. In the TapThat extension, open Settings and fill in");
1094
+ say(` Server URL ${bold(url)}`);
1095
+ if (process.platform === "darwin" && process.stdout.isTTY && (await run2("pbcopy", [], { input: wsVars.TAPTHAT_TOKEN ?? "" })).code === 0) {
1096
+ say(` Token ${green("on your clipboard")} ${dim(`(also: railway variable list -s ${wsName} -e ${opts.playground} --kv | grep TAPTHAT_TOKEN)`)}`);
1097
+ } else {
1098
+ say(` Token ${dim(`railway variable list -s ${wsName} -e ${opts.playground} --kv | grep TAPTHAT_TOKEN`)}`);
1099
+ }
1100
+ say(` Sites ${url}`);
1101
+ say(`
1102
+ Then open ${url}, press ${bold("Start session")} and comment away. ${dim("Run this installer again any time; it only fixes what is missing.")}`);
1103
+ return 0;
1104
+ }
1105
+ function usesOf(config, devServices, included, service) {
1106
+ const repos = config.repos ?? [];
1107
+ const out = [];
1108
+ for (const name of included) {
1109
+ const spec = devServices.find((s) => s.name === name);
1110
+ const env = repos.find((r) => r.name === name)?.devServer?.env ?? {};
1111
+ for (const [key, value] of Object.entries(spec?.variables ?? {})) {
1112
+ if (key in env && value.includes(`\${{${service}.`)) out.push(`${name}.${key}`);
1113
+ }
1114
+ }
1115
+ return out;
1116
+ }
1117
+ function mode(values) {
1118
+ const counts = /* @__PURE__ */ new Map();
1119
+ for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
1120
+ return [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
1121
+ }
1122
+ async function linkedEnvironment(rw) {
1123
+ const r = await run2(process.env.TAPTHAT_RAILWAY_BIN ?? "railway", ["status"], { cwd: rw.cwd });
1124
+ return /Environment:\s+(\S+)/.exec(r.stdout)?.[1] ?? null;
1125
+ }
1126
+ async function waitForServices(rw, project, env, expected, ready = (s) => s.length >= expected) {
1127
+ const fresh = await rw.project() ?? project;
1128
+ const got = await waitFor(`waiting for ${env}'s services`, async () => {
1129
+ const s = await rw.services(fresh, env).catch(() => null);
1130
+ return s && ready(s) ? s : null;
1131
+ }, 18e4, 5e3);
1132
+ if (!got) {
1133
+ throw new Error(
1134
+ `Railway created ${env} without the services it should have. Delete ${env} in the dashboard (or duplicate it there: environment settings \u2192 Duplicate) and run the installer again.`
1135
+ );
1136
+ }
1137
+ return got;
1138
+ }
1139
+ function pushRedisFix(actions, rw, env, redis, outside = false) {
1140
+ const missing = Object.entries(REDIS_TEMPLATE_VARIABLES).filter(([k, v]) => redis.variables[k] !== v && !(k !== "RAILWAY_RUN_UID" && redis.variables[k]));
1141
+ const noPassword = !redis.variables.REDIS_PASSWORD;
1142
+ if (!missing.length && !noPassword) return;
1143
+ actions.push({
1144
+ label: `${env}/${redis.name}: ${[noPassword && "a password", missing.length && `the template's variables (${missing.map(([k]) => k).join(", ")})`].filter(Boolean).join(" and ")}`,
1145
+ run: async () => {
1146
+ if (noPassword) await rw.setSecret(redis.name, env, "REDIS_PASSWORD", token());
1147
+ await rw.setPlain(redis.name, env, Object.fromEntries(missing));
1148
+ await rw.redeploy(redis.name, env);
1149
+ },
1150
+ outside
1151
+ });
1152
+ }
1153
+ async function rotatePostgres(rw, env, service) {
1154
+ const psql2 = await psqlRunner();
1155
+ let vars = await rw.rendered(service, env);
1156
+ let temporary = false;
1157
+ if (!vars.RAILWAY_TCP_PROXY_DOMAIN) {
1158
+ await rw.createTcpProxy(service, env, 5432);
1159
+ temporary = true;
1160
+ const up = await waitFor("waiting for a temporary TCP proxy", async () => {
1161
+ const v = await rw.rendered(service, env);
1162
+ return v.RAILWAY_TCP_PROXY_DOMAIN ? v : null;
1163
+ }, 12e4, 3e3);
1164
+ if (!up) throw new Error(`The temporary TCP proxy on ${env}/${service} did not appear.`);
1165
+ vars = up;
1166
+ }
1167
+ const conn = { PGHOST: vars.RAILWAY_TCP_PROXY_DOMAIN, PGPORT: vars.RAILWAY_TCP_PROXY_PORT, PGUSER: vars.PGUSER, PGDATABASE: "postgres" };
1168
+ const old = vars.PGPASSWORD;
1169
+ const fresh = token();
1170
+ const reachable = await waitFor("waiting for Postgres", async () => await psql2({ ...conn, PGPASSWORD: old }, "select 1") === 0 ? true : null, 9e4, 3e3);
1171
+ if (!reachable) throw new Error(`Could not log in to ${env}/${service} with its stored password.`);
1172
+ const sql = `\\set ON_ERROR_STOP on
1173
+ \\getenv new_pw NEW_PW
1174
+ ALTER ROLE ${quoteIdent2(conn.PGUSER)} PASSWORD :'new_pw';
1175
+ `;
1176
+ if (await psql2({ ...conn, PGPASSWORD: old, NEW_PW: fresh }, sql) !== 0) throw new Error(`Changing the password inside ${env}/${service} failed.`);
1177
+ await rw.setSecret(service, env, "POSTGRES_PASSWORD", fresh);
1178
+ if (await psql2({ ...conn, PGPASSWORD: fresh }, "select 1") !== 0) throw new Error(`${env}/${service}: the new password does not log in.`);
1179
+ if (await psql2({ ...conn, PGPASSWORD: old }, "select 1") === 0) throw new Error(`${env}/${service}: the old password still logs in.`);
1180
+ if (temporary) for (const id of await rw.tcpProxyIds(service, env)) await rw.deleteTcpProxy(id, service, env);
1181
+ }
1182
+ async function psqlRunner() {
1183
+ const local = await run2("psql", ["--version"]);
1184
+ const major = Number(/(\d+)\./.exec(local.stdout)?.[1] ?? 0);
1185
+ if (local.code === 0 && major >= 15) {
1186
+ return async (env, sql) => (await run2("psql", ["-qAt", "--file", "-"], { env, input: sql })).code;
1187
+ }
1188
+ if ((await run2("docker", ["info"])).code !== 0) {
1189
+ throw new Error("Changing the Postgres password needs psql 15+ or Docker running. Install one and run the installer again.");
1190
+ }
1191
+ return async (env, sql) => (await run2("docker", ["run", "--rm", "-i", ...Object.keys(env).flatMap((k) => ["-e", k]), "postgres:18-alpine", "psql", "-qAt", "--file", "-"], { env, input: sql })).code;
1192
+ }
1193
+ async function getJson(url, headers = {}) {
1194
+ try {
1195
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(1e4) });
1196
+ return res.ok ? await res.json() : null;
1197
+ } catch {
1198
+ return null;
1199
+ }
1200
+ }
1201
+ var INSTALL_USAGE, token, key32, quoteIdent2;
1202
+ var init_install = __esm({
1203
+ "src/install/index.ts"() {
1204
+ "use strict";
1205
+ init_github();
1206
+ init_model();
1207
+ init_railway();
1208
+ init_sh();
1209
+ init_ui();
1210
+ INSTALL_USAGE = `tapthat-server install \u2014 set up a TapThat playground environment
1211
+
1212
+ Run it in a folder linked to your Railway project (or it links one).
1213
+ Asks for the platform, your dev branch and the site reviewers comment on;
1214
+ finds and fixes everything else, after one confirmation.
1215
+
1216
+ --platform <name> railway (the only one for now)
1217
+ --branch <name> the dev branch Commit pushes to (asks; default dev)
1218
+ --site <service> the service reviewers comment on (asks; suggests one)
1219
+ --playground <name> the playground environment (default tapthat)
1220
+ --dry-run show what would change, change nothing
1221
+ --yes accept defaults and the plan without asking
1222
+ --git-token-stdin read the workspace's GitHub token from stdin
1223
+
1224
+ Needs the Railway CLI (logged in) and the GitHub CLI (gh, logged in).`;
1225
+ token = () => randomBytes3(24).toString("base64url");
1226
+ key32 = () => randomBytes3(32).toString("base64");
1227
+ quoteIdent2 = (s) => `"${s.replace(/"/g, '""')}"`;
1228
+ }
1229
+ });
2
1230
 
3
1231
  // src/cli.ts
4
- import { execFile as execFile3, spawn as spawn3 } from "node:child_process";
5
- import { createHash as createHash2, randomBytes as randomBytes3 } from "node:crypto";
1232
+ import { execFile as execFile3, spawn as spawn4 } from "node:child_process";
1233
+ import { createHash as createHash2, randomBytes as randomBytes4 } from "node:crypto";
6
1234
  import { existsSync } from "node:fs";
7
1235
  import { appendFile as appendFile2, chmod, mkdir as mkdir5, readdir as readdir2, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
8
1236
  import { join as join7, relative as relative2, resolve as resolve3 } from "node:path";
@@ -853,9 +2081,9 @@ import { rm } from "node:fs/promises";
853
2081
  import { join as join2 } from "node:path";
854
2082
  import { promisify } from "node:util";
855
2083
  var exec = promisify(execFile);
856
- function gitAuthArgs(token) {
857
- if (!token) return [];
858
- const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
2084
+ function gitAuthArgs(token2) {
2085
+ if (!token2) return [];
2086
+ const basic = Buffer.from(`x-access-token:${token2}`).toString("base64");
859
2087
  return ["-c", `http.extraHeader=Authorization: Basic ${basic}`];
860
2088
  }
861
2089
  var Repo = class _Repo {
@@ -1067,11 +2295,11 @@ var Repo = class _Repo {
1067
2295
  async deleteBranch(branch) {
1068
2296
  await this.git("branch", "-D", branch);
1069
2297
  }
1070
- static async clone(url, branch, dest, token) {
1071
- await exec("git", [...gitAuthArgs(token), "clone", "--branch", branch, url, dest], {
2298
+ static async clone(url, branch, dest, token2) {
2299
+ await exec("git", [...gitAuthArgs(token2), "clone", "--branch", branch, url, dest], {
1072
2300
  maxBuffer: 16 * 1024 * 1024
1073
2301
  });
1074
- return new _Repo(dest, token);
2302
+ return new _Repo(dest, token2);
1075
2303
  }
1076
2304
  /**
1077
2305
  * The first half of an all-or-nothing undo across repositories: the revert is
@@ -1632,14 +2860,14 @@ var Sessions = class {
1632
2860
  const s = this.current();
1633
2861
  if (!s) return;
1634
2862
  if (BUSY.has(s.state)) {
1635
- this.fail(s, `The sidecar restarted while the session was ${s.state}. Discard it and start again.`);
2863
+ this.fail(s, `The sidecar restarted while the session was ${s.state}. Cancel the session and start again.`);
1636
2864
  return;
1637
2865
  }
1638
2866
  if (s.state === "active") {
1639
2867
  for (const e of this.deps.workspace.entries) {
1640
2868
  const on = await e.repo.branch().catch(() => null);
1641
2869
  if (on !== s.branch) {
1642
- this.fail(s, `${e.name} is no longer on the session branch (${on ?? "unknown"}). Discard the session and start again.`);
2870
+ this.fail(s, `${e.name} is no longer on the session branch (${on ?? "unknown"}). Cancel the session and start again.`);
1643
2871
  return;
1644
2872
  }
1645
2873
  }
@@ -1696,7 +2924,7 @@ var Sessions = class {
1696
2924
  );
1697
2925
  }
1698
2926
  if (existing?.state === "failed") {
1699
- throw new SessionError(409, "session_failed", "The last session failed. Discard it first, which also resets the data.");
2927
+ throw new SessionError(409, "session_failed", "The last session failed. Cancel it first, which also resets the data.");
1700
2928
  }
1701
2929
  const id = sessionId();
1702
2930
  const session = {
@@ -2562,13 +3790,13 @@ ${p}\r
2562
3790
  ...db ? [cmd("SELECT", String(db))] : [],
2563
3791
  cmd("FLUSHDB")
2564
3792
  ];
2565
- await new Promise((done, fail) => {
3793
+ await new Promise((done, fail2) => {
2566
3794
  const socket = tls ? tlsConnect({ host, port, servername: host }) : netConnect({ host, port });
2567
3795
  let replies = 0;
2568
3796
  let buffer = "";
2569
3797
  socket.setTimeout(1e4, () => {
2570
3798
  socket.destroy();
2571
- fail(new Error("Redis did not answer in time"));
3799
+ fail2(new Error("Redis did not answer in time"));
2572
3800
  });
2573
3801
  socket.on("connect", () => socket.write(commands.join("")));
2574
3802
  socket.on("secureConnect", () => socket.write(commands.join("")));
@@ -2579,7 +3807,7 @@ ${p}\r
2579
3807
  for (const line of lines) {
2580
3808
  if (line.startsWith("-")) {
2581
3809
  socket.destroy();
2582
- fail(new Error(`Redis refused: ${line.slice(1)}`));
3810
+ fail2(new Error(`Redis refused: ${line.slice(1)}`));
2583
3811
  return;
2584
3812
  }
2585
3813
  if (line.startsWith("+")) replies++;
@@ -2589,7 +3817,7 @@ ${p}\r
2589
3817
  done();
2590
3818
  }
2591
3819
  });
2592
- socket.on("error", fail);
3820
+ socket.on("error", fail2);
2593
3821
  });
2594
3822
  }
2595
3823
  function makeSnapshotHooks(deps) {
@@ -2757,6 +3985,7 @@ var Store = class _Store {
2757
3985
  // src/cli.ts
2758
3986
  var USAGE = `tapthat-server \u2014 apply TapThat comments to this repo with a coding agent
2759
3987
 
3988
+ tapthat-server install set up a playground environment on Railway (see install --help)
2760
3989
  tapthat-server init write tapthat.config.json and generate secrets
2761
3990
  tapthat-server serve start the HTTP API (default)
2762
3991
  tapthat-server run-file <batch.json> run one batch from a file (no HTTP)
@@ -2764,7 +3993,7 @@ var USAGE = `tapthat-server \u2014 apply TapThat comments to this repo with a co
2764
3993
  tapthat-server audit-prod fail if the sidecar is in a production dependency tree
2765
3994
 
2766
3995
  Development tool only. serve and run-file require TAPTHAT_ENABLE=1.`;
2767
- var VERSION = "0.1.1";
3996
+ var VERSION = "0.2.0";
2768
3997
  var SECRETS_FILE = join7(".tapthat", "secrets.env");
2769
3998
  var execFileP = promisify3(execFile3);
2770
3999
  function credentialFromEnv() {
@@ -2906,7 +4135,7 @@ async function prepareAll(config) {
2906
4135
  }
2907
4136
  function runShell(command, cwd, env = {}) {
2908
4137
  return new Promise((done) => {
2909
- const child = spawn3(command, { cwd, shell: true, stdio: "inherit", env: { ...process.env, ...env } });
4138
+ const child = spawn4(command, { cwd, shell: true, stdio: "inherit", env: { ...process.env, ...env } });
2910
4139
  child.on("exit", (code) => done(code ?? 1));
2911
4140
  child.on("error", () => done(1));
2912
4141
  });
@@ -2956,8 +4185,8 @@ state: ${result.state}`);
2956
4185
  async function cmdServe() {
2957
4186
  let config = await readConfig(process.cwd(), true);
2958
4187
  if (!config) return 78;
2959
- const token = process.env.TAPTHAT_TOKEN ?? null;
2960
- if (config.auth.mode === "token" && !token) {
4188
+ const token2 = process.env.TAPTHAT_TOKEN ?? null;
4189
+ if (config.auth.mode === "token" && !token2) {
2961
4190
  console.error(
2962
4191
  'TAPTHAT_TOKEN is not set.\n\nThis endpoint accepts instructions that modify your repository, so it will not\nstart unauthenticated. Run `npx tapthat-server init` to generate one, or set\nauth.mode to "none" (permitted only when bound to loopback).'
2963
4192
  );
@@ -3012,7 +4241,10 @@ async function cmdServe() {
3012
4241
  return 1;
3013
4242
  }
3014
4243
  }
3015
- if (!await prepareAll(config)) return 1;
4244
+ if (!await prepareAll(config)) {
4245
+ if (!config.session.snapshot) return 1;
4246
+ console.error("[tapthat] continuing without it: Start session copies the data and prepares again");
4247
+ }
3016
4248
  const servers = new DevServers(
3017
4249
  config.devServer.start ? config.repos.filter((r) => r.devServer?.command).map((r) => ({ name: r.name, command: r.devServer.command, cwd: r.root, url: r.devServer.url, env: r.devServer.env })) : []
3018
4250
  );
@@ -3037,7 +4269,7 @@ async function cmdServe() {
3037
4269
  sessionHooks,
3038
4270
  store,
3039
4271
  encryptionKey,
3040
- token,
4272
+ token: token2,
3041
4273
  envCredential: credentialFromEnv(),
3042
4274
  version: VERSION,
3043
4275
  agentVersion,
@@ -3070,7 +4302,7 @@ async function cmdServe() {
3070
4302
  console.log("");
3071
4303
  console.log(" Paste into the extension options page:");
3072
4304
  console.log(` Sidecar URL ${sidecarUrl}`);
3073
- console.log(` Token ${token ? `${token.slice(0, 4)}\u2026 (TAPTHAT_TOKEN)` : "(auth disabled)"}`);
4305
+ console.log(` Token ${token2 ? `${token2.slice(0, 4)}\u2026 (TAPTHAT_TOKEN)` : "(auth disabled)"}`);
3074
4306
  if (platform && config.git.push && config.git.mode === "commit") {
3075
4307
  console.log("");
3076
4308
  console.log(` \u26A0 ${platform} redeploys on push. git.push is enabled, so if this service`);
@@ -3151,21 +4383,21 @@ Run init in the root of the repository you want the agent to edit.`);
3151
4383
  console.log(` wrote ${CONFIG_FILENAME} (branch "${branch}", dev server ${dev.url})`);
3152
4384
  }
3153
4385
  const secretsPath = join7(cwd, SECRETS_FILE);
3154
- let token;
4386
+ let token2;
3155
4387
  if (existsSync(secretsPath)) {
3156
4388
  await loadSecretsFile(cwd);
3157
- token = process.env.TAPTHAT_TOKEN ?? "(see .tapthat/secrets.env)";
4389
+ token2 = process.env.TAPTHAT_TOKEN ?? "(see .tapthat/secrets.env)";
3158
4390
  console.log(` kept ${SECRETS_FILE} (already exists)`);
3159
4391
  } else {
3160
- token = randomBytes3(24).toString("base64url");
3161
- const key = randomBytes3(32).toString("base64");
4392
+ token2 = randomBytes4(24).toString("base64url");
4393
+ const key = randomBytes4(32).toString("base64");
3162
4394
  await mkdir5(join7(cwd, ".tapthat"), { recursive: true });
3163
4395
  await writeFile3(
3164
4396
  secretsPath,
3165
4397
  [
3166
4398
  "# Generated by `tapthat-server init`. Never commit this file.",
3167
4399
  "# The bearer token the extension sends, and the key that seals stored credentials.",
3168
- `TAPTHAT_TOKEN=${token}`,
4400
+ `TAPTHAT_TOKEN=${token2}`,
3169
4401
  `TAPTHAT_ENCRYPTION_KEY=${key}`,
3170
4402
  ""
3171
4403
  ].join("\n")
@@ -3187,7 +4419,7 @@ Run init in the root of the repository you want the agent to edit.`);
3187
4419
  console.log(" TAPTHAT_ENABLE=1 npx tapthat-server");
3188
4420
  console.log(" 3. In the TapThat extension's options page, paste:");
3189
4421
  console.log(" Sidecar URL http://localhost:7420");
3190
- console.log(` Token ${token}`);
4422
+ console.log(` Token ${token2}`);
3191
4423
  console.log(` Allowed sites ${new URL(dev.url).origin}`);
3192
4424
  console.log("");
3193
4425
  console.log(`Branch "${branch}" is what the agent will commit to. Check out a dev branch first if`);
@@ -3242,6 +4474,21 @@ async function main() {
3242
4474
  console.log(VERSION);
3243
4475
  return 0;
3244
4476
  }
4477
+ if (command === "install") {
4478
+ const { install: install2, INSTALL_USAGE: INSTALL_USAGE2, parseInstallArgs: parseInstallArgs2 } = await Promise.resolve().then(() => (init_install(), install_exports));
4479
+ if (rest.includes("--help") || rest.includes("-h")) {
4480
+ console.log(INSTALL_USAGE2);
4481
+ return 0;
4482
+ }
4483
+ const opts = parseInstallArgs2(rest, process.cwd());
4484
+ if (typeof opts === "string") {
4485
+ console.error(`${opts}
4486
+
4487
+ ${INSTALL_USAGE2}`);
4488
+ return 1;
4489
+ }
4490
+ return install2(opts);
4491
+ }
3245
4492
  if (command === "init") return cmdInit();
3246
4493
  if (command === "audit-prod") return cmdAuditProd();
3247
4494
  await loadSecretsFile(process.cwd());