@replayio/app-building 1.17.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 CHANGED
@@ -15,12 +15,11 @@ import {
15
15
  loadDotEnv,
16
16
  FileContainerRegistry,
17
17
  getInfisicalConfig,
18
- createMachine,
19
- destroyMachine,
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,45 +27,41 @@ import {
28
27
  const orchestrationVars = loadDotEnv("/path/to/project");
29
28
  const infisicalConfig = await getInfisicalConfig(orchestrationVars);
30
29
 
31
- // Only pass Infisical credentials to the container not actual secrets.
32
- // The container fetches secrets from Infisical at startup and manages them
33
- // via an internal secrets server. The agent never has direct access to secrets.
34
- const containerEnvVars: Record<string, string> = {
35
- INFISICAL_TOKEN: infisicalConfig.token,
36
- INFISICAL_PROJECT_ID: infisicalConfig.projectId,
37
- INFISICAL_ENVIRONMENT: infisicalConfig.environment,
38
- };
39
-
30
+ // Local container (no flyToken/flyApp)
40
31
  const config: ContainerConfig = {
41
- projectRoot: "/path/to/project", // optional — only needed for local Docker operations
42
- envVars: containerEnvVars,
32
+ projectRoot: "/path/to/project",
33
+ infisical: infisicalConfig,
43
34
  registry: new FileContainerRegistry("/path/to/.container-registry.jsonl"),
35
+ };
36
+
37
+ // Remote container (set flyToken + flyApp)
38
+ const remoteConfig: ContainerConfig = {
39
+ ...config,
44
40
  flyToken: orchestrationVars.FLY_API_TOKEN,
45
41
  flyApp: orchestrationVars.FLY_APP_NAME,
46
42
  };
47
43
 
48
- // Create a Fly machine (automatically provisions a volume)
49
- const { machineId, volumeId } = await createMachine(
50
- config.flyApp, config.flyToken, imageRef, containerEnv, machineName,
51
- );
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);
52
47
 
53
48
  // Check status
54
- const status = await httpGet(`https://${config.flyApp}.fly.dev/status`);
49
+ const status = await httpGet(`${state.baseUrl}/status`, httpOptsFor(state));
55
50
 
56
51
  // Query the registry
57
52
  const alive = await config.registry.findAlive();
58
53
 
59
- // Clean up (destroys machine and its volume)
60
- await destroyMachine(config.flyApp, config.flyToken, machineId, volumeId);
54
+ // Stop handles both local and remote
55
+ await stopContainer(config, state);
61
56
  ```
62
57
 
63
58
  ## Secrets architecture
64
59
 
65
60
  Secrets are never passed directly to the container or agent. Instead:
66
61
 
67
- 1. The orchestration host passes **Infisical credentials** (token, project ID, environment) to the container.
68
- 2. At startup, the container fetches all secrets from Infisical and stores them in memory.
69
- 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.
70
65
  4. The agent process runs with a **restricted environment** — only `ANTHROPIC_API_KEY` (required for the Claude CLI) is present.
71
66
  5. When the agent needs to run a command that requires secrets, it uses `exec-secrets`:
72
67
 
@@ -75,9 +70,9 @@ exec-secrets NEON_API_KEY -- curl -s -H "Authorization: Bearer $NEON_API_KEY" ht
75
70
  exec-secrets NETLIFY_AUTH_TOKEN NETLIFY_ACCOUNT_SLUG -- netlify deploy --prod
76
71
  ```
77
72
 
78
- The secrets server spawns the command with the requested secrets in its environment and **redacts all secret values** from the output.
73
+ The secrets server spawns the command with the requested secrets in its environment and **redacts requested secret values** from the output.
79
74
 
80
- 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.
81
76
 
82
77
  ## Exported API
83
78
 
@@ -85,8 +80,9 @@ The agent can also run `list-secrets` to see which secrets are available, and `s
85
80
 
86
81
  | Export | Description |
87
82
  |---|---|
88
- | `ContainerConfig` | Interface bundling all external state: optional `projectRoot` (only needed for local Docker operations), `envVars` (Infisical credentials), `registry`, optional `flyToken`/`flyApp`/`imageRef`/`webhookUrl`/`webhookSecret`/`detached`/`initialPrompt`/`localPort`/`absorbTasks`. See [Webhooks](#webhooks) and [Container lifecycle](#container-lifecycle) below. |
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`. |
89
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. |
90
86
  | `ContainerRegistry` | Interface for container registry storage. Methods: `log`, `markStopped`, `clearStopped`, `getRecent`, `find`, `findAlive`. |
91
87
  | `FileContainerRegistry` | Built-in file-backed implementation of `ContainerRegistry`, backed by a `.jsonl` file. |
92
88
 
@@ -94,14 +90,12 @@ The agent can also run `list-secrets` to see which secrets are available, and `s
94
90
 
95
91
  | Export | Description |
96
92
  |---|---|
97
- | `startContainer(config, repo)` | Build the Docker image locally and start a container with `--network host`. Returns `AgentState`. |
98
- | `stopContainer(config, containerName)` | Stop a local Docker container by name. |
99
- | `buildImage(config)` | Build the Docker image locally (called automatically by `startContainer`). |
100
- | `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`. |
101
97
  | `loadDotEnv(projectRoot)` | Parse a `.env` file and return key-value pairs. |
102
98
 
103
- **Types:** `AgentState`, `ContainerConfig`, `RepoOptions`
104
-
105
99
  ### Container registry (`ContainerRegistry` interface / `FileContainerRegistry` class)
106
100
 
107
101
  | Method | Description |
@@ -131,20 +125,6 @@ The agent can also run `list-secrets` to see which secrets are available, and `s
131
125
  | `httpOptsFor(state)` | Return `HttpOptions` for a container (adds `fly-force-instance-id` header for remote containers). |
132
126
  | `probeAlive(entry)` | Check if a container is responding to `/status`. |
133
127
 
134
- ### Fly.io utilities
135
-
136
- | Export | Description |
137
- |---|---|
138
- | `createApp(token, name, org?)` | Create a Fly app and allocate IPs. |
139
- | `createMachine(app, token, image, env, name)` | Create a Fly machine with a 50GB volume mounted at `/repo`. Returns `{ machineId, volumeId }`. |
140
- | `waitForMachine(app, token, machineId)` | Poll until a machine reaches `started` state. |
141
- | `listMachines(app, token)` | List all machines for an app. |
142
- | `destroyMachine(app, token, machineId, volumeId?)` | Force-destroy a machine and optionally its volume. |
143
- | `listVolumes(app, token)` | List all volumes for an app. |
144
- | `deleteVolume(app, token, volumeId)` | Delete a Fly volume. |
145
-
146
- **Types:** `FlyMachineInfo`, `FlyVolumeInfo`, `CreateMachineResult`
147
-
148
128
  ### Secrets (Infisical)
149
129
 
150
130
  | Export | Description |
@@ -153,30 +133,11 @@ The agent can also run `list-secrets` to see which secrets are available, and `s
153
133
  | `getInfisicalConfig(envVars)` | Extract `InfisicalConfig` from env vars and log in. Requires `INFISICAL_CLIENT_ID`, `INFISICAL_CLIENT_SECRET`, `INFISICAL_PROJECT_ID`, `INFISICAL_ENVIRONMENT`. |
154
134
  | `fetchGlobalSecrets(config)` | Fetch secrets from the `/global/` path. |
155
135
  | `fetchBranchSecrets(config, branch)` | Fetch secrets from `/branches/<branch>/`. |
156
- | `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. |
157
137
  | `fetchInfisicalSecrets(config, path)` | Raw fetch from any Infisical folder path. |
158
138
 
159
139
  **Types:** `InfisicalConfig`
160
140
 
161
- **Usage pattern** (orchestration scripts):
162
-
163
- ```ts
164
- const orchestrationVars = loadDotEnv(projectRoot);
165
- const infisicalConfig = await getInfisicalConfig(orchestrationVars);
166
-
167
- // Pass only Infisical credentials to the container
168
- const config: ContainerConfig = {
169
- envVars: {
170
- INFISICAL_TOKEN: infisicalConfig.token,
171
- INFISICAL_PROJECT_ID: infisicalConfig.projectId,
172
- INFISICAL_ENVIRONMENT: infisicalConfig.environment,
173
- },
174
- flyToken: orchestrationVars.FLY_API_TOKEN,
175
- flyApp: orchestrationVars.FLY_APP_NAME,
176
- ...
177
- };
178
- ```
179
-
180
141
  ### Image ref
181
142
 
182
143
  | Export | Description |
@@ -269,11 +230,7 @@ const infisicalConfig = await getInfisicalConfig(orchestrationVars);
269
230
 
270
231
  const config: ContainerConfig = {
271
232
  projectRoot: "/path/to/project",
272
- envVars: {
273
- INFISICAL_TOKEN: infisicalConfig.token,
274
- INFISICAL_PROJECT_ID: infisicalConfig.projectId,
275
- INFISICAL_ENVIRONMENT: infisicalConfig.environment,
276
- },
233
+ infisical: infisicalConfig,
277
234
  registry: new FileContainerRegistry("/path/to/.container-registry.jsonl"),
278
235
  webhookUrl: "https://example.com/hooks/container-events",
279
236
  webhookSecret: "your-webhook-secret",
@@ -1,4 +1,5 @@
1
- import type { ContainerRegistry } from "./container-registry";
1
+ import type { ContainerRegistry, RegistryEntry } from "./container-registry";
2
+ import type { InfisicalConfig } from "./secrets";
2
3
  export interface AgentState {
3
4
  type: "local" | "remote";
4
5
  containerName: string;
@@ -10,7 +11,8 @@ export interface AgentState {
10
11
  }
11
12
  export interface ContainerConfig {
12
13
  projectRoot?: string;
13
- envVars: Record<string, string>;
14
+ /** Infisical credentials — required for all containers. */
15
+ infisical: InfisicalConfig;
14
16
  registry: ContainerRegistry;
15
17
  flyToken?: string;
16
18
  flyApp?: string;
@@ -33,6 +35,16 @@ export interface RepoOptions {
33
35
  }
34
36
  export declare function loadDotEnv(projectRoot: string): Record<string, string>;
35
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
+ */
36
42
  export declare function startContainer(config: ContainerConfig, repo: RepoOptions): Promise<AgentState>;
37
- export declare function stopContainer(config: ContainerConfig, containerName: string): void;
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
+ */
38
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,7 +75,7 @@ function findFreePort() {
73
75
  }
74
76
  return port;
75
77
  }
76
- function buildContainerEnv(repo, envVars, extra = {}) {
78
+ function buildContainerEnv(repo, infisical, extra = {}) {
77
79
  const env = {
78
80
  REPO_URL: repo.repoUrl,
79
81
  CLONE_BRANCH: repo.cloneBranch,
@@ -83,7 +85,9 @@ function buildContainerEnv(repo, envVars, extra = {}) {
83
85
  GIT_COMMITTER_NAME: "App Builder",
84
86
  GIT_COMMITTER_EMAIL: "app-builder@localhost",
85
87
  PLAYWRIGHT_BROWSERS_PATH: "/opt/playwright",
86
- ...envVars,
88
+ INFISICAL_TOKEN: infisical.token,
89
+ INFISICAL_PROJECT_ID: infisical.projectId,
90
+ INFISICAL_ENVIRONMENT: infisical.environment,
87
91
  ...extra,
88
92
  };
89
93
  if (process.env.DEBUG) {
@@ -91,24 +95,9 @@ function buildContainerEnv(repo, envVars, extra = {}) {
91
95
  }
92
96
  return env;
93
97
  }
94
- export async function startContainer(config, repo) {
95
- debugLog("startContainer config:", {
96
- projectRoot: config.projectRoot,
97
- flyApp: config.flyApp,
98
- imageRef: config.imageRef,
99
- webhookUrl: config.webhookUrl,
100
- detached: config.detached,
101
- initialPrompt: config.initialPrompt ? `${config.initialPrompt.slice(0, 100)}...` : undefined,
102
- envVarKeys: Object.keys(config.envVars),
103
- });
104
- debugLog("startContainer repo:", repo);
105
- buildImage(config);
106
- const uniqueId = Math.random().toString(36).slice(2, 8);
107
- const containerName = `app-building-${uniqueId}`;
108
- const containerPort = 3000;
109
- const hostPort = config.localPort ?? findFreePort();
98
+ function buildExtraEnv(config, containerName) {
110
99
  const extra = {
111
- PORT: String(containerPort),
100
+ PORT: "3000",
112
101
  CONTAINER_NAME: containerName,
113
102
  };
114
103
  if (config.webhookUrl)
@@ -121,35 +110,44 @@ export async function startContainer(config, repo) {
121
110
  extra.INITIAL_PROMPT = config.initialPrompt;
122
111
  if (config.absorbTasks)
123
112
  extra.ABSORB_TASKS = "1";
124
- const containerEnv = buildContainerEnv(repo, config.envVars, extra);
125
- // Build docker run args
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);
129
+ const containerEnv = buildContainerEnv(repo, config.infisical, extra);
126
130
  const args = ["run", "-d", "--rm", "--name", containerName];
127
- // Use explicit port mapping for macOS Docker Desktop compatibility
128
- // (--network host only works on Linux)
129
131
  args.push("-p", `${hostPort}:${containerPort}`);
130
132
  for (const [k, v] of Object.entries(containerEnv)) {
131
133
  args.push("--env", `${k}=${v}`);
132
134
  }
133
- // Image name — CMD is baked in (server.ts)
134
135
  args.push(IMAGE_NAME);
135
136
  const containerId = execFileSync("docker", args, {
136
137
  encoding: "utf-8",
137
138
  timeout: 30000,
138
139
  }).trim();
139
140
  console.log(`Container started: ${containerId.slice(0, 12)} (${containerName})`);
140
- // Wait for the HTTP server to become ready
141
141
  const baseUrl = `http://127.0.0.1:${hostPort}`;
142
- const maxWait = 120000; // clone can take a while
142
+ const maxWait = 120000;
143
143
  const interval = 1000;
144
144
  const start = Date.now();
145
145
  let ready = false;
146
146
  while (Date.now() - start < maxWait) {
147
- // Check if the container is still alive (--rm removes it on exit)
148
147
  try {
149
148
  execFileSync("docker", ["inspect", "--format", "{{.State.Running}}", containerName], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5000 });
150
149
  }
151
150
  catch {
152
- // Container is gone — grab logs from docker if possible, otherwise just report
153
151
  let logs = "";
154
152
  try {
155
153
  logs = execFileSync("docker", ["logs", "--tail", "30", containerName], {
@@ -177,19 +175,145 @@ export async function startContainer(config, repo) {
177
175
  if (!ready) {
178
176
  throw new Error("Container did not become ready within timeout");
179
177
  }
180
- const agentState = { type: "local", containerName, port: hostPort, baseUrl };
181
- config.registry.log(agentState);
182
- return agentState;
178
+ return { type: "local", containerName, port: hostPort, baseUrl };
183
179
  }
184
- export function stopContainer(config, containerName) {
180
+ function stopLocalContainer(containerName) {
185
181
  try {
186
182
  execFileSync("docker", ["stop", containerName], { stdio: "ignore", timeout: 30000 });
187
183
  }
188
184
  catch {
189
185
  // Container may already be stopped
190
186
  }
191
- config.registry.markStopped(containerName);
192
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
+ */
193
317
  export function spawnTestContainer(config) {
194
318
  if (!config.projectRoot)
195
319
  throw new Error("projectRoot is required for local Docker operations");
@@ -203,9 +327,9 @@ export function spawnTestContainer(config) {
203
327
  args.push("--user", `${process.getuid()}:${process.getgid()}`);
204
328
  args.push("--env", "HOME=/repo/.agent-home");
205
329
  args.push("--env", "PLAYWRIGHT_BROWSERS_PATH=/opt/playwright");
206
- for (const [k, v] of Object.entries(config.envVars)) {
207
- args.push("--env", `${k}=${v}`);
208
- }
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}`);
209
333
  args.push(IMAGE_NAME, "bash");
210
334
  return new Promise((resolvePromise, reject) => {
211
335
  const child = spawn("docker", args, { stdio: "inherit" });
package/dist/index.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- export * from "./fly";
2
1
  export * from "./container";
3
2
  export * from "./container-registry";
4
3
  export * from "./container-utils";
package/dist/index.js CHANGED
@@ -1,4 +1,3 @@
1
- export * from "./fly";
2
1
  export * from "./container";
3
2
  export * from "./container-registry";
4
3
  export * from "./container-utils";
package/dist/secrets.d.ts CHANGED
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Infisical secrets API.
3
+ *
4
+ * API versions used (per https://infisical.com/docs/api-reference):
5
+ * - Auth: POST /api/v1/auth/universal-auth/login
6
+ * - Secrets: GET/POST/PATCH /api/v4/secrets[/{secretName}]
7
+ * - Folders: POST /api/v2/folders
8
+ */
1
9
  export interface InfisicalConfig {
2
10
  token: string;
3
11
  projectId: string;
@@ -5,12 +13,14 @@ export interface InfisicalConfig {
5
13
  }
6
14
  /**
7
15
  * Log in to Infisical using Universal Auth (Client ID + Client Secret).
8
- * Returns a short-lived access token (default 30 day TTL).
16
+ * POST /api/v1/auth/universal-auth/login
17
+ * Returns a short-lived access token.
9
18
  */
10
19
  export declare function infisicalLogin(clientId: string, clientSecret: string): Promise<string>;
11
20
  /**
12
21
  * Fetch secrets from an Infisical folder path.
13
- * Returns a key-value record of secret names to values.
22
+ * GET /api/v4/secrets?projectId=…&environment=…&secretPath=…
23
+ * Returns a key→value record.
14
24
  */
15
25
  export declare function fetchInfisicalSecrets(config: InfisicalConfig, secretPath: string): Promise<Record<string, string>>;
16
26
  /**
@@ -23,7 +33,12 @@ export declare function fetchGlobalSecrets(config: InfisicalConfig): Promise<Rec
23
33
  export declare function fetchBranchSecrets(config: InfisicalConfig, branch: string): Promise<Record<string, string>>;
24
34
  /**
25
35
  * Create or update a branch secret in Infisical.
26
- * Uses POST to create, falls back to PATCH if it already exists.
36
+ * Creates the folder path if it doesn't exist yet.
37
+ *
38
+ * POST /api/v4/secrets/{name} — create
39
+ * PATCH /api/v4/secrets/{name} — update (if secret already exists)
40
+ *
41
+ * Body: { projectId, environment, secretPath, secretValue, type }
27
42
  */
28
43
  export declare function createBranchSecret(config: InfisicalConfig, branch: string, name: string, value: string): Promise<void>;
29
44
  /**
package/dist/secrets.js CHANGED
@@ -1,7 +1,19 @@
1
+ /**
2
+ * Infisical secrets API.
3
+ *
4
+ * API versions used (per https://infisical.com/docs/api-reference):
5
+ * - Auth: POST /api/v1/auth/universal-auth/login
6
+ * - Secrets: GET/POST/PATCH /api/v4/secrets[/{secretName}]
7
+ * - Folders: POST /api/v2/folders
8
+ */
1
9
  const INFISICAL_API_BASE = "https://app.infisical.com";
10
+ // ---------------------------------------------------------------------------
11
+ // Auth
12
+ // ---------------------------------------------------------------------------
2
13
  /**
3
14
  * Log in to Infisical using Universal Auth (Client ID + Client Secret).
4
- * Returns a short-lived access token (default 30 day TTL).
15
+ * POST /api/v1/auth/universal-auth/login
16
+ * Returns a short-lived access token.
5
17
  */
6
18
  export async function infisicalLogin(clientId, clientSecret) {
7
19
  const res = await fetch(`${INFISICAL_API_BASE}/api/v1/auth/universal-auth/login`, {
@@ -16,30 +28,33 @@ export async function infisicalLogin(clientId, clientSecret) {
16
28
  const data = (await res.json());
17
29
  return data.accessToken;
18
30
  }
19
- async function infisicalFetch(path, config) {
20
- const res = await fetch(`${INFISICAL_API_BASE}${path}`, {
21
- headers: {
22
- Authorization: `Bearer ${config.token}`,
23
- "Content-Type": "application/json",
24
- },
25
- });
26
- if (!res.ok) {
27
- const body = await res.text().catch(() => "");
28
- throw new Error(`Infisical API GET ${path} → ${res.status}: ${body}`);
29
- }
30
- return res;
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+ function authHeaders(config) {
35
+ return {
36
+ Authorization: `Bearer ${config.token}`,
37
+ "Content-Type": "application/json",
38
+ };
31
39
  }
32
40
  /**
33
41
  * Fetch secrets from an Infisical folder path.
34
- * Returns a key-value record of secret names to values.
42
+ * GET /api/v4/secrets?projectId=…&environment=…&secretPath=…
43
+ * Returns a key→value record.
35
44
  */
36
45
  export async function fetchInfisicalSecrets(config, secretPath) {
37
46
  const params = new URLSearchParams({
38
- workspaceId: config.projectId,
47
+ projectId: config.projectId,
39
48
  environment: config.environment,
40
49
  secretPath,
41
50
  });
42
- const res = await infisicalFetch(`/api/v3/secrets/raw?${params}`, config);
51
+ const res = await fetch(`${INFISICAL_API_BASE}/api/v4/secrets?${params}`, {
52
+ headers: authHeaders(config),
53
+ });
54
+ if (!res.ok) {
55
+ const body = await res.text().catch(() => "");
56
+ throw new Error(`Infisical GET secrets ${secretPath} → ${res.status}: ${body}`);
57
+ }
43
58
  const data = (await res.json());
44
59
  const secrets = {};
45
60
  for (const s of data.secrets) {
@@ -59,24 +74,66 @@ export async function fetchGlobalSecrets(config) {
59
74
  export async function fetchBranchSecrets(config, branch) {
60
75
  return fetchInfisicalSecrets(config, `/branches/${branch}/`);
61
76
  }
77
+ // ---------------------------------------------------------------------------
78
+ // Folders
79
+ // ---------------------------------------------------------------------------
80
+ /**
81
+ * Ensure all folders in a path exist, creating any missing ones.
82
+ * POST /api/v2/folders — body: { projectId, environment, name, path }
83
+ *
84
+ * Infisical requires folders to exist before secrets can be written into them.
85
+ * We walk each segment of the path and issue a create; a 400 response means
86
+ * the folder already exists (the docs list 400 for "Bad Request" which
87
+ * Infisical returns for duplicate folder names).
88
+ */
89
+ async function ensureFolder(config, folderPath) {
90
+ const segments = folderPath.split("/").filter(Boolean);
91
+ let parentPath = "/";
92
+ for (const segment of segments) {
93
+ const res = await fetch(`${INFISICAL_API_BASE}/api/v2/folders`, {
94
+ method: "POST",
95
+ headers: authHeaders(config),
96
+ body: JSON.stringify({
97
+ projectId: config.projectId,
98
+ environment: config.environment,
99
+ name: segment,
100
+ path: parentPath,
101
+ }),
102
+ });
103
+ // 200 = created. 400 = folder already exists (Infisical returns 400 for
104
+ // duplicate folder names under the same parent).
105
+ if (res.ok || res.status === 400) {
106
+ parentPath += segment + "/";
107
+ continue;
108
+ }
109
+ const text = await res.text().catch(() => "");
110
+ throw new Error(`Infisical create folder ${parentPath}${segment} → ${res.status}: ${text}`);
111
+ }
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // Secrets — write
115
+ // ---------------------------------------------------------------------------
62
116
  /**
63
117
  * Create or update a branch secret in Infisical.
64
- * Uses POST to create, falls back to PATCH if it already exists.
118
+ * Creates the folder path if it doesn't exist yet.
119
+ *
120
+ * POST /api/v4/secrets/{name} — create
121
+ * PATCH /api/v4/secrets/{name} — update (if secret already exists)
122
+ *
123
+ * Body: { projectId, environment, secretPath, secretValue, type }
65
124
  */
66
125
  export async function createBranchSecret(config, branch, name, value) {
67
126
  const secretPath = `/branches/${branch}/`;
68
- const url = `${INFISICAL_API_BASE}/api/v3/secrets/raw/${encodeURIComponent(name)}`;
127
+ const url = `${INFISICAL_API_BASE}/api/v4/secrets/${encodeURIComponent(name)}`;
69
128
  const body = {
70
- workspaceId: config.projectId,
129
+ projectId: config.projectId,
71
130
  environment: config.environment,
72
131
  secretPath,
73
132
  secretValue: value,
74
133
  type: "shared",
75
134
  };
76
- const headers = {
77
- Authorization: `Bearer ${config.token}`,
78
- "Content-Type": "application/json",
79
- };
135
+ const headers = authHeaders(config);
136
+ // --- Try POST (create) ---------------------------------------------------
80
137
  const res = await fetch(url, {
81
138
  method: "POST",
82
139
  headers,
@@ -84,8 +141,8 @@ export async function createBranchSecret(config, branch, name, value) {
84
141
  });
85
142
  if (res.ok)
86
143
  return;
87
- // If conflict (already exists), try PATCH to update
88
- if (res.status === 400 || res.status === 409) {
144
+ // Secret already exists PATCH to update
145
+ if (res.status === 400) {
89
146
  const patchRes = await fetch(url, {
90
147
  method: "PATCH",
91
148
  headers,
@@ -96,9 +153,37 @@ export async function createBranchSecret(config, branch, name, value) {
96
153
  const text = await patchRes.text().catch(() => "");
97
154
  throw new Error(`Infisical PATCH ${name} → ${patchRes.status}: ${text}`);
98
155
  }
156
+ // Folder doesn't exist → create folders then retry POST
157
+ if (res.status === 404) {
158
+ await ensureFolder(config, secretPath);
159
+ const retryRes = await fetch(url, {
160
+ method: "POST",
161
+ headers,
162
+ body: JSON.stringify(body),
163
+ });
164
+ if (retryRes.ok)
165
+ return;
166
+ // Retry may 400 if another process created it concurrently → try PATCH
167
+ if (retryRes.status === 400) {
168
+ const patchRes = await fetch(url, {
169
+ method: "PATCH",
170
+ headers,
171
+ body: JSON.stringify(body),
172
+ });
173
+ if (patchRes.ok)
174
+ return;
175
+ const text = await patchRes.text().catch(() => "");
176
+ throw new Error(`Infisical PATCH ${name} (after folder creation) → ${patchRes.status}: ${text}`);
177
+ }
178
+ const text = await retryRes.text().catch(() => "");
179
+ throw new Error(`Infisical POST ${name} (after folder creation) → ${retryRes.status}: ${text}`);
180
+ }
99
181
  const text = await res.text().catch(() => "");
100
182
  throw new Error(`Infisical POST ${name} → ${res.status}: ${text}`);
101
183
  }
184
+ // ---------------------------------------------------------------------------
185
+ // Config helper
186
+ // ---------------------------------------------------------------------------
102
187
  /**
103
188
  * Extract Infisical config from environment variables and log in.
104
189
  * Reads INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET, INFISICAL_PROJECT_ID,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@replayio/app-building",
3
- "version": "1.17.0",
3
+ "version": "1.19.0",
4
4
  "description": "Library for managing agentic app-building containers",
5
5
  "type": "module",
6
6
  "exports": {