@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,394 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, writeFile, rm, stat, open } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join, resolve, posix } from 'node:path';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
import { run } from './system.mjs';
|
|
8
|
+
|
|
9
|
+
const FORMAT = 'agent-env-snapshot';
|
|
10
|
+
const VERSION = 1;
|
|
11
|
+
const IMAGE = 'busybox:1.37';
|
|
12
|
+
const VOLUME_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
13
|
+
|
|
14
|
+
function locationInfo(location) {
|
|
15
|
+
if (typeof location !== 'string' || !location) throw new Error('Specify a snapshot directory or an S3 URL.');
|
|
16
|
+
if (!location.startsWith('s3://')) return { directory: resolve(location) };
|
|
17
|
+
const match = /^s3:\/\/([^/]+)\/(.+?)\/*$/.exec(location);
|
|
18
|
+
if (!match || /[\r\n?#]/.test(location) || match[2].split('/').some(part => part === '..' || part === '.')) throw new Error('Use s3://bucket/nonempty-prefix without query parameters.');
|
|
19
|
+
return { bucket: match[1], prefix: `${match[2]}/` };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function aws(options, args) {
|
|
23
|
+
return run(options.awsCommand ?? 'aws', [
|
|
24
|
+
...(options.endpointUrl ? ['--endpoint-url', options.endpointUrl] : []),
|
|
25
|
+
'--no-cli-pager', 's3api', ...args,
|
|
26
|
+
], { env: { ...process.env, ...options.awsEnv, AWS_PAGER: '' } });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function matchingContainers(provider, spec, filter) {
|
|
30
|
+
const ids = (await provider.docker(spec, ['container', 'ls', '-aq', '--filter', filter])).split(/\s+/).filter(Boolean);
|
|
31
|
+
if (!ids.length) return [];
|
|
32
|
+
try { return JSON.parse(await provider.docker(spec, ['inspect', ...ids])); }
|
|
33
|
+
catch {
|
|
34
|
+
const containers = [];
|
|
35
|
+
for (const id of ids) {
|
|
36
|
+
try { containers.push(...JSON.parse(await provider.docker(spec, ['inspect', id]))); }
|
|
37
|
+
catch (error) {
|
|
38
|
+
// An unrelated temporary container can disappear between listing and inspection.
|
|
39
|
+
const remaining = await provider.docker(spec, ['container', 'ls', '-aq', '--filter', `id=${id}`]);
|
|
40
|
+
if (remaining.trim()) throw error;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return containers;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function serviceContainers(service) {
|
|
48
|
+
return service.containers ?? (service.container ? [service.container] : []);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function checkStopped(info) {
|
|
52
|
+
if (info.State?.Running || info.State?.Restarting || info.State?.Paused) throw new Error(`Stop service container ${info.Name ?? info.Id} before snapshot or restore.`);
|
|
53
|
+
if (!['exited', 'created', 'dead'].includes(info.State?.Status)) throw new Error('The service state is unknown. Check the Docker host before snapshot or restore.');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function discover(handle, provider, options) {
|
|
57
|
+
const entries = Object.entries(handle.services ?? {});
|
|
58
|
+
if (!entries.length) throw new Error('The handle must identify its service containers.');
|
|
59
|
+
const containers = [];
|
|
60
|
+
for (const [service, value] of entries) {
|
|
61
|
+
const ids = serviceContainers(value);
|
|
62
|
+
if (!Array.isArray(ids) || !ids.length || ids.some(id => typeof id !== 'string' || !id)) throw new Error(`Service ${service} must identify its container or containers.`);
|
|
63
|
+
for (const info of JSON.parse(await provider.docker(handle.spec, ['inspect', ...ids]))) {
|
|
64
|
+
checkStopped(info);
|
|
65
|
+
containers.push({ service, info });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const ownIds = new Set(containers.map(({ info }) => info.Id));
|
|
69
|
+
const volumeUsers = new Map();
|
|
70
|
+
const volumes = new Map(), omittedMounts = [];
|
|
71
|
+
for (const { service, info } of containers) {
|
|
72
|
+
for (const mount of info.Mounts ?? []) {
|
|
73
|
+
const omit = reason => {
|
|
74
|
+
omittedMounts.push({ service, target: mount.Destination, type: mount.Type, reason });
|
|
75
|
+
};
|
|
76
|
+
if (mount.Type === 'bind' && (mount.Source === (handle.workspace ?? handle.spec.cwd) || mount.Source?.startsWith(`${handle.workspace ?? handle.spec.cwd}/`))) {
|
|
77
|
+
omit('Source files require a separate Git worktree or source synchronization.');
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
let reason;
|
|
81
|
+
if (mount.Type !== 'volume' || !mount.Name) reason = 'This mount is not a managed named volume.';
|
|
82
|
+
else {
|
|
83
|
+
const volume = JSON.parse(await provider.docker(handle.spec, ['volume', 'inspect', mount.Name]))[0];
|
|
84
|
+
const labels = volume.Labels ?? {};
|
|
85
|
+
const project = info.Config?.Labels?.['com.docker.compose.project'];
|
|
86
|
+
const managed = labels['agent-env.owner'] && labels['agent-env.owner'] === handle.spec.owner
|
|
87
|
+
|| project && labels['com.docker.compose.project'] === project;
|
|
88
|
+
if (!managed) reason = 'This volume is external or has no matching ownership label.';
|
|
89
|
+
else if (volume.Driver !== 'local' || Object.keys(volume.Options ?? {}).length || volume.Scope !== 'local') reason = 'This volume uses an external driver or mount options.';
|
|
90
|
+
else {
|
|
91
|
+
if (!volumeUsers.has(mount.Name)) volumeUsers.set(mount.Name, await matchingContainers(provider, handle.spec, `volume=${mount.Name}`));
|
|
92
|
+
if (volumeUsers.get(mount.Name).some(other => !ownIds.has(other.Id) && (other.Mounts ?? []).some(item => item.Type === 'volume' && item.Name === mount.Name))) reason = 'Another container shares this volume.';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!reason && provider.snapshotVolumeNames) {
|
|
96
|
+
try { await provider.snapshotVolumeNames(handle.spec, { volumes: [{ id: 'check', mounts: [{ service, target: mount.Destination }] }] }); }
|
|
97
|
+
catch (error) { reason = error.message; }
|
|
98
|
+
}
|
|
99
|
+
if (reason) {
|
|
100
|
+
if (!options.omitUnsupportedMounts) throw new Error(`${service}:${mount.Destination}: ${reason} Create a separate backup for this mount or select omitUnsupportedMounts.`);
|
|
101
|
+
omit(reason);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
let volume = volumes.get(mount.Name);
|
|
105
|
+
if (!volume) {
|
|
106
|
+
volume = { id: `v${volumes.size}`, sourceName: mount.Name, archive: `v${volumes.size}.tar`, mounts: [] };
|
|
107
|
+
volumes.set(mount.Name, volume);
|
|
108
|
+
}
|
|
109
|
+
const key = Object.entries(handle.spec.services?.[service]?.volumes ?? {}).find(([, target]) => target === mount.Destination)?.[0];
|
|
110
|
+
volume.mounts.push({ service, target: mount.Destination, ...(key ? { key } : {}) });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { volumes: [...volumes.values()], omittedMounts };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function streamDocker(provider, spec, args, { input, output }) {
|
|
117
|
+
const file = input ? await open(input, 'r') : await open(output, 'wx', 0o600);
|
|
118
|
+
try {
|
|
119
|
+
// Redirect bytes through OS file descriptors. The provider only captures text on stderr.
|
|
120
|
+
await provider.docker(spec, args, { stdio: [input ? file.fd : 'ignore', output ? file.fd : 'ignore', 'pipe'] });
|
|
121
|
+
} finally { await file.close(); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function archiveVolume(provider, spec, volume, archive, options, restore = false) {
|
|
125
|
+
const token = randomUUID();
|
|
126
|
+
const name = `agent-env-snapshot-${token}`;
|
|
127
|
+
const args = ['run', '--rm', ...(restore ? ['-i'] : []), '--name', name, '--label', `agent-env.snapshot=${token}`, '--network', 'none', '--read-only', '--mount', `type=volume,src=${volume},dst=/data,volume-nocopy${restore ? '' : ',readonly'}`, options.image ?? IMAGE, 'tar', '-C', '/data', restore ? '-xf' : '-cf', '-', ...(restore ? [] : ['.'])];
|
|
128
|
+
try { await streamDocker(provider, spec, args, restore ? { input: archive } : { output: archive }); }
|
|
129
|
+
finally {
|
|
130
|
+
// A failed transport can leave a helper on the remote Docker host.
|
|
131
|
+
const info = await provider.docker(spec, ['inspect', name]).then(value => JSON.parse(value)[0], () => null);
|
|
132
|
+
if (info?.Config?.Labels?.['agent-env.snapshot'] === token) await provider.docker(spec, ['rm', '-f', name]);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function tarString(buffer) { return buffer.subarray(0, buffer.indexOf(0) < 0 ? buffer.length : buffer.indexOf(0)).toString('utf8'); }
|
|
137
|
+
|
|
138
|
+
function archivePath(value) {
|
|
139
|
+
if (!value || value.includes('\\') || value.includes('\ufffd') || posix.isAbsolute(value) || value.split('/').includes('..')) throw new Error('The archive contains an unsafe extraction path.');
|
|
140
|
+
return posix.normalize(value).replace(/\/$/, '');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Validate tar records without buffering file bodies or invoking an extractor.
|
|
144
|
+
async function verifyTar(file) {
|
|
145
|
+
let pending = Buffer.alloc(0), remaining = 0, zeros = 0, ended = false;
|
|
146
|
+
let extension, longName, longLink;
|
|
147
|
+
const paths = new Map();
|
|
148
|
+
for await (const chunk of createReadStream(file)) {
|
|
149
|
+
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
|
|
150
|
+
while (pending.length) {
|
|
151
|
+
if (remaining) {
|
|
152
|
+
const consumed = Math.min(remaining, pending.length);
|
|
153
|
+
if (extension && extension.read < extension.size) {
|
|
154
|
+
const size = Math.min(consumed, extension.size - extension.read);
|
|
155
|
+
extension.chunks.push(Buffer.from(pending.subarray(0, size)));
|
|
156
|
+
extension.read += size;
|
|
157
|
+
}
|
|
158
|
+
remaining -= consumed;
|
|
159
|
+
pending = pending.subarray(consumed);
|
|
160
|
+
if (!remaining && extension) {
|
|
161
|
+
const body = Buffer.concat(extension.chunks);
|
|
162
|
+
if (body.indexOf(0) !== body.length - 1) throw new Error('The archive contains an invalid GNU path extension.');
|
|
163
|
+
const value = tarString(body);
|
|
164
|
+
if (extension.type === 'L') longName = value;
|
|
165
|
+
else longLink = value;
|
|
166
|
+
extension = undefined;
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (pending.length < 512) break;
|
|
171
|
+
const header = pending.subarray(0, 512);
|
|
172
|
+
pending = pending.subarray(512);
|
|
173
|
+
if (header.every(byte => byte === 0)) { zeros++; ended = true; continue; }
|
|
174
|
+
if (ended) throw new Error('The archive contains data after its end marker.');
|
|
175
|
+
let sum = 0;
|
|
176
|
+
for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : header[i];
|
|
177
|
+
const checksum = tarString(header.subarray(148, 156)).trim();
|
|
178
|
+
const sizeField = tarString(header.subarray(124, 136)).trim();
|
|
179
|
+
if (!/^[0-7]+$/.test(checksum) || parseInt(checksum, 8) !== sum || !/^[0-7]+$/.test(sizeField)) throw new Error('The archive contains an invalid tar header.');
|
|
180
|
+
const size = parseInt(sizeField, 8);
|
|
181
|
+
if (!Number.isSafeInteger(size)) throw new Error('The archive file size is not supported.');
|
|
182
|
+
const magic = header.subarray(257, 265).toString('latin1');
|
|
183
|
+
const gnu = magic === 'ustar \0';
|
|
184
|
+
if (magic !== `ustar${String.fromCharCode(0)}00` && !gnu) throw new Error('The archive tar format is not supported.');
|
|
185
|
+
if (gnu && header.subarray(345, 500).some(byte => byte !== 0)) throw new Error('The archive contains unsupported GNU metadata.');
|
|
186
|
+
const type = String.fromCharCode(header[156] || 48);
|
|
187
|
+
if (['L', 'K'].includes(type)) {
|
|
188
|
+
if (size < 1 || size > 1024 * 1024 || type === 'L' && longName !== undefined || type === 'K' && longLink !== undefined) throw new Error('The archive contains an invalid GNU path extension.');
|
|
189
|
+
extension = { type, size, read: 0, chunks: [] };
|
|
190
|
+
remaining = Math.ceil(size / 512) * 512;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!['0', '1', '2', '5'].includes(type)) throw new Error('The archive contains an unsupported entry. Only files, directories, and internal links are supported.');
|
|
194
|
+
if (type !== '0' && size !== 0) throw new Error('The archive contains an invalid directory or link size.');
|
|
195
|
+
const prefix = gnu ? '' : tarString(header.subarray(345, 500));
|
|
196
|
+
const rawName = tarString(header.subarray(0, 100));
|
|
197
|
+
archivePath(rawName);
|
|
198
|
+
if (prefix) archivePath(prefix);
|
|
199
|
+
const path = archivePath(longName ?? `${prefix ? `${prefix}/` : ''}${rawName}`);
|
|
200
|
+
const target = longLink ?? tarString(header.subarray(157, 257));
|
|
201
|
+
longName = undefined; longLink = undefined;
|
|
202
|
+
if (path === '.' && type !== '5') throw new Error('The archive root must be a directory.');
|
|
203
|
+
if (paths.has(path)) throw new Error('The archive contains duplicate extraction paths.');
|
|
204
|
+
if (type === '1') {
|
|
205
|
+
const linked = paths.get(archivePath(target));
|
|
206
|
+
if (!linked || !['0', '1'].includes(linked.type)) throw new Error('The archive hard link does not reference an earlier regular file.');
|
|
207
|
+
}
|
|
208
|
+
if (type === '2') {
|
|
209
|
+
if (!target || posix.isAbsolute(target) || target.includes('\\') || target.includes('\ufffd')) throw new Error('The archive contains an unsafe symbolic link.');
|
|
210
|
+
}
|
|
211
|
+
paths.set(path, { type, target });
|
|
212
|
+
remaining = Math.ceil(size / 512) * 512;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (remaining || pending.length || zeros < 2 || longName !== undefined || longLink !== undefined) throw new Error('The archive is truncated.');
|
|
216
|
+
const resolveLink = (target, base, visited) => {
|
|
217
|
+
const stack = [...base];
|
|
218
|
+
for (const part of target.split('/')) {
|
|
219
|
+
if (part === '.' || part === '') continue;
|
|
220
|
+
if (part === '..') {
|
|
221
|
+
if (!stack.length) throw new Error('The symbolic link leaves the volume.');
|
|
222
|
+
stack.pop();
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
stack.push(part);
|
|
226
|
+
const path = stack.join('/'), entry = paths.get(path);
|
|
227
|
+
if (entry?.type === '2') {
|
|
228
|
+
if (visited.has(path) || visited.size > 64) throw new Error('The archive contains a symbolic link cycle or an excessive link chain.');
|
|
229
|
+
const resolved = resolveLink(entry.target, stack.slice(0, -1), new Set([...visited, path]));
|
|
230
|
+
stack.splice(0, stack.length, ...resolved);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return stack;
|
|
234
|
+
};
|
|
235
|
+
for (const [path, entry] of paths) {
|
|
236
|
+
if (entry.type === '2') resolveLink(entry.target, posix.dirname(path).split('/').filter(part => part !== '.'), new Set([path]));
|
|
237
|
+
let ancestor = posix.dirname(path);
|
|
238
|
+
while (ancestor !== '.') {
|
|
239
|
+
if (paths.has(ancestor) && paths.get(ancestor).type !== '5') throw new Error('An archive path traverses a symbolic link or file.');
|
|
240
|
+
ancestor = posix.dirname(ancestor);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function digest(file) {
|
|
246
|
+
const hash = createHash('sha256');
|
|
247
|
+
let bytes = 0;
|
|
248
|
+
for await (const chunk of createReadStream(file)) { hash.update(chunk); bytes += chunk.length; }
|
|
249
|
+
return { sha256: hash.digest('hex'), bytes };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function validateManifest(manifest) {
|
|
253
|
+
if (manifest.format !== FORMAT || manifest.version !== VERSION || !Array.isArray(manifest.volumes) || !Array.isArray(manifest.omittedMounts)) throw new Error('The snapshot manifest format or version is not supported.');
|
|
254
|
+
const ids = new Set(), archives = new Set(), sources = new Set();
|
|
255
|
+
for (const volume of manifest.volumes) {
|
|
256
|
+
if (!/^v\d+$/.test(volume.id) || volume.archive !== `${volume.id}.tar` || !VOLUME_NAME.test(volume.sourceName)
|
|
257
|
+
|| ids.has(volume.id) || archives.has(volume.archive) || sources.has(volume.sourceName)
|
|
258
|
+
|| !Number.isSafeInteger(volume.bytes) || volume.bytes < 1024 || !/^[a-f0-9]{64}$/.test(volume.sha256)
|
|
259
|
+
|| !Array.isArray(volume.mounts) || !volume.mounts.length
|
|
260
|
+
|| volume.mounts.some(mount => typeof mount.service !== 'string' || typeof mount.target !== 'string' || !mount.target.startsWith('/'))) throw new Error('The snapshot contains an invalid volume record.');
|
|
261
|
+
ids.add(volume.id); archives.add(volume.archive); sources.add(volume.sourceName);
|
|
262
|
+
}
|
|
263
|
+
return manifest;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Export stopped, managed Docker volumes to a new local directory or S3 prefix. */
|
|
267
|
+
export async function snapshotInstance(handle, location, provider, options = {}) {
|
|
268
|
+
const storage = locationInfo(location);
|
|
269
|
+
const discovered = await discover(handle, provider, options);
|
|
270
|
+
let directory, owned = false;
|
|
271
|
+
try {
|
|
272
|
+
if (storage.bucket) {
|
|
273
|
+
const existing = JSON.parse(await aws(options, ['list-objects-v2', '--bucket', storage.bucket, '--prefix', storage.prefix, '--max-keys', '1', '--output', 'json']));
|
|
274
|
+
if (existing.Contents?.length) throw new Error('The S3 snapshot prefix already contains data. Choose a new prefix.');
|
|
275
|
+
directory = await mkdtemp(join(tmpdir(), 'agent-env-snapshot-'));
|
|
276
|
+
} else {
|
|
277
|
+
directory = storage.directory;
|
|
278
|
+
await mkdir(directory, { mode: 0o700 });
|
|
279
|
+
}
|
|
280
|
+
owned = true;
|
|
281
|
+
const manifest = {
|
|
282
|
+
format: FORMAT, version: VERSION, createdAt: new Date().toISOString(),
|
|
283
|
+
source: { name: handle.spec.name, owner: handle.spec.owner, provider: handle.spec.provider },
|
|
284
|
+
volumes: discovered.volumes, omittedMounts: discovered.omittedMounts,
|
|
285
|
+
};
|
|
286
|
+
for (const volume of manifest.volumes) {
|
|
287
|
+
const file = join(directory, volume.archive);
|
|
288
|
+
await archiveVolume(provider, handle.spec, volume.sourceName, file, options);
|
|
289
|
+
await verifyTar(file);
|
|
290
|
+
Object.assign(volume, await digest(file));
|
|
291
|
+
}
|
|
292
|
+
// Reject a snapshot if a service restarted during export.
|
|
293
|
+
await discover(handle, provider, options);
|
|
294
|
+
await writeFile(join(directory, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
295
|
+
if (storage.bucket) {
|
|
296
|
+
// Conditional writes prevent races from overwriting another writer's objects.
|
|
297
|
+
for (const name of [...manifest.volumes.map(volume => volume.archive), 'manifest.json']) {
|
|
298
|
+
await aws(options, ['put-object', '--bucket', storage.bucket, '--key', `${storage.prefix}${name}`, '--body', join(directory, name), '--if-none-match', '*']);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return { location, manifest };
|
|
302
|
+
} catch (error) {
|
|
303
|
+
if (owned && !storage.bucket) await rm(directory, { recursive: true, force: true });
|
|
304
|
+
throw error;
|
|
305
|
+
} finally {
|
|
306
|
+
if (owned && storage.bucket) await rm(directory, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function destinationVolumes(spec, manifest, provider, options) {
|
|
311
|
+
const names = new Set();
|
|
312
|
+
const mapped = options.volumeNames ?? await provider.snapshotVolumeNames?.(spec, manifest);
|
|
313
|
+
return manifest.volumes.map(volume => {
|
|
314
|
+
let name = mapped?.[volume.id];
|
|
315
|
+
if (!name) {
|
|
316
|
+
const destinations = volume.mounts.map(mount => {
|
|
317
|
+
const key = Object.entries(spec.services?.[mount.service]?.volumes ?? {}).find(([, target]) => target === mount.target)?.[0];
|
|
318
|
+
return key && typeof provider.volume === 'function' ? provider.volume(spec, mount.service, key) : undefined;
|
|
319
|
+
});
|
|
320
|
+
if (destinations.some(item => !item) || new Set(destinations).size !== 1) throw new Error(`Set volumeNames.${volume.id} to the destination volume name. Match all mounts of this volume.`);
|
|
321
|
+
name = destinations[0];
|
|
322
|
+
}
|
|
323
|
+
if (!VOLUME_NAME.test(name) || names.has(name)) throw new Error('Destination volumes must have distinct valid Docker volume names.');
|
|
324
|
+
names.add(name);
|
|
325
|
+
return { ...volume, destinationName: name };
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Restore verified archives into new volumes. This function never starts services. */
|
|
330
|
+
export async function restoreSnapshot(spec, location, provider, options = {}) {
|
|
331
|
+
const storage = locationInfo(location);
|
|
332
|
+
const directory = await mkdtemp(join(tmpdir(), 'agent-env-restore-'));
|
|
333
|
+
const created = [], token = randomUUID();
|
|
334
|
+
try {
|
|
335
|
+
const fetch = async name => {
|
|
336
|
+
const file = join(directory, name);
|
|
337
|
+
if (storage.bucket) await aws(options, ['get-object', '--bucket', storage.bucket, '--key', `${storage.prefix}${name}`, file]);
|
|
338
|
+
else await pipeline(createReadStream(join(storage.directory, name)), createWriteStream(file, { flags: 'wx', mode: 0o600 }));
|
|
339
|
+
return file;
|
|
340
|
+
};
|
|
341
|
+
const manifestFile = await fetch('manifest.json');
|
|
342
|
+
if ((await stat(manifestFile)).size > 4 * 1024 * 1024) throw new Error('The snapshot manifest exceeds 4 MiB.');
|
|
343
|
+
const manifest = validateManifest(JSON.parse(await readFile(manifestFile, 'utf8')));
|
|
344
|
+
// Verify every archive before allocating any destination resource.
|
|
345
|
+
for (const volume of manifest.volumes) {
|
|
346
|
+
const file = await fetch(volume.archive);
|
|
347
|
+
const actual = await digest(file);
|
|
348
|
+
if (actual.sha256 !== volume.sha256 || actual.bytes !== volume.bytes) throw new Error(`Checksum or size mismatch for ${volume.archive}.`);
|
|
349
|
+
await verifyTar(file);
|
|
350
|
+
}
|
|
351
|
+
if (options.destinationHandle) await discover(options.destinationHandle, provider, { omitUnsupportedMounts: true });
|
|
352
|
+
const volumes = await destinationVolumes(spec, manifest, provider, options);
|
|
353
|
+
const prefix = typeof provider.prefix === 'function' ? provider.prefix(spec) : undefined;
|
|
354
|
+
const containers = [
|
|
355
|
+
...(prefix ? await matchingContainers(provider, spec, `name=${prefix}`) : []),
|
|
356
|
+
...(options.composeProject ? await matchingContainers(provider, spec, `label=com.docker.compose.project=${options.composeProject}`) : []),
|
|
357
|
+
];
|
|
358
|
+
for (const info of containers) {
|
|
359
|
+
if (prefix && (info.Name === `/${prefix}` || info.Name?.startsWith(`/${prefix}-`))
|
|
360
|
+
|| options.composeProject && info.Config?.Labels?.['com.docker.compose.project'] === options.composeProject) checkStopped(info);
|
|
361
|
+
}
|
|
362
|
+
const existing = new Set((await provider.docker(spec, ['volume', 'ls', '-q'])).split(/\s+/).filter(Boolean));
|
|
363
|
+
for (const volume of volumes) if (existing.has(volume.destinationName)) throw new Error(`Volume ${volume.destinationName} already exists. Restore requires new destination volumes.`);
|
|
364
|
+
for (const volume of volumes) {
|
|
365
|
+
// Docker volume create is idempotent. A unique claim label detects a concurrent creator.
|
|
366
|
+
created.push(volume.destinationName);
|
|
367
|
+
const labels = provider.snapshotVolumeLabels?.(spec, volume.destinationName) ?? {};
|
|
368
|
+
await provider.docker(spec, ['volume', 'create', '--label', `agent-env.owner=${spec.owner}`, '--label', `agent-env.restore=${token}`,
|
|
369
|
+
...Object.entries(labels).flatMap(([key, value]) => ['--label', `${key}=${value}`]), volume.destinationName]);
|
|
370
|
+
const info = JSON.parse(await provider.docker(spec, ['volume', 'inspect', volume.destinationName]))[0];
|
|
371
|
+
if (info.Labels?.['agent-env.restore'] !== token) throw new Error('Another operation created the destination volume. Restore stopped without modifying it.');
|
|
372
|
+
await archiveVolume(provider, spec, volume.destinationName, join(directory, volume.archive), options, true);
|
|
373
|
+
}
|
|
374
|
+
return { location, manifest, volumes, restoreId: token, volumeNames: Object.fromEntries(volumes.map(volume => [volume.id, volume.destinationName])) };
|
|
375
|
+
} catch (error) {
|
|
376
|
+
const cleanupErrors = [];
|
|
377
|
+
for (const name of created.reverse()) {
|
|
378
|
+
try {
|
|
379
|
+
const info = JSON.parse(await provider.docker(spec, ['volume', 'inspect', name]))[0];
|
|
380
|
+
if (info.Labels?.['agent-env.restore'] === token) await provider.docker(spec, ['volume', 'rm', name]);
|
|
381
|
+
} catch (cleanupError) { cleanupErrors.push(cleanupError); }
|
|
382
|
+
}
|
|
383
|
+
if (cleanupErrors.length) error.cleanupErrors = cleanupErrors;
|
|
384
|
+
throw error;
|
|
385
|
+
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export async function disposeRestoredVolumes(result, spec, provider) {
|
|
389
|
+
for (const volume of result.volumes) {
|
|
390
|
+
const info = JSON.parse(await provider.docker(spec, ['volume', 'inspect', volume.destinationName]))[0];
|
|
391
|
+
if (info.Labels?.['agent-env.restore'] !== result.restoreId) throw new Error('The restored volume ownership changed. Cleanup stopped.');
|
|
392
|
+
await provider.docker(spec, ['volume', 'rm', volume.destinationName]);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Unanchored rules apply at every depth. Leading slashes anchor rules to the checkout root.
|
|
2
|
+
export const sourceExclusions = Object.freeze(['.git', 'node_modules', '.agent-env', '.env', '.env.*', '/.next', '/dist', '/build', '/coverage', '/.cache']);
|
|
3
|
+
export const worktreeExclusions = Object.freeze(['/vendor', '/target', '/.nuxt', '/.turbo', '/.venv', '/venv', '/__pycache__']);
|
|
4
|
+
|
|
5
|
+
export function shouldSync(file, exclusions = []) {
|
|
6
|
+
if (!file) return true;
|
|
7
|
+
const path = String(file).replaceAll('\\', '/').replace(/^\.\//, '');
|
|
8
|
+
const parts = path.split('/');
|
|
9
|
+
const excluded = [...sourceExclusions, ...exclusions].some(rule => {
|
|
10
|
+
if (rule.startsWith('/')) return path === rule.slice(1) || path.startsWith(`${rule.slice(1)}/`);
|
|
11
|
+
if (rule.endsWith('*')) return parts.some(part => part.startsWith(rule.slice(0, -1)));
|
|
12
|
+
return parts.includes(rule) || path.startsWith(`${rule}/`);
|
|
13
|
+
});
|
|
14
|
+
return !excluded;
|
|
15
|
+
}
|
package/src/system.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
|
|
6
|
+
export { delay };
|
|
7
|
+
export const quote = value => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
8
|
+
export function run(command, args, options = {}) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], ...options });
|
|
11
|
+
let stdout = '', stderr = '';
|
|
12
|
+
child.stdout?.on('data', data => { stdout += data; });
|
|
13
|
+
child.stderr?.on('data', data => { stderr += data; if (options.combined) stdout += data; });
|
|
14
|
+
child.on('error', reject);
|
|
15
|
+
child.on('close', code => {
|
|
16
|
+
if (code === 0) return resolve(stdout.trimEnd());
|
|
17
|
+
const output = options.errorStdout === false ? stderr.trimEnd()
|
|
18
|
+
: options.combined ? stdout.trimEnd() : [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join('\n');
|
|
19
|
+
reject(Object.assign(new Error(`${command} failed (${code}): ${output}`), { exitCode: code ?? 1 }));
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export async function freePort() {
|
|
24
|
+
const server = net.createServer();
|
|
25
|
+
server.listen(0, '127.0.0.1');
|
|
26
|
+
await once(server, 'listening');
|
|
27
|
+
const port = server.address().port;
|
|
28
|
+
await new Promise(resolve => server.close(resolve));
|
|
29
|
+
return port;
|
|
30
|
+
}
|
|
31
|
+
export async function reachable(port) {
|
|
32
|
+
return new Promise(resolve => {
|
|
33
|
+
const socket = net.connect(port, '127.0.0.1');
|
|
34
|
+
const finish = value => { socket.destroy(); resolve(value); };
|
|
35
|
+
socket.setTimeout(500, () => finish(false));
|
|
36
|
+
socket.on('connect', () => finish(true));
|
|
37
|
+
socket.on('error', () => finish(false));
|
|
38
|
+
});
|
|
39
|
+
}
|
package/src/tls.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, chmod, rm } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
import { run } from './system.mjs';
|
|
5
|
+
import { home } from './paths.mjs';
|
|
6
|
+
import { createPrivateKey, createPublicKey, X509Certificate } from 'node:crypto';
|
|
7
|
+
import { withFileLock } from './locks.mjs';
|
|
8
|
+
|
|
9
|
+
export async function ensureTls(directory = join(home, 'tls'), hosts = []) {
|
|
10
|
+
if (hosts.some(host => !/^[a-z0-9][a-z0-9-]{0,62}\.localhost$/.test(host))) throw new Error('TLS names must be valid localhost hostnames.');
|
|
11
|
+
hosts = [...new Set(hosts)].sort();
|
|
12
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
13
|
+
await chmod(directory, 0o700);
|
|
14
|
+
return withFileLock(join(directory, 'generation.lock'), () => generateTls(directory, hosts), { waitMs: 15000 });
|
|
15
|
+
}
|
|
16
|
+
async function generateTls(directory, hosts) {
|
|
17
|
+
const ca = join(directory, 'ca.pem'), caKey = join(directory, 'ca.key');
|
|
18
|
+
const cert = join(directory, 'server.pem'), key = join(directory, 'server.key');
|
|
19
|
+
const exists = path => readFile(path).then(() => true, error => { if (error.code === 'ENOENT') return false; throw error; });
|
|
20
|
+
if (await exists(ca) !== await exists(caKey)) throw new Error('The local CA is incomplete. Restore its certificate and key before enabling HTTPS.');
|
|
21
|
+
const config = join(directory, 'openssl.cnf');
|
|
22
|
+
await writeFile(config, `[req]\ndistinguished_name=dn\nx509_extensions=ca\nprompt=no\n[dn]\nCN=agent-env local development\n[ca]\nbasicConstraints=critical,CA:TRUE\nkeyUsage=critical,keyCertSign,cRLSign\nnameConstraints=critical,permitted;DNS:localhost,permitted;DNS:.localhost,permitted;IP:127.0.0.1/255.255.255.255,permitted;IP:::1/ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff\n[server]\nbasicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:localhost,DNS:*.localhost,IP:127.0.0.1,IP:::1${hosts.map(host => `,DNS:${host}`).join('')}\n`, { mode: 0o600 });
|
|
23
|
+
try {
|
|
24
|
+
if (!await exists(ca)) {
|
|
25
|
+
await run('openssl', ['ecparam', '-genkey', '-name', 'prime256v1', '-noout', '-out', caKey]);
|
|
26
|
+
await chmod(caKey, 0o600);
|
|
27
|
+
await run('openssl', ['req', '-new', '-x509', '-sha256', '-key', caKey, '-out', ca, '-days', '3650', '-config', config]);
|
|
28
|
+
}
|
|
29
|
+
const authority = new X509Certificate(await readFile(ca));
|
|
30
|
+
const publicKey = createPublicKey(createPrivateKey(await readFile(caKey))).export({ type: 'spki', format: 'der' });
|
|
31
|
+
if (!authority.publicKey.export({ type: 'spki', format: 'der' }).equals(publicKey)) throw new Error('The local CA certificate and key do not match. Restore the matching pair before enabling HTTPS.');
|
|
32
|
+
await run('openssl', ['x509', '-checkend', '86400', '-noout', '-in', ca]).catch(() => { throw new Error('The local CA is expired. Replace it and repeat agent-env trust.'); });
|
|
33
|
+
const oldNames = await readFile(join(directory, 'names.json'), 'utf8').catch(() => '');
|
|
34
|
+
const valid = oldNames === JSON.stringify(hosts) && await exists(key) && await exists(cert) && await run('openssl', ['x509', '-checkend', '86400', '-noout', '-in', cert]).then(() => true, () => false);
|
|
35
|
+
if (!valid) {
|
|
36
|
+
await run('openssl', ['ecparam', '-genkey', '-name', 'prime256v1', '-noout', '-out', key]);
|
|
37
|
+
await chmod(key, 0o600);
|
|
38
|
+
const csr = join(directory, 'server.csr');
|
|
39
|
+
try {
|
|
40
|
+
await run('openssl', ['req', '-new', '-key', key, '-out', csr, '-subj', '/CN=localhost']);
|
|
41
|
+
await run('openssl', ['x509', '-req', '-sha256', '-in', csr, '-CA', ca, '-CAkey', caKey, '-CAcreateserial', '-out', cert, '-days', '365', '-extfile', config, '-extensions', 'server']);
|
|
42
|
+
} finally { await rm(csr, { force: true }); }
|
|
43
|
+
}
|
|
44
|
+
await writeFile(join(directory, 'names.json'), JSON.stringify(hosts), { mode: 0o600 });
|
|
45
|
+
return { key: await readFile(key), cert: await readFile(cert), caPath: ca };
|
|
46
|
+
} finally { await rm(config, { force: true }); }
|
|
47
|
+
}
|
|
48
|
+
export async function trustTls() {
|
|
49
|
+
const { caPath } = await ensureTls();
|
|
50
|
+
if (platform() === 'darwin') {
|
|
51
|
+
await run('security', ['add-trusted-cert', '-r', 'trustRoot', '-k', join(homedir(), 'Library/Keychains/login.keychain-db'), caPath]);
|
|
52
|
+
} else if (platform() === 'linux') {
|
|
53
|
+
await run('sudo', ['-n', 'install', '-m', '0644', caPath, '/usr/local/share/ca-certificates/agent-env.crt']);
|
|
54
|
+
await run('sudo', ['-n', 'update-ca-certificates']);
|
|
55
|
+
} else throw new Error(`Install ${caPath} in your user trust store. Automatic trust supports macOS and Debian-based Linux.`);
|
|
56
|
+
return { message: `Trusted the agent-env local CA: ${caPath}` };
|
|
57
|
+
}
|
package/src/ui-client.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const $ = id => document.getElementById(id);
|
|
2
|
+
const element = (tag, text, className) => { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (className) node.className = className; return node; };
|
|
3
|
+
let loading = false;
|
|
4
|
+
async function refresh() {
|
|
5
|
+
if (loading || $('logs').open) return;
|
|
6
|
+
loading = true;
|
|
7
|
+
try {
|
|
8
|
+
const response = await fetch('/api/status');
|
|
9
|
+
if (!response.ok) throw new Error('Status is unavailable. Retry shortly.');
|
|
10
|
+
const data = await response.json();
|
|
11
|
+
$('notice').textContent = data.error ?? '';
|
|
12
|
+
$('empty').hidden = data.instances.length !== 0;
|
|
13
|
+
$('instances').replaceChildren();
|
|
14
|
+
for (const item of data.instances) {
|
|
15
|
+
const section = element('section');
|
|
16
|
+
const top = element('div', undefined, 'top');
|
|
17
|
+
const title = element('div'); title.append(element('h2', item.name), element('div', `${item.provider} · ${item.host}${item.environment ? ` · ${item.environment}` : ''}`, 'sub'));
|
|
18
|
+
const state = !item.running ? 'Stopped' : item.connected ? 'Connected' : 'Reconnecting';
|
|
19
|
+
top.append(title, element('span', state, `badge${item.connected ? '' : ' off'}`));
|
|
20
|
+
section.append(top);
|
|
21
|
+
const services = element('div', undefined, 'services');
|
|
22
|
+
for (const [name, service] of Object.entries(item.services)) {
|
|
23
|
+
const row = element('div', undefined, 'service'); row.append(element('strong', name));
|
|
24
|
+
const url = service.httpsUrl ?? service.url;
|
|
25
|
+
if (url?.startsWith('http')) { const link = element('a', url); link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer'; row.append(link); }
|
|
26
|
+
else row.append(element('span', url ?? 'Internal service', 'address sub'));
|
|
27
|
+
const actions = element('div', undefined, 'actions');
|
|
28
|
+
actions.append(element('span', item.status?.[name] ?? '', 'sub'));
|
|
29
|
+
const button = element('button', 'Logs'); button.setAttribute('aria-label', `Read ${item.name} ${name} logs`);
|
|
30
|
+
button.onclick = async () => {
|
|
31
|
+
$('log-title').textContent = `${item.name} / ${name}`; $('log-body').textContent = 'Loading logs...'; $('logs').showModal();
|
|
32
|
+
try { const response = await fetch(`/api/logs?name=${encodeURIComponent(item.name)}&service=${encodeURIComponent(name)}`); const data = await response.json(); $('log-body').textContent = data.output ?? data.error ?? 'No logs yet.'; }
|
|
33
|
+
catch { $('log-body').textContent = 'Could not read logs.'; }
|
|
34
|
+
};
|
|
35
|
+
actions.append(button); row.append(actions); services.append(row);
|
|
36
|
+
}
|
|
37
|
+
section.append(services);
|
|
38
|
+
if (item.error) section.append(element('p', item.error, 'hint error'));
|
|
39
|
+
else if (item.sync) section.append(element('p', `Sync: ${item.sync}`, 'hint'));
|
|
40
|
+
$('instances').append(section);
|
|
41
|
+
}
|
|
42
|
+
$('updated').textContent = `Local access only · Updated ${new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Madrid', hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date())} BCN`;
|
|
43
|
+
} catch (error) { $('notice').textContent = error.message; }
|
|
44
|
+
finally { loading = false; }
|
|
45
|
+
}
|
|
46
|
+
$('refresh').onclick = refresh;
|
|
47
|
+
$('close').onclick = () => $('logs').close();
|
|
48
|
+
$('auto').checked = localStorage.getItem('agent-env-auto-refresh') === 'true';
|
|
49
|
+
$('auto').onchange = () => localStorage.setItem('agent-env-auto-refresh', String($('auto').checked));
|
|
50
|
+
setInterval(() => { if ($('auto').checked && !document.hidden) refresh(); }, 5000);
|
|
51
|
+
refresh();
|
package/src/ui.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>agent-env</title>
|
|
4
|
+
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
|
5
|
+
<style>
|
|
6
|
+
:root{color-scheme:light;--ink:#20241f;--muted:#62695e;--line:#d9ded3;--paper:#f6f7f2;--green:#256941}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 ui-sans-serif,system-ui,sans-serif}main{max-width:1040px;margin:0 auto;padding:48px 24px}header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:40px}h1{font-size:26px;letter-spacing:-1px;margin:0}h2{font-size:18px;margin:0}.sub{color:var(--muted);font-size:13px}button,select,a{font:inherit}button,select{padding:7px 12px;border:1px solid var(--line);border-radius:7px;background:white;color:var(--ink)}button{cursor:pointer}button:hover{border-color:var(--green)}button:focus-visible,a:focus-visible{outline:3px solid #90b899;outline-offset:3px}section{border:1px solid var(--line);border-radius:12px;background:#fff;margin-bottom:18px;overflow:hidden}.top{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:20px 22px}.top>div{min-width:0;overflow-wrap:anywhere}.service>strong{min-width:0;overflow-wrap:anywhere}.badge{flex-shrink:0;font-size:12px;border-radius:99px;padding:3px 9px;background:#edf3e9;color:#286240}.badge.off{background:#f0f0ec;color:#5e6559}.services{border-top:1px solid var(--line)}.service{display:grid;grid-template-columns:140px 1fr auto;align-items:center;gap:12px;padding:12px 22px;border-bottom:1px solid #eef0e9}.service:last-child{border-bottom:0}.service a{color:var(--green);overflow-wrap:anywhere}.actions{display:flex;align-items:center;gap:8px}.hint{margin:0 22px 18px;color:var(--muted);font-size:12px}.error{color:#983c25;white-space:pre-wrap}#empty{padding:60px 20px;text-align:center;border:1px dashed var(--line);border-radius:12px}code{font:13px ui-monospace,monospace}dialog{width:min(850px,95vw);border:1px solid var(--line);border-radius:12px;padding:20px}dialog::backdrop{background:#20241f80}dialog header{margin-bottom:16px}pre{background:#181d18;color:#e5eae0;white-space:pre-wrap;overflow-wrap:anywhere;max-height:65vh;overflow:auto;border-radius:8px;padding:16px;font:12px/1.6 ui-monospace,monospace}footer{margin-top:30px;color:var(--muted);font-size:12px}@media(max-width:600px){main{padding:24px 14px}header{margin-bottom:24px}.top{padding:16px}.service{padding:12px 16px;grid-template-columns:1fr auto}.service a,.service .address{grid-row:2;grid-column:1/-1}.hint{margin-left:16px}.top .actions{flex-wrap:wrap;justify-content:flex-end}}
|
|
7
|
+
.brand{display:flex;align-items:center;gap:12px}.brand img{flex-shrink:0}@media(max-width:600px){main>header{flex-wrap:wrap}}
|
|
8
|
+
</style></head>
|
|
9
|
+
<body><main><header><div class="brand"><img src="/icon.svg" width="44" height="44" alt=""><div><h1>agent-env<span style="color:var(--green)">.</span></h1><div class="sub">Your development environments</div></div></div><div class="actions"><label class="sub"><input id="auto" type="checkbox"> Auto refresh</label><button id="refresh">Refresh</button></div></header>
|
|
10
|
+
<p id="notice" class="error" role="status"></p><div id="instances"></div><div id="empty" hidden><h2>Nothing running yet</h2><p class="sub">Start an environment from your terminal.</p><code>agent-env up</code></div><footer id="updated">Local access only</footer></main>
|
|
11
|
+
<dialog id="logs"><header><h2 id="log-title">Logs</h2><button id="close">Close</button></header><pre id="log-body"></pre></dialog>
|
|
12
|
+
<script src="/ui.js"></script></body></html>
|
package/src/ui.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
|
|
5
|
+
export async function startUi({ port = 4780, status, logs }) {
|
|
6
|
+
const [html, script, icon] = await Promise.all(['ui.html', 'ui-client.js', '../assets/icon.svg'].map(file => readFile(new URL(file, import.meta.url))));
|
|
7
|
+
const server = http.createServer(async (request, response) => {
|
|
8
|
+
const send = (code, data, type = 'application/json') => {
|
|
9
|
+
response.writeHead(code, { 'content-type': `${type}; charset=utf-8`, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'" });
|
|
10
|
+
response.end(type === 'application/json' ? JSON.stringify(data) : data);
|
|
11
|
+
};
|
|
12
|
+
if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.socket.remoteAddress)) return send(403, { error: 'Local access only.' });
|
|
13
|
+
const address = server.address();
|
|
14
|
+
const allowed = new Set([`127.0.0.1:${address.port}`, `localhost:${address.port}`, `[::1]:${address.port}`]);
|
|
15
|
+
if (!allowed.has(request.headers.host)) return send(403, { error: 'Use the local dashboard address.' });
|
|
16
|
+
if (request.headers.origin && ![`http://${request.headers.host}`].includes(request.headers.origin)) return send(403, { error: 'Cross-origin requests are not allowed.' });
|
|
17
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return send(403, { error: 'Cross-site requests are not allowed.' });
|
|
18
|
+
if (request.method !== 'GET') return send(405, { error: 'This dashboard is read-only.' });
|
|
19
|
+
const url = new URL(request.url, 'http://localhost');
|
|
20
|
+
try {
|
|
21
|
+
if (url.pathname === '/') return send(200, html, 'text/html');
|
|
22
|
+
if (url.pathname === '/ui.js') return send(200, script, 'text/javascript');
|
|
23
|
+
if (url.pathname === '/icon.svg') return send(200, icon, 'image/svg+xml');
|
|
24
|
+
if (url.pathname === '/api/status') return send(200, { instances: await status() });
|
|
25
|
+
if (url.pathname === '/api/logs') return send(200, { output: await logs(url.searchParams.get('name'), url.searchParams.get('service')) });
|
|
26
|
+
send(404, { error: 'Not found.' });
|
|
27
|
+
} catch (error) { send(400, { error: error.message }); }
|
|
28
|
+
});
|
|
29
|
+
server.listen(port, '127.0.0.1');
|
|
30
|
+
await once(server, 'listening');
|
|
31
|
+
return { url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise(resolve => { server.closeAllConnections(); server.close(resolve); }) };
|
|
32
|
+
}
|
package/src/watch.mjs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { watch, existsSync } from 'node:fs';
|
|
2
|
+
import { resolve, sep, basename } from 'node:path';
|
|
3
|
+
import { shouldSync } from './source-paths.mjs';
|
|
4
|
+
export { shouldSync } from './source-paths.mjs';
|
|
5
|
+
// The caller serializes synchronization with lifecycle changes.
|
|
6
|
+
export function watchSource(cwd, { changed, error = () => {}, debounceMs = 350, exclusions = [], stateDirectory } = {}) {
|
|
7
|
+
let timer;
|
|
8
|
+
const state = stateDirectory ? resolve(stateDirectory) : null;
|
|
9
|
+
const watcher = watch(cwd, { recursive: true, persistent: false }, (_, filename) => {
|
|
10
|
+
const path = resolve(cwd, filename ?? '');
|
|
11
|
+
if (filename === basename(cwd) && !existsSync(path)) return;
|
|
12
|
+
if (state && (path === state || path.startsWith(`${state}${sep}`))) return;
|
|
13
|
+
if (!shouldSync(filename, exclusions)) return;
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
timer = setTimeout(changed, debounceMs);
|
|
16
|
+
timer.unref();
|
|
17
|
+
});
|
|
18
|
+
watcher.on('error', error);
|
|
19
|
+
return () => { clearTimeout(timer); watcher.close(); };
|
|
20
|
+
}
|