@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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { chmod, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const keyPattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7
|
+
function checkKey(key) {
|
|
8
|
+
if (typeof key !== 'string' || !keyPattern.test(key)) throw new Error('Use a valid environment variable name.');
|
|
9
|
+
}
|
|
10
|
+
export function stringList(value, label) {
|
|
11
|
+
if (value === undefined) return [];
|
|
12
|
+
const values = Array.isArray(value) ? value : [value];
|
|
13
|
+
if (values.some(item => typeof item !== 'string' || !item)) throw new Error(`${label} requires a string or an array of strings.`);
|
|
14
|
+
return values;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function discoverEnvironmentFiles(cwd, environment = 'default') {
|
|
18
|
+
const candidates = ['.env', '.env.local'];
|
|
19
|
+
if (environment !== 'default') candidates.push(`.env.${environment}`, `.env.${environment}.local`);
|
|
20
|
+
const files = [];
|
|
21
|
+
for (const candidate of candidates) {
|
|
22
|
+
const filename = resolve(cwd, candidate);
|
|
23
|
+
try { if ((await stat(filename)).isFile()) files.push(filename); }
|
|
24
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
25
|
+
}
|
|
26
|
+
return files;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Values are literal. This parser never executes commands or expands variables.
|
|
30
|
+
export function parseEnvFile(source) {
|
|
31
|
+
const result = Object.create(null);
|
|
32
|
+
const lines = source.replace(/^\uFEFF/, '').replaceAll('\r\n', '\n').split('\n');
|
|
33
|
+
for (let index = 0; index < lines.length; index++) {
|
|
34
|
+
const line = lines[index].trim();
|
|
35
|
+
if (!line || line.startsWith('#')) continue;
|
|
36
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
37
|
+
if (!match) throw new Error(`Invalid environment assignment on line ${index + 1}.`);
|
|
38
|
+
let value = match[2];
|
|
39
|
+
if (value.startsWith('"') || value.startsWith("'")) {
|
|
40
|
+
const quote = value[0];
|
|
41
|
+
let end = -1;
|
|
42
|
+
for (let cursor = 1; ; cursor++) {
|
|
43
|
+
if (cursor >= value.length) {
|
|
44
|
+
if (++index >= lines.length) throw new Error('An environment value has no closing quote.');
|
|
45
|
+
value += `\n${lines[index]}`;
|
|
46
|
+
}
|
|
47
|
+
if (value[cursor] === '\\' && quote === '"') { cursor++; continue; }
|
|
48
|
+
if (value[cursor] === quote) { end = cursor; break; }
|
|
49
|
+
}
|
|
50
|
+
if (!/^\s*(?:#.*)?$/.test(value.slice(end + 1))) throw new Error(`Invalid text after an environment value on line ${index + 1}.`);
|
|
51
|
+
value = value.slice(1, end);
|
|
52
|
+
if (quote === '"') value = value.replace(/\\([nrt"\\])/g, (_, character) => ({ n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' })[character]);
|
|
53
|
+
} else value = value.startsWith('#') ? '' : value.replace(/\s+#.*$/, '').trimEnd();
|
|
54
|
+
if (value.includes('\0')) throw new Error('Environment values cannot contain null bytes.');
|
|
55
|
+
result[match[1]] = value;
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveEnvironment(config, name = 'default') {
|
|
61
|
+
if (typeof name !== 'string' || !/^[a-z][a-z0-9-]{0,47}$/.test(name)) throw new Error('Use a valid environment name.');
|
|
62
|
+
const environments = config.environments ?? {};
|
|
63
|
+
if (name !== 'default' && !Object.hasOwn(environments, name)) throw new Error(`Environment ${name} is not defined in agent-env.json.`);
|
|
64
|
+
const { environments: ignored, ...base } = config;
|
|
65
|
+
const overlay = Object.hasOwn(environments, name) ? environments[name] : {};
|
|
66
|
+
if (!overlay || typeof overlay !== 'object' || Array.isArray(overlay)) throw new Error('Environment definitions must be objects.');
|
|
67
|
+
const result = { ...base, ...overlay, env: { ...base.env, ...overlay.env } };
|
|
68
|
+
if (base.compose || overlay.compose) result.compose = { ...base.compose, ...overlay.compose };
|
|
69
|
+
if (base.services || overlay.services) {
|
|
70
|
+
result.services = { ...base.services };
|
|
71
|
+
for (const [service, value] of Object.entries(overlay.services ?? {})) {
|
|
72
|
+
result.services[service] = { ...base.services?.[service], ...value, env: { ...base.services?.[service]?.env, ...value.env } };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function storePath({ cwd = process.cwd(), environment = 'default', home } = {}) {
|
|
79
|
+
if (!/^[a-z][a-z0-9-]{0,47}$/.test(environment)) throw new Error('Use a valid environment name.');
|
|
80
|
+
const project = await realpath(cwd);
|
|
81
|
+
const digest = createHash('sha256').update(project).digest('hex');
|
|
82
|
+
const root = resolve(home ?? process.env.AGENT_ENV_HOME ?? join(homedir(), '.local/state/agent-env'));
|
|
83
|
+
return join(root, 'secrets', digest, `${environment}.json`);
|
|
84
|
+
}
|
|
85
|
+
export async function readEnvironmentSecrets(options = {}) {
|
|
86
|
+
try {
|
|
87
|
+
const filename = await storePath(options);
|
|
88
|
+
const file = await open(filename, 'r');
|
|
89
|
+
try {
|
|
90
|
+
await file.chmod(0o600);
|
|
91
|
+
const values = JSON.parse(await file.readFile('utf8'));
|
|
92
|
+
if (!values || typeof values !== 'object' || Array.isArray(values)) throw new Error();
|
|
93
|
+
for (const [key, value] of Object.entries(values)) {
|
|
94
|
+
checkKey(key);
|
|
95
|
+
if (typeof value !== 'string' || value.includes('\0')) throw new Error();
|
|
96
|
+
}
|
|
97
|
+
return values;
|
|
98
|
+
} finally { await file.close(); }
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error.code === 'ENOENT') return {};
|
|
101
|
+
throw new Error('Cannot read the private environment store. Check its permissions and JSON structure.');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function writeSecrets(options, values) {
|
|
105
|
+
const filename = await storePath(options);
|
|
106
|
+
const directory = resolve(filename, '..');
|
|
107
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
108
|
+
await chmod(directory, 0o700);
|
|
109
|
+
const temporary = `${filename}.${randomUUID()}.tmp`;
|
|
110
|
+
try {
|
|
111
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
112
|
+
try { await file.writeFile(`${JSON.stringify(values)}\n`); } finally { await file.close(); }
|
|
113
|
+
await rename(temporary, filename);
|
|
114
|
+
} finally { await rm(temporary, { force: true }); }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Pass a readable stdin stream or a variable name. Literal value arguments are rejected.
|
|
118
|
+
export async function setEnvironmentSecret(options) {
|
|
119
|
+
const { key, stdin, fromEnv, env = process.env } = options;
|
|
120
|
+
checkKey(key);
|
|
121
|
+
if (Object.hasOwn(options, 'value')) throw new Error('Read secrets from stdin or an environment reference.');
|
|
122
|
+
if (Boolean(stdin) === Boolean(fromEnv)) throw new Error('Select exactly one secret source: stdin or an environment reference.');
|
|
123
|
+
let value;
|
|
124
|
+
if (fromEnv) {
|
|
125
|
+
checkKey(fromEnv);
|
|
126
|
+
if (!Object.hasOwn(env, fromEnv)) throw new Error('The source environment variable is not defined.');
|
|
127
|
+
value = env[fromEnv];
|
|
128
|
+
} else {
|
|
129
|
+
if (typeof stdin === 'string' || !stdin[Symbol.asyncIterator]) throw new Error('Provide a readable stdin stream.');
|
|
130
|
+
const chunks = [];
|
|
131
|
+
let size = 0;
|
|
132
|
+
for await (const chunk of stdin) {
|
|
133
|
+
const buffer = Buffer.from(chunk);
|
|
134
|
+
size += buffer.length;
|
|
135
|
+
if (size > 1024 * 1024) throw new Error('The secret exceeds the one megabyte limit.');
|
|
136
|
+
chunks.push(buffer);
|
|
137
|
+
}
|
|
138
|
+
value = Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '');
|
|
139
|
+
}
|
|
140
|
+
if (typeof value !== 'string' || value.includes('\0')) throw new Error('Secrets must be strings without null bytes.');
|
|
141
|
+
const values = await readEnvironmentSecrets(options);
|
|
142
|
+
await writeSecrets(options, { ...values, [key]: value });
|
|
143
|
+
return { key };
|
|
144
|
+
}
|
|
145
|
+
export async function listEnvironmentSecrets(options = {}) {
|
|
146
|
+
return Object.keys(await readEnvironmentSecrets(options)).sort();
|
|
147
|
+
}
|
|
148
|
+
export async function unsetEnvironmentSecret(options) {
|
|
149
|
+
checkKey(options.key);
|
|
150
|
+
const values = await readEnvironmentSecrets(options);
|
|
151
|
+
const existed = Object.hasOwn(values, options.key);
|
|
152
|
+
delete values[options.key];
|
|
153
|
+
await writeSecrets(options, values);
|
|
154
|
+
return existed;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function loadEnvironment({ cwd, environment = 'default', files = [], values = {}, home, env = process.env, includeStored = true, sensitiveValues = [] }) {
|
|
158
|
+
const result = Object.create(null);
|
|
159
|
+
for (const filename of stringList(files, 'env-file')) {
|
|
160
|
+
const fileValues = parseEnvFile(await readFile(resolve(cwd, filename), 'utf8'));
|
|
161
|
+
sensitiveValues.push(...credentialValues(fileValues));
|
|
162
|
+
Object.assign(result, fileValues);
|
|
163
|
+
}
|
|
164
|
+
const secrets = await readEnvironmentSecrets({ cwd, environment, home });
|
|
165
|
+
if (includeStored) { Object.assign(result, secrets); sensitiveValues.push(...Object.values(secrets)); }
|
|
166
|
+
for (const [key, reference] of Object.entries(values)) {
|
|
167
|
+
checkKey(key);
|
|
168
|
+
if (typeof reference === 'string') result[key] = reference;
|
|
169
|
+
else if (reference && Object.keys(reference).length === 1 && typeof reference.secret === 'string') {
|
|
170
|
+
if (!Object.hasOwn(secrets, reference.secret)) throw new Error(`Private variable ${reference.secret} is not set for environment ${environment}.`);
|
|
171
|
+
result[key] = secrets[reference.secret];
|
|
172
|
+
sensitiveValues.push(result[key]);
|
|
173
|
+
} else if (reference && Object.keys(reference).length === 1 && typeof reference.env === 'string') {
|
|
174
|
+
checkKey(reference.env);
|
|
175
|
+
if (!Object.hasOwn(env, reference.env)) throw new Error(`Environment reference ${reference.env} is not defined.`);
|
|
176
|
+
result[key] = env[reference.env];
|
|
177
|
+
sensitiveValues.push(result[key]);
|
|
178
|
+
} else throw new Error(`Use a string, {secret: name}, or {env: name} for variable ${key}.`);
|
|
179
|
+
if (typeof result[key] !== 'string' || result[key].includes('\0')) throw new Error('Environment values must be strings without null bytes.');
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function credentialValues(values = {}) {
|
|
185
|
+
return Object.entries(values).filter(([key, value]) =>
|
|
186
|
+
/PASSWORD|PASSWD|(?:^|_)(?:SECRET|TOKEN|PAT|CREDENTIALS?|KEY|SALT|APIKEY|AUTHORIZATION|COOKIE)(?:_|$)/i.test(key)
|
|
187
|
+
|| typeof value === 'string' && (/^[a-z][a-z0-9+.-]*:\/\/[^/\s]*:[^/\s]+@/i.test(value)
|
|
188
|
+
|| /^(?:gh[pousr]_|github_pat_|npm_|sk[-_]|xox[baprs]-|AKIA[A-Z0-9]{16}|glpat-|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----)/.test(value)))
|
|
189
|
+
.map(([, value]) => value);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function sensitiveValuesForSpec(spec = {}) {
|
|
193
|
+
return [...new Set([
|
|
194
|
+
...(spec.sensitiveValues ?? []), ...credentialValues(spec.env), ...credentialValues(spec.compose?.environment),
|
|
195
|
+
...Object.values(spec.services ?? {}).flatMap(service => credentialValues(service.env)),
|
|
196
|
+
...Object.values(spec.compose?.model?.services ?? {}).flatMap(service => credentialValues(service.environment).map(value => typeof value === 'string' ? value.replaceAll('$$', '$') : value)),
|
|
197
|
+
].filter(value => typeof value === 'string').flatMap(value => [value, value.trimEnd()]).filter(Boolean))];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function redactEnvironment(output, values) {
|
|
201
|
+
const strings = Array.isArray(values) ? values : Object.values(values);
|
|
202
|
+
let parts = [output];
|
|
203
|
+
for (const value of [...new Set(strings)].filter(value => typeof value === 'string' && value).sort((a, b) => b.length - a.length)) {
|
|
204
|
+
parts = parts.flatMap(part => part === null ? [null] : part.split(value).flatMap((text, index) => index ? [null, text] : [text]));
|
|
205
|
+
}
|
|
206
|
+
return parts.map(part => part === null ? '[redacted]' : part).join('');
|
|
207
|
+
}
|
package/src/goodies.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { detectComposeFiles } from './providers/compose.mjs';
|
|
5
|
+
import { loadSpec } from './config.mjs';
|
|
6
|
+
import { readEnvironmentSecrets, setEnvironmentSecret, resolveEnvironment } from './environments.mjs';
|
|
7
|
+
|
|
8
|
+
export async function addService(kind, options = {}) {
|
|
9
|
+
if (!['postgres', 'redis'].includes(kind)) throw new Error('Available services: postgres, redis.');
|
|
10
|
+
const cwd = options.cwd ?? process.cwd();
|
|
11
|
+
const filename = resolve(cwd, options.config ?? 'agent-env.json');
|
|
12
|
+
let config, existing = true;
|
|
13
|
+
try { config = JSON.parse(await readFile(filename, 'utf8')); }
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error.code !== 'ENOENT') throw error;
|
|
16
|
+
existing = false;
|
|
17
|
+
const files = await detectComposeFiles(cwd);
|
|
18
|
+
config = files.length ? { provider: 'compose', compose: { files } } : { provider: 'docker', services: {} };
|
|
19
|
+
}
|
|
20
|
+
if (config.provider && !['docker', 'compose'].includes(config.provider)) throw new Error('Service templates support Docker and Compose projects.');
|
|
21
|
+
const environment = options.environment ?? 'default';
|
|
22
|
+
const existingEnvironment = environment === 'default' || Object.hasOwn(config.environments ?? {}, environment);
|
|
23
|
+
if (environment !== 'default') { config.environments ??= {}; config.environments[environment] ??= {}; }
|
|
24
|
+
const resolved = resolveEnvironment(config, environment);
|
|
25
|
+
const target = environment === 'default' ? config : config.environments[environment];
|
|
26
|
+
const port = Number(options.port ?? (kind === 'postgres' ? 55432 : 56379));
|
|
27
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Use a public port between 1024 and 65535.');
|
|
28
|
+
const postgres = kind === 'postgres';
|
|
29
|
+
const service = {
|
|
30
|
+
image: postgres ? 'postgres:16-alpine' : 'redis:7-alpine',
|
|
31
|
+
port, containerPort: postgres ? 5432 : 6379, protocol: 'tcp',
|
|
32
|
+
health: postgres ? 'pg_isready -h 127.0.0.1 -U postgres' : 'redis-cli ping',
|
|
33
|
+
volumes: { data: postgres ? '/var/lib/postgresql/data' : '/data' },
|
|
34
|
+
...(postgres ? { env: { POSTGRES_DB: 'app', POSTGRES_PASSWORD: { secret: 'POSTGRES_PASSWORD' } } } : { command: 'redis-server --dir /data --appendonly yes' }),
|
|
35
|
+
};
|
|
36
|
+
if (config.provider === 'compose' || config.compose) {
|
|
37
|
+
const current = await loadSpec({ cwd, config: existing ? options.config : undefined, env: existingEnvironment ? environment : undefined });
|
|
38
|
+
if (current.compose.model.services[kind]) throw new Error(`Service ${kind} already exists in the Compose project.`);
|
|
39
|
+
if (Object.values(current.services).some(service => service.port === port)) throw new Error(`Port ${port} already belongs to another service.`);
|
|
40
|
+
const addonName = environment === 'default' ? 'agent-env.addons.json' : `agent-env.addons.${environment}.json`;
|
|
41
|
+
const addon = resolve(cwd, addonName);
|
|
42
|
+
let model;
|
|
43
|
+
try { model = JSON.parse(await readFile(addon, 'utf8')); }
|
|
44
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; model = { services: {}, volumes: {} }; }
|
|
45
|
+
if (model.services[kind]) throw new Error(`Service ${kind} already exists in ${addonName}.`);
|
|
46
|
+
model.services[kind] = {
|
|
47
|
+
image: service.image, ports: [`${port}:${service.containerPort}`],
|
|
48
|
+
volumes: [`${kind}-data:${service.volumes.data}`],
|
|
49
|
+
healthcheck: { test: ['CMD-SHELL', service.health], interval: '2s', timeout: '2s', retries: 30 },
|
|
50
|
+
...(postgres ? { environment: { POSTGRES_DB: 'app', POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD with agent-env env set}' } }
|
|
51
|
+
: { command: ['redis-server', '--dir', '/data', '--appendonly', 'yes'] }),
|
|
52
|
+
};
|
|
53
|
+
model.volumes[`${kind}-data`] = {};
|
|
54
|
+
target.provider = 'compose';
|
|
55
|
+
target.compose ??= {};
|
|
56
|
+
target.compose.files = [...new Set([...(resolved.compose?.files ?? await detectComposeFiles(cwd)), addonName])];
|
|
57
|
+
await writeFile(addon, `${JSON.stringify(model, null, 2)}\n`);
|
|
58
|
+
} else {
|
|
59
|
+
target.services ??= {};
|
|
60
|
+
if (resolved.services?.[kind]) throw new Error(`Service ${kind} already exists.`);
|
|
61
|
+
if (Object.values(resolved.services ?? {}).some(service => service.port === port)) throw new Error(`Port ${port} already belongs to another service.`);
|
|
62
|
+
target.services[kind] = service;
|
|
63
|
+
}
|
|
64
|
+
if (postgres) {
|
|
65
|
+
const secretOptions = { cwd, environment };
|
|
66
|
+
const values = await readEnvironmentSecrets(secretOptions);
|
|
67
|
+
if (!Object.hasOwn(values, 'POSTGRES_PASSWORD')) await setEnvironmentSecret({ ...secretOptions, key: 'POSTGRES_PASSWORD', fromEnv: 'GENERATED', env: { GENERATED: randomBytes(24).toString('base64url') } });
|
|
68
|
+
}
|
|
69
|
+
await writeFile(filename, `${JSON.stringify(config, null, 2)}\n`);
|
|
70
|
+
return { message: `Added ${kind} on localhost:${port}. Run agent-env up${environment === 'default' ? '' : ` --env ${environment}`}.` };
|
|
71
|
+
}
|
package/src/init.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { lstat, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { detectComposeFiles } from './providers/compose.mjs';
|
|
4
|
+
import { quote } from './system.mjs';
|
|
5
|
+
|
|
6
|
+
async function exists(filename) {
|
|
7
|
+
try { return (await lstat(filename)).isFile(); }
|
|
8
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Return public configuration only. This function never reads environment values.
|
|
12
|
+
export async function createInitialConfig(options = {}) {
|
|
13
|
+
const cwd = await realpath(options.cwd ?? process.cwd());
|
|
14
|
+
const files = await detectComposeFiles(cwd);
|
|
15
|
+
if (files.length && !options.command?.length) return { provider: 'compose', compose: { files } };
|
|
16
|
+
let manifest = {};
|
|
17
|
+
try { manifest = JSON.parse(await readFile(resolve(cwd, 'package.json'), 'utf8')); }
|
|
18
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
19
|
+
let manager = manifest.packageManager?.split('@')[0];
|
|
20
|
+
const locks = [['bun.lock', 'bun'], ['bun.lockb', 'bun'], ['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['package-lock.json', 'npm'], ['npm-shrinkwrap.json', 'npm']];
|
|
21
|
+
let locked = false;
|
|
22
|
+
for (const [filename, candidate] of locks) {
|
|
23
|
+
if (await exists(resolve(cwd, filename))) {
|
|
24
|
+
if (manager && manager !== candidate) throw new Error('The packageManager field conflicts with a lockfile. Select one package manager before initialization.');
|
|
25
|
+
manager = candidate;
|
|
26
|
+
locked = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
manager ??= 'npm';
|
|
30
|
+
if (!['npm', 'pnpm', 'yarn', 'bun'].includes(manager)) throw new Error('Use npm, pnpm, yarn, or bun, or specify an explicit initialization command.');
|
|
31
|
+
const dependencies = { ...manifest.dependencies, ...manifest.devDependencies };
|
|
32
|
+
const frameworkPort = dependencies.astro ? 4321
|
|
33
|
+
: dependencies.vite || dependencies['@sveltejs/kit'] ? 5173
|
|
34
|
+
: dependencies['@angular/core'] ? 4200
|
|
35
|
+
: dependencies['webpack-dev-server'] ? 8080 : 3000;
|
|
36
|
+
const script = manifest.scripts?.dev ? 'dev' : manifest.scripts?.start ? 'start' : undefined;
|
|
37
|
+
const port = Number(options.port ?? frameworkPort);
|
|
38
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('Public ports must be integers between 1024 and 65535.');
|
|
39
|
+
let command;
|
|
40
|
+
if (Array.isArray(options.command) && options.command.length) command = options.command.map(quote).join(' ');
|
|
41
|
+
else if (typeof options.command === 'string' && options.command.trim()) command = options.command;
|
|
42
|
+
else if (script) {
|
|
43
|
+
const executable = ['pnpm', 'yarn'].includes(manager) ? `corepack ${manager}` : manager;
|
|
44
|
+
const install = manager === 'npm' ? locked ? 'npm ci' : 'npm install'
|
|
45
|
+
: `${executable} install${locked && ['pnpm', 'bun'].includes(manager) ? ' --frozen-lockfile' : ''}`;
|
|
46
|
+
const host = dependencies.vite || dependencies.astro || dependencies.nuxt || dependencies['@sveltejs/kit'] || dependencies['webpack-dev-server'] ? ' --host 0.0.0.0'
|
|
47
|
+
: dependencies.next ? ' --hostname 0.0.0.0' : dependencies['@angular/core'] ? ' --host 0.0.0.0' : '';
|
|
48
|
+
const separator = manager === 'npm' && host ? ' --' : '';
|
|
49
|
+
command = `${install} && ${executable} run ${script}${separator}${host}${host ? ` --port ${port}` : ''}`;
|
|
50
|
+
} else throw new Error('Cannot detect a development command. Add a dev or start script, or initialize with an explicit command after --.');
|
|
51
|
+
return {
|
|
52
|
+
provider: 'docker',
|
|
53
|
+
services: { web: {
|
|
54
|
+
image: options.image ?? (manager === 'bun' ? 'oven/bun:1' : 'node:22-bookworm-slim'),
|
|
55
|
+
port, command,
|
|
56
|
+
env: { HOST: '0.0.0.0', PORT: String(port) },
|
|
57
|
+
volumes: { dependencies: '/workspace/node_modules', cache: manager === 'bun' ? '/root/.bun' : manager === 'pnpm' ? '/root/.local/share/pnpm/store' : manager === 'yarn' ? '/usr/local/share/.cache/yarn' : '/root/.npm' },
|
|
58
|
+
} },
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function initialize(options = {}) {
|
|
63
|
+
const cwd = await realpath(options.cwd ?? process.cwd());
|
|
64
|
+
const config = await createInitialConfig({ ...options, cwd });
|
|
65
|
+
const filename = resolve(cwd, options.config ?? 'agent-env.json');
|
|
66
|
+
await writeFile(filename, `${JSON.stringify(config, null, 2)}\n`, { flag: 'wx' });
|
|
67
|
+
return { filename, config };
|
|
68
|
+
}
|
package/src/locks.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { link, open, readFile, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { delay } from './system.mjs';
|
|
4
|
+
|
|
5
|
+
const busy = () => Object.assign(new Error('The resource is busy or locked. Retry after the active operation finishes.'), { code: 'ELOCKED' });
|
|
6
|
+
|
|
7
|
+
// Publish a complete owner file atomically. A crash cannot expose an empty lock.
|
|
8
|
+
async function publish(path) {
|
|
9
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
10
|
+
try {
|
|
11
|
+
const file = await open(temporary, 'wx', 0o600);
|
|
12
|
+
try { await file.writeFile(String(process.pid)); await file.sync(); }
|
|
13
|
+
finally { await file.close(); }
|
|
14
|
+
await link(temporary, path);
|
|
15
|
+
} finally { await rm(temporary, { force: true }); }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function acquire(path, depth = 0) {
|
|
19
|
+
if (depth > 32) throw busy();
|
|
20
|
+
try { await publish(path); return; }
|
|
21
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
22
|
+
const recovery = `${path}.recovery`;
|
|
23
|
+
// Recovery locks use the same protocol, including recovery after their owner dies.
|
|
24
|
+
await acquire(recovery, depth + 1);
|
|
25
|
+
try {
|
|
26
|
+
let contents;
|
|
27
|
+
try { contents = await readFile(path, 'utf8'); }
|
|
28
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
29
|
+
if (contents !== undefined) {
|
|
30
|
+
const pid = Number(contents);
|
|
31
|
+
if (!contents.trim()) {
|
|
32
|
+
if (Date.now() - (await stat(path)).mtimeMs < 5000) throw busy();
|
|
33
|
+
} else {
|
|
34
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) throw busy();
|
|
35
|
+
try { process.kill(pid, 0); throw busy(); }
|
|
36
|
+
catch (error) { if (error.code !== 'ESRCH') throw error; }
|
|
37
|
+
}
|
|
38
|
+
await rm(path, { force: true });
|
|
39
|
+
}
|
|
40
|
+
try { await publish(path); }
|
|
41
|
+
catch (error) { if (error.code === 'EEXIST') throw busy(); throw error; }
|
|
42
|
+
} finally { await rm(recovery, { force: true }); }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function withFileLock(path, work, { waitMs = 0 } = {}) {
|
|
46
|
+
const deadline = Date.now() + waitMs;
|
|
47
|
+
for (;;) {
|
|
48
|
+
try { await acquire(path); break; }
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code !== 'ELOCKED' || Date.now() >= deadline) throw error;
|
|
51
|
+
await delay(50);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
try { return await work(); }
|
|
55
|
+
finally { await rm(path, { force: true }); }
|
|
56
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { redact } from './runtime.mjs';
|
|
2
|
+
|
|
3
|
+
export function cloudPeers(handle, instances) {
|
|
4
|
+
const recordId = handle.spec.cloud?.recordId;
|
|
5
|
+
return recordId ? Object.values(instances).filter(peer => peer.spec.cloud?.recordId === recordId) : [];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function cloudCanSleep(handle, instances) {
|
|
9
|
+
return !!handle.spec.cloud?.recordId && !cloudPeers(handle, instances).some(peer => peer !== handle && peer.running);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function expireCloudMachine(handle, { instances, destroy, disconnect, save, forget = () => {} }) {
|
|
13
|
+
const peers = cloudPeers(handle, instances);
|
|
14
|
+
if (!peers.length) throw new Error('No owned cloud machine exists for this instance.');
|
|
15
|
+
for (const peer of peers) await disconnect(peer.spec.name);
|
|
16
|
+
await destroy(handle);
|
|
17
|
+
for (const peer of peers) {
|
|
18
|
+
delete instances[peer.spec.name];
|
|
19
|
+
forget(peer);
|
|
20
|
+
}
|
|
21
|
+
await save();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function maintainInstances({ instances, action, connected, reconnect, retries, errors, now = Date.now }) {
|
|
25
|
+
const started = now();
|
|
26
|
+
const handled = new Set();
|
|
27
|
+
const attemptLifecycle = async (key, peers, work) => {
|
|
28
|
+
if (handled.has(key)) return;
|
|
29
|
+
handled.add(key);
|
|
30
|
+
const retry = retries.get(key) ?? { next: 0, count: 0 };
|
|
31
|
+
if (started < retry.next) return;
|
|
32
|
+
try {
|
|
33
|
+
await work();
|
|
34
|
+
retries.delete(key);
|
|
35
|
+
for (const peer of peers) errors.delete(peer.spec.name);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
retry.count++;
|
|
38
|
+
retry.next = now() + Math.min(60000, 1000 * 2 ** Math.min(retry.count, 6));
|
|
39
|
+
retries.set(key, retry);
|
|
40
|
+
for (const peer of peers) errors.set(peer.spec.name, redact(peer, error.message));
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
for (const handle of Object.values(instances)) {
|
|
44
|
+
const name = handle.spec.name;
|
|
45
|
+
if (instances[name] !== handle) continue;
|
|
46
|
+
const cloud = handle.spec.cloud;
|
|
47
|
+
if (cloud?.expiresAt && Date.parse(cloud.expiresAt) <= started) {
|
|
48
|
+
await attemptLifecycle(`expiry:${cloud.recordId}`, cloudPeers(handle, instances), () => action({ type: 'expire', name }));
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!handle.running) continue;
|
|
52
|
+
const lastActivity = Math.max(handle.lastActivity ?? started, ...cloudPeers(handle, instances).map(peer => peer.lastActivity ?? started));
|
|
53
|
+
if (cloud?.idleTimeoutMs && started - lastActivity >= cloud.idleTimeoutMs) {
|
|
54
|
+
const peers = cloudPeers(handle, instances);
|
|
55
|
+
await attemptLifecycle(`idle:${cloud.recordId}`, peers, async () => {
|
|
56
|
+
for (const peer of peers) if (peer.running) await action({ type: 'down', name: peer.spec.name });
|
|
57
|
+
});
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (!handle.spec.host || connected(handle)) continue;
|
|
61
|
+
const retry = retries.get(name) ?? { next: 0, count: 0 };
|
|
62
|
+
if (started < retry.next) continue;
|
|
63
|
+
try {
|
|
64
|
+
await reconnect(handle);
|
|
65
|
+
retries.delete(name);
|
|
66
|
+
errors.delete(name);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
retry.count++;
|
|
69
|
+
retry.next = now() + Math.min(60000, 1000 * 2 ** Math.min(retry.count, 6));
|
|
70
|
+
retries.set(name, retry);
|
|
71
|
+
errors.set(name, redact(handle, `Reconnecting: ${error.message}`));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/paths.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
export const home = resolve(process.env.AGENT_ENV_HOME ?? join(homedir(), '.local/state/agent-env'));
|
|
4
|
+
export const socketPath = join(home, 'control.sock');
|
|
5
|
+
export const statePath = join(home, 'state.json');
|