tapthat-server 0.1.2 → 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.
- package/dist/cli.js +1275 -28
- 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
|
|
5
|
-
import { createHash as createHash2, randomBytes as
|
|
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(
|
|
857
|
-
if (!
|
|
858
|
-
const basic = Buffer.from(`x-access-token:${
|
|
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,
|
|
1071
|
-
await exec("git", [...gitAuthArgs(
|
|
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,
|
|
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}.
|
|
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"}).
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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.
|
|
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 =
|
|
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
|
|
2960
|
-
if (config.auth.mode === "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))
|
|
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 ${
|
|
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
|
|
4386
|
+
let token2;
|
|
3155
4387
|
if (existsSync(secretsPath)) {
|
|
3156
4388
|
await loadSecretsFile(cwd);
|
|
3157
|
-
|
|
4389
|
+
token2 = process.env.TAPTHAT_TOKEN ?? "(see .tapthat/secrets.env)";
|
|
3158
4390
|
console.log(` kept ${SECRETS_FILE} (already exists)`);
|
|
3159
4391
|
} else {
|
|
3160
|
-
|
|
3161
|
-
const key =
|
|
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=${
|
|
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 ${
|
|
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());
|