@replayio/app-building 1.18.0 → 1.19.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 +27 -52
- package/dist/container.d.ts +12 -2
- package/dist/container.js +158 -41
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,12 +15,11 @@ import {
|
|
|
15
15
|
loadDotEnv,
|
|
16
16
|
FileContainerRegistry,
|
|
17
17
|
getInfisicalConfig,
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
startContainer,
|
|
19
|
+
stopContainer,
|
|
20
20
|
type ContainerConfig,
|
|
21
21
|
type RepoOptions,
|
|
22
22
|
httpGet,
|
|
23
|
-
httpPost,
|
|
24
23
|
httpOptsFor,
|
|
25
24
|
} from "@replayio/app-building";
|
|
26
25
|
|
|
@@ -28,36 +27,41 @@ import {
|
|
|
28
27
|
const orchestrationVars = loadDotEnv("/path/to/project");
|
|
29
28
|
const infisicalConfig = await getInfisicalConfig(orchestrationVars);
|
|
30
29
|
|
|
30
|
+
// Local container (no flyToken/flyApp)
|
|
31
31
|
const config: ContainerConfig = {
|
|
32
|
-
projectRoot: "/path/to/project",
|
|
32
|
+
projectRoot: "/path/to/project",
|
|
33
33
|
infisical: infisicalConfig,
|
|
34
34
|
registry: new FileContainerRegistry("/path/to/.container-registry.jsonl"),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Remote container (set flyToken + flyApp)
|
|
38
|
+
const remoteConfig: ContainerConfig = {
|
|
39
|
+
...config,
|
|
35
40
|
flyToken: orchestrationVars.FLY_API_TOKEN,
|
|
36
41
|
flyApp: orchestrationVars.FLY_APP_NAME,
|
|
37
42
|
};
|
|
38
43
|
|
|
39
|
-
//
|
|
40
|
-
const {
|
|
41
|
-
|
|
42
|
-
);
|
|
44
|
+
// Start — automatically chooses local or remote based on config
|
|
45
|
+
const repo: RepoOptions = { repoUrl: "https://...", cloneBranch: "main", pushBranch: "feature/x" };
|
|
46
|
+
const state = await startContainer(config, repo);
|
|
43
47
|
|
|
44
48
|
// Check status
|
|
45
|
-
const status = await httpGet(
|
|
49
|
+
const status = await httpGet(`${state.baseUrl}/status`, httpOptsFor(state));
|
|
46
50
|
|
|
47
51
|
// Query the registry
|
|
48
52
|
const alive = await config.registry.findAlive();
|
|
49
53
|
|
|
50
|
-
//
|
|
51
|
-
await
|
|
54
|
+
// Stop — handles both local and remote
|
|
55
|
+
await stopContainer(config, state);
|
|
52
56
|
```
|
|
53
57
|
|
|
54
58
|
## Secrets architecture
|
|
55
59
|
|
|
56
60
|
Secrets are never passed directly to the container or agent. Instead:
|
|
57
61
|
|
|
58
|
-
1. The orchestration host passes **Infisical credentials** (
|
|
59
|
-
2. At startup, the container fetches
|
|
60
|
-
3. A **secrets server** (`127.0.0.1:9119`) runs inside the container, accessible only locally.
|
|
62
|
+
1. The orchestration host passes **Infisical credentials** (`InfisicalConfig`) to the container via `ContainerConfig.infisical`.
|
|
63
|
+
2. At startup, the container fetches global secrets from Infisical for internal use (clone token, agent API key).
|
|
64
|
+
3. A **secrets server** (`127.0.0.1:9119`) runs inside the container, accessible only locally. It fetches secrets live from Infisical on every request — no caching.
|
|
61
65
|
4. The agent process runs with a **restricted environment** — only `ANTHROPIC_API_KEY` (required for the Claude CLI) is present.
|
|
62
66
|
5. When the agent needs to run a command that requires secrets, it uses `exec-secrets`:
|
|
63
67
|
|
|
@@ -66,9 +70,9 @@ exec-secrets NEON_API_KEY -- curl -s -H "Authorization: Bearer $NEON_API_KEY" ht
|
|
|
66
70
|
exec-secrets NETLIFY_AUTH_TOKEN NETLIFY_ACCOUNT_SLUG -- netlify deploy --prod
|
|
67
71
|
```
|
|
68
72
|
|
|
69
|
-
The secrets server spawns the command with the requested secrets in its environment and **redacts
|
|
73
|
+
The secrets server spawns the command with the requested secrets in its environment and **redacts requested secret values** from the output.
|
|
70
74
|
|
|
71
|
-
The agent can also run `list-secrets` to see which secrets are available, and `set-branch-secret` to store new branch-level secrets (e.g., `DATABASE_URL` created at deploy time). The server rejects values that have already appeared in logs.
|
|
75
|
+
The agent can also run `list-secrets` to see which secrets are available, and `set-branch-secret` to store new branch-level secrets (e.g., `DATABASE_URL` created at deploy time). The server rejects credential values that have already appeared in logs.
|
|
72
76
|
|
|
73
77
|
## Exported API
|
|
74
78
|
|
|
@@ -76,8 +80,9 @@ The agent can also run `list-secrets` to see which secrets are available, and `s
|
|
|
76
80
|
|
|
77
81
|
| Export | Description |
|
|
78
82
|
|---|---|
|
|
79
|
-
| `ContainerConfig` |
|
|
83
|
+
| `ContainerConfig` | `infisical` (required `InfisicalConfig`), optional `projectRoot` (local Docker only), `registry`, `flyToken`/`flyApp` (set both for remote Fly.io), `imageRef`, `webhookUrl`/`webhookSecret`, `detached`, `initialPrompt`, `localPort`, `absorbTasks`. |
|
|
80
84
|
| `RepoOptions` | Per-invocation git settings: `repoUrl`, `cloneBranch`, `pushBranch`. |
|
|
85
|
+
| `AgentState` | Returned by `startContainer`. Contains `type`, `containerName`, `port`, `baseUrl`, and Fly-specific fields for remote containers. |
|
|
81
86
|
| `ContainerRegistry` | Interface for container registry storage. Methods: `log`, `markStopped`, `clearStopped`, `getRecent`, `find`, `findAlive`. |
|
|
82
87
|
| `FileContainerRegistry` | Built-in file-backed implementation of `ContainerRegistry`, backed by a `.jsonl` file. |
|
|
83
88
|
|
|
@@ -85,14 +90,12 @@ The agent can also run `list-secrets` to see which secrets are available, and `s
|
|
|
85
90
|
|
|
86
91
|
| Export | Description |
|
|
87
92
|
|---|---|
|
|
88
|
-
| `startContainer(config, repo)` |
|
|
89
|
-
| `stopContainer(config,
|
|
90
|
-
| `buildImage(config)` | Build the Docker image locally (called automatically by `startContainer`). |
|
|
91
|
-
| `spawnTestContainer(config)` | Start an interactive (`-it`) container with the repo mounted at `/repo`. |
|
|
93
|
+
| `startContainer(config, repo)` | Start a container. Uses local Docker if `flyToken`/`flyApp` are not set, Fly.io if they are. Returns `AgentState`. |
|
|
94
|
+
| `stopContainer(config, state)` | Stop a container by its `AgentState` or `RegistryEntry`. Handles both local and remote. |
|
|
95
|
+
| `buildImage(config)` | Build the Docker image locally (called automatically by `startContainer` for local containers). |
|
|
96
|
+
| `spawnTestContainer(config)` | Start an interactive (`-it`) local container with the repo mounted at `/repo`. |
|
|
92
97
|
| `loadDotEnv(projectRoot)` | Parse a `.env` file and return key-value pairs. |
|
|
93
98
|
|
|
94
|
-
**Types:** `AgentState`, `ContainerConfig`, `RepoOptions`
|
|
95
|
-
|
|
96
99
|
### Container registry (`ContainerRegistry` interface / `FileContainerRegistry` class)
|
|
97
100
|
|
|
98
101
|
| Method | Description |
|
|
@@ -122,20 +125,6 @@ The agent can also run `list-secrets` to see which secrets are available, and `s
|
|
|
122
125
|
| `httpOptsFor(state)` | Return `HttpOptions` for a container (adds `fly-force-instance-id` header for remote containers). |
|
|
123
126
|
| `probeAlive(entry)` | Check if a container is responding to `/status`. |
|
|
124
127
|
|
|
125
|
-
### Fly.io utilities
|
|
126
|
-
|
|
127
|
-
| Export | Description |
|
|
128
|
-
|---|---|
|
|
129
|
-
| `createApp(token, name, org?)` | Create a Fly app and allocate IPs. |
|
|
130
|
-
| `createMachine(app, token, image, env, name)` | Create a Fly machine with a 50GB volume mounted at `/repo`. Returns `{ machineId, volumeId }`. |
|
|
131
|
-
| `waitForMachine(app, token, machineId)` | Poll until a machine reaches `started` state. |
|
|
132
|
-
| `listMachines(app, token)` | List all machines for an app. |
|
|
133
|
-
| `destroyMachine(app, token, machineId, volumeId?)` | Force-destroy a machine and optionally its volume. |
|
|
134
|
-
| `listVolumes(app, token)` | List all volumes for an app. |
|
|
135
|
-
| `deleteVolume(app, token, volumeId)` | Delete a Fly volume. |
|
|
136
|
-
|
|
137
|
-
**Types:** `FlyMachineInfo`, `FlyVolumeInfo`, `CreateMachineResult`
|
|
138
|
-
|
|
139
128
|
### Secrets (Infisical)
|
|
140
129
|
|
|
141
130
|
| Export | Description |
|
|
@@ -144,25 +133,11 @@ The agent can also run `list-secrets` to see which secrets are available, and `s
|
|
|
144
133
|
| `getInfisicalConfig(envVars)` | Extract `InfisicalConfig` from env vars and log in. Requires `INFISICAL_CLIENT_ID`, `INFISICAL_CLIENT_SECRET`, `INFISICAL_PROJECT_ID`, `INFISICAL_ENVIRONMENT`. |
|
|
145
134
|
| `fetchGlobalSecrets(config)` | Fetch secrets from the `/global/` path. |
|
|
146
135
|
| `fetchBranchSecrets(config, branch)` | Fetch secrets from `/branches/<branch>/`. |
|
|
147
|
-
| `createBranchSecret(config, branch, name, value)` | Create or update a secret in `/branches/<branch>/`. |
|
|
136
|
+
| `createBranchSecret(config, branch, name, value)` | Create or update a secret in `/branches/<branch>/`. Creates the folder if needed. |
|
|
148
137
|
| `fetchInfisicalSecrets(config, path)` | Raw fetch from any Infisical folder path. |
|
|
149
138
|
|
|
150
139
|
**Types:** `InfisicalConfig`
|
|
151
140
|
|
|
152
|
-
**Usage pattern** (orchestration scripts):
|
|
153
|
-
|
|
154
|
-
```ts
|
|
155
|
-
const orchestrationVars = loadDotEnv(projectRoot);
|
|
156
|
-
const infisicalConfig = await getInfisicalConfig(orchestrationVars);
|
|
157
|
-
|
|
158
|
-
const config: ContainerConfig = {
|
|
159
|
-
infisical: infisicalConfig,
|
|
160
|
-
flyToken: orchestrationVars.FLY_API_TOKEN,
|
|
161
|
-
flyApp: orchestrationVars.FLY_APP_NAME,
|
|
162
|
-
...
|
|
163
|
-
};
|
|
164
|
-
```
|
|
165
|
-
|
|
166
141
|
### Image ref
|
|
167
142
|
|
|
168
143
|
| Export | Description |
|
package/dist/container.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContainerRegistry } from "./container-registry";
|
|
1
|
+
import type { ContainerRegistry, RegistryEntry } from "./container-registry";
|
|
2
2
|
import type { InfisicalConfig } from "./secrets";
|
|
3
3
|
export interface AgentState {
|
|
4
4
|
type: "local" | "remote";
|
|
@@ -35,6 +35,16 @@ export interface RepoOptions {
|
|
|
35
35
|
}
|
|
36
36
|
export declare function loadDotEnv(projectRoot: string): Record<string, string>;
|
|
37
37
|
export declare function buildImage(config: ContainerConfig): void;
|
|
38
|
+
/**
|
|
39
|
+
* Start a container (local Docker or remote Fly.io based on config).
|
|
40
|
+
* If flyToken and flyApp are set, starts remotely; otherwise locally.
|
|
41
|
+
*/
|
|
38
42
|
export declare function startContainer(config: ContainerConfig, repo: RepoOptions): Promise<AgentState>;
|
|
39
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Stop a container by its state or registry entry.
|
|
45
|
+
*/
|
|
46
|
+
export declare function stopContainer(config: ContainerConfig, state: AgentState | RegistryEntry): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Spawn an interactive test container (local only).
|
|
49
|
+
*/
|
|
40
50
|
export declare function spawnTestContainer(config: ContainerConfig): Promise<void>;
|
package/dist/container.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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";
|
|
4
6
|
const IMAGE_NAME = "app-building";
|
|
5
7
|
function debugLog(...args) {
|
|
6
8
|
if (process.env.DEBUG)
|
|
@@ -73,13 +75,6 @@ function findFreePort() {
|
|
|
73
75
|
}
|
|
74
76
|
return port;
|
|
75
77
|
}
|
|
76
|
-
function infisicalEnvVars(config) {
|
|
77
|
-
return {
|
|
78
|
-
INFISICAL_TOKEN: config.token,
|
|
79
|
-
INFISICAL_PROJECT_ID: config.projectId,
|
|
80
|
-
INFISICAL_ENVIRONMENT: config.environment,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
78
|
function buildContainerEnv(repo, infisical, extra = {}) {
|
|
84
79
|
const env = {
|
|
85
80
|
REPO_URL: repo.repoUrl,
|
|
@@ -90,7 +85,9 @@ function buildContainerEnv(repo, infisical, extra = {}) {
|
|
|
90
85
|
GIT_COMMITTER_NAME: "App Builder",
|
|
91
86
|
GIT_COMMITTER_EMAIL: "app-builder@localhost",
|
|
92
87
|
PLAYWRIGHT_BROWSERS_PATH: "/opt/playwright",
|
|
93
|
-
|
|
88
|
+
INFISICAL_TOKEN: infisical.token,
|
|
89
|
+
INFISICAL_PROJECT_ID: infisical.projectId,
|
|
90
|
+
INFISICAL_ENVIRONMENT: infisical.environment,
|
|
94
91
|
...extra,
|
|
95
92
|
};
|
|
96
93
|
if (process.env.DEBUG) {
|
|
@@ -98,23 +95,9 @@ function buildContainerEnv(repo, infisical, extra = {}) {
|
|
|
98
95
|
}
|
|
99
96
|
return env;
|
|
100
97
|
}
|
|
101
|
-
|
|
102
|
-
debugLog("startContainer config:", {
|
|
103
|
-
projectRoot: config.projectRoot,
|
|
104
|
-
flyApp: config.flyApp,
|
|
105
|
-
imageRef: config.imageRef,
|
|
106
|
-
webhookUrl: config.webhookUrl,
|
|
107
|
-
detached: config.detached,
|
|
108
|
-
initialPrompt: config.initialPrompt ? `${config.initialPrompt.slice(0, 100)}...` : undefined,
|
|
109
|
-
});
|
|
110
|
-
debugLog("startContainer repo:", repo);
|
|
111
|
-
buildImage(config);
|
|
112
|
-
const uniqueId = Math.random().toString(36).slice(2, 8);
|
|
113
|
-
const containerName = `app-building-${uniqueId}`;
|
|
114
|
-
const containerPort = 3000;
|
|
115
|
-
const hostPort = config.localPort ?? findFreePort();
|
|
98
|
+
function buildExtraEnv(config, containerName) {
|
|
116
99
|
const extra = {
|
|
117
|
-
PORT:
|
|
100
|
+
PORT: "3000",
|
|
118
101
|
CONTAINER_NAME: containerName,
|
|
119
102
|
};
|
|
120
103
|
if (config.webhookUrl)
|
|
@@ -127,35 +110,44 @@ export async function startContainer(config, repo) {
|
|
|
127
110
|
extra.INITIAL_PROMPT = config.initialPrompt;
|
|
128
111
|
if (config.absorbTasks)
|
|
129
112
|
extra.ABSORB_TASKS = "1";
|
|
113
|
+
return extra;
|
|
114
|
+
}
|
|
115
|
+
function isRemote(config) {
|
|
116
|
+
return !!(config.flyToken && config.flyApp);
|
|
117
|
+
}
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Local container
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
async function startLocalContainer(config, repo) {
|
|
122
|
+
buildImage(config);
|
|
123
|
+
const uniqueId = Math.random().toString(36).slice(2, 8);
|
|
124
|
+
const containerName = `app-building-${uniqueId}`;
|
|
125
|
+
const containerPort = 3000;
|
|
126
|
+
const hostPort = config.localPort ?? findFreePort();
|
|
127
|
+
const extra = buildExtraEnv(config, containerName);
|
|
128
|
+
extra.PORT = String(containerPort);
|
|
130
129
|
const containerEnv = buildContainerEnv(repo, config.infisical, extra);
|
|
131
|
-
// Build docker run args
|
|
132
130
|
const args = ["run", "-d", "--rm", "--name", containerName];
|
|
133
|
-
// Use explicit port mapping for macOS Docker Desktop compatibility
|
|
134
|
-
// (--network host only works on Linux)
|
|
135
131
|
args.push("-p", `${hostPort}:${containerPort}`);
|
|
136
132
|
for (const [k, v] of Object.entries(containerEnv)) {
|
|
137
133
|
args.push("--env", `${k}=${v}`);
|
|
138
134
|
}
|
|
139
|
-
// Image name — CMD is baked in (server.ts)
|
|
140
135
|
args.push(IMAGE_NAME);
|
|
141
136
|
const containerId = execFileSync("docker", args, {
|
|
142
137
|
encoding: "utf-8",
|
|
143
138
|
timeout: 30000,
|
|
144
139
|
}).trim();
|
|
145
140
|
console.log(`Container started: ${containerId.slice(0, 12)} (${containerName})`);
|
|
146
|
-
// Wait for the HTTP server to become ready
|
|
147
141
|
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
|
148
|
-
const maxWait = 120000;
|
|
142
|
+
const maxWait = 120000;
|
|
149
143
|
const interval = 1000;
|
|
150
144
|
const start = Date.now();
|
|
151
145
|
let ready = false;
|
|
152
146
|
while (Date.now() - start < maxWait) {
|
|
153
|
-
// Check if the container is still alive (--rm removes it on exit)
|
|
154
147
|
try {
|
|
155
148
|
execFileSync("docker", ["inspect", "--format", "{{.State.Running}}", containerName], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 });
|
|
156
149
|
}
|
|
157
150
|
catch {
|
|
158
|
-
// Container is gone — grab logs from docker if possible, otherwise just report
|
|
159
151
|
let logs = "";
|
|
160
152
|
try {
|
|
161
153
|
logs = execFileSync("docker", ["logs", "--tail", "30", containerName], {
|
|
@@ -183,26 +175,151 @@ export async function startContainer(config, repo) {
|
|
|
183
175
|
if (!ready) {
|
|
184
176
|
throw new Error("Container did not become ready within timeout");
|
|
185
177
|
}
|
|
186
|
-
|
|
187
|
-
config.registry.log(agentState);
|
|
188
|
-
return agentState;
|
|
178
|
+
return { type: "local", containerName, port: hostPort, baseUrl };
|
|
189
179
|
}
|
|
190
|
-
|
|
180
|
+
function stopLocalContainer(containerName) {
|
|
191
181
|
try {
|
|
192
182
|
execFileSync("docker", ["stop", containerName], { stdio: "ignore", timeout: 30000 });
|
|
193
183
|
}
|
|
194
184
|
catch {
|
|
195
185
|
// Container may already be stopped
|
|
196
186
|
}
|
|
197
|
-
config.registry.markStopped(containerName);
|
|
198
187
|
}
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Remote container (Fly.io)
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
async function startRemoteContainerImpl(config, repo) {
|
|
192
|
+
if (!config.flyToken)
|
|
193
|
+
throw new Error("flyToken is required for remote containers");
|
|
194
|
+
if (!config.flyApp)
|
|
195
|
+
throw new Error("flyApp is required for remote containers");
|
|
196
|
+
const imageRef = config.imageRef ?? getImageRef();
|
|
197
|
+
const uniqueId = Math.random().toString(36).slice(2, 8);
|
|
198
|
+
const machineName = `app-building-${uniqueId}`;
|
|
199
|
+
const extra = buildExtraEnv(config, machineName);
|
|
200
|
+
const containerEnv = buildContainerEnv(repo, config.infisical, extra);
|
|
201
|
+
const existing = await listMachines(config.flyApp, config.flyToken);
|
|
202
|
+
if (existing.length > 0) {
|
|
203
|
+
console.log(`${existing.length} existing machine(s) in ${config.flyApp}:`);
|
|
204
|
+
for (const m of existing) {
|
|
205
|
+
console.log(` ${m.id} (${m.name}) — ${m.state}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
console.log("Creating Fly machine (with volume)...");
|
|
209
|
+
let machineId = "";
|
|
210
|
+
let volumeId = "";
|
|
211
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
212
|
+
try {
|
|
213
|
+
const result = await createMachine(config.flyApp, config.flyToken, imageRef, containerEnv, machineName);
|
|
214
|
+
machineId = result.machineId;
|
|
215
|
+
volumeId = result.volumeId;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
220
|
+
if (msg.includes("MANIFEST_UNKNOWN") && attempt < 4) {
|
|
221
|
+
console.log("Image not yet available in registry, retrying in 5s...");
|
|
222
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
console.log(`Machine created: ${machineId} (volume: ${volumeId})`);
|
|
229
|
+
const baseUrl = `https://${config.flyApp}.fly.dev`;
|
|
230
|
+
const agentState = {
|
|
231
|
+
type: "remote",
|
|
232
|
+
containerName: machineName,
|
|
233
|
+
port: 443,
|
|
234
|
+
baseUrl,
|
|
235
|
+
flyApp: config.flyApp,
|
|
236
|
+
flyMachineId: machineId,
|
|
237
|
+
flyVolumeId: volumeId,
|
|
238
|
+
};
|
|
239
|
+
console.log("Waiting for machine to start...");
|
|
240
|
+
await waitForMachine(config.flyApp, config.flyToken, machineId);
|
|
241
|
+
console.log("Machine started.");
|
|
242
|
+
const maxWait = 180000;
|
|
243
|
+
const interval = 2000;
|
|
244
|
+
const start = Date.now();
|
|
245
|
+
let ready = false;
|
|
246
|
+
while (Date.now() - start < maxWait) {
|
|
247
|
+
try {
|
|
248
|
+
const res = await fetch(`${baseUrl}/status`, {
|
|
249
|
+
headers: { "fly-force-instance-id": machineId },
|
|
250
|
+
});
|
|
251
|
+
if (res.ok) {
|
|
252
|
+
ready = true;
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// Not ready yet
|
|
258
|
+
}
|
|
259
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
260
|
+
}
|
|
261
|
+
if (!ready) {
|
|
262
|
+
console.log("Timed out waiting for machine, destroying...");
|
|
263
|
+
await destroyMachine(config.flyApp, config.flyToken, machineId, volumeId).catch(() => { });
|
|
264
|
+
throw new Error("Remote container did not become ready within timeout");
|
|
265
|
+
}
|
|
266
|
+
return agentState;
|
|
267
|
+
}
|
|
268
|
+
async function stopRemoteContainerImpl(config, state) {
|
|
269
|
+
if (!state.flyApp || !state.flyMachineId) {
|
|
270
|
+
throw new Error("Missing flyApp or flyMachineId in agent state");
|
|
271
|
+
}
|
|
272
|
+
if (!config.flyToken)
|
|
273
|
+
throw new Error("flyToken is required to stop remote container");
|
|
274
|
+
console.log(`Destroying Fly machine ${state.flyMachineId}...`);
|
|
275
|
+
await destroyMachine(state.flyApp, config.flyToken, state.flyMachineId, state.flyVolumeId);
|
|
276
|
+
console.log("Machine destroyed.");
|
|
277
|
+
}
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Public API
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
/**
|
|
282
|
+
* Start a container (local Docker or remote Fly.io based on config).
|
|
283
|
+
* If flyToken and flyApp are set, starts remotely; otherwise locally.
|
|
284
|
+
*/
|
|
285
|
+
export async function startContainer(config, repo) {
|
|
286
|
+
debugLog("startContainer config:", {
|
|
287
|
+
projectRoot: config.projectRoot,
|
|
288
|
+
flyApp: config.flyApp,
|
|
289
|
+
imageRef: config.imageRef,
|
|
290
|
+
webhookUrl: config.webhookUrl,
|
|
291
|
+
detached: config.detached,
|
|
292
|
+
remote: isRemote(config),
|
|
293
|
+
initialPrompt: config.initialPrompt ? `${config.initialPrompt.slice(0, 100)}...` : undefined,
|
|
294
|
+
});
|
|
295
|
+
debugLog("startContainer repo:", repo);
|
|
296
|
+
const state = isRemote(config)
|
|
297
|
+
? await startRemoteContainerImpl(config, repo)
|
|
298
|
+
: await startLocalContainer(config, repo);
|
|
299
|
+
config.registry.log(state);
|
|
300
|
+
return state;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Stop a container by its state or registry entry.
|
|
304
|
+
*/
|
|
305
|
+
export async function stopContainer(config, state) {
|
|
306
|
+
if (state.type === "remote") {
|
|
307
|
+
await stopRemoteContainerImpl(config, state);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
stopLocalContainer(state.containerName);
|
|
311
|
+
}
|
|
312
|
+
config.registry.markStopped(state.containerName);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Spawn an interactive test container (local only).
|
|
316
|
+
*/
|
|
199
317
|
export function spawnTestContainer(config) {
|
|
200
318
|
if (!config.projectRoot)
|
|
201
319
|
throw new Error("projectRoot is required for local Docker operations");
|
|
202
320
|
ensureImageExists(config.projectRoot);
|
|
203
321
|
const uniqueId = Math.random().toString(36).slice(2, 8);
|
|
204
322
|
const containerName = `app-building-test-${uniqueId}`;
|
|
205
|
-
const infisicalVars = infisicalEnvVars(config.infisical);
|
|
206
323
|
const args = ["run", "-it", "--rm", "--name", containerName];
|
|
207
324
|
args.push("-v", `${config.projectRoot}:/repo`);
|
|
208
325
|
args.push("-w", "/repo");
|
|
@@ -210,9 +327,9 @@ export function spawnTestContainer(config) {
|
|
|
210
327
|
args.push("--user", `${process.getuid()}:${process.getgid()}`);
|
|
211
328
|
args.push("--env", "HOME=/repo/.agent-home");
|
|
212
329
|
args.push("--env", "PLAYWRIGHT_BROWSERS_PATH=/opt/playwright");
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
330
|
+
args.push("--env", `INFISICAL_TOKEN=${config.infisical.token}`);
|
|
331
|
+
args.push("--env", `INFISICAL_PROJECT_ID=${config.infisical.projectId}`);
|
|
332
|
+
args.push("--env", `INFISICAL_ENVIRONMENT=${config.infisical.environment}`);
|
|
216
333
|
args.push(IMAGE_NAME, "bash");
|
|
217
334
|
return new Promise((resolvePromise, reject) => {
|
|
218
335
|
const child = spawn("docker", args, { stdio: "inherit" });
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED