@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 CHANGED
@@ -7,12 +7,12 @@ import {
7
7
  FoundationNetwork,
8
8
  FoundationPipeline,
9
9
  NewsletterStack
10
- } from "../chunk-4aye5cee.js";
10
+ } from "../chunk-m4pn6wc2.js";
11
11
  import {
12
12
  adminsFor,
13
13
  instanceNames,
14
14
  loadInstanceFile
15
- } from "../chunk-9ddxyvq2.js";
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-v7tz8g50.js";
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-9ddxyvq2.js";
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 run(["tar", "-xzf", tarball, "-C", contents], { dryRun: ctx.dryRun });
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
- const p = Bun.spawn(["aws", ...cmd, "--profile", profile, "--region", region], {
1197
- stdin: "pipe",
1198
- stdout: "pipe",
1199
- stderr: "pipe"
1200
- });
1201
- p.stdin.write(value);
1202
- p.stdin.end();
1203
- const code = await p.exited;
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 server = Bun.serve({
1229
- port,
1230
- async fetch(req) {
1231
- const url = new URL(req.url);
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
- return new Response(html, { headers: { "content-type": "text/html" } });
1239
- }
1240
- if (url.pathname === "/callback") {
1241
- const code = url.searchParams.get("code");
1242
- if (url.searchParams.get("state") !== state || code === null)
1243
- return new Response("bad state/code", { status: 400 });
1244
- const res = await fetch(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, {
1245
- method: "POST",
1246
- headers: {
1247
- Accept: "application/vnd.github+json",
1248
- "X-GitHub-Api-Version": "2022-11-28"
1249
- }
1250
- });
1251
- if (!res.ok)
1252
- return new Response(`conversion failed: HTTP ${res.status}`, { status: 502 });
1253
- const app = await res.json();
1254
- await writeSecret(JSON.stringify({ app_id: String(app.id), installation_id: "", private_key: app.pem }));
1255
- const installUrl = `https://github.com/apps/${app.slug}/installations/new`;
1256
- console.log(JSON.stringify({
1257
- app_id: app.id,
1258
- slug: app.slug,
1259
- html_url: app.html_url,
1260
- install_url: installUrl,
1261
- secret: secretName
1262
- }));
1263
- setTimeout(() => {
1264
- server.stop(true);
1265
- process.exit(0);
1266
- }, 500);
1267
- 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" } });
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
- const docker = Bun.spawn(["docker", "info"], { stdout: "ignore", stderr: "ignore" });
1543
- if (await docker.exited !== 0)
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 = await Bun.file(codexFile).text();
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}`);
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
3
  DEFAULT_RELEASE_BUCKET
4
- } from "../chunk-v7tz8g50.js";
5
- import"../chunk-9ddxyvq2.js";
4
+ } from "../chunk-qc5d46ky.js";
5
+ import"../chunk-t86rw0f2.js";
6
6
 
7
7
  // bin/release-account.ts
8
8
  import * as cdk2 from "aws-cdk-lib";
@@ -14,7 +14,7 @@ import {
14
14
  releaseImageRepositoryArn,
15
15
  requiresAuthenticatedQueue,
16
16
  slackCommandPrefixes
17
- } from "./chunk-9ddxyvq2.js";
17
+ } from "./chunk-t86rw0f2.js";
18
18
 
19
19
  // src/stacks/agent-stack.ts
20
20
  import * as cdk from "aws-cdk-lib";
@@ -6,7 +6,7 @@ import {
6
6
  manifestKey,
7
7
  releaseCacheDir,
8
8
  verifyManifest
9
- } from "./chunk-9ddxyvq2.js";
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 spawn(cmd, opts, stdout) {
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
- return Bun.spawn([bin, ...rest], {
99
+ const child = nodeSpawn(bin, rest, {
99
100
  cwd: opts.cwd,
100
101
  env: { ...process.env, ...opts.env },
101
- stdin: opts.stdin === undefined ? "ignore" : "pipe",
102
- stdout,
103
- stderr: stdout === "inherit" ? "inherit" : "pipe"
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
- async function feed(proc, stdin) {
107
- if (stdin === undefined)
108
- return;
109
- const sink = proc.stdin;
110
- if (sink === null)
111
- return;
112
- sink.write(stdin);
113
- sink.end();
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 proc = spawn(cmd, opts, "inherit");
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 proc = spawn(cmd, opts, "pipe");
128
- await feed(proc, opts.stdin);
139
+ const child = start(cmd, opts, "pipe");
129
140
  const [out, err, code] = await Promise.all([
130
- new Response(proc.stdout).text(),
131
- new Response(proc.stderr).text(),
132
- proc.exited
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-4aye5cee.js";
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-9ddxyvq2.js";
45
+ } from "../chunk-t86rw0f2.js";
46
46
  export {
47
47
  BUILD_TIMEOUT,
48
48
  BUN_VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deployfoundation/foundation-deploy",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Deploys one Foundation instance from a published, signed release: the CDK app and the deploy tool.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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 run(["tar", "-xzf", tarball, "-C", contents], { dryRun: ctx.dryRun });
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
- const p = Bun.spawn(["aws", ...cmd, "--profile", profile, "--region", region], {
60
- stdin: "pipe",
61
- stdout: "pipe",
62
- stderr: "pipe",
63
- });
64
- p.stdin.write(value);
65
- p.stdin.end();
66
- const code = await p.exited;
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 server = Bun.serve({
92
- port,
93
- async fetch(req) {
94
- const url = new URL(req.url);
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
- return new Response(html, { headers: { "content-type": "text/html" } });
102
- }
103
- if (url.pathname === "/callback") {
104
- const code = url.searchParams.get("code");
105
- if (url.searchParams.get("state") !== state || code === null)
106
- return new Response("bad state/code", { status: 400 });
107
- const res = await fetch(
108
- `https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`,
109
- {
110
- method: "POST",
111
- headers: {
112
- Accept: "application/vnd.github+json",
113
- "X-GitHub-Api-Version": "2022-11-28",
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
- if (!res.ok) return new Response(`conversion failed: HTTP ${res.status}`, { status: 502 });
118
- const app = (await res.json()) as {
119
- id: number;
120
- slug: string;
121
- pem: string;
122
- html_url: string;
123
- };
124
- await writeSecret(
125
- JSON.stringify({ app_id: String(app.id), installation_id: "", private_key: app.pem }),
126
- );
127
- const installUrl = `https://github.com/apps/${app.slug}/installations/new`;
128
- console.log(
129
- JSON.stringify({
130
- app_id: app.id,
131
- slug: app.slug,
132
- html_url: app.html_url,
133
- install_url: installUrl,
134
- secret: secretName,
135
- }),
136
- );
137
- setTimeout(() => {
138
- server.stop(true);
139
- process.exit(0);
140
- }, 500);
141
- return new Response(
142
- `<!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>`,
143
- { headers: { "content-type": "text/html" } },
144
- );
145
- }
146
- return new Response("not found", { status: 404 });
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");
@@ -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
- if (ctx.dryRun === true) {
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
- const docker = Bun.spawn(["docker", "info"], { stdout: "ignore", stderr: "ignore" });
119
- if ((await docker.exited) !== 0) throw new Error("docker is not running");
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 = await Bun.file(codexFile).text();
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
- function spawn(cmd: string[], opts: RunOptions, stdout: "inherit" | "pipe") {
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
- return Bun.spawn([bin, ...rest], {
44
+ const child = nodeSpawn(bin, rest, {
35
45
  cwd: opts.cwd,
36
46
  env: { ...process.env, ...opts.env },
37
- stdin: opts.stdin === undefined ? "ignore" : "pipe",
38
- stdout,
39
- stderr: stdout === "inherit" ? "inherit" : "pipe",
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
- async function feed(proc: { stdin: unknown }, stdin: string | undefined): Promise<void> {
44
- if (stdin === undefined) return;
45
- const sink = proc.stdin as { write: (s: string) => void; end: () => void } | null;
46
- if (sink === null) return;
47
- sink.write(stdin);
48
- sink.end();
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 proc = spawn(cmd, opts, "inherit");
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 proc = spawn(cmd, opts, "pipe");
66
- await feed(proc, opts.stdin);
88
+ const child = start(cmd, opts, "pipe");
67
89
  const [out, err, code] = await Promise.all([
68
- new Response(proc.stdout).text(),
69
- new Response(proc.stderr).text(),
70
- proc.exited,
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