@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 +17 -18
- package/dist/container.d.ts +1 -2
- package/dist/container.js +0 -112
- package/dist/fly.d.ts +31 -4
- package/dist/fly.js +74 -26
- package/package.json +1 -1
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
|
-
//
|
|
37
|
-
|
|
38
|
-
config.
|
|
39
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
112
|
-
| `waitForMachine(app, token, machineId
|
|
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.
|
|
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
|
|
package/dist/container.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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<
|
|
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
|
|
56
|
-
* Returns the
|
|
55
|
+
* Create a Fly Volume in the app's primary region.
|
|
56
|
+
* Returns the volume ID.
|
|
57
57
|
*/
|
|
58
|
-
export async function
|
|
59
|
-
const res = await flyFetch(`/apps/${app}/
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
+
}
|