@desplega.ai/agent-env 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +343 -2
- package/assets/icon.svg +5 -0
- package/bin/agent-env.mjs +210 -0
- package/docs/agent-swarm.md +146 -0
- package/docs/releases.md +207 -0
- package/docs/screenshots/dashboard-desktop.png +0 -0
- package/docs/screenshots/dashboard-mobile.png +0 -0
- package/docs/snapshots-and-worktrees.md +221 -0
- package/examples/agent-swarm/README.md +5 -0
- package/examples/agent-swarm/agent-env.json +11 -0
- package/examples/agent-swarm/compose.agent-env.yaml +101 -0
- package/examples/agent-swarm/verify.mjs +55 -0
- package/examples/compose/.dockerignore +3 -0
- package/examples/compose/Dockerfile +4 -0
- package/examples/compose/README.md +109 -0
- package/examples/compose/compose.yaml +36 -0
- package/examples/compose/server.mjs +6 -0
- package/examples/hello/agent-env.json +20 -0
- package/examples/hello/index.html +12 -0
- package/package.json +49 -4
- package/src/cloud.mjs +314 -0
- package/src/config.mjs +133 -0
- package/src/daemon.mjs +484 -0
- package/src/environments.mjs +207 -0
- package/src/goodies.mjs +71 -0
- package/src/init.mjs +68 -0
- package/src/locks.mjs +56 -0
- package/src/maintenance.mjs +74 -0
- package/src/paths.mjs +5 -0
- package/src/providers/CLOUD_SANDBOX.md +240 -0
- package/src/providers/README.md +62 -0
- package/src/providers/compose.mjs +460 -0
- package/src/providers/docker.mjs +207 -0
- package/src/providers/index.mjs +13 -0
- package/src/providers/sandbox.mjs +170 -0
- package/src/proxy.mjs +149 -0
- package/src/runtime.mjs +83 -0
- package/src/snapshots.mjs +394 -0
- package/src/source-paths.mjs +15 -0
- package/src/system.mjs +39 -0
- package/src/tls.mjs +57 -0
- package/src/ui-client.js +51 -0
- package/src/ui.html +12 -0
- package/src/ui.mjs +32 -0
- package/src/watch.mjs +20 -0
- package/src/worktrees.mjs +154 -0
package/src/cloud.mjs
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { isIP } from 'node:net';
|
|
6
|
+
import { run as systemRun, delay as systemDelay } from './system.mjs';
|
|
7
|
+
import { withFileLock } from './locks.mjs';
|
|
8
|
+
|
|
9
|
+
const digest = value => createHash('sha256').update(value).digest('hex');
|
|
10
|
+
const methods = ['server', 'primary-ip', 'ssh-key'];
|
|
11
|
+
const fields = { server: 'serverId', 'primary-ip': 'primaryIpId', 'ssh-key': 'keyId' };
|
|
12
|
+
const safeName = value => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$/.test(value);
|
|
13
|
+
const sshQuote = value => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
14
|
+
|
|
15
|
+
export function cloudOptions(value) {
|
|
16
|
+
const options = typeof value === 'string' ? { provider: value } : value;
|
|
17
|
+
if (!options || options.provider !== 'hetzner') throw new Error('Select the cloud provider explicitly: hetzner.');
|
|
18
|
+
const result = { type: 'cx23', location: 'fsn1', image: 'ubuntu-24.04', idleTimeoutMs: 30 * 60 * 1000, ...options, provider: 'hetzner' };
|
|
19
|
+
for (const field of ['type', 'location', 'image']) {
|
|
20
|
+
if (typeof result[field] !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(result[field])) throw new Error(`Cloud ${field} must be a resource name or numeric ID.`);
|
|
21
|
+
}
|
|
22
|
+
if (!Number.isSafeInteger(result.idleTimeoutMs) || result.idleTimeoutMs < 0) throw new Error('Cloud idleTimeoutMs must be a nonnegative integer.');
|
|
23
|
+
if (result.expiresAt != null) {
|
|
24
|
+
if (typeof result.expiresAt !== 'string' || !Number.isFinite(Date.parse(result.expiresAt))) throw new Error('Cloud expiresAt must be an ISO date string.');
|
|
25
|
+
result.expiresAt = new Date(result.expiresAt).toISOString();
|
|
26
|
+
} else result.expiresAt = null;
|
|
27
|
+
return Object.fromEntries(['provider', 'type', 'location', 'image', 'idleTimeoutMs', 'expiresAt'].map(key => [key, result[key]]));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Pin the host key before the first connection. Never accept an unauthenticated scan.
|
|
31
|
+
export function cloudInit(hostPrivateKey, hostPublicKey) {
|
|
32
|
+
const script = `set -eu
|
|
33
|
+
. /etc/os-release
|
|
34
|
+
case "$ID" in ubuntu|debian) ;; *) exit 1 ;; esac
|
|
35
|
+
apt-get update
|
|
36
|
+
apt-get install -y ca-certificates curl rsync
|
|
37
|
+
install -m 0755 -d /etc/apt/keyrings
|
|
38
|
+
curl -fsSL "https://download.docker.com/linux/$ID/gpg" -o /etc/apt/keyrings/docker.asc
|
|
39
|
+
chmod a+r /etc/apt/keyrings/docker.asc
|
|
40
|
+
printf 'Types: deb\\nURIs: https://download.docker.com/linux/%s\\nSuites: %s\\nComponents: stable\\nArchitectures: %s\\nSigned-By: /etc/apt/keyrings/docker.asc\\n' "$ID" "\${UBUNTU_CODENAME:-$VERSION_CODENAME}" "$(dpkg --print-architecture)" > /etc/apt/sources.list.d/docker.sources
|
|
41
|
+
apt-get update
|
|
42
|
+
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
|
43
|
+
systemctl enable --now docker
|
|
44
|
+
`;
|
|
45
|
+
// JSON values are valid YAML values and retain all newlines in the private key.
|
|
46
|
+
return `#cloud-config\nssh_deletekeys: true\nssh_keys:\n ed25519_private: ${JSON.stringify(hostPrivateKey)}\n ed25519_public: ${JSON.stringify(hostPublicKey.trim())}\nssh_pwauth: false\ndisable_root: false\nwrite_files:\n - path: /root/agent-env-bootstrap.sh\n permissions: '0700'\n content: ${JSON.stringify(script)}\nruncmd:\n - [sh, /root/agent-env-bootstrap.sh]\n`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createCloudManager({
|
|
50
|
+
home = resolve(process.env.AGENT_ENV_HOME ?? join(homedir(), '.local/state/agent-env')),
|
|
51
|
+
run = systemRun, delay = systemDelay, now = Date.now, readinessTimeoutMs = 300000,
|
|
52
|
+
} = {}) {
|
|
53
|
+
const root = join(resolve(home), 'cloud');
|
|
54
|
+
if (/[\r\n\0%]/.test(root)) throw new Error('Cloud home cannot contain newlines, null bytes, or SSH percent tokens.');
|
|
55
|
+
const recordKey = identity => {
|
|
56
|
+
if (!safeName(identity?.owner) || !safeName(identity?.name)) throw new Error('Cloud owner and name must be safe resource names.');
|
|
57
|
+
return digest(`${identity.owner}\0${identity.name}`);
|
|
58
|
+
};
|
|
59
|
+
const directory = identity => join(root, recordKey(identity));
|
|
60
|
+
const recordPath = identity => join(directory(identity), 'record.json');
|
|
61
|
+
const labels = record => ({ 'agent-env.owner': record.owner, 'agent-env.instance': record.name, 'agent-env.record': record.recordId });
|
|
62
|
+
const labelArgs = record => Object.entries(labels(record)).flatMap(([key, value]) => ['--label', `${key}=${value}`]);
|
|
63
|
+
const metadata = record => ({
|
|
64
|
+
...record.options, owner: record.owner, name: record.name, recordId: record.recordId,
|
|
65
|
+
serverId: record.serverId ?? null, keyId: record.keyId ?? null,
|
|
66
|
+
primaryIpIds: record.primaryIpId ? [record.primaryIpId] : [],
|
|
67
|
+
createdAt: record.createdAt, createdForStart: !record.committed,
|
|
68
|
+
});
|
|
69
|
+
const extract = handle => handle?.spec?.cloud ?? handle?.cloud ?? handle;
|
|
70
|
+
async function privateWrite(path, contents) {
|
|
71
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
72
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
73
|
+
try { await file.writeFile(contents); await file.sync(); } finally { await file.close(); }
|
|
74
|
+
try { await rename(temporary, path); } finally { await rm(temporary, { force: true }); }
|
|
75
|
+
}
|
|
76
|
+
const save = record => privateWrite(recordPath(record), `${JSON.stringify(record, null, 2)}\n`);
|
|
77
|
+
async function read(identity) {
|
|
78
|
+
try {
|
|
79
|
+
const record = JSON.parse(await readFile(recordPath(identity), 'utf8'));
|
|
80
|
+
if (record.owner !== identity.owner || record.name !== identity.name || record.version !== 1 || !/^[a-f0-9]{32}$/.test(record.recordId)) {
|
|
81
|
+
throw new Error('Cloud ownership record is invalid.');
|
|
82
|
+
}
|
|
83
|
+
return record;
|
|
84
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
85
|
+
}
|
|
86
|
+
async function locked(identity, action) {
|
|
87
|
+
recordKey(identity);
|
|
88
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
89
|
+
await chmod(root, 0o700);
|
|
90
|
+
const path = join(root, `${recordKey(identity)}.lock`);
|
|
91
|
+
return withFileLock(path, action);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function command(binary, args, env) {
|
|
95
|
+
try { return await run(binary, args, { timeout: 120000, killSignal: 'SIGKILL', ...(env ? { env } : {}) }); }
|
|
96
|
+
catch { throw new Error(`${binary} ${binary === 'hcloud' ? args.slice(0, 2).join(' ') : 'command'} failed. Check authentication, connectivity, and resource availability.`); }
|
|
97
|
+
}
|
|
98
|
+
async function authentication(record) {
|
|
99
|
+
if (record && !record.authentication?.fingerprint) throw new Error('This older cloud record has no credential binding. Preserve its local keys and recover its resources in Hetzner.');
|
|
100
|
+
const quiet = { ...process.env, HCLOUD_DEBUG: 'false', HCLOUD_DEBUG_FILE: '' };
|
|
101
|
+
const get = field => command('hcloud', ['config', 'get', field, '--allow-sensitive'], quiet);
|
|
102
|
+
const context = (await get('context')).trim();
|
|
103
|
+
const fixed = { ...quiet, HCLOUD_CONTEXT: context };
|
|
104
|
+
const endpoint = (await command('hcloud', ['config', 'get', 'endpoint'], fixed)).trim();
|
|
105
|
+
const token = (await command('hcloud', ['config', 'get', 'token', '--allow-sensitive'], fixed)).trim();
|
|
106
|
+
if (!token || !endpoint) throw new Error('Configure an authenticated hcloud context before using cloud instances.');
|
|
107
|
+
const binding = { context, endpoint, fingerprint: digest(JSON.stringify([context, endpoint, token])) };
|
|
108
|
+
if (record && record.authentication?.fingerprint !== binding.fingerprint) throw new Error('The hcloud context, endpoint, or credential differs from this cloud record. Restore the original settings. Local keys remain intact.');
|
|
109
|
+
return { binding, env: { ...fixed, HCLOUD_ENDPOINT: endpoint, HCLOUD_TOKEN: token } };
|
|
110
|
+
}
|
|
111
|
+
async function hcloud(record, kind, action, args = [], json = false) {
|
|
112
|
+
const { env } = await authentication(record);
|
|
113
|
+
const output = await command('hcloud', [kind, action, ...args, ...(json ? ['--output', 'json'] : [])], env);
|
|
114
|
+
if (!json) return output;
|
|
115
|
+
try { return JSON.parse(output); } catch { throw new Error(`hcloud ${kind} ${action} returned invalid JSON.`); }
|
|
116
|
+
}
|
|
117
|
+
function verify(record, kind, resource) {
|
|
118
|
+
if (!Number.isSafeInteger(resource.id) || resource.id <= 0 || resource.name !== `${record.resourceName}-${kind}` ||
|
|
119
|
+
Object.entries(labels(record)).some(([key, value]) => resource.labels?.[key] !== value)) {
|
|
120
|
+
throw new Error(`Cloud ownership check failed for ${kind}. No resource was changed.`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function reconcile(record, kind) {
|
|
124
|
+
// Listing also distinguishes absence from API errors and changed ownership labels.
|
|
125
|
+
const resources = await hcloud(record, kind, 'list', [], true);
|
|
126
|
+
if (!Array.isArray(resources)) throw new Error(`hcloud ${kind} list returned invalid resources.`);
|
|
127
|
+
const field = fields[kind];
|
|
128
|
+
const owned = resources.filter(resource => resource.labels?.['agent-env.record'] === record.recordId);
|
|
129
|
+
if (owned.length > 1) throw new Error(`Cloud has multiple ${kind} resources for this record. Refuse automatic changes.`);
|
|
130
|
+
const resource = record[field] ? resources.find(item => item.id === record[field]) : owned[0];
|
|
131
|
+
if (record[field] && owned.some(item => item.id !== record[field])) throw new Error(`Cloud ${kind} ID conflicts with its ownership record.`);
|
|
132
|
+
if (resource) {
|
|
133
|
+
verify(record, kind, resource);
|
|
134
|
+
if (record[field] && record[field] !== resource.id) throw new Error(`Cloud ${kind} ID does not match its ownership record.`);
|
|
135
|
+
if (!record[field]) { record[field] = resource.id; await save(record); }
|
|
136
|
+
}
|
|
137
|
+
return resource ?? null;
|
|
138
|
+
}
|
|
139
|
+
async function loadHandle(handle) {
|
|
140
|
+
const identity = extract(handle);
|
|
141
|
+
const record = await read(identity);
|
|
142
|
+
if (!record || identity.recordId !== record.recordId) throw new Error('No matching cloud ownership record exists.');
|
|
143
|
+
for (const field of ['serverId', 'keyId']) {
|
|
144
|
+
if (identity[field] != null && record[field] != null && identity[field] !== record[field]) throw new Error(`Cloud handle ${field} does not match its ownership record.`);
|
|
145
|
+
}
|
|
146
|
+
if (identity.primaryIpIds?.length && record.primaryIpId && !identity.primaryIpIds.includes(record.primaryIpId)) throw new Error('Cloud handle IP does not match its ownership record.');
|
|
147
|
+
await authentication(record);
|
|
148
|
+
return record;
|
|
149
|
+
}
|
|
150
|
+
async function createResource(record, kind, args) {
|
|
151
|
+
const existing = await reconcile(record, kind);
|
|
152
|
+
if (existing) return existing;
|
|
153
|
+
if (record.attempted[kind]) throw new Error(`Cloud ${kind} creation has an uncertain result or a missing resource. Run agent-env rm ${record.name} --purge before replacement.`);
|
|
154
|
+
// Store intent before the external side effect, including requests with lost responses.
|
|
155
|
+
record.attempted[kind] = true;
|
|
156
|
+
await save(record);
|
|
157
|
+
const result = await hcloud(record, kind, 'create', ['--name', `${record.resourceName}-${kind}`, ...labelArgs(record), ...args], true);
|
|
158
|
+
const resource = result[kind.replaceAll('-', '_')] ?? result;
|
|
159
|
+
verify(record, kind, resource);
|
|
160
|
+
record[fields[kind]] = resource.id;
|
|
161
|
+
await save(record);
|
|
162
|
+
return resource;
|
|
163
|
+
}
|
|
164
|
+
async function keyPair(path) {
|
|
165
|
+
const exists = async name => readFile(name).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; });
|
|
166
|
+
const privateExists = await exists(path);
|
|
167
|
+
const publicExists = await exists(`${path}.pub`);
|
|
168
|
+
if (privateExists !== publicExists) throw new Error('Cloud key pair is incomplete. Dispose the failed start before retry.');
|
|
169
|
+
if (!privateExists) {
|
|
170
|
+
await command('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'agent-env', '-f', path]);
|
|
171
|
+
}
|
|
172
|
+
await chmod(path, 0o600);
|
|
173
|
+
return { privateKey: await readFile(path, 'utf8'), publicKey: await readFile(`${path}.pub`, 'utf8') };
|
|
174
|
+
}
|
|
175
|
+
async function connection(record, server) {
|
|
176
|
+
const address = server.public_net?.ipv4?.ip;
|
|
177
|
+
if (isIP(address ?? '') !== 4) throw new Error('Cloud server has no public IPv4 address.');
|
|
178
|
+
const dir = directory(record);
|
|
179
|
+
const alias = `agent-env-${record.recordId}`;
|
|
180
|
+
const publicKey = (await readFile(join(dir, 'host-key.pub'), 'utf8')).trim().split(/\s+/).slice(0, 2).join(' ');
|
|
181
|
+
await privateWrite(join(dir, 'known_hosts'), `${alias} ${publicKey}\n`);
|
|
182
|
+
const config = join(dir, 'ssh_config');
|
|
183
|
+
await privateWrite(config, `Host ${alias}\n HostName ${address}\n User root\n Port 22\n IdentityFile ${sshQuote(join(dir, 'identity'))}\n IdentitiesOnly yes\n IdentityAgent none\n PasswordAuthentication no\n KbdInteractiveAuthentication no\n BatchMode yes\n ConnectTimeout 10\n StrictHostKeyChecking yes\n HostKeyAlias ${alias}\n HostKeyAlgorithms ssh-ed25519\n UserKnownHostsFile ${sshQuote(join(dir, 'known_hosts'))}\n GlobalKnownHostsFile /dev/null\n UpdateHostKeys no\n`);
|
|
184
|
+
const deadline = now() + readinessTimeoutMs;
|
|
185
|
+
do {
|
|
186
|
+
try {
|
|
187
|
+
await command('ssh', ['-F', config, '-o', 'ControlMaster=no', '-S', 'none', '--', alias,
|
|
188
|
+
'cloud-init status --wait >/dev/null 2>&1 && docker info >/dev/null 2>&1 && docker compose version >/dev/null 2>&1 && command -v rsync >/dev/null']);
|
|
189
|
+
return { host: alias, sshConfig: config };
|
|
190
|
+
} catch {
|
|
191
|
+
if (now() >= deadline) throw new Error('Cloud SSH readiness failed. Check the pinned host identity and cloud-init installation.');
|
|
192
|
+
await delay(1000);
|
|
193
|
+
}
|
|
194
|
+
} while (true);
|
|
195
|
+
}
|
|
196
|
+
async function prepareCloud(spec, existingHandle) {
|
|
197
|
+
recordKey(spec);
|
|
198
|
+
const supplied = extract(existingHandle ?? (spec.cloud?.recordId ? spec : null));
|
|
199
|
+
return locked(spec, async () => {
|
|
200
|
+
let record = await read(spec);
|
|
201
|
+
if (supplied) {
|
|
202
|
+
const matched = await loadHandle(supplied);
|
|
203
|
+
if (matched.owner !== spec.owner || matched.name !== spec.name) throw new Error('Cloud handle belongs to a different instance.');
|
|
204
|
+
record = matched;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const explicit = typeof spec.cloud === 'object' ? spec.cloud : {};
|
|
208
|
+
const options = cloudOptions({ ...record?.options, ...(typeof spec.cloud === 'string' ? { provider: spec.cloud } : explicit) });
|
|
209
|
+
if (record && ['type', 'image', 'location'].some(key => options[key] !== record.options[key])) throw new Error('Cloud machine options differ from the ownership record. Purge before replacement.');
|
|
210
|
+
if (options.expiresAt && Date.parse(options.expiresAt) <= now()) throw new Error('Cloud expiry has elapsed. Destroy or extend this instance before start.');
|
|
211
|
+
const { binding } = await authentication(record);
|
|
212
|
+
if (!record) {
|
|
213
|
+
const recordId = randomUUID().replaceAll('-', '');
|
|
214
|
+
record = { version: 1, owner: spec.owner, name: spec.name, recordId, resourceName: `dn-${recordId}`, createdAt: new Date(now()).toISOString(), options, authentication: binding, attempted: {}, committed: false };
|
|
215
|
+
await mkdir(directory(record), { recursive: true, mode: 0o700 });
|
|
216
|
+
await chmod(directory(record), 0o700);
|
|
217
|
+
await save(record);
|
|
218
|
+
}
|
|
219
|
+
if (record.destroying) throw new Error('Cloud cleanup is incomplete. Retry destroyCloud before start.');
|
|
220
|
+
record.options = options;
|
|
221
|
+
await save(record);
|
|
222
|
+
const dir = directory(record);
|
|
223
|
+
const identity = await keyPair(join(dir, 'identity'));
|
|
224
|
+
const host = await keyPair(join(dir, 'host-key'));
|
|
225
|
+
await privateWrite(join(dir, 'cloud-init.yaml'), cloudInit(host.privateKey, host.publicKey));
|
|
226
|
+
const key = await createResource(record, 'ssh-key', ['--public-key-from-file', join(dir, 'identity.pub')]);
|
|
227
|
+
if (key.public_key?.trim() !== identity.publicKey.trim()) throw new Error('Cloud SSH key does not match the dedicated local key.');
|
|
228
|
+
const ip = await createResource(record, 'primary-ip', ['--type', 'ipv4', '--location', options.location, '--auto-delete=false']);
|
|
229
|
+
let server = await createResource(record, 'server', ['--type', options.type, '--location', options.location, '--image', options.image,
|
|
230
|
+
'--ssh-key', String(key.id), '--primary-ipv4', String(ip.id), '--without-ipv6', '--user-data-from-file', join(dir, 'cloud-init.yaml')]);
|
|
231
|
+
if (server.public_net?.ipv4?.id !== ip.id) throw new Error('Cloud server IP does not match the ownership record.');
|
|
232
|
+
for (let attempt = 0; server?.status === 'off' && attempt < 3; attempt++) {
|
|
233
|
+
try { await hcloud(record, 'server', 'poweron', [String(server.id)]); }
|
|
234
|
+
catch (error) {
|
|
235
|
+
server = await reconcile(record, 'server');
|
|
236
|
+
if (!server || !['off', 'running', 'starting'].includes(server.status) || (server.status === 'off' && attempt === 2)) throw error;
|
|
237
|
+
if (server.status === 'off') await delay(1000 * (attempt + 1));
|
|
238
|
+
}
|
|
239
|
+
server = await reconcile(record, 'server');
|
|
240
|
+
}
|
|
241
|
+
if (!server || !['running', 'starting'].includes(server.status)) throw new Error('Cloud server cannot start from its current state.');
|
|
242
|
+
const ssh = await connection(record, server);
|
|
243
|
+
// Remove the cloud-init copy that contains the private host key after readiness.
|
|
244
|
+
await rm(join(dir, 'cloud-init.yaml'), { force: true });
|
|
245
|
+
return { ...spec, ...ssh, cloud: metadata(record) };
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (record) error.cloud = metadata(record);
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
async function cloudStatus(handle) {
|
|
253
|
+
return locked(extract(handle), async () => {
|
|
254
|
+
const record = await loadHandle(handle);
|
|
255
|
+
const server = await reconcile(record, 'server');
|
|
256
|
+
return { ...metadata(record), status: server?.status ?? 'missing', committed: record.committed, destroying: !!record.destroying };
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
async function sleepCloud(handle) {
|
|
260
|
+
return locked(extract(handle), async () => {
|
|
261
|
+
const record = await loadHandle(handle);
|
|
262
|
+
const server = await reconcile(record, 'server');
|
|
263
|
+
if (!server) throw new Error('Cloud server is missing. No resource was changed.');
|
|
264
|
+
if (server.status !== 'off') await hcloud(record, 'server', 'shutdown', ['--wait', '--wait-timeout', '120s', String(server.id)]);
|
|
265
|
+
return { ...metadata(record), status: 'off' };
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
async function destroyLocked(record) {
|
|
269
|
+
// Check every stored resource before the first destructive operation.
|
|
270
|
+
const resources = {};
|
|
271
|
+
for (const kind of methods) resources[kind] = await reconcile(record, kind);
|
|
272
|
+
const server = resources.server;
|
|
273
|
+
const ip = resources['primary-ip'];
|
|
274
|
+
if (server && server.public_net?.ipv4?.id !== record.primaryIpId) throw new Error('Cloud server IP differs from the ownership record. Refuse deletion.');
|
|
275
|
+
if (server?.public_net?.ipv6?.id) throw new Error('Cloud server has an untracked IPv6 address. Refuse deletion.');
|
|
276
|
+
if (ip?.assignee_id && ip.assignee_id !== record.serverId) throw new Error('Cloud IP belongs to a different server. Refuse deletion.');
|
|
277
|
+
record.destroying = true;
|
|
278
|
+
await save(record);
|
|
279
|
+
for (const kind of methods) {
|
|
280
|
+
if (resources[kind]) {
|
|
281
|
+
// Recheck ownership immediately before each destructive operation.
|
|
282
|
+
const resource = await reconcile(record, kind);
|
|
283
|
+
if (resource) await hcloud(record, kind, 'delete', [String(resource.id)]);
|
|
284
|
+
}
|
|
285
|
+
delete record[fields[kind]];
|
|
286
|
+
await save(record);
|
|
287
|
+
}
|
|
288
|
+
await rm(directory(record), { recursive: true, force: true });
|
|
289
|
+
return { destroyed: true };
|
|
290
|
+
}
|
|
291
|
+
async function destroyCloud(handle) {
|
|
292
|
+
return locked(extract(handle), async () => destroyLocked(await loadHandle(handle)));
|
|
293
|
+
}
|
|
294
|
+
async function commitCloud(handle) {
|
|
295
|
+
return locked(extract(handle), async () => {
|
|
296
|
+
const record = await loadHandle(handle);
|
|
297
|
+
if (record.destroying || !await reconcile(record, 'server')) throw new Error('Cannot commit missing cloud infrastructure.');
|
|
298
|
+
record.committed = true;
|
|
299
|
+
await save(record);
|
|
300
|
+
return metadata(record);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
async function disposeFailedCloud(handle) {
|
|
304
|
+
return locked(extract(handle), async () => {
|
|
305
|
+
const record = await loadHandle(handle);
|
|
306
|
+
if (record.committed) return { destroyed: false, reason: 'committed' };
|
|
307
|
+
return destroyLocked(record);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return { prepareCloud, sleepCloud, destroyCloud, cloudStatus, commitCloud, disposeFailedCloud };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const manager = createCloudManager();
|
|
314
|
+
export const { prepareCloud, sleepCloud, destroyCloud, cloudStatus, commitCloud, disposeFailedCloud } = manager;
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { basename, resolve } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { discoverEnvironmentFiles, loadEnvironment, resolveEnvironment, stringList } from './environments.mjs';
|
|
5
|
+
import { cloudOptions } from './cloud.mjs';
|
|
6
|
+
|
|
7
|
+
export const hash = value => createHash('sha256').update(value).digest('hex').slice(0, 8);
|
|
8
|
+
export function validName(value) {
|
|
9
|
+
if (typeof value !== 'string' || !/^[a-z][a-z0-9-]{0,47}$/.test(value)) throw new Error('Use a name with 1 to 48 lowercase letters, digits, or hyphens. Start with a letter.');
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
export function validate(spec) {
|
|
13
|
+
validName(spec.name);
|
|
14
|
+
if (spec.startupTimeoutSeconds !== undefined && (!Number.isInteger(spec.startupTimeoutSeconds) || spec.startupTimeoutSeconds < 1 || spec.startupTimeoutSeconds > 3600)) {
|
|
15
|
+
throw new Error('Set the startup timeout to an integer from 1 to 3600 seconds.');
|
|
16
|
+
}
|
|
17
|
+
if (typeof spec.provider !== 'string' || !spec.provider) throw new Error('Specify a provider name.');
|
|
18
|
+
if (spec.host && !/^[a-zA-Z0-9_][a-zA-Z0-9_.@-]*$/.test(spec.host)) throw new Error('Use an SSH alias or user@host. Configure ports and keys in SSH config.');
|
|
19
|
+
if (spec.provider === 'compose') {
|
|
20
|
+
if (!spec.compose || !stringList(spec.compose.files, 'compose-file').length) throw new Error('Specify at least one Compose file.');
|
|
21
|
+
if (!spec.services || !Object.keys(spec.services).length) throw new Error('Enable at least one Compose service.');
|
|
22
|
+
const ports = new Set();
|
|
23
|
+
for (const [name, service] of Object.entries(spec.services)) {
|
|
24
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name) || !service.composeService) throw new Error('Compose services require valid names and source service metadata.');
|
|
25
|
+
if (service.internal) continue;
|
|
26
|
+
validName(name);
|
|
27
|
+
if (!Number.isInteger(service.port) || service.port < 1 || service.port > 65535) throw new Error('Compose public ports must be integers between 1 and 65535.');
|
|
28
|
+
if (!Number.isInteger(service.containerPort) || service.containerPort < 1 || service.containerPort > 65535) throw new Error('Container ports must be integers between 1 and 65535.');
|
|
29
|
+
if (!['http', 'tcp'].includes(service.protocol)) throw new Error('The protocol must be http or tcp.');
|
|
30
|
+
if (ports.has(service.port)) throw new Error(`Port ${service.port} appears more than once in this stack.`);
|
|
31
|
+
ports.add(service.port);
|
|
32
|
+
}
|
|
33
|
+
return spec;
|
|
34
|
+
}
|
|
35
|
+
if (!spec.services || !Object.keys(spec.services).length) throw new Error('Define at least one service.');
|
|
36
|
+
const ports = new Set();
|
|
37
|
+
for (const [name, service] of Object.entries(spec.services)) {
|
|
38
|
+
validName(name);
|
|
39
|
+
if (!service.image || typeof service.image !== 'string') throw new Error(`Set an image for ${name}.`);
|
|
40
|
+
service.containerPort ??= service.port;
|
|
41
|
+
service.protocol ??= 'http';
|
|
42
|
+
if (!Number.isInteger(service.port) || service.port < 1024 || service.port > 65535) throw new Error('Public ports must be integers between 1024 and 65535.');
|
|
43
|
+
if (!Number.isInteger(service.containerPort) || service.containerPort < 1 || service.containerPort > 65535) throw new Error('Container ports must be integers between 1 and 65535.');
|
|
44
|
+
if (ports.has(service.port)) throw new Error(`Port ${service.port} appears more than once in this stack.`);
|
|
45
|
+
ports.add(service.port);
|
|
46
|
+
if (!['http', 'tcp'].includes(service.protocol)) throw new Error('The protocol must be http or tcp.');
|
|
47
|
+
if (service.health !== undefined && typeof service.health !== 'string') throw new Error('The health command must be a string.');
|
|
48
|
+
if (service.command !== undefined && typeof service.command !== 'string') throw new Error('The service command must be a string.');
|
|
49
|
+
for (const [key, value] of Object.entries(service.env ?? {})) {
|
|
50
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value !== 'string') throw new Error('Environment variables must have valid names and string values.');
|
|
51
|
+
}
|
|
52
|
+
for (const [volume, target] of Object.entries(service.volumes ?? {})) {
|
|
53
|
+
validName(volume);
|
|
54
|
+
if (typeof target !== 'string' || !target.startsWith('/') || /[:,\n]/.test(target)) throw new Error('Volume destinations must be absolute paths without commas or colons.');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return spec;
|
|
58
|
+
}
|
|
59
|
+
// env accepts one name. profile, compose-file, and env-file accept strings or arrays.
|
|
60
|
+
export async function loadSpec(options = {}, command = []) {
|
|
61
|
+
const cwd = await realpath(options.cwd ?? process.cwd());
|
|
62
|
+
let config = {};
|
|
63
|
+
try { config = JSON.parse(await readFile(resolve(cwd, options.config ?? 'agent-env.json'), 'utf8')); }
|
|
64
|
+
catch (error) { if (error.code !== 'ENOENT' || options.config) throw error; }
|
|
65
|
+
if (options.provider) config.provider = options.provider;
|
|
66
|
+
const names = stringList(options.env, 'env');
|
|
67
|
+
if (names.length > 1) throw new Error('Select one named environment per instance.');
|
|
68
|
+
const environment = names[0] ?? 'default';
|
|
69
|
+
if (!/^[a-z][a-z0-9-]{0,47}$/.test(environment)) throw new Error('Use a valid environment name.');
|
|
70
|
+
if (options['no-env-file'] && options['env-file']) throw new Error('Select --env-file or --no-env-file, not both.');
|
|
71
|
+
const envConfig = config.environments?.[environment] ?? {};
|
|
72
|
+
const configuredFiles = options['env-file'] ?? envConfig.envFiles ?? envConfig.envFile ?? config.envFiles ?? config.envFile;
|
|
73
|
+
const selectedFiles = options['no-env-file'] ? [] : stringList(configuredFiles ?? await discoverEnvironmentFiles(cwd, environment), 'env-file').map(file => resolve(cwd, file));
|
|
74
|
+
if (environment !== 'default' && !Object.hasOwn(config.environments ?? {}, environment)
|
|
75
|
+
&& selectedFiles.some(file => file === resolve(cwd, `.env.${environment}`) || file === resolve(cwd, `.env.${environment}.local`))) {
|
|
76
|
+
config.environments = { ...config.environments, [environment]: {} };
|
|
77
|
+
}
|
|
78
|
+
const environmentName = config.environments?.[environment]?.name;
|
|
79
|
+
config = resolveEnvironment(config, environment);
|
|
80
|
+
const base = basename(cwd).toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, environment === 'default' ? 30 : 24);
|
|
81
|
+
const suffix = environment === 'default' ? '' : `-${hash(environment)}`;
|
|
82
|
+
const configuredName = environmentName ?? (config.name ? environment === 'default' ? config.name : `${config.name.slice(0, 39)}${suffix}` : undefined);
|
|
83
|
+
const spec = { ...config, cwd, environment, name: options.name ?? configuredName ?? `dev-${base}-${hash(cwd)}${suffix}`, provider: config.provider ?? 'docker' };
|
|
84
|
+
if (options.timeout !== undefined) spec.startupTimeoutSeconds = Number(options.timeout);
|
|
85
|
+
if ([options.ssh, options.local, options.cloud].filter(Boolean).length > 1) throw new Error('Select only one target: --ssh, --local, or --cloud.');
|
|
86
|
+
if (options.ssh) { spec.host = options.ssh; delete spec.cloud; }
|
|
87
|
+
if (options.local) { delete spec.host; delete spec.cloud; delete spec.sshConfig; }
|
|
88
|
+
if (options.cloud) spec.cloud = { ...(typeof spec.cloud === 'object' ? spec.cloud : {}), provider: options.cloud };
|
|
89
|
+
const cloudFlags = ['cloud-type', 'location', 'idle-minutes', 'ttl-hours'];
|
|
90
|
+
if (!spec.cloud && cloudFlags.some(key => options[key] !== undefined)) throw new Error('Cloud options require --cloud hetzner or an explicit cloud provider in configuration.');
|
|
91
|
+
if (spec.cloud) {
|
|
92
|
+
const cloud = typeof spec.cloud === 'string' ? { provider: spec.cloud } : { ...spec.cloud };
|
|
93
|
+
if (options['cloud-type'] !== undefined) cloud.type = options['cloud-type'];
|
|
94
|
+
if (options.location !== undefined) cloud.location = options.location;
|
|
95
|
+
if (options['idle-minutes'] !== undefined) cloud.idleTimeoutMs = Number(options['idle-minutes']) * 60000;
|
|
96
|
+
if (options['ttl-hours'] !== undefined) {
|
|
97
|
+
const expires = Date.now() + Number(options['ttl-hours']) * 3600000;
|
|
98
|
+
if (!Number.isFinite(expires) || expires <= Date.now() || expires > 8640000000000000) throw new Error('TTL hours must be a positive number within the supported date range.');
|
|
99
|
+
cloud.expiresAt = new Date(expires).toISOString();
|
|
100
|
+
}
|
|
101
|
+
spec.cloud = cloudOptions(cloud);
|
|
102
|
+
}
|
|
103
|
+
if (options['ssh-config']) spec.sshConfig = resolve(cwd, options['ssh-config']);
|
|
104
|
+
spec.envFiles = selectedFiles;
|
|
105
|
+
// These values remain in memory and private daemon state. Initialization never writes them.
|
|
106
|
+
spec.sensitiveValues = [];
|
|
107
|
+
const { detectComposeFiles, resolveComposeSpec } = await import('./providers/compose.mjs');
|
|
108
|
+
const composeFiles = stringList(options['compose-file'] ?? config.compose?.files ?? config.composeFiles, 'compose-file');
|
|
109
|
+
const detected = !config.provider && !config.services && !command.length ? await detectComposeFiles(cwd) : [];
|
|
110
|
+
if (!command.length && (spec.provider === 'compose' || composeFiles.length || detected.length)) {
|
|
111
|
+
if (config.services) throw new Error('Use Compose override files for service configuration. Use env and compose.routes in agent-env.json.');
|
|
112
|
+
spec.provider = 'compose';
|
|
113
|
+
// Compose resolves dotenv interpolation. Only explicit values override its environment.
|
|
114
|
+
spec.env = await loadEnvironment({ cwd, environment, values: config.env, sensitiveValues: spec.sensitiveValues });
|
|
115
|
+
spec.compose = {
|
|
116
|
+
...config.compose,
|
|
117
|
+
files: (composeFiles.length ? composeFiles : detected.length ? detected : await detectComposeFiles(cwd)).map(file => resolve(cwd, file)),
|
|
118
|
+
profiles: stringList(options.profile ?? config.compose?.profiles ?? config.profiles, 'profile'),
|
|
119
|
+
};
|
|
120
|
+
return validate(await resolveComposeSpec(spec));
|
|
121
|
+
}
|
|
122
|
+
if (options.profile !== undefined || composeFiles.length) throw new Error('Compose profiles and files require the Compose provider without a command override.');
|
|
123
|
+
spec.env = await loadEnvironment({ cwd, environment, files: spec.envFiles, values: config.env, sensitiveValues: spec.sensitiveValues });
|
|
124
|
+
if (command.length) {
|
|
125
|
+
spec.provider = 'docker';
|
|
126
|
+
spec.services = { web: { image: options.image ?? 'node:22-bookworm-slim', port: Number(options.port ?? 3000), command: command.map(value => `'${value.replaceAll("'", "'\\''")}'`).join(' '), volumes: { dependencies: '/workspace/node_modules', cache: '/root/.npm' } } };
|
|
127
|
+
}
|
|
128
|
+
for (const service of Object.values(spec.services ?? {})) {
|
|
129
|
+
service.env = { ...spec.env, ...await loadEnvironment({ cwd, environment, values: service.env, includeStored: false, sensitiveValues: spec.sensitiveValues }) };
|
|
130
|
+
}
|
|
131
|
+
spec.sensitiveValues = [...new Set(spec.sensitiveValues)].sort();
|
|
132
|
+
return validate(spec);
|
|
133
|
+
}
|