@deployfoundation/foundation-deploy 0.1.0 → 0.1.2
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/bin/app.js +2 -2
- package/dist/bin/foundation-deploy.js +76 -57
- package/dist/bin/release-account.js +2 -2
- package/dist/{chunk-4aye5cee.js → chunk-m4pn6wc2.js} +1 -1
- package/dist/{chunk-v7tz8g50.js → chunk-qc5d46ky.js} +34 -23
- package/dist/src/index.js +2 -2
- package/package.json +1 -1
- package/src/deploy/config-sync.ts +24 -3
- package/src/deploy/github-app-create.ts +70 -61
- package/src/deploy/setup.ts +18 -5
- package/src/deploy/sh.ts +41 -19
- /package/dist/{chunk-9ddxyvq2.js → chunk-t86rw0f2.js} +0 -0
package/dist/bin/app.js
CHANGED
|
@@ -7,12 +7,12 @@ import {
|
|
|
7
7
|
FoundationNetwork,
|
|
8
8
|
FoundationPipeline,
|
|
9
9
|
NewsletterStack
|
|
10
|
-
} from "../chunk-
|
|
10
|
+
} from "../chunk-m4pn6wc2.js";
|
|
11
11
|
import {
|
|
12
12
|
adminsFor,
|
|
13
13
|
instanceNames,
|
|
14
14
|
loadInstanceFile
|
|
15
|
-
} from "../chunk-
|
|
15
|
+
} from "../chunk-t86rw0f2.js";
|
|
16
16
|
|
|
17
17
|
// bin/app.ts
|
|
18
18
|
import { execFileSync } from "node:child_process";
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
callerAccountId,
|
|
13
13
|
cdkEnv,
|
|
14
14
|
createSecretString,
|
|
15
|
+
hasFoundationWorkspace,
|
|
15
16
|
instanceBanner,
|
|
16
17
|
loadInstanceContext,
|
|
17
18
|
putSecretJson,
|
|
@@ -28,7 +29,7 @@ import {
|
|
|
28
29
|
stackExists,
|
|
29
30
|
stackOutput,
|
|
30
31
|
toolVersion
|
|
31
|
-
} from "../chunk-
|
|
32
|
+
} from "../chunk-qc5d46ky.js";
|
|
32
33
|
import {
|
|
33
34
|
BASE_SLACK_EVENTS,
|
|
34
35
|
CAPABILITY_IDS,
|
|
@@ -41,7 +42,7 @@ import {
|
|
|
41
42
|
requiredSlackScopes,
|
|
42
43
|
skillsKey,
|
|
43
44
|
slackCommandPrefix
|
|
44
|
-
} from "../chunk-
|
|
45
|
+
} from "../chunk-t86rw0f2.js";
|
|
45
46
|
|
|
46
47
|
// src/deploy/config-sync.ts
|
|
47
48
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync } from "node:fs";
|
|
@@ -69,10 +70,16 @@ async function unpackReleaseSkills(ctx, release) {
|
|
|
69
70
|
const key = release.manifest?.skills.key ?? skillsKey(release.version);
|
|
70
71
|
const dir = mkdtempSync(join(tmpdir(), "foundation-skills-"));
|
|
71
72
|
const tarball = join(dir, "skills.tar.gz");
|
|
72
|
-
const contents = join(dir, "skills");
|
|
73
|
-
mkdirSync(contents, { recursive: true });
|
|
74
73
|
await awsMutate(ctx, ["s3", "cp", `s3://${release.bucket}/${key}`, tarball]);
|
|
75
|
-
await
|
|
74
|
+
return await extractSkillsTarball(tarball, dir, { dryRun: ctx.dryRun });
|
|
75
|
+
}
|
|
76
|
+
async function extractSkillsTarball(tarball, dir, opts = {}) {
|
|
77
|
+
const contents = join(dir, "skills");
|
|
78
|
+
await run(["tar", "-xzf", tarball, "-C", dir], { dryRun: opts.dryRun });
|
|
79
|
+
if (opts.dryRun !== true && !existsSync(contents))
|
|
80
|
+
throw new Error(`${tarball} has no top-level skills/ directory`);
|
|
81
|
+
if (opts.dryRun === true)
|
|
82
|
+
mkdirSync(contents, { recursive: true });
|
|
76
83
|
return contents;
|
|
77
84
|
}
|
|
78
85
|
|
|
@@ -1142,6 +1149,9 @@ async function deploy(ctx, opts) {
|
|
|
1142
1149
|
}
|
|
1143
1150
|
}
|
|
1144
1151
|
|
|
1152
|
+
// src/deploy/github-app-create.ts
|
|
1153
|
+
import { createServer } from "node:http";
|
|
1154
|
+
|
|
1145
1155
|
// src/deploy/github-app-manifest.ts
|
|
1146
1156
|
import { readFileSync as readFileSync3 } from "node:fs";
|
|
1147
1157
|
import { join as join4 } from "node:path";
|
|
@@ -1193,15 +1203,14 @@ async function githubAppCreate(options) {
|
|
|
1193
1203
|
const body = JSON.stringify({ ...manifest, redirect_url: redirectUrl });
|
|
1194
1204
|
async function writeSecret(value) {
|
|
1195
1205
|
const run2 = async (cmd) => {
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
return { code, err: await new Response(p.stderr).text() };
|
|
1206
|
+
try {
|
|
1207
|
+
await runCapture(["aws", ...cmd, "--profile", profile, "--region", region], {
|
|
1208
|
+
stdin: value
|
|
1209
|
+
});
|
|
1210
|
+
return { code: 0, err: "" };
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
return { code: 1, err: error instanceof Error ? error.message : String(error) };
|
|
1213
|
+
}
|
|
1205
1214
|
};
|
|
1206
1215
|
let r = await run2([
|
|
1207
1216
|
"secretsmanager",
|
|
@@ -1225,50 +1234,56 @@ async function githubAppCreate(options) {
|
|
|
1225
1234
|
if (r.code !== 0)
|
|
1226
1235
|
throw new Error(`aws secretsmanager failed: ${r.err.trim()}`);
|
|
1227
1236
|
}
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
const
|
|
1232
|
-
if (url.pathname === "/") {
|
|
1233
|
-
const html = `<!doctype html><title>Create ${instance.displayName} GitHub App</title><body style="font-family:system-ui;padding:2rem">
|
|
1237
|
+
const handler = async (req) => {
|
|
1238
|
+
const url = new URL(req.url);
|
|
1239
|
+
if (url.pathname === "/") {
|
|
1240
|
+
const html = `<!doctype html><title>Create ${instance.displayName} GitHub App</title><body style="font-family:system-ui;padding:2rem">
|
|
1234
1241
|
<h2>Creating the <b>${instance.displayName}</b> GitHub App on <b>${org}</b>…</h2><p>If nothing happens, click the button.</p>
|
|
1235
1242
|
<form id="f" method="post" action="https://github.com/organizations/${org}/settings/apps/new?state=${state}">
|
|
1236
1243
|
<input type="hidden" name="manifest" id="m"><button>Create GitHub App</button></form>
|
|
1237
1244
|
<script>document.getElementById('m').value=${JSON.stringify(body)};document.getElementById('f').submit();</script></body>`;
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
}
|
|
1269
|
-
return new Response("not found", { status: 404 });
|
|
1245
|
+
return new Response(html, { headers: { "content-type": "text/html" } });
|
|
1246
|
+
}
|
|
1247
|
+
if (url.pathname === "/callback") {
|
|
1248
|
+
const code = url.searchParams.get("code");
|
|
1249
|
+
if (url.searchParams.get("state") !== state || code === null)
|
|
1250
|
+
return new Response("bad state/code", { status: 400 });
|
|
1251
|
+
const res = await fetch(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, {
|
|
1252
|
+
method: "POST",
|
|
1253
|
+
headers: {
|
|
1254
|
+
Accept: "application/vnd.github+json",
|
|
1255
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
if (!res.ok)
|
|
1259
|
+
return new Response(`conversion failed: HTTP ${res.status}`, { status: 502 });
|
|
1260
|
+
const app = await res.json();
|
|
1261
|
+
await writeSecret(JSON.stringify({ app_id: String(app.id), installation_id: "", private_key: app.pem }));
|
|
1262
|
+
const installUrl = `https://github.com/apps/${app.slug}/installations/new`;
|
|
1263
|
+
console.log(JSON.stringify({
|
|
1264
|
+
app_id: app.id,
|
|
1265
|
+
slug: app.slug,
|
|
1266
|
+
html_url: app.html_url,
|
|
1267
|
+
install_url: installUrl,
|
|
1268
|
+
secret: secretName
|
|
1269
|
+
}));
|
|
1270
|
+
setTimeout(() => {
|
|
1271
|
+
server.close();
|
|
1272
|
+
process.exit(0);
|
|
1273
|
+
}, 500);
|
|
1274
|
+
return new Response(`<!doctype html><body style="font-family:system-ui;padding:2rem"><h2>✅ App "${app.slug}" created (id ${app.id}); private key stored in Secrets Manager ${secretName}.</h2><p>Next: <a href="${installUrl}">install it on your repos</a>.</p></body>`, { headers: { "content-type": "text/html" } });
|
|
1270
1275
|
}
|
|
1276
|
+
return new Response("not found", { status: 404 });
|
|
1277
|
+
};
|
|
1278
|
+
const server = createServer(async (req, res) => {
|
|
1279
|
+
const request = new Request(`http://localhost:${port}${req.url ?? "/"}`, {
|
|
1280
|
+
method: req.method ?? "GET"
|
|
1281
|
+
});
|
|
1282
|
+
const response = await handler(request);
|
|
1283
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
1284
|
+
res.end(await response.text());
|
|
1271
1285
|
});
|
|
1286
|
+
server.listen(port);
|
|
1272
1287
|
console.log(`Open http://localhost:${port}/ to create the app (waiting up to 10 minutes)…`);
|
|
1273
1288
|
setTimeout(() => {
|
|
1274
1289
|
console.error("timed out");
|
|
@@ -1320,7 +1335,7 @@ async function postDeploy(ctx, opts = {}) {
|
|
|
1320
1335
|
}
|
|
1321
1336
|
|
|
1322
1337
|
// src/deploy/setup.ts
|
|
1323
|
-
import { existsSync as existsSync3 } from "node:fs";
|
|
1338
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5 } from "node:fs";
|
|
1324
1339
|
import { resolve as resolve3 } from "node:path";
|
|
1325
1340
|
|
|
1326
1341
|
// src/deploy/slack-manifest.ts
|
|
@@ -1524,6 +1539,8 @@ async function output(ctx, stack, key) {
|
|
|
1524
1539
|
}
|
|
1525
1540
|
}
|
|
1526
1541
|
async function installDependencies(ctx) {
|
|
1542
|
+
if (!hasFoundationWorkspace())
|
|
1543
|
+
return;
|
|
1527
1544
|
await run(["bun", "install", "--frozen-lockfile"], {
|
|
1528
1545
|
cwd: FOUNDATION_ROOT,
|
|
1529
1546
|
dryRun: ctx.dryRun
|
|
@@ -1536,12 +1553,14 @@ async function preflight(ctx) {
|
|
|
1536
1553
|
if (account !== expected)
|
|
1537
1554
|
throw new Error(`profile ${ctx.profile} is account ${account}, expected ${expected} for instance ${ctx.instance.name}`);
|
|
1538
1555
|
console.log(` account ${account} via profile ${ctx.profile} (${ctx.region})`);
|
|
1539
|
-
if (ctx.dryRun === true) {
|
|
1556
|
+
if (!hasFoundationWorkspace()) {} else if (ctx.dryRun === true) {
|
|
1540
1557
|
await run(["docker", "info"], { dryRun: true });
|
|
1541
1558
|
} else {
|
|
1542
|
-
|
|
1543
|
-
|
|
1559
|
+
try {
|
|
1560
|
+
await runCapture(["docker", "info"]);
|
|
1561
|
+
} catch {
|
|
1544
1562
|
throw new Error("docker is not running");
|
|
1563
|
+
}
|
|
1545
1564
|
console.log(" docker ✓");
|
|
1546
1565
|
}
|
|
1547
1566
|
for (const id of [
|
|
@@ -1571,7 +1590,7 @@ async function writeRuntimeSecret(ctx) {
|
|
|
1571
1590
|
async function seedCodex(ctx, codexFile) {
|
|
1572
1591
|
if (!existsSync3(codexFile))
|
|
1573
1592
|
throw new Error(`${codexFile} not found — sign in locally first, or pass --codex-file`);
|
|
1574
|
-
const document =
|
|
1593
|
+
const document = readFileSync5(codexFile, "utf8");
|
|
1575
1594
|
JSON.parse(document);
|
|
1576
1595
|
await putSecretString(ctx, ctx.names.secretCodex, document);
|
|
1577
1596
|
console.log(` ${ctx.names.secretCodex} seeded from ${codexFile}`);
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
manifestKey,
|
|
7
7
|
releaseCacheDir,
|
|
8
8
|
verifyManifest
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-t86rw0f2.js";
|
|
10
10
|
|
|
11
11
|
// src/deploy/release.ts
|
|
12
12
|
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2 } from "node:fs";
|
|
@@ -83,6 +83,7 @@ function instanceBanner(context) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
// src/deploy/sh.ts
|
|
86
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
86
87
|
function formatCommand(cmd, env) {
|
|
87
88
|
const prefix = Object.entries(env ?? {}).map(([k, v]) => `${k}=${quote(v)}`).join(" ");
|
|
88
89
|
const body = cmd.map(quote).join(" ");
|
|
@@ -91,45 +92,55 @@ function formatCommand(cmd, env) {
|
|
|
91
92
|
function quote(value) {
|
|
92
93
|
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
|
|
93
94
|
}
|
|
94
|
-
function
|
|
95
|
+
function start(cmd, opts, stdout) {
|
|
95
96
|
const [bin, ...rest] = cmd;
|
|
96
97
|
if (bin === undefined)
|
|
97
98
|
throw new Error("run: empty command");
|
|
98
|
-
|
|
99
|
+
const child = nodeSpawn(bin, rest, {
|
|
99
100
|
cwd: opts.cwd,
|
|
100
101
|
env: { ...process.env, ...opts.env },
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
stdio: [
|
|
103
|
+
opts.stdin === undefined ? "ignore" : "pipe",
|
|
104
|
+
stdout,
|
|
105
|
+
stdout === "inherit" ? "inherit" : "pipe"
|
|
106
|
+
]
|
|
104
107
|
});
|
|
108
|
+
if (opts.stdin !== undefined && child.stdin !== null) {
|
|
109
|
+
child.stdin.end(opts.stdin);
|
|
110
|
+
}
|
|
111
|
+
return child;
|
|
105
112
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
function exited(child) {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
child.once("error", reject);
|
|
116
|
+
child.once("close", (code, signal) => resolve(code ?? (signal === null ? 1 : 128)));
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function collect(stream) {
|
|
120
|
+
if (stream === null)
|
|
121
|
+
return Promise.resolve("");
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const chunks = [];
|
|
124
|
+
stream.on("data", (c) => chunks.push(c));
|
|
125
|
+
stream.once("error", reject);
|
|
126
|
+
stream.once("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
127
|
+
});
|
|
114
128
|
}
|
|
115
129
|
async function run(cmd, opts = {}) {
|
|
116
130
|
if (opts.dryRun === true) {
|
|
117
131
|
console.log(` $ ${formatCommand(cmd, opts.env)}`);
|
|
118
132
|
return;
|
|
119
133
|
}
|
|
120
|
-
const
|
|
121
|
-
await feed(proc, opts.stdin);
|
|
122
|
-
const code = await proc.exited;
|
|
134
|
+
const code = await exited(start(cmd, opts, "inherit"));
|
|
123
135
|
if (code !== 0)
|
|
124
136
|
throw new Error(`${formatCommand(cmd)} exited ${code}`);
|
|
125
137
|
}
|
|
126
138
|
async function runCapture(cmd, opts = {}) {
|
|
127
|
-
const
|
|
128
|
-
await feed(proc, opts.stdin);
|
|
139
|
+
const child = start(cmd, opts, "pipe");
|
|
129
140
|
const [out, err, code] = await Promise.all([
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
141
|
+
collect(child.stdout),
|
|
142
|
+
collect(child.stderr),
|
|
143
|
+
exited(child)
|
|
133
144
|
]);
|
|
134
145
|
if (code !== 0)
|
|
135
146
|
throw new Error(`${formatCommand(cmd)} exited ${code}: ${err.trim()}`);
|
|
@@ -425,4 +436,4 @@ function releaseContext(release) {
|
|
|
425
436
|
];
|
|
426
437
|
}
|
|
427
438
|
|
|
428
|
-
export { resolveInstanceFilePath, loadInstanceContext, instanceBanner, run, runCapture, awsContext, argv, cdkEnv, aws, awsMutate, stackOutput, stackExists, secretExists, readSecretString, readSecretJson, putSecretString, createSecretString, putSecretJson, callerAccountId, INFRA_ROOT, FOUNDATION_ROOT, AGENT_DOCKERFILE, PACKAGE_ASSETS, DEFAULT_RELEASE_BUCKET, toolVersion, releaseRequest, resolveRelease, releaseContext };
|
|
439
|
+
export { resolveInstanceFilePath, loadInstanceContext, instanceBanner, run, runCapture, awsContext, argv, cdkEnv, aws, awsMutate, stackOutput, stackExists, secretExists, readSecretString, readSecretJson, putSecretString, createSecretString, putSecretJson, callerAccountId, INFRA_ROOT, FOUNDATION_ROOT, AGENT_DOCKERFILE, PACKAGE_ASSETS, DEFAULT_RELEASE_BUCKET, toolVersion, hasFoundationWorkspace, releaseRequest, resolveRelease, releaseContext };
|
package/dist/src/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
FoundationPipeline,
|
|
10
10
|
NewsletterStack,
|
|
11
11
|
deployStatements
|
|
12
|
-
} from "../chunk-
|
|
12
|
+
} from "../chunk-m4pn6wc2.js";
|
|
13
13
|
import {
|
|
14
14
|
BUN_VERSION,
|
|
15
15
|
LAMBDA_ENTRY_POINTS,
|
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
skillsKey,
|
|
43
43
|
skipBundle,
|
|
44
44
|
verifyManifest
|
|
45
|
-
} from "../chunk-
|
|
45
|
+
} from "../chunk-t86rw0f2.js";
|
|
46
46
|
export {
|
|
47
47
|
BUILD_TIMEOUT,
|
|
48
48
|
BUN_VERSION,
|
package/package.json
CHANGED
|
@@ -83,11 +83,32 @@ export async function unpackReleaseSkills(
|
|
|
83
83
|
const key = release.manifest?.skills.key ?? skillsKey(release.version);
|
|
84
84
|
const dir = mkdtempSync(join(tmpdir(), "foundation-skills-"));
|
|
85
85
|
const tarball = join(dir, "skills.tar.gz");
|
|
86
|
-
const contents = join(dir, "skills");
|
|
87
|
-
mkdirSync(contents, { recursive: true });
|
|
88
86
|
// `awsMutate` rather than a plain read, only so a dry run prints the copy
|
|
89
87
|
// instead of performing it.
|
|
90
88
|
await awsMutate(ctx, ["s3", "cp", `s3://${release.bucket}/${key}`, tarball]);
|
|
91
|
-
await
|
|
89
|
+
return await extractSkillsTarball(tarball, dir, { dryRun: ctx.dryRun });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Unpack a release's `skills.tar.gz` and return the directory holding the
|
|
94
|
+
* skills themselves.
|
|
95
|
+
*
|
|
96
|
+
* `release-build` runs `tar -czf skills.tar.gz skills`, so the archive's top
|
|
97
|
+
* level is a `skills/` directory. It is extracted INTO `dir`, landing at
|
|
98
|
+
* `dir/skills`. v0.1.0 and v0.1.1 extracted into `dir/skills` instead, which
|
|
99
|
+
* produced `dir/skills/skills/…` and synced every product skill to
|
|
100
|
+
* `s3://…/skills/skills/`, one level below where the agent looks — so a release
|
|
101
|
+
* deploy silently shipped no product skills at all.
|
|
102
|
+
*/
|
|
103
|
+
export async function extractSkillsTarball(
|
|
104
|
+
tarball: string,
|
|
105
|
+
dir: string,
|
|
106
|
+
opts: { dryRun?: boolean } = {},
|
|
107
|
+
): Promise<string> {
|
|
108
|
+
const contents = join(dir, "skills");
|
|
109
|
+
await run(["tar", "-xzf", tarball, "-C", dir], { dryRun: opts.dryRun });
|
|
110
|
+
if (opts.dryRun !== true && !existsSync(contents))
|
|
111
|
+
throw new Error(`${tarball} has no top-level skills/ directory`);
|
|
112
|
+
if (opts.dryRun === true) mkdirSync(contents, { recursive: true });
|
|
92
113
|
return contents;
|
|
93
114
|
}
|
|
@@ -20,9 +20,11 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Record the installation_id in the same secret after installing the App.
|
|
22
22
|
*/
|
|
23
|
+
import { createServer } from "node:http";
|
|
23
24
|
import { instanceNames } from "@deployfoundation/foundation-core/instance";
|
|
24
25
|
import { loadAppManifest } from "./github-app-manifest.ts";
|
|
25
26
|
import type { InstanceContext } from "./instance.ts";
|
|
27
|
+
import { runCapture } from "./sh.ts";
|
|
26
28
|
|
|
27
29
|
export interface GithubAppCreateOptions {
|
|
28
30
|
paths: InstanceContext;
|
|
@@ -56,15 +58,14 @@ export async function githubAppCreate(options: GithubAppCreateOptions): Promise<
|
|
|
56
58
|
|
|
57
59
|
async function writeSecret(value: string): Promise<void> {
|
|
58
60
|
const run = async (cmd: string[]) => {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return { code, err: await new Response(p.stderr).text() };
|
|
61
|
+
try {
|
|
62
|
+
await runCapture(["aws", ...cmd, "--profile", profile, "--region", region], {
|
|
63
|
+
stdin: value,
|
|
64
|
+
});
|
|
65
|
+
return { code: 0, err: "" };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return { code: 1, err: error instanceof Error ? error.message : String(error) };
|
|
68
|
+
}
|
|
68
69
|
};
|
|
69
70
|
let r = await run([
|
|
70
71
|
"secretsmanager",
|
|
@@ -88,64 +89,72 @@ export async function githubAppCreate(options: GithubAppCreateOptions): Promise<
|
|
|
88
89
|
if (r.code !== 0) throw new Error(`aws secretsmanager failed: ${r.err.trim()}`);
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
if (url.pathname === "/") {
|
|
96
|
-
const html = `<!doctype html><title>Create ${instance.displayName} GitHub App</title><body style="font-family:system-ui;padding:2rem">
|
|
92
|
+
const handler = async (req: Request): Promise<Response> => {
|
|
93
|
+
const url = new URL(req.url);
|
|
94
|
+
if (url.pathname === "/") {
|
|
95
|
+
const html = `<!doctype html><title>Create ${instance.displayName} GitHub App</title><body style="font-family:system-ui;padding:2rem">
|
|
97
96
|
<h2>Creating the <b>${instance.displayName}</b> GitHub App on <b>${org}</b>…</h2><p>If nothing happens, click the button.</p>
|
|
98
97
|
<form id="f" method="post" action="https://github.com/organizations/${org}/settings/apps/new?state=${state}">
|
|
99
98
|
<input type="hidden" name="manifest" id="m"><button>Create GitHub App</button></form>
|
|
100
99
|
<script>document.getElementById('m').value=${JSON.stringify(body)};document.getElementById('f').submit();</script></body>`;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
},
|
|
100
|
+
return new Response(html, { headers: { "content-type": "text/html" } });
|
|
101
|
+
}
|
|
102
|
+
if (url.pathname === "/callback") {
|
|
103
|
+
const code = url.searchParams.get("code");
|
|
104
|
+
if (url.searchParams.get("state") !== state || code === null)
|
|
105
|
+
return new Response("bad state/code", { status: 400 });
|
|
106
|
+
const res = await fetch(
|
|
107
|
+
`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`,
|
|
108
|
+
{
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: {
|
|
111
|
+
Accept: "application/vnd.github+json",
|
|
112
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
115
113
|
},
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
if (!res.ok) return new Response(`conversion failed: HTTP ${res.status}`, { status: 502 });
|
|
117
|
+
const app = (await res.json()) as {
|
|
118
|
+
id: number;
|
|
119
|
+
slug: string;
|
|
120
|
+
pem: string;
|
|
121
|
+
html_url: string;
|
|
122
|
+
};
|
|
123
|
+
await writeSecret(
|
|
124
|
+
JSON.stringify({ app_id: String(app.id), installation_id: "", private_key: app.pem }),
|
|
125
|
+
);
|
|
126
|
+
const installUrl = `https://github.com/apps/${app.slug}/installations/new`;
|
|
127
|
+
console.log(
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
app_id: app.id,
|
|
130
|
+
slug: app.slug,
|
|
131
|
+
html_url: app.html_url,
|
|
132
|
+
install_url: installUrl,
|
|
133
|
+
secret: secretName,
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
server.close();
|
|
138
|
+
process.exit(0);
|
|
139
|
+
}, 500);
|
|
140
|
+
return new Response(
|
|
141
|
+
`<!doctype html><body style="font-family:system-ui;padding:2rem"><h2>✅ App "${app.slug}" created (id ${app.id}); private key stored in Secrets Manager ${secretName}.</h2><p>Next: <a href="${installUrl}">install it on your repos</a>.</p></body>`,
|
|
142
|
+
{ headers: { "content-type": "text/html" } },
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return new Response("not found", { status: 404 });
|
|
146
|
+
};
|
|
147
|
+
// Node's `http`, not `Bun.serve`: this command ships in the npm package and
|
|
148
|
+
// runs under Node. The handler keeps the fetch-style Request/Response shape.
|
|
149
|
+
const server = createServer(async (req, res) => {
|
|
150
|
+
const request = new Request(`http://localhost:${port}${req.url ?? "/"}`, {
|
|
151
|
+
method: req.method ?? "GET",
|
|
152
|
+
});
|
|
153
|
+
const response = await handler(request);
|
|
154
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
155
|
+
res.end(await response.text());
|
|
148
156
|
});
|
|
157
|
+
server.listen(port);
|
|
149
158
|
console.log(`Open http://localhost:${port}/ to create the app (waiting up to 10 minutes)…`);
|
|
150
159
|
setTimeout(() => {
|
|
151
160
|
console.error("timed out");
|
package/src/deploy/setup.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* `--seed-codex` copies a local Codex credential into the instance's Codex
|
|
13
13
|
* secret so the teammate is authenticated on day one without a Slack `login`.
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync } from "node:fs";
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
16
|
import { resolve } from "node:path";
|
|
17
17
|
import {
|
|
18
18
|
type AwsContext,
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
} from "./endpoint.ts";
|
|
45
45
|
import { currentImageTag } from "./image.ts";
|
|
46
46
|
import { FOUNDATION_ROOT, INFRA_ROOT } from "./paths.ts";
|
|
47
|
+
import { hasFoundationWorkspace } from "./release.ts";
|
|
47
48
|
import { run, runCapture } from "./sh.ts";
|
|
48
49
|
import { slackManifestFor } from "./slack-manifest.ts";
|
|
49
50
|
import { stageCustomization } from "./stage-customization.ts";
|
|
@@ -96,6 +97,11 @@ async function output(ctx: AwsContext, stack: string, key: string): Promise<stri
|
|
|
96
97
|
* workspace of this repo rather than a separate npm project.
|
|
97
98
|
*/
|
|
98
99
|
async function installDependencies(ctx: AwsContext): Promise<void> {
|
|
100
|
+
// Only a Foundation checkout has a workspace to install. A copy of this tool
|
|
101
|
+
// installed from npm carries its dependencies already, and has neither Bun
|
|
102
|
+
// nor a lockfile beside it; running `bun install` there failed setup before
|
|
103
|
+
// it did anything, for every instance that is not Foundation itself.
|
|
104
|
+
if (!hasFoundationWorkspace()) return;
|
|
99
105
|
await run(["bun", "install", "--frozen-lockfile"], {
|
|
100
106
|
cwd: FOUNDATION_ROOT,
|
|
101
107
|
dryRun: ctx.dryRun,
|
|
@@ -112,11 +118,18 @@ async function preflight(ctx: AwsContext): Promise<void> {
|
|
|
112
118
|
);
|
|
113
119
|
console.log(` account ${account} via profile ${ctx.profile} (${ctx.region})`);
|
|
114
120
|
|
|
115
|
-
|
|
121
|
+
// Docker builds the agent image locally, which only a checkout does. A
|
|
122
|
+
// release deploy pulls the signed image the manifest names instead.
|
|
123
|
+
if (!hasFoundationWorkspace()) {
|
|
124
|
+
// nothing to build
|
|
125
|
+
} else if (ctx.dryRun === true) {
|
|
116
126
|
await run(["docker", "info"], { dryRun: true });
|
|
117
127
|
} else {
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
try {
|
|
129
|
+
await runCapture(["docker", "info"]);
|
|
130
|
+
} catch {
|
|
131
|
+
throw new Error("docker is not running");
|
|
132
|
+
}
|
|
120
133
|
console.log(" docker ✓");
|
|
121
134
|
}
|
|
122
135
|
|
|
@@ -151,7 +164,7 @@ async function writeRuntimeSecret(ctx: AwsContext): Promise<void> {
|
|
|
151
164
|
async function seedCodex(ctx: AwsContext, codexFile: string): Promise<void> {
|
|
152
165
|
if (!existsSync(codexFile))
|
|
153
166
|
throw new Error(`${codexFile} not found — sign in locally first, or pass --codex-file`);
|
|
154
|
-
const document =
|
|
167
|
+
const document = readFileSync(codexFile, "utf8");
|
|
155
168
|
JSON.parse(document); // fail here rather than storing a broken store document
|
|
156
169
|
await putSecretString(ctx, ctx.names.secretCodex, document);
|
|
157
170
|
console.log(` ${ctx.names.secretCodex} seeded from ${codexFile}`);
|
package/src/deploy/sh.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* stdout for the caller and only surfaces stderr when the command fails.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { type ChildProcess, spawn as nodeSpawn } from "node:child_process";
|
|
10
|
+
import type { Readable } from "node:stream";
|
|
11
|
+
|
|
9
12
|
export interface RunOptions {
|
|
10
13
|
cwd?: string;
|
|
11
14
|
env?: Record<string, string>;
|
|
@@ -28,24 +31,46 @@ function quote(value: string): string {
|
|
|
28
31
|
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'\\''`)}'`;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Node's `child_process`, not `Bun.spawn`. This file ships in
|
|
36
|
+
* `@deployfoundation/foundation-deploy`, which an instance's buildspec runs
|
|
37
|
+
* with `npx` under Node; a Bun-only API here made every command die with
|
|
38
|
+
* "Bun is not defined" before doing anything. `node:child_process` works under
|
|
39
|
+
* both runtimes, so the Bun-run tests and the Node-run tool share one path.
|
|
40
|
+
*/
|
|
41
|
+
function start(cmd: string[], opts: RunOptions, stdout: "inherit" | "pipe"): ChildProcess {
|
|
32
42
|
const [bin, ...rest] = cmd;
|
|
33
43
|
if (bin === undefined) throw new Error("run: empty command");
|
|
34
|
-
|
|
44
|
+
const child = nodeSpawn(bin, rest, {
|
|
35
45
|
cwd: opts.cwd,
|
|
36
46
|
env: { ...process.env, ...opts.env },
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
stdio: [
|
|
48
|
+
opts.stdin === undefined ? "ignore" : "pipe",
|
|
49
|
+
stdout,
|
|
50
|
+
stdout === "inherit" ? "inherit" : "pipe",
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
if (opts.stdin !== undefined && child.stdin !== null) {
|
|
54
|
+
child.stdin.end(opts.stdin);
|
|
55
|
+
}
|
|
56
|
+
return child;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function exited(child: ChildProcess): Promise<number> {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
child.once("error", reject);
|
|
62
|
+
child.once("close", (code, signal) => resolve(code ?? (signal === null ? 1 : 128)));
|
|
40
63
|
});
|
|
41
64
|
}
|
|
42
65
|
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
function collect(stream: Readable | null): Promise<string> {
|
|
67
|
+
if (stream === null) return Promise.resolve("");
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const chunks: Buffer[] = [];
|
|
70
|
+
stream.on("data", (c: Buffer) => chunks.push(c));
|
|
71
|
+
stream.once("error", reject);
|
|
72
|
+
stream.once("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
73
|
+
});
|
|
49
74
|
}
|
|
50
75
|
|
|
51
76
|
/** Run a command with inherited stdio. Throws if it exits non-zero. */
|
|
@@ -54,20 +79,17 @@ export async function run(cmd: string[], opts: RunOptions = {}): Promise<void> {
|
|
|
54
79
|
console.log(` $ ${formatCommand(cmd, opts.env)}`);
|
|
55
80
|
return;
|
|
56
81
|
}
|
|
57
|
-
const
|
|
58
|
-
await feed(proc, opts.stdin);
|
|
59
|
-
const code = await proc.exited;
|
|
82
|
+
const code = await exited(start(cmd, opts, "inherit"));
|
|
60
83
|
if (code !== 0) throw new Error(`${formatCommand(cmd)} exited ${code}`);
|
|
61
84
|
}
|
|
62
85
|
|
|
63
86
|
/** Run a command and return its trimmed stdout. Throws with stderr on failure. */
|
|
64
87
|
export async function runCapture(cmd: string[], opts: RunOptions = {}): Promise<string> {
|
|
65
|
-
const
|
|
66
|
-
await feed(proc, opts.stdin);
|
|
88
|
+
const child = start(cmd, opts, "pipe");
|
|
67
89
|
const [out, err, code] = await Promise.all([
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
90
|
+
collect(child.stdout),
|
|
91
|
+
collect(child.stderr),
|
|
92
|
+
exited(child),
|
|
71
93
|
]);
|
|
72
94
|
if (code !== 0) throw new Error(`${formatCommand(cmd)} exited ${code}: ${err.trim()}`);
|
|
73
95
|
return out.trim();
|
|
File without changes
|