@replayio/app-building 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,10 +14,10 @@ npm install @replayio/app-building
14
14
  import {
15
15
  loadDotEnv,
16
16
  FileContainerRegistry,
17
+ createMachine,
18
+ destroyMachine,
17
19
  type ContainerConfig,
18
20
  type RepoOptions,
19
- startRemoteContainer,
20
- stopRemoteContainer,
21
21
  httpGet,
22
22
  httpPost,
23
23
  httpOptsFor,
@@ -33,20 +33,19 @@ const config: ContainerConfig = {
33
33
  flyApp: envVars.FLY_APP_NAME,
34
34
  };
35
35
 
36
- // Start a detached container with an initial prompt — it will process and exit when done
37
- config.detached = true;
38
- config.initialPrompt = "Build the app";
39
- const repo: RepoOptions = { repoUrl: "https://github.com/...", cloneBranch: "main", pushBranch: "main" };
40
- const state = await startRemoteContainer(config, repo);
36
+ // Create a Fly machine (automatically provisions a volume)
37
+ const { machineId, volumeId } = await createMachine(
38
+ config.flyApp, config.flyToken, imageRef, containerEnv, machineName,
39
+ );
41
40
 
42
41
  // Check status
43
- const status = await httpGet(`${state.baseUrl}/status`, httpOpts);
42
+ const status = await httpGet(`https://${config.flyApp}.fly.dev/status`);
44
43
 
45
44
  // Query the registry
46
45
  const alive = await config.registry.findAlive();
47
46
 
48
- // Clean up
49
- await stopRemoteContainer(config, state);
47
+ // Clean up (destroys machine and its volume)
48
+ await destroyMachine(config.flyApp, config.flyToken, machineId, volumeId);
50
49
  ```
51
50
 
52
51
  ## Exported API
@@ -65,9 +64,7 @@ await stopRemoteContainer(config, state);
65
64
  | Export | Description |
66
65
  |---|---|
67
66
  | `startContainer(config, repo)` | Build the Docker image locally and start a container with `--network host`. Returns `AgentState`. |
68
- | `startRemoteContainer(config, repo)` | Create a Fly.io machine using the GHCR image. Requires `config.flyToken` and `config.flyApp`. Returns `AgentState`. |
69
67
  | `stopContainer(config, containerName)` | Stop a local Docker container by name. |
70
- | `stopRemoteContainer(config, state)` | Destroy the Fly.io machine for a remote container. Requires `config.flyToken`. |
71
68
  | `buildImage(config)` | Build the Docker image locally (called automatically by `startContainer`). |
72
69
  | `spawnTestContainer(config)` | Start an interactive (`-it`) container with the repo mounted at `/repo`. |
73
70
  | `loadDotEnv(projectRoot)` | Parse a `.env` file and return key-value pairs. |
@@ -103,23 +100,25 @@ await stopRemoteContainer(config, state);
103
100
  | `httpOptsFor(state)` | Return `HttpOptions` for a container (adds `fly-force-instance-id` header for remote containers). |
104
101
  | `probeAlive(entry)` | Check if a container is responding to `/status`. |
105
102
 
106
- ### Fly.io machines
103
+ ### Fly.io utilities
107
104
 
108
105
  | Export | Description |
109
106
  |---|---|
110
107
  | `createApp(token, name, org?)` | Create a Fly app and allocate IPs. |
111
- | `createMachine(app, token, image, env, name)` | Create a Fly machine. Returns machine ID. |
112
- | `waitForMachine(app, token, machineId, timeout?)` | Wait for a machine to reach `started` state. |
113
- | `destroyMachine(app, token, machineId)` | Force-destroy a machine. |
108
+ | `createMachine(app, token, image, env, name)` | Create a Fly machine with a 50GB volume mounted at `/repo`. Returns `{ machineId, volumeId }`. |
109
+ | `waitForMachine(app, token, machineId)` | Poll until a machine reaches `started` state. |
114
110
  | `listMachines(app, token)` | List all machines for an app. |
111
+ | `destroyMachine(app, token, machineId, volumeId?)` | Force-destroy a machine and optionally its volume. |
112
+ | `listVolumes(app, token)` | List all volumes for an app. |
113
+ | `deleteVolume(app, token, volumeId)` | Delete a Fly volume. |
115
114
 
116
- **Types:** `FlyMachineInfo`
115
+ **Types:** `FlyMachineInfo`, `FlyVolumeInfo`, `CreateMachineResult`
117
116
 
118
117
  ### Image ref
119
118
 
120
119
  | Export | Description |
121
120
  |---|---|
122
- | `getImageRef()` | Returns `CONTAINER_IMAGE_REF` env var, or `ghcr.io/replayio/app-building:latest` by default. Used by `startRemoteContainer`. |
121
+ | `getImageRef()` | Returns `CONTAINER_IMAGE_REF` env var, or `ghcr.io/replayio/app-building:latest` by default. |
123
122
 
124
123
  ## Container HTTP API
125
124
 
@@ -6,6 +6,7 @@ export interface AgentState {
6
6
  baseUrl: string;
7
7
  flyApp?: string;
8
8
  flyMachineId?: string;
9
+ flyVolumeId?: string;
9
10
  }
10
11
  export interface ContainerConfig {
11
12
  projectRoot?: string;
@@ -28,7 +29,5 @@ export interface RepoOptions {
28
29
  export declare function loadDotEnv(projectRoot: string): Record<string, string>;
29
30
  export declare function buildImage(config: ContainerConfig): void;
30
31
  export declare function startContainer(config: ContainerConfig, repo: RepoOptions): Promise<AgentState>;
31
- export declare function startRemoteContainer(config: ContainerConfig, repo: RepoOptions): Promise<AgentState>;
32
- export declare function stopRemoteContainer(config: ContainerConfig, state: AgentState): Promise<void>;
33
32
  export declare function stopContainer(config: ContainerConfig, containerName: string): void;
34
33
  export declare function spawnTestContainer(config: ContainerConfig): Promise<void>;
package/dist/container.js CHANGED
@@ -1,8 +1,6 @@
1
1
  import { execFileSync, spawn } from "child_process";
2
2
  import { readFileSync, existsSync } from "fs";
3
3
  import { resolve } from "path";
4
- import { createMachine, waitForMachine, destroyMachine, listMachines } from "./fly";
5
- import { getImageRef } from "./image-ref";
6
4
  const IMAGE_NAME = "app-building";
7
5
  function debugLog(...args) {
8
6
  if (process.env.DEBUG)
@@ -177,116 +175,6 @@ export async function startContainer(config, repo) {
177
175
  config.registry.log(agentState);
178
176
  return agentState;
179
177
  }
180
- export async function startRemoteContainer(config, repo) {
181
- debugLog("startRemoteContainer config:", {
182
- projectRoot: config.projectRoot,
183
- flyApp: config.flyApp,
184
- imageRef: config.imageRef,
185
- webhookUrl: config.webhookUrl,
186
- detached: config.detached,
187
- initialPrompt: config.initialPrompt ? `${config.initialPrompt.slice(0, 100)}...` : undefined,
188
- envVarKeys: Object.keys(config.envVars),
189
- });
190
- debugLog("startRemoteContainer repo:", repo);
191
- if (!config.flyToken)
192
- throw new Error("flyToken is required for remote containers");
193
- if (!config.flyApp)
194
- throw new Error("flyApp is required for remote containers");
195
- const imageRef = config.imageRef ?? getImageRef();
196
- const uniqueId = Math.random().toString(36).slice(2, 8);
197
- const machineName = `app-building-${uniqueId}`;
198
- // Build env vars for the machine
199
- const remoteExtra = {
200
- PORT: "3000",
201
- CONTAINER_NAME: machineName,
202
- };
203
- if (config.webhookUrl)
204
- remoteExtra.WEBHOOK_URL = config.webhookUrl;
205
- if (config.detached)
206
- remoteExtra.DETACHED = "1";
207
- if (config.initialPrompt)
208
- remoteExtra.INITIAL_PROMPT = config.initialPrompt;
209
- const containerEnv = buildContainerEnv(repo, config.envVars, remoteExtra);
210
- // Log existing machines (but don't destroy — multiple containers may run concurrently)
211
- const existing = await listMachines(config.flyApp, config.flyToken);
212
- if (existing.length > 0) {
213
- console.log(`${existing.length} existing machine(s) in ${config.flyApp}:`);
214
- for (const m of existing) {
215
- console.log(` ${m.id} (${m.name}) — ${m.state}`);
216
- }
217
- }
218
- // Retry machine creation — the registry tag may take a moment to propagate
219
- console.log("Creating Fly machine...");
220
- let machineId = "";
221
- for (let attempt = 0; attempt < 5; attempt++) {
222
- try {
223
- machineId = await createMachine(config.flyApp, config.flyToken, imageRef, containerEnv, machineName);
224
- break;
225
- }
226
- catch (err) {
227
- const msg = err instanceof Error ? err.message : String(err);
228
- if (msg.includes("MANIFEST_UNKNOWN") && attempt < 4) {
229
- console.log("Image not yet available in registry, retrying in 5s...");
230
- await new Promise((r) => setTimeout(r, 5000));
231
- continue;
232
- }
233
- throw err;
234
- }
235
- }
236
- console.log(`Machine created: ${machineId}`);
237
- // Register immediately so the container is tracked even if startup times out
238
- const baseUrl = `https://${config.flyApp}.fly.dev`;
239
- const agentState = {
240
- type: "remote",
241
- containerName: machineName,
242
- port: 443,
243
- baseUrl,
244
- flyApp: config.flyApp,
245
- flyMachineId: machineId,
246
- };
247
- config.registry.log(agentState);
248
- console.log("Waiting for machine to start...");
249
- await waitForMachine(config.flyApp, config.flyToken, machineId);
250
- console.log("Machine started.");
251
- // Poll the public URL until the HTTP server is ready, targeting this specific machine
252
- const maxWait = 180000;
253
- const interval = 2000;
254
- const start = Date.now();
255
- let ready = false;
256
- while (Date.now() - start < maxWait) {
257
- try {
258
- const res = await fetch(`${baseUrl}/status`, {
259
- headers: { "fly-force-instance-id": machineId },
260
- });
261
- if (res.ok) {
262
- ready = true;
263
- break;
264
- }
265
- }
266
- catch {
267
- // Not ready yet
268
- }
269
- await new Promise((r) => setTimeout(r, interval));
270
- }
271
- if (!ready) {
272
- // Clean up machine if we can't reach it
273
- console.log("Timed out waiting for machine, destroying...");
274
- await destroyMachine(config.flyApp, config.flyToken, machineId).catch(() => { });
275
- throw new Error("Remote container did not become ready within timeout");
276
- }
277
- return agentState;
278
- }
279
- export async function stopRemoteContainer(config, state) {
280
- if (!state.flyApp || !state.flyMachineId) {
281
- throw new Error("Missing flyApp or flyMachineId in agent state");
282
- }
283
- if (!config.flyToken)
284
- throw new Error("flyToken is required to stop remote container");
285
- console.log(`Destroying Fly machine ${state.flyMachineId}...`);
286
- await destroyMachine(state.flyApp, config.flyToken, state.flyMachineId);
287
- console.log("Machine destroyed.");
288
- config.registry.markStopped(state.containerName);
289
- }
290
178
  export function stopContainer(config, containerName) {
291
179
  try {
292
180
  execFileSync("docker", ["stop", containerName], { stdio: "ignore", timeout: 30000 });
package/dist/fly.d.ts CHANGED
@@ -2,19 +2,33 @@
2
2
  * Create a Fly app via the Machines API and allocate IPs so .fly.dev DNS works.
3
3
  */
4
4
  export declare function createApp(token: string, name: string, org?: string): Promise<void>;
5
+ /**
6
+ * Create a Fly Volume in the app's primary region.
7
+ * Returns the volume ID.
8
+ */
9
+ export declare function createVolume(app: string, token: string, name: string, sizeGb?: number): Promise<string>;
10
+ /**
11
+ * Delete a Fly Volume.
12
+ */
13
+ export declare function deleteVolume(app: string, token: string, volumeId: string): Promise<void>;
14
+ export interface CreateMachineResult {
15
+ machineId: string;
16
+ volumeId: string;
17
+ }
5
18
  /**
6
19
  * Create a Fly Machine with the given image and env vars.
7
- * Returns the machine ID.
20
+ * Creates a volume mounted at /repo for storage.
21
+ * Returns the machine ID and volume ID.
8
22
  */
9
- export declare function createMachine(app: string, token: string, image: string, env: Record<string, string>, name: string): Promise<string>;
23
+ export declare function createMachine(app: string, token: string, image: string, env: Record<string, string>, name: string): Promise<CreateMachineResult>;
10
24
  /**
11
25
  * Wait for a Fly Machine to reach the "started" state.
12
26
  */
13
27
  export declare function waitForMachine(app: string, token: string, machineId: string, timeoutMs?: number): Promise<void>;
14
28
  /**
15
- * Destroy a Fly Machine (force).
29
+ * Destroy a Fly Machine (force) and its attached volume.
16
30
  */
17
- export declare function destroyMachine(app: string, token: string, machineId: string): Promise<void>;
31
+ export declare function destroyMachine(app: string, token: string, machineId: string, volumeId?: string): Promise<void>;
18
32
  export interface FlyMachineInfo {
19
33
  id: string;
20
34
  name: string;
@@ -26,3 +40,16 @@ export interface FlyMachineInfo {
26
40
  * List all machines for a Fly app.
27
41
  */
28
42
  export declare function listMachines(app: string, token: string): Promise<FlyMachineInfo[]>;
43
+ export interface FlyVolumeInfo {
44
+ id: string;
45
+ name: string;
46
+ state: string;
47
+ size_gb: number;
48
+ region: string;
49
+ created_at: string;
50
+ attached_machine_id: string | null;
51
+ }
52
+ /**
53
+ * List all volumes for a Fly app.
54
+ */
55
+ export declare function listVolumes(app: string, token: string): Promise<FlyVolumeInfo[]>;
package/dist/fly.js CHANGED
@@ -52,39 +52,75 @@ export async function createApp(token, name, org) {
52
52
  await gqlFetch(allocateMutation, { input: { appId: name, type: "v6" } });
53
53
  }
54
54
  /**
55
- * Create a Fly Machine with the given image and env vars.
56
- * Returns the machine ID.
55
+ * Create a Fly Volume in the app's primary region.
56
+ * Returns the volume ID.
57
57
  */
58
- export async function createMachine(app, token, image, env, name) {
59
- const res = await flyFetch(`/apps/${app}/machines`, token, {
58
+ export async function createVolume(app, token, name, sizeGb = 50) {
59
+ const res = await flyFetch(`/apps/${app}/volumes`, token, {
60
60
  method: "POST",
61
61
  body: JSON.stringify({
62
62
  name,
63
- config: {
64
- image,
65
- env,
66
- auto_destroy: true,
67
- restart: { policy: "no" },
68
- guest: {
69
- cpu_kind: "performance",
70
- cpus: 16,
71
- memory_mb: 32768,
72
- },
73
- services: [
74
- {
75
- ports: [{ port: 443, handlers: ["tls", "http"] }],
76
- protocol: "tcp",
77
- internal_port: 3000,
78
- autostart: false,
79
- autostop: "off",
80
- },
81
- ],
82
- },
63
+ size_gb: sizeGb,
64
+ encrypted: true,
65
+ require_unique_zone: false,
83
66
  }),
84
67
  });
85
68
  const data = (await res.json());
86
69
  return data.id;
87
70
  }
71
+ /**
72
+ * Delete a Fly Volume.
73
+ */
74
+ export async function deleteVolume(app, token, volumeId) {
75
+ await flyFetch(`/apps/${app}/volumes/${volumeId}`, token, {
76
+ method: "DELETE",
77
+ });
78
+ }
79
+ /**
80
+ * Create a Fly Machine with the given image and env vars.
81
+ * Creates a volume mounted at /repo for storage.
82
+ * Returns the machine ID and volume ID.
83
+ */
84
+ export async function createMachine(app, token, image, env, name) {
85
+ // Create a volume for /repo storage
86
+ const volumeId = await createVolume(app, token, `repo-${name}`, 50);
87
+ try {
88
+ const res = await flyFetch(`/apps/${app}/machines`, token, {
89
+ method: "POST",
90
+ body: JSON.stringify({
91
+ name,
92
+ config: {
93
+ image,
94
+ env,
95
+ auto_destroy: true,
96
+ restart: { policy: "no" },
97
+ guest: {
98
+ cpu_kind: "performance",
99
+ cpus: 16,
100
+ memory_mb: 32768,
101
+ },
102
+ mounts: [{ volume: volumeId, path: "/repo" }],
103
+ services: [
104
+ {
105
+ ports: [{ port: 443, handlers: ["tls", "http"] }],
106
+ protocol: "tcp",
107
+ internal_port: 3000,
108
+ autostart: false,
109
+ autostop: "off",
110
+ },
111
+ ],
112
+ },
113
+ }),
114
+ });
115
+ const data = (await res.json());
116
+ return { machineId: data.id, volumeId };
117
+ }
118
+ catch (err) {
119
+ // Clean up volume if machine creation fails
120
+ await deleteVolume(app, token, volumeId).catch(() => { });
121
+ throw err;
122
+ }
123
+ }
88
124
  /**
89
125
  * Wait for a Fly Machine to reach the "started" state.
90
126
  */
@@ -103,12 +139,17 @@ export async function waitForMachine(app, token, machineId, timeoutMs = 180000)
103
139
  throw new Error(`Machine ${machineId} did not reach started state within ${timeoutMs / 1000}s`);
104
140
  }
105
141
  /**
106
- * Destroy a Fly Machine (force).
142
+ * Destroy a Fly Machine (force) and its attached volume.
107
143
  */
108
- export async function destroyMachine(app, token, machineId) {
144
+ export async function destroyMachine(app, token, machineId, volumeId) {
109
145
  await flyFetch(`/apps/${app}/machines/${machineId}?force=true`, token, {
110
146
  method: "DELETE",
111
147
  });
148
+ if (volumeId) {
149
+ await deleteVolume(app, token, volumeId).catch((err) => {
150
+ console.log(`Warning: failed to delete volume ${volumeId}: ${err instanceof Error ? err.message : err}`);
151
+ });
152
+ }
112
153
  }
113
154
  /**
114
155
  * List all machines for a Fly app.
@@ -117,3 +158,10 @@ export async function listMachines(app, token) {
117
158
  const res = await flyFetch(`/apps/${app}/machines`, token);
118
159
  return (await res.json());
119
160
  }
161
+ /**
162
+ * List all volumes for a Fly app.
163
+ */
164
+ export async function listVolumes(app, token) {
165
+ const res = await flyFetch(`/apps/${app}/volumes`, token);
166
+ return (await res.json());
167
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio/app-building",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Library for managing agentic app-building containers",
5
5
  "type": "module",
6
6
  "exports": {