@urateam/cli 0.1.44 → 0.1.46
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/__tests__/bootstrap.e2e.test.d.ts +12 -0
- package/dist/__tests__/bootstrap.e2e.test.d.ts.map +1 -0
- package/dist/__tests__/bootstrap.e2e.test.js +173 -0
- package/dist/__tests__/bootstrap.e2e.test.js.map +1 -0
- package/dist/__tests__/bootstrap.unit.test.d.ts +8 -0
- package/dist/__tests__/bootstrap.unit.test.d.ts.map +1 -0
- package/dist/__tests__/bootstrap.unit.test.js +444 -0
- package/dist/__tests__/bootstrap.unit.test.js.map +1 -0
- package/dist/__tests__/cli-integration.test.js +10 -4
- package/dist/__tests__/cli-integration.test.js.map +1 -1
- package/dist/commands/bootstrap.d.ts +208 -0
- package/dist/commands/bootstrap.d.ts.map +1 -0
- package/dist/commands/bootstrap.js +769 -0
- package/dist/commands/bootstrap.js.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ura bootstrap` — one-command self-hosted onboarding wizard.
|
|
3
|
+
*
|
|
4
|
+
* Walks the operator through:
|
|
5
|
+
* 1. Pre-flight checks (Docker, ports, tools)
|
|
6
|
+
* 2. GitHub App creation via manifest flow
|
|
7
|
+
* 3. Linear webhook registration
|
|
8
|
+
* 4. .env + docker-compose.dogfood.yml generation
|
|
9
|
+
* 5. Reverse-proxy config (Caddyfile or cloudflared command)
|
|
10
|
+
* 6. Optional first-run validation (POST synthetic webhook)
|
|
11
|
+
*
|
|
12
|
+
* All exported functions accept a `deps` parameter so unit tests can inject
|
|
13
|
+
* mocked implementations of I/O, network, and child-process calls.
|
|
14
|
+
*/
|
|
15
|
+
import { Command } from "commander";
|
|
16
|
+
import { createLogger } from "@urateam/core";
|
|
17
|
+
import * as http from "node:http";
|
|
18
|
+
import * as net from "node:net";
|
|
19
|
+
import * as fs from "node:fs/promises";
|
|
20
|
+
import * as childProcess from "node:child_process";
|
|
21
|
+
import * as readline from "node:readline";
|
|
22
|
+
import * as crypto from "node:crypto";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Module-level constants
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/** Ports that the urateam stack occupies: app (3000) and dashboard (3001). */
|
|
28
|
+
const APP_PORTS = [3000, 3001];
|
|
29
|
+
/** Port range scanned when looking for a free callback server port. */
|
|
30
|
+
const CALLBACK_PORT_RANGE = { min: 9876, max: 9896 };
|
|
31
|
+
/**
|
|
32
|
+
* Returns the canonical output directory for generated files.
|
|
33
|
+
* Falls back to `process.cwd()` when `dir` is omitted.
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
const getOutputDir = (dir) => dir ?? process.cwd();
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Helpers
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
/**
|
|
41
|
+
* Resolves the actual `execFile` to use, applying an optional dep override.
|
|
42
|
+
* When no dep is provided, wraps `childProcess.execFile` in a compatible shape.
|
|
43
|
+
* @internal
|
|
44
|
+
*/
|
|
45
|
+
function getExecFile(deps) {
|
|
46
|
+
if (deps?.execFile)
|
|
47
|
+
return deps.execFile;
|
|
48
|
+
// Wrap the overloaded child_process.execFile in our simpler signature.
|
|
49
|
+
return (file, args, callback) => {
|
|
50
|
+
childProcess.execFile(file, args, (err, stdout, stderr) => {
|
|
51
|
+
callback(err, stdout, stderr);
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolves the `fetch` function to use, applying an optional dep override.
|
|
57
|
+
* @internal
|
|
58
|
+
*/
|
|
59
|
+
function getFetch(deps) {
|
|
60
|
+
return deps?.fetch ?? globalThis.fetch;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolves the `writeFile` function to use.
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
function getWriteFile(deps) {
|
|
67
|
+
return deps?.writeFile ?? fs.writeFile;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Promisified execFile that respects the `deps` override.
|
|
71
|
+
* @internal
|
|
72
|
+
*/
|
|
73
|
+
async function execFileP(deps, file, args) {
|
|
74
|
+
const ef = getExecFile(deps);
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
ef(file, args, (err, stdout, stderr) => {
|
|
77
|
+
if (err) {
|
|
78
|
+
reject(err);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const outStr = stdout == null ? "" : typeof stdout === "string" ? stdout : stdout.toString();
|
|
82
|
+
const errStr = stderr == null ? "" : typeof stderr === "string" ? stderr : stderr.toString();
|
|
83
|
+
resolve({ stdout: outStr, stderr: errStr });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Checks whether a TCP port is free on 0.0.0.0.
|
|
90
|
+
* Returns true if free, false if in use.
|
|
91
|
+
*/
|
|
92
|
+
async function isPortFree(port) {
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
const server = net.createServer();
|
|
95
|
+
server.once("error", () => resolve(false));
|
|
96
|
+
server.once("listening", () => {
|
|
97
|
+
server.close(() => resolve(true));
|
|
98
|
+
});
|
|
99
|
+
server.listen(port, "0.0.0.0");
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Opens a URL in the default browser using platform-appropriate commands.
|
|
104
|
+
* @internal
|
|
105
|
+
*/
|
|
106
|
+
function openBrowserDefault(url) {
|
|
107
|
+
const platform = process.platform;
|
|
108
|
+
let cmd;
|
|
109
|
+
let args;
|
|
110
|
+
if (platform === "darwin") {
|
|
111
|
+
cmd = "open";
|
|
112
|
+
args = [url];
|
|
113
|
+
}
|
|
114
|
+
else if (platform === "win32") {
|
|
115
|
+
cmd = "cmd";
|
|
116
|
+
args = ["/c", "start", url];
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
cmd = "xdg-open";
|
|
120
|
+
args = [url];
|
|
121
|
+
}
|
|
122
|
+
// Fire-and-forget; if the browser fails to open the user can copy the URL.
|
|
123
|
+
childProcess.execFile(cmd, args, () => { });
|
|
124
|
+
}
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Step 1: Pre-flight checks
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
/**
|
|
129
|
+
* Verifies that the local environment is ready for a bootstrap run:
|
|
130
|
+
* - Docker daemon is reachable (`docker info`)
|
|
131
|
+
* - Ports 3000 and 3001 are free
|
|
132
|
+
* - Required CLI tools are present: curl, openssl, jq
|
|
133
|
+
*
|
|
134
|
+
* Throws a descriptive `Error` on the first failure. The bootstrap action
|
|
135
|
+
* calls `process.exit(1)` after logging the error.
|
|
136
|
+
*
|
|
137
|
+
* @param deps - Optional injectable dependencies (for testing).
|
|
138
|
+
*/
|
|
139
|
+
export async function preflightChecks(deps) {
|
|
140
|
+
// --- Docker ----------------------------------------------------------------
|
|
141
|
+
try {
|
|
142
|
+
await execFileP(deps, "docker", ["info"]);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
throw new Error("Docker is not running or not installed.\n" +
|
|
146
|
+
"Install Docker Desktop from https://docs.docker.com/get-docker/ and " +
|
|
147
|
+
"ensure the daemon is running before retrying.");
|
|
148
|
+
}
|
|
149
|
+
// --- Ports -----------------------------------------------------------------
|
|
150
|
+
const portCheck = deps?.isPortFree ?? isPortFree;
|
|
151
|
+
const portResults = await Promise.all(APP_PORTS.map((p) => portCheck(p)));
|
|
152
|
+
const busyPortIdx = portResults.findIndex((free) => !free);
|
|
153
|
+
if (busyPortIdx !== -1) {
|
|
154
|
+
const port = APP_PORTS[busyPortIdx];
|
|
155
|
+
throw new Error(`Port ${port} is already in use.\n` +
|
|
156
|
+
`Stop the process occupying port ${port} and re-run bootstrap.\n` +
|
|
157
|
+
`You can identify it with: lsof -i :${port}`);
|
|
158
|
+
}
|
|
159
|
+
// --- Tools -----------------------------------------------------------------
|
|
160
|
+
const tools = [
|
|
161
|
+
{ name: "curl", checkArgs: ["--version"] },
|
|
162
|
+
{ name: "openssl", checkArgs: ["version"] },
|
|
163
|
+
{ name: "jq", checkArgs: ["--version"] },
|
|
164
|
+
];
|
|
165
|
+
const toolResults = await Promise.allSettled(tools.map((t) => execFileP(deps, t.name, t.checkArgs)));
|
|
166
|
+
const failedToolIdx = toolResults.findIndex((r) => r.status === "rejected");
|
|
167
|
+
if (failedToolIdx !== -1) {
|
|
168
|
+
const tool = tools[failedToolIdx];
|
|
169
|
+
throw new Error(`Required tool "${tool.name}" is not installed or not on PATH.\n` +
|
|
170
|
+
`Install it before running bootstrap:\n` +
|
|
171
|
+
` macOS: brew install ${tool.name}\n` +
|
|
172
|
+
` Linux: apt-get install ${tool.name} (or equivalent)`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Step 2: GitHub App manifest flow
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
/**
|
|
179
|
+
* Creates a GitHub App via the manifest flow:
|
|
180
|
+
* 1. Starts a temporary HTTP server on a local port to capture the OAuth code.
|
|
181
|
+
* 2. Opens the browser to GitHub's manifest endpoint.
|
|
182
|
+
* 3. Waits for GitHub to redirect back with `?code=`.
|
|
183
|
+
* 4. Exchanges the code for full app credentials via the GitHub API.
|
|
184
|
+
*
|
|
185
|
+
* Returns the app credentials (`appId`, `privateKey`, `webhookSecret`, etc.).
|
|
186
|
+
* Throws if the callback times out or the exchange fails.
|
|
187
|
+
*
|
|
188
|
+
* @param opts - Options including org name, timeout, and injectable deps.
|
|
189
|
+
*/
|
|
190
|
+
export async function createGitHubApp(opts = {}) {
|
|
191
|
+
const { org, timeoutMs = 30_000, deps, } = opts;
|
|
192
|
+
const fetchFn = getFetch(deps);
|
|
193
|
+
const openFn = deps?.openBrowser ?? openBrowserDefault;
|
|
194
|
+
const portCheck = deps?.isPortFree ?? isPortFree;
|
|
195
|
+
// Find a free port for the callback server.
|
|
196
|
+
let callbackPort = opts.callbackPort;
|
|
197
|
+
if (!callbackPort) {
|
|
198
|
+
// Try ports in the callback range until one is free.
|
|
199
|
+
for (let p = CALLBACK_PORT_RANGE.min; p <= CALLBACK_PORT_RANGE.max; p++) {
|
|
200
|
+
if (await portCheck(p)) {
|
|
201
|
+
callbackPort = p;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!callbackPort) {
|
|
206
|
+
throw new Error(`Could not find a free port for the GitHub App callback server ` +
|
|
207
|
+
`(tried ${CALLBACK_PORT_RANGE.min}-${CALLBACK_PORT_RANGE.max}).`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const state = crypto.randomBytes(16).toString("hex");
|
|
211
|
+
const callbackUrl = `http://localhost:${callbackPort}/callback`;
|
|
212
|
+
// Build the GitHub App manifest.
|
|
213
|
+
const manifest = {
|
|
214
|
+
name: "urateam",
|
|
215
|
+
url: "https://github.com/JonB32/urateam",
|
|
216
|
+
hook_attributes: { url: "https://placeholder.invalid/webhooks/github" },
|
|
217
|
+
redirect_url: callbackUrl,
|
|
218
|
+
callback_urls: [callbackUrl],
|
|
219
|
+
public: false,
|
|
220
|
+
default_permissions: {
|
|
221
|
+
issues: "read",
|
|
222
|
+
pull_requests: "write",
|
|
223
|
+
contents: "write",
|
|
224
|
+
metadata: "read",
|
|
225
|
+
},
|
|
226
|
+
default_events: [
|
|
227
|
+
"push",
|
|
228
|
+
"pull_request",
|
|
229
|
+
"pull_request_review",
|
|
230
|
+
"pull_request_review_comment",
|
|
231
|
+
"issue_comment",
|
|
232
|
+
"check_suite",
|
|
233
|
+
"check_run",
|
|
234
|
+
"status",
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
const manifestJson = encodeURIComponent(JSON.stringify(manifest));
|
|
238
|
+
const baseUrl = org
|
|
239
|
+
? `https://github.com/organizations/${encodeURIComponent(org)}/settings/apps/new`
|
|
240
|
+
: "https://github.com/settings/apps/new";
|
|
241
|
+
const githubUrl = `${baseUrl}?state=${state}&manifest=${manifestJson}`;
|
|
242
|
+
return new Promise((resolve, reject) => {
|
|
243
|
+
let settled = false;
|
|
244
|
+
const timeout = setTimeout(() => {
|
|
245
|
+
if (settled)
|
|
246
|
+
return;
|
|
247
|
+
settled = true;
|
|
248
|
+
server.close();
|
|
249
|
+
reject(new Error(`Timed out waiting for GitHub App OAuth callback after ${timeoutMs}ms.\n` +
|
|
250
|
+
"If the browser did not open, visit:\n" +
|
|
251
|
+
` ${githubUrl}`));
|
|
252
|
+
}, timeoutMs);
|
|
253
|
+
const server = http.createServer(async (req, res) => {
|
|
254
|
+
if (settled) {
|
|
255
|
+
res.end();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const reqUrl = new URL(req.url ?? "/", `http://localhost:${callbackPort}`);
|
|
259
|
+
if (reqUrl.pathname !== "/callback") {
|
|
260
|
+
res.writeHead(404).end("Not found");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const code = reqUrl.searchParams.get("code");
|
|
264
|
+
const receivedState = reqUrl.searchParams.get("state");
|
|
265
|
+
if (receivedState !== state) {
|
|
266
|
+
res.writeHead(400).end("State mismatch — possible CSRF. Please retry.");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (!code) {
|
|
270
|
+
res.writeHead(400).end("Missing code parameter.");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// Acknowledge the callback immediately.
|
|
274
|
+
res.writeHead(200, { "Content-Type": "text/html" }).end("<html><body><h1>urateam bootstrap</h1>" +
|
|
275
|
+
"<p>GitHub App created! You can close this tab and return to the terminal.</p>" +
|
|
276
|
+
"</body></html>");
|
|
277
|
+
settled = true;
|
|
278
|
+
clearTimeout(timeout);
|
|
279
|
+
server.close();
|
|
280
|
+
// Exchange code for credentials.
|
|
281
|
+
try {
|
|
282
|
+
const exchangeResp = await fetchFn(`https://api.github.com/app-manifests/${code}/conversions`, {
|
|
283
|
+
method: "POST",
|
|
284
|
+
headers: {
|
|
285
|
+
Accept: "application/vnd.github+json",
|
|
286
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
if (!exchangeResp.ok) {
|
|
290
|
+
const body = await exchangeResp.text();
|
|
291
|
+
reject(new Error(`GitHub App manifest exchange failed (HTTP ${exchangeResp.status}): ${body}`));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const data = (await exchangeResp.json());
|
|
295
|
+
resolve({
|
|
296
|
+
appId: data.id,
|
|
297
|
+
appName: data.name ?? "urateam",
|
|
298
|
+
privateKey: data.pem,
|
|
299
|
+
webhookSecret: data.webhook_secret ?? "",
|
|
300
|
+
clientId: data.client_id,
|
|
301
|
+
clientSecret: data.client_secret,
|
|
302
|
+
htmlUrl: data.html_url ?? "",
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
reject(err);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
server.listen(callbackPort, "127.0.0.1", () => {
|
|
310
|
+
openFn(githubUrl);
|
|
311
|
+
});
|
|
312
|
+
server.on("error", (err) => {
|
|
313
|
+
if (!settled) {
|
|
314
|
+
settled = true;
|
|
315
|
+
clearTimeout(timeout);
|
|
316
|
+
reject(new Error(`Callback server failed to start: ${err.message}`));
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Step 3: Linear webhook registration
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
/**
|
|
325
|
+
* Registers a webhook in the Linear workspace via the GraphQL API.
|
|
326
|
+
*
|
|
327
|
+
* The webhook is registered for `Issue` resource type events. If `teamId` is
|
|
328
|
+
* supplied the webhook is scoped to that team; otherwise it applies
|
|
329
|
+
* workspace-wide (requires admin privileges).
|
|
330
|
+
*
|
|
331
|
+
* **Important**: when `secret` is provided, it is sent to Linear as the
|
|
332
|
+
* webhook signing secret. The caller MUST persist the same value (e.g. in
|
|
333
|
+
* `.env` as `LINEAR_WEBHOOK_SECRET`) so the urateam handler can verify
|
|
334
|
+
* incoming webhook signatures. Omitting `secret` leaves Linear's webhook
|
|
335
|
+
* unsigned — incoming events will not carry a verifiable signature.
|
|
336
|
+
*
|
|
337
|
+
* @param apiKey - Linear API key (starts with `lin_api_`).
|
|
338
|
+
* @param webhookUrl - Publicly reachable URL for the webhook endpoint.
|
|
339
|
+
* @param teamId - Optional Linear team ID to scope the webhook.
|
|
340
|
+
* @param secret - Optional HMAC signing secret sent to Linear; must match
|
|
341
|
+
* the `LINEAR_WEBHOOK_SECRET` env var used by the handler.
|
|
342
|
+
* @param deps - Optional injectable dependencies (for testing).
|
|
343
|
+
*/
|
|
344
|
+
export async function registerLinearWebhook(apiKey, webhookUrl, teamId, secret, deps) {
|
|
345
|
+
const fetchFn = getFetch(deps);
|
|
346
|
+
const query = `
|
|
347
|
+
mutation CreateWebhook($url: String!, $enabled: Boolean!, $teamId: String, $secret: String, $resourceTypes: [String!]!) {
|
|
348
|
+
webhookCreate(input: {
|
|
349
|
+
url: $url
|
|
350
|
+
enabled: $enabled
|
|
351
|
+
teamId: $teamId
|
|
352
|
+
secret: $secret
|
|
353
|
+
resourceTypes: $resourceTypes
|
|
354
|
+
}) {
|
|
355
|
+
success
|
|
356
|
+
webhook {
|
|
357
|
+
id
|
|
358
|
+
url
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
`;
|
|
363
|
+
const variables = {
|
|
364
|
+
url: webhookUrl,
|
|
365
|
+
enabled: true,
|
|
366
|
+
resourceTypes: ["Issue"],
|
|
367
|
+
};
|
|
368
|
+
if (teamId)
|
|
369
|
+
variables.teamId = teamId;
|
|
370
|
+
if (secret)
|
|
371
|
+
variables.secret = secret;
|
|
372
|
+
const resp = await fetchFn("https://api.linear.app/graphql", {
|
|
373
|
+
method: "POST",
|
|
374
|
+
headers: {
|
|
375
|
+
"Content-Type": "application/json",
|
|
376
|
+
Authorization: apiKey,
|
|
377
|
+
},
|
|
378
|
+
body: JSON.stringify({ query, variables }),
|
|
379
|
+
});
|
|
380
|
+
if (!resp.ok) {
|
|
381
|
+
const body = await resp.text();
|
|
382
|
+
throw new Error(`Linear GraphQL request failed (HTTP ${resp.status}): ${body}`);
|
|
383
|
+
}
|
|
384
|
+
const data = (await resp.json());
|
|
385
|
+
if (data.errors?.length) {
|
|
386
|
+
throw new Error(`Linear webhook registration failed: ${JSON.stringify(data.errors)}`);
|
|
387
|
+
}
|
|
388
|
+
if (!data.data?.webhookCreate?.success) {
|
|
389
|
+
throw new Error(`Linear webhook registration returned success=false: ${JSON.stringify(data.data)}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// Step 4: .env generation
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
/**
|
|
396
|
+
* Generates a `.env` file in `outputDir` (default: `process.cwd()`) with all
|
|
397
|
+
* required environment variables populated from `ctx`.
|
|
398
|
+
*
|
|
399
|
+
* @param ctx - Bootstrap context containing all credential values.
|
|
400
|
+
* @param outputDir - Directory to write `.env` to. Defaults to `process.cwd()`.
|
|
401
|
+
* @param deps - Optional injectable dependencies (for testing).
|
|
402
|
+
*/
|
|
403
|
+
export async function generateEnvFile(ctx, outputDir, deps) {
|
|
404
|
+
const writeFn = getWriteFile(deps);
|
|
405
|
+
const dir = getOutputDir(outputDir);
|
|
406
|
+
// Escape private key newlines for a single-line .env value.
|
|
407
|
+
const privateKeyEscaped = ctx.privateKey.replace(/\n/g, "\\n");
|
|
408
|
+
const envContent = [
|
|
409
|
+
"# urateam — generated by `ura bootstrap`",
|
|
410
|
+
`# Generated: ${new Date().toISOString()}`,
|
|
411
|
+
"",
|
|
412
|
+
"# ── GitHub App ───────────────────────────────────────────────────────────",
|
|
413
|
+
`GITHUB_APP_ID=${ctx.appId}`,
|
|
414
|
+
`GITHUB_PRIVATE_KEY="${privateKeyEscaped}"`,
|
|
415
|
+
`GITHUB_WEBHOOK_SECRET=${ctx.githubWebhookSecret}`,
|
|
416
|
+
"",
|
|
417
|
+
"# ── Linear ───────────────────────────────────────────────────────────────",
|
|
418
|
+
`LINEAR_API_KEY=${ctx.linearApiKey}`,
|
|
419
|
+
`LINEAR_WEBHOOK_SECRET=${ctx.linearWebhookSecret}`,
|
|
420
|
+
"",
|
|
421
|
+
"# ── Webhook URL ──────────────────────────────────────────────────────────",
|
|
422
|
+
`WEBHOOK_URL=${ctx.webhookUrl}`,
|
|
423
|
+
"",
|
|
424
|
+
"# ── Database ─────────────────────────────────────────────────────────────",
|
|
425
|
+
`DATABASE_URL=${ctx.databaseUrl ?? "file:/data/urateam.db"}`,
|
|
426
|
+
"",
|
|
427
|
+
"# ── Dashboard auth ───────────────────────────────────────────────────────",
|
|
428
|
+
`DASHBOARD_USER=${ctx.dashboardUser ?? "admin"}`,
|
|
429
|
+
`DASHBOARD_PASSWORD=${ctx.dashboardPassword ?? "changeme"}`,
|
|
430
|
+
"",
|
|
431
|
+
"# ── Claude ───────────────────────────────────────────────────────────────",
|
|
432
|
+
"# Set ONE of the following:",
|
|
433
|
+
"# ANTHROPIC_API_KEY=sk-ant-...",
|
|
434
|
+
"# CLAUDE_CODE_OAUTH_TOKEN=...",
|
|
435
|
+
"",
|
|
436
|
+
].join("\n");
|
|
437
|
+
await writeFn(path.join(dir, ".env"), envContent, "utf8");
|
|
438
|
+
}
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Step 5: docker-compose generation
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
/**
|
|
443
|
+
* Generates a `docker-compose.dogfood.yml` in `outputDir` with two services:
|
|
444
|
+
* - `app` — webhook server on port 3000
|
|
445
|
+
* - `dashboard` — ops dashboard on port 3001
|
|
446
|
+
*
|
|
447
|
+
* Both services reference `env_file: .env` for all credentials.
|
|
448
|
+
*
|
|
449
|
+
* @param ctx - Bootstrap context (used for documentation/comments only).
|
|
450
|
+
* @param outputDir - Directory to write the compose file. Defaults to `process.cwd()`.
|
|
451
|
+
* @param deps - Optional injectable dependencies (for testing).
|
|
452
|
+
*/
|
|
453
|
+
export async function generateDockerCompose(ctx, outputDir, deps) {
|
|
454
|
+
const writeFn = getWriteFile(deps);
|
|
455
|
+
const dir = getOutputDir(outputDir);
|
|
456
|
+
const composeContent = [
|
|
457
|
+
"# urateam docker-compose — generated by `ura bootstrap`",
|
|
458
|
+
`# Generated: ${new Date().toISOString()}`,
|
|
459
|
+
"# Usage: docker compose -f docker-compose.dogfood.yml up -d",
|
|
460
|
+
"",
|
|
461
|
+
"services:",
|
|
462
|
+
" app:",
|
|
463
|
+
" image: ghcr.io/jonb32/urateam:latest",
|
|
464
|
+
" restart: unless-stopped",
|
|
465
|
+
" ports:",
|
|
466
|
+
' - "3000:3000"',
|
|
467
|
+
" env_file: .env",
|
|
468
|
+
" environment:",
|
|
469
|
+
" - PORT=3000",
|
|
470
|
+
" volumes:",
|
|
471
|
+
' - urateam_data:/data',
|
|
472
|
+
"",
|
|
473
|
+
" dashboard:",
|
|
474
|
+
" image: ghcr.io/jonb32/urateam-dashboard:latest",
|
|
475
|
+
" restart: unless-stopped",
|
|
476
|
+
" ports:",
|
|
477
|
+
' - "3001:3001"',
|
|
478
|
+
" env_file: .env",
|
|
479
|
+
" environment:",
|
|
480
|
+
" - DASHBOARD_PORT=3001",
|
|
481
|
+
` - WEBHOOK_URL=${ctx.webhookUrl}`,
|
|
482
|
+
" volumes:",
|
|
483
|
+
' - urateam_data:/data',
|
|
484
|
+
" depends_on:",
|
|
485
|
+
" - app",
|
|
486
|
+
"",
|
|
487
|
+
"volumes:",
|
|
488
|
+
" urateam_data:",
|
|
489
|
+
"",
|
|
490
|
+
].join("\n");
|
|
491
|
+
await writeFn(path.join(dir, "docker-compose.dogfood.yml"), composeContent, "utf8");
|
|
492
|
+
}
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// Step 6: Reverse-proxy config
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
/**
|
|
497
|
+
* Generates reverse-proxy configuration for the given domain.
|
|
498
|
+
*
|
|
499
|
+
* For `"caddy"`: writes a `Caddyfile` in `outputDir`.
|
|
500
|
+
* For `"cloudflared"`: prints the `cloudflared tunnel` command to stdout
|
|
501
|
+
* (no file is written — cloudflared uses its own config store).
|
|
502
|
+
*
|
|
503
|
+
* @param domain - Fully-qualified domain name (e.g. `hooks.example.com`).
|
|
504
|
+
* @param choice - Proxy type: `"caddy"` or `"cloudflared"`.
|
|
505
|
+
* @param outputDir - Directory to write generated files. Defaults to `process.cwd()`.
|
|
506
|
+
* @param deps - Optional injectable dependencies (for testing).
|
|
507
|
+
*/
|
|
508
|
+
export async function generateReverseProxyConfig(domain, choice, outputDir, deps) {
|
|
509
|
+
const writeFn = getWriteFile(deps);
|
|
510
|
+
const log = deps?.log ?? ((msg) => process.stdout.write(msg + "\n"));
|
|
511
|
+
const dir = getOutputDir(outputDir);
|
|
512
|
+
if (choice === "caddy") {
|
|
513
|
+
const caddyfile = [
|
|
514
|
+
`# Caddyfile — generated by \`ura bootstrap\``,
|
|
515
|
+
`# Usage: caddy run --config Caddyfile`,
|
|
516
|
+
"",
|
|
517
|
+
`${domain} {`,
|
|
518
|
+
" reverse_proxy localhost:3000",
|
|
519
|
+
"}",
|
|
520
|
+
"",
|
|
521
|
+
].join("\n");
|
|
522
|
+
await writeFn(path.join(dir, "Caddyfile"), caddyfile, "utf8");
|
|
523
|
+
log(`Caddyfile written to ${path.join(dir, "Caddyfile")}`);
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
// cloudflared: just print the command — no file to write.
|
|
527
|
+
log("To expose port 3000 via Cloudflare Tunnel, run:");
|
|
528
|
+
log(` cloudflared tunnel --url http://localhost:3000`);
|
|
529
|
+
log("Then register your domain in the Cloudflare Zero Trust dashboard and " +
|
|
530
|
+
"point it to the tunnel.");
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
// Step 7: Validation
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
/**
|
|
537
|
+
* Validates the running stack by POSTing a synthetic Linear webhook to the
|
|
538
|
+
* local webhook server. Polls every 2 seconds for up to `timeoutMs`
|
|
539
|
+
* (default: 30 000 ms). Resolves on a 2xx response; throws on timeout.
|
|
540
|
+
*
|
|
541
|
+
* @param port - Local webhook server port (default: 3000).
|
|
542
|
+
* @param timeoutMs - Total time to wait in milliseconds (default: 30 000).
|
|
543
|
+
* @param deps - Optional injectable dependencies (for testing).
|
|
544
|
+
*/
|
|
545
|
+
export async function validateSetup(port = 3000, timeoutMs = 30_000, deps) {
|
|
546
|
+
const fetchFn = getFetch(deps);
|
|
547
|
+
const url = `http://localhost:${port}/webhooks/linear`;
|
|
548
|
+
const syntheticPayload = JSON.stringify({
|
|
549
|
+
action: "bootstrap-validation",
|
|
550
|
+
type: "Issue",
|
|
551
|
+
data: { id: "bootstrap-check", title: "bootstrap validation" },
|
|
552
|
+
});
|
|
553
|
+
const deadline = Date.now() + timeoutMs;
|
|
554
|
+
// eslint-disable-next-line no-constant-condition
|
|
555
|
+
while (true) {
|
|
556
|
+
try {
|
|
557
|
+
const resp = await fetchFn(url, {
|
|
558
|
+
method: "POST",
|
|
559
|
+
headers: { "Content-Type": "application/json" },
|
|
560
|
+
body: syntheticPayload,
|
|
561
|
+
});
|
|
562
|
+
if (resp.status >= 200 && resp.status < 300) {
|
|
563
|
+
return; // Success!
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
// Connection refused — server not up yet, keep polling.
|
|
568
|
+
}
|
|
569
|
+
if (Date.now() >= deadline) {
|
|
570
|
+
throw new Error(`Validation timed out after ${timeoutMs}ms.\n` +
|
|
571
|
+
`The webhook server on port ${port} did not respond with a 2xx status.\n` +
|
|
572
|
+
`Check 'docker compose logs app' for startup errors.`);
|
|
573
|
+
}
|
|
574
|
+
// Wait 2 seconds before retrying.
|
|
575
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
// Interactive prompts helper
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
/**
|
|
582
|
+
* Creates a readline interface and asks a single question.
|
|
583
|
+
* Returns the trimmed answer. Closes the interface after.
|
|
584
|
+
* @internal
|
|
585
|
+
*/
|
|
586
|
+
async function prompt(question, defaultValue) {
|
|
587
|
+
const rl = readline.createInterface({
|
|
588
|
+
input: process.stdin,
|
|
589
|
+
output: process.stdout,
|
|
590
|
+
});
|
|
591
|
+
return new Promise((resolve) => {
|
|
592
|
+
const displayQuestion = defaultValue
|
|
593
|
+
? `${question} [${defaultValue}]: `
|
|
594
|
+
: `${question}: `;
|
|
595
|
+
rl.question(displayQuestion, (answer) => {
|
|
596
|
+
rl.close();
|
|
597
|
+
resolve(answer.trim() || defaultValue || "");
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
// ---------------------------------------------------------------------------
|
|
602
|
+
// Command definition
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
/**
|
|
605
|
+
* `ura bootstrap` — the Commander.js command object.
|
|
606
|
+
*
|
|
607
|
+
* Orchestrates the full self-hosted onboarding sequence in interactive mode.
|
|
608
|
+
*/
|
|
609
|
+
export const bootstrapCommand = new Command("bootstrap")
|
|
610
|
+
.description("One-command self-hosted onboarding: creates a GitHub App, registers a " +
|
|
611
|
+
"Linear webhook, generates .env + docker-compose.dogfood.yml, and " +
|
|
612
|
+
"optionally validates the running stack.")
|
|
613
|
+
.option("--skip-github-app", "Skip GitHub App creation (use APP_ID/PRIVATE_KEY from env)", false)
|
|
614
|
+
.option("--skip-linear", "Skip Linear webhook registration", false)
|
|
615
|
+
.option("--validate", "POST a synthetic webhook to confirm the stack is healthy", false)
|
|
616
|
+
.option("--domain <domain>", "Domain for reverse-proxy config (e.g. hooks.example.com)")
|
|
617
|
+
.option("--proxy <type>", "Reverse-proxy type: caddy or cloudflared", "caddy")
|
|
618
|
+
.option("--output-dir <dir>", "Directory for generated files (default: cwd)")
|
|
619
|
+
.option("--port <port>", "Webhook server port for validation", "3000")
|
|
620
|
+
.action(async (opts) => {
|
|
621
|
+
const logger = createLogger({ component: "bootstrap" });
|
|
622
|
+
/** Logs an error via the structured logger then exits with code 1. */
|
|
623
|
+
function exitWithError(message, err) {
|
|
624
|
+
logger.error({ err: err.message }, message);
|
|
625
|
+
process.exit(1);
|
|
626
|
+
}
|
|
627
|
+
logger.info("urateam — Self-Hosted Bootstrap Wizard starting");
|
|
628
|
+
// ── Step 1: Pre-flight checks ──────────────────────────────────────────
|
|
629
|
+
logger.info("[1/7] Running pre-flight checks...");
|
|
630
|
+
try {
|
|
631
|
+
await preflightChecks();
|
|
632
|
+
logger.info("[1/7] Pre-flight checks passed.");
|
|
633
|
+
}
|
|
634
|
+
catch (err) {
|
|
635
|
+
exitWithError("Pre-flight check failed", err);
|
|
636
|
+
}
|
|
637
|
+
// ── Step 2: Interactive prompts ────────────────────────────────────────
|
|
638
|
+
logger.info("[2/7] Gathering configuration...");
|
|
639
|
+
const org = await prompt("GitHub organisation (leave blank for personal account)");
|
|
640
|
+
const linearApiKey = process.env.LINEAR_API_KEY ||
|
|
641
|
+
(await prompt("Linear API key (lin_api_...)"));
|
|
642
|
+
const linearTeamId = process.env.LINEAR_TEAM_ID ||
|
|
643
|
+
(await prompt("Linear team ID (optional, leave blank for workspace-wide)")) ||
|
|
644
|
+
undefined;
|
|
645
|
+
let domain = opts.domain;
|
|
646
|
+
if (!domain) {
|
|
647
|
+
domain = await prompt("Public domain for reverse-proxy config (leave blank to skip)");
|
|
648
|
+
if (!domain)
|
|
649
|
+
domain = undefined;
|
|
650
|
+
}
|
|
651
|
+
let proxyType = opts.proxy;
|
|
652
|
+
if (domain && !opts.proxy) {
|
|
653
|
+
const choice = await prompt("Reverse-proxy type (caddy/cloudflared)", "caddy");
|
|
654
|
+
proxyType = choice === "cloudflared" ? "cloudflared" : "caddy";
|
|
655
|
+
}
|
|
656
|
+
// ── Step 3: GitHub App ─────────────────────────────────────────────────
|
|
657
|
+
let appCredentials = null;
|
|
658
|
+
if (opts.skipGithubApp) {
|
|
659
|
+
logger.info("[3/7] Skipping GitHub App creation (--skip-github-app set).");
|
|
660
|
+
const appIdStr = process.env.GITHUB_APP_ID ||
|
|
661
|
+
(await prompt("GITHUB_APP_ID"));
|
|
662
|
+
const privateKey = process.env.GITHUB_PRIVATE_KEY?.replace(/\\n/g, "\n") ||
|
|
663
|
+
(await prompt("GITHUB_PRIVATE_KEY (PEM, newlines as \\n)"));
|
|
664
|
+
const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET ||
|
|
665
|
+
(await prompt("GITHUB_WEBHOOK_SECRET"));
|
|
666
|
+
appCredentials = {
|
|
667
|
+
appId: parseInt(appIdStr, 10),
|
|
668
|
+
appName: "urateam",
|
|
669
|
+
privateKey,
|
|
670
|
+
webhookSecret,
|
|
671
|
+
clientId: process.env.GITHUB_CLIENT_ID ?? "",
|
|
672
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "",
|
|
673
|
+
htmlUrl: "",
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
logger.info("[3/7] Creating GitHub App via manifest flow — a browser window will open.");
|
|
678
|
+
try {
|
|
679
|
+
appCredentials = await createGitHubApp({ org: org || undefined });
|
|
680
|
+
logger.info({ appId: appCredentials.appId, appName: appCredentials.appName }, "[3/7] GitHub App created.");
|
|
681
|
+
}
|
|
682
|
+
catch (err) {
|
|
683
|
+
exitWithError("GitHub App creation failed", err);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// ── Step 4: Linear webhook ─────────────────────────────────────────────
|
|
687
|
+
const webhookUrl = domain ? `https://${domain}/webhooks/linear` : "https://PLACEHOLDER/webhooks/linear";
|
|
688
|
+
const linearWebhookSecret = crypto.randomBytes(32).toString("hex");
|
|
689
|
+
if (opts.skipLinear) {
|
|
690
|
+
logger.info("[4/7] Skipping Linear webhook registration (--skip-linear set).");
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
logger.info({ webhookUrl }, "[4/7] Registering Linear webhook...");
|
|
694
|
+
try {
|
|
695
|
+
await registerLinearWebhook(linearApiKey, webhookUrl, linearTeamId, linearWebhookSecret);
|
|
696
|
+
logger.info("[4/7] Linear webhook registered.");
|
|
697
|
+
}
|
|
698
|
+
catch (err) {
|
|
699
|
+
exitWithError("Linear webhook registration failed", err);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// ── Step 5: .env file ──────────────────────────────────────────────────
|
|
703
|
+
logger.info("[5/7] Generating .env...");
|
|
704
|
+
// appCredentials is always set here — both branches of the skip/create flow
|
|
705
|
+
// either assign it or call process.exit(1).
|
|
706
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
707
|
+
const resolvedCreds = appCredentials;
|
|
708
|
+
const ctx = {
|
|
709
|
+
appId: resolvedCreds.appId,
|
|
710
|
+
privateKey: resolvedCreds.privateKey,
|
|
711
|
+
githubWebhookSecret: resolvedCreds.webhookSecret,
|
|
712
|
+
linearApiKey,
|
|
713
|
+
linearWebhookSecret,
|
|
714
|
+
webhookUrl,
|
|
715
|
+
};
|
|
716
|
+
try {
|
|
717
|
+
await generateEnvFile(ctx, opts.outputDir);
|
|
718
|
+
const envPath = path.join(getOutputDir(opts.outputDir), ".env");
|
|
719
|
+
logger.info({ path: envPath }, "[5/7] .env written.");
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
exitWithError("Failed to write .env", err);
|
|
723
|
+
}
|
|
724
|
+
// ── Step 6: docker-compose ─────────────────────────────────────────────
|
|
725
|
+
logger.info("[6/7] Generating docker-compose.dogfood.yml...");
|
|
726
|
+
try {
|
|
727
|
+
await generateDockerCompose(ctx, opts.outputDir);
|
|
728
|
+
const composePath = path.join(getOutputDir(opts.outputDir), "docker-compose.dogfood.yml");
|
|
729
|
+
logger.info({ path: composePath }, "[6/7] docker-compose.dogfood.yml written.");
|
|
730
|
+
}
|
|
731
|
+
catch (err) {
|
|
732
|
+
exitWithError("Failed to write docker-compose.dogfood.yml", err);
|
|
733
|
+
}
|
|
734
|
+
// Reverse-proxy config.
|
|
735
|
+
if (domain) {
|
|
736
|
+
logger.info({ domain, proxyType }, "Generating reverse-proxy config...");
|
|
737
|
+
try {
|
|
738
|
+
await generateReverseProxyConfig(domain, proxyType, opts.outputDir);
|
|
739
|
+
}
|
|
740
|
+
catch (err) {
|
|
741
|
+
// Non-fatal — user can set up proxy manually.
|
|
742
|
+
logger.warn({ err: err.message }, "Failed to generate proxy config (non-fatal).");
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
// ── Step 7: Validation ─────────────────────────────────────────────────
|
|
746
|
+
const port = parseInt(opts.port ?? "3000", 10);
|
|
747
|
+
if (opts.validate) {
|
|
748
|
+
logger.info({ port }, "[7/7] Validating stack — waiting up to 30s for webhook server...");
|
|
749
|
+
try {
|
|
750
|
+
await validateSetup(port);
|
|
751
|
+
logger.info("[7/7] Validation passed — the webhook server is healthy.");
|
|
752
|
+
}
|
|
753
|
+
catch (err) {
|
|
754
|
+
// Non-fatal — stack may just need a moment.
|
|
755
|
+
logger.warn({ err: err.message }, "[7/7] Validation failed (non-fatal).");
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
logger.info("[7/7] Skipping validation (pass --validate to enable).");
|
|
760
|
+
}
|
|
761
|
+
// ── Success ────────────────────────────────────────────────────────────
|
|
762
|
+
logger.info({
|
|
763
|
+
envPath: path.join(getOutputDir(opts.outputDir), ".env"),
|
|
764
|
+
composePath: path.join(getOutputDir(opts.outputDir), "docker-compose.dogfood.yml"),
|
|
765
|
+
webhookUrl,
|
|
766
|
+
}, "Bootstrap complete! Next: add ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN to .env, " +
|
|
767
|
+
"then run: docker compose -f docker-compose.dogfood.yml up -d");
|
|
768
|
+
});
|
|
769
|
+
//# sourceMappingURL=bootstrap.js.map
|