@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 +29 -72
- package/dist/container.d.ts +15 -3
- package/dist/container.js +160 -36
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/secrets.d.ts +18 -3
- package/dist/secrets.js +110 -25
- 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,45 +27,41 @@ import {
|
|
|
28
27
|
const orchestrationVars = loadDotEnv("/path/to/project");
|
|
29
28
|
const infisicalConfig = await getInfisicalConfig(orchestrationVars);
|
|
30
29
|
|
|
31
|
-
//
|
|
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",
|
|
42
|
-
|
|
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
|
-
//
|
|
49
|
-
const {
|
|
50
|
-
|
|
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(
|
|
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
|
-
//
|
|
60
|
-
await
|
|
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** (
|
|
68
|
-
2. At startup, the container fetches
|
|
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
|
|
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` |
|
|
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)` |
|
|
98
|
-
| `stopContainer(config,
|
|
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
|
-
|
|
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",
|
package/dist/container.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
125
|
-
|
|
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;
|
|
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
|
-
|
|
181
|
-
config.registry.log(agentState);
|
|
182
|
-
return agentState;
|
|
178
|
+
return { type: "local", containerName, port: hostPort, baseUrl };
|
|
183
179
|
}
|
|
184
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
package/dist/index.js
CHANGED
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
47
|
+
projectId: config.projectId,
|
|
39
48
|
environment: config.environment,
|
|
40
49
|
secretPath,
|
|
41
50
|
});
|
|
42
|
-
const res = await
|
|
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
|
-
*
|
|
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/
|
|
127
|
+
const url = `${INFISICAL_API_BASE}/api/v4/secrets/${encodeURIComponent(name)}`;
|
|
69
128
|
const body = {
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
88
|
-
if (res.status === 400
|
|
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,
|