@kubilaycakmak/projectfreeze 0.0.0-stage → 0.1.0-dev.2
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 +27 -2
- package/bin/projectfreeze.js +4 -0
- package/docs/interfaces.md +84 -0
- package/docs/operations.md +40 -0
- package/examples/demo.js +49 -0
- package/examples/stream-test.mjs +71 -0
- package/examples/workload.js +29 -0
- package/helpers/.gitkeep +0 -0
- package/helpers/projectfreeze-darwin-arm64 +0 -0
- package/helpers/projectfreeze-darwin-arm64.sha256 +1 -0
- package/helpers/projectfreeze-darwin-x64 +0 -0
- package/helpers/projectfreeze-darwin-x64.sha256 +1 -0
- package/helpers/projectfreeze-linux-arm64 +0 -0
- package/helpers/projectfreeze-linux-arm64.sha256 +1 -0
- package/helpers/projectfreeze-linux-x64 +0 -0
- package/helpers/projectfreeze-linux-x64.sha256 +1 -0
- package/helpers/projectfreeze-win32-arm64.exe +0 -0
- package/helpers/projectfreeze-win32-arm64.exe.sha256 +1 -0
- package/helpers/projectfreeze-win32-x64.exe +0 -0
- package/helpers/projectfreeze-win32-x64.exe.sha256 +1 -0
- package/package.json +17 -4
- package/src/cli.js +117 -0
- package/src/contracts.d.ts +28 -0
- package/src/discovery.js +94 -0
- package/src/engine.js +734 -0
- package/src/errors.js +20 -0
- package/src/integrations.js +195 -0
- package/src/ipc.js +258 -0
- package/src/line-menu.js +132 -0
- package/src/mcp.js +110 -0
- package/src/platform.js +274 -0
- package/src/recipe.js +66 -0
- package/src/store.js +61 -0
- package/src/supervisors.js +163 -0
- package/src/systemd.js +104 -0
- package/src/tui.js +443 -0
- package/src/windows-supervisors.ps1 +83 -0
package/src/errors.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export class Fault extends Error {
|
|
2
|
+
/** @param {string} code @param {string} message @param {unknown} [details] */
|
|
3
|
+
constructor(code, message, details) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.details = details;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** @param {unknown} error */
|
|
11
|
+
export function problem(error) {
|
|
12
|
+
if (error instanceof Fault) return { code: error.code, message: error.message, details: error.details };
|
|
13
|
+
return { code: 'INTERNAL', message: 'An internal operation failed. Inspect the operation journal.' };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @param {string} code */
|
|
17
|
+
export function exitCode(code) {
|
|
18
|
+
return ({ USAGE: 2, NOT_FOUND: 3, DENIED: 4, UNSUPPORTED: 5, BUSY: 6, PARTIAL: 7,
|
|
19
|
+
CANCELLED: 8, UNAVAILABLE: 9, INTERNAL: 1 })[code] ?? 1;
|
|
20
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { arch, release } from 'node:os';
|
|
5
|
+
import { isAbsolute, join } from 'node:path';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import { Fault } from './errors.js';
|
|
8
|
+
import { bootId, command, executable, linux } from './platform.js';
|
|
9
|
+
|
|
10
|
+
const dockerEnv = () => {
|
|
11
|
+
const env = { ...process.env };
|
|
12
|
+
for (const name of Object.keys(env)) if (/^DOCKER_(HOST|CONTEXT|TLS|TLS_VERIFY|CERT_PATH|API_VERSION)$/i.test(name)) delete env[name];
|
|
13
|
+
return env;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
async function docker(args, options = {}) {
|
|
17
|
+
const env = dockerEnv();
|
|
18
|
+
const cli = await executable('docker', process.cwd(), env);
|
|
19
|
+
const host = process.platform === 'win32' ? 'npipe:////./pipe/docker_engine' : 'unix:///var/run/docker.sock';
|
|
20
|
+
return command(cli, ['--host', host, ...args], { env, ...options });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function composeTargets(project, all) {
|
|
24
|
+
const targets = [];
|
|
25
|
+
for (const service of project.recipe?.containers ?? []) {
|
|
26
|
+
const shared = service.shared || all.some(other => other.id !== project.id && other.recipe?.containers.some(candidate =>
|
|
27
|
+
candidate.project === service.project && candidate.service === service.service));
|
|
28
|
+
if (shared) { targets.push({ ...service, shared }); continue; }
|
|
29
|
+
const output = await docker(['ps', '-aq', '--no-trunc', '--filter', `label=com.docker.compose.project=${service.project}`,
|
|
30
|
+
'--filter', `label=com.docker.compose.service=${service.service}`]);
|
|
31
|
+
const ids = output.trim().split(/\s+/).filter(id => /^[a-f0-9]{64}$/.test(id));
|
|
32
|
+
if (!ids.length) throw new Fault('NOT_FOUND', `No existing Compose container matches ${service.project}/${service.service}.`);
|
|
33
|
+
for (const id of ids) {
|
|
34
|
+
const container = await inspectDocker(id);
|
|
35
|
+
const labels = container.Config.Labels ?? {};
|
|
36
|
+
if (container.Id !== id || labels['com.docker.compose.project'] !== service.project ||
|
|
37
|
+
labels['com.docker.compose.service'] !== service.service || labels['com.docker.compose.oneoff']?.toLowerCase() === 'true') {
|
|
38
|
+
throw new Fault('DENIED', 'Compose container identity did not match the approved policy.');
|
|
39
|
+
}
|
|
40
|
+
const configDir = labels['com.docker.compose.project.working_dir'];
|
|
41
|
+
if (!configDir || configDir !== project.cwd) throw new Fault('DENIED', 'Compose working-directory label differs from the registered project.');
|
|
42
|
+
if (container.HostConfig.RestartPolicy.Name !== 'no' && container.HostConfig.RestartPolicy.Name !== '') {
|
|
43
|
+
throw new Fault('UNSUPPORTED', 'Set the exclusive container restart policy to no before managing it.');
|
|
44
|
+
}
|
|
45
|
+
targets.push({ ...service, id, running: container.State.Running, paused: container.State.Paused });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return targets;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function inspectDocker(id) {
|
|
52
|
+
if (!/^[a-f0-9]{64}$/.test(id)) throw new Fault('USAGE', 'Container identity is invalid.');
|
|
53
|
+
return JSON.parse(await docker(['inspect', '--type', 'container', id]))[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function containerAction(action, id, timeout = 10000) {
|
|
57
|
+
if (!['pause', 'unpause', 'start', 'stop'].includes(action)) throw new Fault('USAGE', 'Unknown container action.');
|
|
58
|
+
await inspectDocker(id);
|
|
59
|
+
await docker([action, ...(action === 'stop' ? ['--timeout', String(Math.ceil(timeout / 1000))] : []), id], { timeout: timeout + 5000 });
|
|
60
|
+
const { State: state } = await inspectDocker(id);
|
|
61
|
+
const verified = { pause: state.Running && state.Paused, unpause: state.Running && !state.Paused,
|
|
62
|
+
start: state.Running && !state.Paused, stop: !state.Running }[action];
|
|
63
|
+
if (!verified) throw new Fault('PARTIAL', 'The container did not reach its requested state.');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function hashFiles(path) {
|
|
67
|
+
const files = {};
|
|
68
|
+
async function walk(relative) {
|
|
69
|
+
const entries = await readdir(join(path, relative));
|
|
70
|
+
for (const name of entries.sort()) {
|
|
71
|
+
if (!relative && name === 'manifest.json') continue;
|
|
72
|
+
const key = relative ? `${relative}/${name}` : name;
|
|
73
|
+
const file = join(path, key);
|
|
74
|
+
const info = await lstat(file);
|
|
75
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(name) || (!info.isDirectory() && !info.isFile())) {
|
|
76
|
+
throw new Fault('DENIED', 'Checkpoint contains an unexpected file type.');
|
|
77
|
+
}
|
|
78
|
+
if (info.isDirectory()) { await walk(key); continue; }
|
|
79
|
+
if (name.endsWith('.log')) continue;
|
|
80
|
+
const hash = createHash('sha256');
|
|
81
|
+
for await (const chunk of createReadStream(file)) hash.update(chunk);
|
|
82
|
+
Object.defineProperty(files, key, { value: hash.digest('hex'), enumerable: true });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
await walk('');
|
|
86
|
+
if (!Object.keys(files).length) throw new Fault('PARTIAL', 'CRIU generated no images.');
|
|
87
|
+
return files;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function criu() {
|
|
91
|
+
if (!linux) throw new Fault('UNSUPPORTED', 'Native checkpoint requires Linux CRIU. Use freeze/resume or sleep/wake on this host.');
|
|
92
|
+
const cli = await executable('criu', process.cwd(), process.env);
|
|
93
|
+
await command(cli, ['check', '--no-default-config'], { timeout: 30000 }).catch(error => {
|
|
94
|
+
throw new Fault(error.code === 'DENIED' ? 'DENIED' : 'UNSUPPORTED', 'CRIU preflight failed. Verify kernel support and CRIU permissions.');
|
|
95
|
+
});
|
|
96
|
+
const version = (await command(cli, ['--version'])).trim();
|
|
97
|
+
return { cli, version };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function checkpoint(state, project, root, backend, targets, mark, verify) {
|
|
101
|
+
const id = randomUUID();
|
|
102
|
+
const path = join(state, `checkpoint-${id}`);
|
|
103
|
+
const manifest = { schemaVersion: 1, id, backend, project: project.id, platform: process.platform, arch: arch(),
|
|
104
|
+
kernel: release(), boot: await bootId(), cwd: project.cwd, captured: new Date().toISOString() };
|
|
105
|
+
if (backend === 'docker') {
|
|
106
|
+
if (!linux) throw new Fault('UNSUPPORTED', 'Docker checkpoint images require a verifiable local Linux filesystem shared with the Docker engine.');
|
|
107
|
+
const eligible = targets.filter(target => !target.shared && target.running && !target.paused);
|
|
108
|
+
if (eligible.length !== 1 || targets.length !== 1 || root) throw new Fault('UNSUPPORTED', 'Docker checkpoint requires a single exclusive running container and no native workload.');
|
|
109
|
+
const version = JSON.parse(await docker(['version', '--format', '{{json .Server}}']));
|
|
110
|
+
const info = JSON.parse(await docker(['info', '--format', '{{json .}}']));
|
|
111
|
+
if (version.Os !== 'linux' || !info.ExperimentalBuild || info.KernelVersion !== release()) {
|
|
112
|
+
throw new Fault('UNSUPPORTED', 'Docker checkpoint requires a local experimental Linux Docker engine with CRIU.');
|
|
113
|
+
}
|
|
114
|
+
const container = await inspectDocker(eligible[0].id);
|
|
115
|
+
if (container.Mounts.length || container.HostConfig.Privileged || container.HostConfig.AutoRemove || container.HostConfig.NetworkMode !== 'host') {
|
|
116
|
+
throw new Fault('UNSUPPORTED', 'This Docker checkpoint backend requires an existing host-network container with no mounts, privilege escalation, or auto-removal. Use freeze or sleep for isolated-network containers.');
|
|
117
|
+
}
|
|
118
|
+
if (!isAbsolute(info.DockerRootDir)) throw new Fault('DENIED', 'Docker did not report a local checkpoint store.');
|
|
119
|
+
const imagePath = join(info.DockerRootDir, 'containers', container.Id, 'checkpoints', id);
|
|
120
|
+
const access = await lstat(join(imagePath, '..')).catch(() => null);
|
|
121
|
+
if (!access?.isDirectory()) throw new Fault('DENIED', 'The caller cannot verify the Docker engine checkpoint directory.');
|
|
122
|
+
Object.assign(manifest, { container: container.Id, image: container.Image, version: version.Version, dockerRoot: info.DockerRootDir });
|
|
123
|
+
await mkdir(path, { mode: 0o700 });
|
|
124
|
+
mark(id);
|
|
125
|
+
await docker(['checkpoint', 'create', '--leave-running', manifest.container, id], { timeout: 120000 });
|
|
126
|
+
manifest.files = await hashFiles(imagePath);
|
|
127
|
+
} else {
|
|
128
|
+
if (!root || targets.length) throw new Fault('UNSUPPORTED', 'Native checkpoint requires a live native root and no container dependencies.');
|
|
129
|
+
const { cli, version } = await criu();
|
|
130
|
+
const namespace = await verify(root);
|
|
131
|
+
Object.assign(manifest, { version, root, namespace });
|
|
132
|
+
await mkdir(path, { mode: 0o700 });
|
|
133
|
+
mark(id);
|
|
134
|
+
await verify(root);
|
|
135
|
+
await command(cli, ['dump', '--no-default-config', '--tree', String(root.pid), '--images-dir', path, '--work-dir', path,
|
|
136
|
+
'--log-file', 'dump.log', '--leave-running'], { timeout: 120000 });
|
|
137
|
+
manifest.files = await hashFiles(path);
|
|
138
|
+
}
|
|
139
|
+
await writeFile(join(path, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
140
|
+
return id;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function restore(state, project, id, targets, mark) {
|
|
144
|
+
if (typeof id !== 'string' || !/^[a-f0-9-]{36}$/.test(id)) throw new Fault('USAGE', 'Supply a checkpoint ID.');
|
|
145
|
+
const path = join(state, `checkpoint-${id}`);
|
|
146
|
+
const manifest = JSON.parse(await readFile(join(path, 'manifest.json'), 'utf8').catch(() => {
|
|
147
|
+
throw new Fault('NOT_FOUND', 'A complete checkpoint manifest is missing.');
|
|
148
|
+
}));
|
|
149
|
+
if (manifest.schemaVersion !== 1 || manifest.project !== project.id || manifest.cwd !== project.cwd ||
|
|
150
|
+
manifest.platform !== process.platform || manifest.arch !== arch() || manifest.kernel !== release() || manifest.boot !== await bootId()) {
|
|
151
|
+
throw new Fault('DENIED', 'Checkpoint requires the original project, host boot, architecture, and kernel.');
|
|
152
|
+
}
|
|
153
|
+
if (manifest.backend === 'docker') {
|
|
154
|
+
const version = JSON.parse(await docker(['version', '--format', '{{json .Server}}']));
|
|
155
|
+
const info = JSON.parse(await docker(['info', '--format', '{{json .}}']));
|
|
156
|
+
const container = await inspectDocker(manifest.container);
|
|
157
|
+
if (version.Version !== manifest.version || container.Image !== manifest.image || info.DockerRootDir !== manifest.dockerRoot ||
|
|
158
|
+
!info.ExperimentalBuild || !targets.some(t => !t.shared && !t.running && t.id === manifest.container)) {
|
|
159
|
+
throw new Fault('DENIED', 'Restore requires the original stopped container and matching Docker version.');
|
|
160
|
+
}
|
|
161
|
+
const images = join(info.DockerRootDir, 'containers', container.Id, 'checkpoints', id);
|
|
162
|
+
if (JSON.stringify(await hashFiles(images)) !== JSON.stringify(manifest.files)) throw new Fault('DENIED', 'Checkpoint integrity verification failed.');
|
|
163
|
+
mark(id);
|
|
164
|
+
const collision = /^failed to upload checkpoint to containerd: commit failed: content sha256:[a-f0-9]{64}: already exists$/;
|
|
165
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
166
|
+
let failure;
|
|
167
|
+
try { await docker(['start', '--checkpoint', id, manifest.container], { timeout: 120000 }); }
|
|
168
|
+
catch (error) { failure = error; }
|
|
169
|
+
const { State: state } = await inspectDocker(manifest.container);
|
|
170
|
+
if (state.Running && !state.Restarting && !state.Paused) return undefined;
|
|
171
|
+
if (!failure || !collision.test(state.Error ?? '') || attempt === 2) {
|
|
172
|
+
throw new Fault('PARTIAL', collision.test(state.Error ?? '')
|
|
173
|
+
? 'Docker restore hit a containerd content collision. The checkpoint is retained.'
|
|
174
|
+
: 'Docker did not verify a running restored container. The checkpoint is retained. Inspect the local Docker container state.');
|
|
175
|
+
}
|
|
176
|
+
await delay(1500);
|
|
177
|
+
const current = await inspectDocker(manifest.container);
|
|
178
|
+
if (current.State.Running || current.State.Restarting || current.State.Paused || current.State.Dead || current.State.Pid) {
|
|
179
|
+
throw new Fault('PARTIAL', 'Docker container state changed after a failed restore. Inspect it before retrying.');
|
|
180
|
+
}
|
|
181
|
+
mark(id);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (manifest.backend !== 'criu') throw new Fault('DENIED', 'Unknown checkpoint backend.');
|
|
185
|
+
if (JSON.stringify(await hashFiles(path)) !== JSON.stringify(manifest.files)) throw new Fault('DENIED', 'Checkpoint integrity verification failed.');
|
|
186
|
+
const { cli, version } = await criu();
|
|
187
|
+
if (manifest.version !== version || targets.length) throw new Fault('DENIED', 'Restore requires the original CRIU version and capture scope.');
|
|
188
|
+
const work = join(state, `restore-${randomUUID()}`);
|
|
189
|
+
await mkdir(work, { mode: 0o700 });
|
|
190
|
+
mark(id);
|
|
191
|
+
const pidFile = join(work, 'pid');
|
|
192
|
+
await command(cli, ['restore', '--no-default-config', '--images-dir', path, '--work-dir', work, '--log-file', 'restore.log',
|
|
193
|
+
'--restore-detached', '--pidfile', pidFile], { timeout: 120000 });
|
|
194
|
+
return Number((await readFile(pidFile, 'utf8')).trim());
|
|
195
|
+
}
|
package/src/ipc.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { chmod, lstat, mkdir, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createConnection, createServer } from 'node:net';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { isAbsolute, join } from 'node:path';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { Engine } from './engine.js';
|
|
11
|
+
import { Fault, problem } from './errors.js';
|
|
12
|
+
import { command, checkHelper, helper, windows } from './platform.js';
|
|
13
|
+
import { Store } from './store.js';
|
|
14
|
+
|
|
15
|
+
export const protocol = 1;
|
|
16
|
+
const maxRequest = 192 * 1024;
|
|
17
|
+
const maxResponse = 16 * 1024 * 1024;
|
|
18
|
+
const nonce = () => randomBytes(32).toString('hex');
|
|
19
|
+
const validHex = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
20
|
+
const mac = (secret, ...parts) => createHmac('sha256', Buffer.from(secret, 'hex')).update(JSON.stringify(parts)).digest('hex');
|
|
21
|
+
const same = (a, b) => validHex(a) && validHex(b) && timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
|
|
22
|
+
const recordPath = dir => join(dir, 'endpoint.json');
|
|
23
|
+
const actions = new Set(['list', 'discover', 'inspect', 'listeners', 'history', 'operation', 'cancel', 'adopt', 'run', 'freeze', 'resume',
|
|
24
|
+
'sleep', 'wake', 'checkpoint', 'restore', 'reconcile', 'daemon-status', 'daemon-stop']);
|
|
25
|
+
|
|
26
|
+
export async function stateDirectory() {
|
|
27
|
+
const dir = process.env.PROJECTFREEZE_HOME ?? (windows ? join(process.env.LOCALAPPDATA ?? homedir(), 'ProjectFreeze') : join(homedir(), '.projectfreeze'));
|
|
28
|
+
if (!isAbsolute(dir)) throw new Fault('USAGE', 'PROJECTFREEZE_HOME must be an absolute path.');
|
|
29
|
+
if (windows) {
|
|
30
|
+
await checkHelper();
|
|
31
|
+
await command(helper, ['secure-directory', dir]).catch(() => { throw new Fault('DENIED', 'ProjectFreeze state requires a private directory owned by the current Windows user.'); });
|
|
32
|
+
} else {
|
|
33
|
+
await mkdir(dir, { mode: 0o700 }).catch(error => { if (error.code !== 'EEXIST') throw error; });
|
|
34
|
+
const info = await lstat(dir);
|
|
35
|
+
if (!info.isDirectory() || info.uid !== process.getuid() || (info.mode & 0o077)) {
|
|
36
|
+
throw new Fault('DENIED', 'ProjectFreeze state requires a real, user-owned 0700 directory.');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return realpath(dir);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function frames(socket, limit, callback) {
|
|
43
|
+
let buffer = Buffer.alloc(0);
|
|
44
|
+
socket.on('data', chunk => {
|
|
45
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
46
|
+
if (buffer.length > limit) { socket.destroy(); return; }
|
|
47
|
+
let end;
|
|
48
|
+
while ((end = buffer.indexOf(10)) !== -1 && !socket.destroyed) {
|
|
49
|
+
const raw = buffer.subarray(0, end).toString('utf8');
|
|
50
|
+
buffer = buffer.subarray(end + 1);
|
|
51
|
+
try { callback(JSON.parse(raw)); }
|
|
52
|
+
catch { socket.destroy(); }
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const line = value => `${JSON.stringify(value)}\n`;
|
|
58
|
+
const object = value => value && typeof value === 'object' && !Array.isArray(value);
|
|
59
|
+
|
|
60
|
+
function validate(action, input) {
|
|
61
|
+
if (!actions.has(action) || !object(input)) throw new Fault('USAGE', 'Unknown action or invalid arguments.');
|
|
62
|
+
if (input.ids !== undefined && (!Array.isArray(input.ids) || input.ids.length > 32 || input.ids.some(id => typeof id !== 'string' || !/^p-[a-f0-9]{16}$/.test(id)))) {
|
|
63
|
+
throw new Fault('USAGE', 'Project IDs must be an array of registered project identifiers.');
|
|
64
|
+
}
|
|
65
|
+
if (input.id !== undefined && (typeof input.id !== 'string' || input.id.length > 64)) throw new Fault('USAGE', 'Invalid identifier.');
|
|
66
|
+
for (const name of ['members', 'exclude']) {
|
|
67
|
+
if (input[name] !== undefined && (!Array.isArray(input[name]) || input[name].length > 256 || input[name].some(value =>
|
|
68
|
+
!object(value) || !Number.isSafeInteger(value.pid) || value.pid < 2 || typeof value.birth !== 'string' ||
|
|
69
|
+
!/^[0-9.]{1,64}$/.test(value.birth) || !validBoot(value.boot)))) throw new Fault('USAGE', 'Invalid process identity.');
|
|
70
|
+
}
|
|
71
|
+
if (input.cwd !== undefined && (typeof input.cwd !== 'string' || !isAbsolute(input.cwd))) throw new Fault('USAGE', 'Supply an absolute project directory.');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const validBoot = value => typeof value === 'string' && /^[a-f0-9-]{12,80}$/i.test(value);
|
|
75
|
+
|
|
76
|
+
function transport(record, dir) {
|
|
77
|
+
if (Number.isInteger(record.port) && record.port > 0 && record.port <= 65535) return { host: '127.0.0.1', port: record.port };
|
|
78
|
+
if (!windows && /^pf-[a-f0-9]{12}\.sock$/.test(record.socket)) return { path: join(dir, record.socket) };
|
|
79
|
+
throw new Fault('UNAVAILABLE', 'Local daemon endpoint is invalid.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function daemon() {
|
|
83
|
+
const dir = await stateDirectory();
|
|
84
|
+
if (!windows) process.umask(0o077);
|
|
85
|
+
const lock = new DatabaseSync(join(dir, 'daemon-lock.sqlite'));
|
|
86
|
+
try { lock.exec('BEGIN EXCLUSIVE'); }
|
|
87
|
+
catch { lock.close(); throw new Fault('BUSY', 'A daemon already owns this state directory.'); }
|
|
88
|
+
const store = new Store(dir);
|
|
89
|
+
const engine = new Engine(store, dir);
|
|
90
|
+
const secret = nonce();
|
|
91
|
+
const name = `pf-${nonce().slice(0, 12)}.sock`;
|
|
92
|
+
const socketPath = join(dir, name);
|
|
93
|
+
const loopback = windows || Buffer.byteLength(socketPath) >= 100;
|
|
94
|
+
const connections = new Set();
|
|
95
|
+
let closing = false;
|
|
96
|
+
let timer;
|
|
97
|
+
const server = createServer(socket => {
|
|
98
|
+
if (connections.size >= 96 || closing) { socket.destroy(); return; }
|
|
99
|
+
connections.add(socket);
|
|
100
|
+
socket.on('close', () => connections.delete(socket));
|
|
101
|
+
socket.on('error', () => socket.destroy());
|
|
102
|
+
socket.setTimeout(5000, () => socket.destroy());
|
|
103
|
+
let greeting;
|
|
104
|
+
let handled = false;
|
|
105
|
+
frames(socket, maxRequest, message => {
|
|
106
|
+
if (!greeting) {
|
|
107
|
+
if (message?.v !== protocol || !validHex(message.client)) { socket.destroy(); return; }
|
|
108
|
+
const server = nonce();
|
|
109
|
+
greeting = { client: message.client, server };
|
|
110
|
+
socket.write(line({ server, proof: mac(secret, 'server', protocol, greeting.client, server) }));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (handled || typeof message?.request !== 'string' || !same(message.proof,
|
|
114
|
+
mac(secret, 'client', protocol, greeting.client, greeting.server, message.request))) { socket.destroy(); return; }
|
|
115
|
+
handled = true;
|
|
116
|
+
socket.setTimeout(20000, () => socket.destroy());
|
|
117
|
+
void (async () => {
|
|
118
|
+
const { action, input } = JSON.parse(message.request);
|
|
119
|
+
validate(action, input);
|
|
120
|
+
if (action === 'daemon-status') return { pid: process.pid, protocol, busy: engine.current?.op.id };
|
|
121
|
+
if (action === 'daemon-stop') {
|
|
122
|
+
if (engine.current) throw new Fault('BUSY', 'Wait for the operation to finish before stopping the daemon.');
|
|
123
|
+
closing = true;
|
|
124
|
+
engine.closing = true;
|
|
125
|
+
while (engine.tracking) await delay(25);
|
|
126
|
+
return { stopped: true };
|
|
127
|
+
}
|
|
128
|
+
return engine.dispatch(action, input);
|
|
129
|
+
})().then(data => ({ ok: true, data }), error => ({ ok: false, error: problem(error) })).then(result => {
|
|
130
|
+
const response = JSON.stringify(result);
|
|
131
|
+
if (closing && result.ok && result.data?.stopped) {
|
|
132
|
+
socket.end(line({ response, proof: mac(secret, 'response', protocol, greeting.client, greeting.server, response) }), () => void shutdown());
|
|
133
|
+
} else socket.end(line({ response, proof: mac(secret, 'response', protocol, greeting.client, greeting.server, response) }));
|
|
134
|
+
}).catch(() => socket.destroy());
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
let stopped = false;
|
|
139
|
+
async function shutdown() {
|
|
140
|
+
if (stopped) return;
|
|
141
|
+
stopped = true;
|
|
142
|
+
closing = true;
|
|
143
|
+
clearInterval(timer);
|
|
144
|
+
for (const socket of connections) socket.destroy();
|
|
145
|
+
await new Promise(resolve => server.close(resolve));
|
|
146
|
+
await unlink(recordPath(dir)).catch(() => {});
|
|
147
|
+
if (!loopback) await unlink(socketPath).catch(() => {});
|
|
148
|
+
while (engine.current || engine.tracking) await delay(100);
|
|
149
|
+
store.close();
|
|
150
|
+
lock.exec('ROLLBACK');
|
|
151
|
+
lock.close();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const old = await readFile(recordPath(dir), 'utf8').then(JSON.parse).catch(() => null);
|
|
155
|
+
if (!windows && old && /^pf-[a-f0-9]{12}\.sock$/.test(old.socket)) await unlink(join(dir, old.socket)).catch(() => {});
|
|
156
|
+
await new Promise((resolve, reject) => {
|
|
157
|
+
server.once('error', reject);
|
|
158
|
+
server.listen(loopback ? { host: '127.0.0.1', port: 0, exclusive: true } : { path: socketPath, exclusive: true }, resolve);
|
|
159
|
+
});
|
|
160
|
+
if (!loopback) await chmod(socketPath, 0o600);
|
|
161
|
+
const record = { v: protocol, secret, ...(loopback ? { port: server.address().port } : { socket: name }) };
|
|
162
|
+
const staged = join(dir, `endpoint-${nonce().slice(0, 12)}.json`);
|
|
163
|
+
await writeFile(staged, line(record), { flag: 'wx', mode: 0o600 });
|
|
164
|
+
await rename(staged, recordPath(dir));
|
|
165
|
+
timer = setInterval(() => { if (!closing) void engine.poll().catch(() => {}); }, 1750);
|
|
166
|
+
timer.unref();
|
|
167
|
+
process.once('SIGINT', () => void shutdown());
|
|
168
|
+
process.once('SIGTERM', () => void shutdown());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function connect(dir, action, input) {
|
|
172
|
+
const record = await readFile(recordPath(dir), 'utf8').then(JSON.parse).catch(() => null);
|
|
173
|
+
if (!record || !validHex(record.secret)) throw new Fault('UNAVAILABLE', 'Local daemon is not running.');
|
|
174
|
+
if (record.v !== protocol) throw new Fault('UNSUPPORTED', 'Local daemon protocol differs. Stop the old daemon and restart it.');
|
|
175
|
+
const address = transport(record, dir);
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const socket = createConnection(address);
|
|
178
|
+
const client = nonce();
|
|
179
|
+
const unavailable = () => reject(new Fault('UNAVAILABLE', 'Local daemon did not complete the authenticated request.'));
|
|
180
|
+
let greeting;
|
|
181
|
+
socket.setTimeout(22000, () => socket.destroy());
|
|
182
|
+
socket.on('error', unavailable);
|
|
183
|
+
socket.on('close', unavailable);
|
|
184
|
+
socket.once('connect', () => socket.write(line({ v: protocol, client })));
|
|
185
|
+
frames(socket, maxResponse, message => {
|
|
186
|
+
if (!greeting) {
|
|
187
|
+
if (!validHex(message?.server) || !same(message.proof, mac(record.secret, 'server', protocol, client, message.server))) {
|
|
188
|
+
socket.destroy(); return;
|
|
189
|
+
}
|
|
190
|
+
greeting = message.server;
|
|
191
|
+
const request = JSON.stringify({ action, input });
|
|
192
|
+
if (Buffer.byteLength(request) > maxRequest / 2) { reject(new Fault('USAGE', 'Request is too large.')); socket.destroy(); return; }
|
|
193
|
+
socket.write(line({ request, proof: mac(record.secret, 'client', protocol, client, greeting, request) }));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (typeof message?.response !== 'string' || !same(message.proof, mac(record.secret, 'response', protocol, client, greeting, message.response))) {
|
|
197
|
+
socket.destroy(); return;
|
|
198
|
+
}
|
|
199
|
+
const result = JSON.parse(message.response);
|
|
200
|
+
if (result.ok) resolve(result.data);
|
|
201
|
+
else reject(new Fault(result.error.code, result.error.message, result.error.details));
|
|
202
|
+
socket.destroy();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
let cachedDir;
|
|
208
|
+
let starting;
|
|
209
|
+
async function start(dir) {
|
|
210
|
+
let running = false;
|
|
211
|
+
for (let i = 0; i < 100; i++) {
|
|
212
|
+
if (!running && i % 10 === 0) {
|
|
213
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('../bin/projectfreeze.js', import.meta.url)), '_daemon'],
|
|
214
|
+
{ detached: true, stdio: 'ignore', windowsHide: true, cwd: dir, env: process.env });
|
|
215
|
+
running = true;
|
|
216
|
+
child.once('error', () => { running = false; });
|
|
217
|
+
child.once('exit', () => { running = false; });
|
|
218
|
+
child.unref();
|
|
219
|
+
}
|
|
220
|
+
await delay(120);
|
|
221
|
+
try { return await connect(dir, 'daemon-status', {}); }
|
|
222
|
+
catch (error) { if (error.code !== 'UNAVAILABLE') throw error; }
|
|
223
|
+
}
|
|
224
|
+
throw new Fault('UNAVAILABLE', 'Daemon startup timed out. Run projectfreeze daemon foreground to inspect the startup error.');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function request(action, input = {}, autoStart = true) {
|
|
228
|
+
const dir = await (cachedDir ??= stateDirectory());
|
|
229
|
+
if (action === 'daemon-start') {
|
|
230
|
+
try { return await connect(dir, 'daemon-status', {}); }
|
|
231
|
+
catch (error) { if (error.code !== 'UNAVAILABLE') throw error; }
|
|
232
|
+
return start(dir);
|
|
233
|
+
}
|
|
234
|
+
if (!autoStart) return connect(dir, action, input);
|
|
235
|
+
if (['list', 'discover', 'inspect', 'listeners', 'history', 'operation'].includes(action)) {
|
|
236
|
+
try { return await connect(dir, action, input); }
|
|
237
|
+
catch (error) { if (error.code !== 'UNAVAILABLE') throw error; }
|
|
238
|
+
}
|
|
239
|
+
try { await connect(dir, 'daemon-status', {}); }
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (error.code !== 'UNAVAILABLE') throw error;
|
|
242
|
+
await (starting ??= start(dir).finally(() => { starting = undefined; }));
|
|
243
|
+
}
|
|
244
|
+
return connect(dir, action, input);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function waitOperation(id, onProgress = () => {}, signal) {
|
|
248
|
+
let last;
|
|
249
|
+
while (!signal?.aborted) {
|
|
250
|
+
const op = await request('operation', { id });
|
|
251
|
+
const stamp = `${op.status}/${op.outcomes.length}/${op.pending}`;
|
|
252
|
+
if (stamp !== last) { onProgress(op); last = stamp; }
|
|
253
|
+
if (op.status !== 'running') return op;
|
|
254
|
+
if (signal?.aborted) return op;
|
|
255
|
+
await delay(200);
|
|
256
|
+
}
|
|
257
|
+
return { id, status: 'running', outcomes: [] };
|
|
258
|
+
}
|
package/src/line-menu.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { problem } from './errors.js';
|
|
5
|
+
import { request, waitOperation } from './ipc.js';
|
|
6
|
+
import { identity } from './platform.js';
|
|
7
|
+
|
|
8
|
+
const write = line => process.stdout.write(`${line}\n`);
|
|
9
|
+
const clean = value => String(value).replace(/[\p{Cc}\p{Cf}]/gu, ' ');
|
|
10
|
+
const endpoints = ports => [...new Set(ports.map(({ port }) => port))].join(', ') || 'none';
|
|
11
|
+
const actions = { f: 'freeze', u: 'resume', z: 'sleep', w: 'wake' };
|
|
12
|
+
|
|
13
|
+
export async function lineMenu() {
|
|
14
|
+
const input = createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
|
|
15
|
+
let nextLine;
|
|
16
|
+
let closed = false;
|
|
17
|
+
function receive(value) {
|
|
18
|
+
const resolveLine = nextLine;
|
|
19
|
+
nextLine = undefined;
|
|
20
|
+
resolveLine?.(value);
|
|
21
|
+
}
|
|
22
|
+
input.on('line', receive);
|
|
23
|
+
input.once('close', () => { closed = true; receive(undefined); });
|
|
24
|
+
const abort = new AbortController();
|
|
25
|
+
const numbers = new Map();
|
|
26
|
+
function detach() { abort.abort(); input.close(); }
|
|
27
|
+
process.once('SIGINT', detach);
|
|
28
|
+
async function ask(prompt) {
|
|
29
|
+
if (closed) return undefined;
|
|
30
|
+
return new Promise(resolveLine => {
|
|
31
|
+
nextLine = value => resolveLine(value?.trim());
|
|
32
|
+
write(prompt);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async function execute(action, payload, label) {
|
|
36
|
+
if ((await ask(`${label}\nApply ${action}? [y/N]`))?.toLowerCase() !== 'y') return;
|
|
37
|
+
const started = await request(action, payload);
|
|
38
|
+
write(`Operation ${started.id}`);
|
|
39
|
+
const result = await waitOperation(started.id, () => {}, abort.signal);
|
|
40
|
+
write(`${action} ${result.status}`);
|
|
41
|
+
for (const outcome of result.outcomes.filter(item => item.status === 'failed')) write(clean(outcome.message ?? outcome.code));
|
|
42
|
+
}
|
|
43
|
+
async function showListeners() {
|
|
44
|
+
const ports = await request('listeners');
|
|
45
|
+
write('TCP listeners (read-only)');
|
|
46
|
+
if (!ports.length) write('No TCP listeners observed.');
|
|
47
|
+
for (const port of ports) write(clean(`${port.address.includes(':') ? `[${port.address}]` : port.address}:${port.port} PID ${port.pid} ${port.name ?? port.image ?? 'unassigned'} (${port.scope})`));
|
|
48
|
+
}
|
|
49
|
+
async function loadRecipe(path) {
|
|
50
|
+
const recipe = JSON.parse(await readFile(resolve(path), 'utf8'));
|
|
51
|
+
recipe.cwd = resolve(recipe.cwd ?? '.');
|
|
52
|
+
return recipe;
|
|
53
|
+
}
|
|
54
|
+
async function adopt(project) {
|
|
55
|
+
if (!project.candidates.length) { write('No current candidates to review.'); return; }
|
|
56
|
+
for (const row of project.candidates) write(clean(`Candidate PID ${row.pid} TCP ${endpoints(row.ports)} ${row.image ?? ''}`));
|
|
57
|
+
const value = await ask('Approved PIDs, comma-separated (Enter cancels)');
|
|
58
|
+
if (!value) return;
|
|
59
|
+
const pids = new Set(value.split(',').map(value => Number(value.trim())));
|
|
60
|
+
const approved = project.candidates.filter(row => pids.has(row.pid));
|
|
61
|
+
if (approved.length !== pids.size) { write('Select only the listed candidate PIDs.'); return; }
|
|
62
|
+
const path = await ask('Optional JSON restart recipe (Enter skips)');
|
|
63
|
+
await execute('adopt', { cwd: project.cwd, confirm: true, members: approved.map(identity), recipe: path ? await loadRecipe(path) : undefined },
|
|
64
|
+
`${clean(project.name)} PID ${approved.map(row => row.pid).join(', ')}`);
|
|
65
|
+
}
|
|
66
|
+
async function inspect(project) {
|
|
67
|
+
while (!abort.signal.aborted) {
|
|
68
|
+
const live = await request('inspect', { id: project.id });
|
|
69
|
+
write(clean(`${live.name} [${live.state}] TCP ${endpoints(live.ports)} ${live.id}`));
|
|
70
|
+
for (const row of live.processes) write(clean(`PID ${row.pid} TCP ${endpoints(row.ports)} ${row.image ?? ''}`));
|
|
71
|
+
const value = await ask('f Freeze u Resume z Sleep w Wake a Review candidates r Refresh b Back q Quit');
|
|
72
|
+
if (value === 'q' || value === undefined) { detach(); return; }
|
|
73
|
+
if (value === 'b' || value === '') return;
|
|
74
|
+
if (value === 'a' || (value === 'f' && live.state === 'discovered')) { await adopt(live); continue; }
|
|
75
|
+
if (!Object.hasOwn(actions, value)) continue;
|
|
76
|
+
await execute(actions[value], { ids: [project.id] }, `${clean(live.name)} ${live.processCount} process(es) TCP ${endpoints(live.ports)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function renderProjects(projects) {
|
|
80
|
+
const lines = projects.map(project => {
|
|
81
|
+
if (!numbers.has(project.id)) numbers.set(project.id, numbers.size + 1);
|
|
82
|
+
return clean(`${numbers.get(project.id)}. ${project.name} [${project.state}] TCP ${endpoints(project.ports)} ${project.processCount} process(es)`);
|
|
83
|
+
});
|
|
84
|
+
return lines.join('\n') || 'No projects observed. Use p to view TCP listeners.';
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
while (!abort.signal.aborted) {
|
|
88
|
+
try {
|
|
89
|
+
const scan = () => request('list').then(rows => rows.sort((a, b) => b.processCount - a.processCount || a.name.localeCompare(b.name)));
|
|
90
|
+
const prompt = 'Project number p Ports l Launch recipe Enter Refresh q Quit';
|
|
91
|
+
let projects = await scan();
|
|
92
|
+
let rendered = renderProjects(projects);
|
|
93
|
+
write(`\nProjectFreeze\n${rendered}`);
|
|
94
|
+
let waiting = true;
|
|
95
|
+
let updating = false;
|
|
96
|
+
const timer = setInterval(async () => {
|
|
97
|
+
if (updating) return;
|
|
98
|
+
updating = true;
|
|
99
|
+
try {
|
|
100
|
+
const snapshot = await scan();
|
|
101
|
+
if (!waiting || !nextLine) return;
|
|
102
|
+
projects = snapshot;
|
|
103
|
+
const current = renderProjects(projects);
|
|
104
|
+
if (rendered !== current) {
|
|
105
|
+
rendered = current;
|
|
106
|
+
write(`\nProjectFreeze\n${rendered}\n${prompt}`);
|
|
107
|
+
}
|
|
108
|
+
} catch { /* The previous snapshot remains visible until the next scan. */ }
|
|
109
|
+
finally { updating = false; }
|
|
110
|
+
}, 2500);
|
|
111
|
+
let value;
|
|
112
|
+
try { value = await ask(prompt); }
|
|
113
|
+
finally { waiting = false; clearInterval(timer); }
|
|
114
|
+
if (value === 'q' || value === undefined) return;
|
|
115
|
+
if (value === 'p') await showListeners();
|
|
116
|
+
else if (value === 'l') {
|
|
117
|
+
const path = await ask('Path to JSON launch recipe (Enter cancels)');
|
|
118
|
+
if (path) {
|
|
119
|
+
const recipe = await loadRecipe(path);
|
|
120
|
+
await execute('run', { recipe }, clean(`${recipe.cwd}\nExecutable ${recipe.argv?.[0] ?? '(missing)'}`));
|
|
121
|
+
}
|
|
122
|
+
} else if (/^[1-9]\d*$/.test(value)) {
|
|
123
|
+
const project = projects.find(row => numbers.get(row.id) === Number(value));
|
|
124
|
+
if (project) await inspect(project);
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
write(clean(problem(error).message));
|
|
128
|
+
if ((await ask('Enter Retry q Quit')) !== '') return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} finally { process.off('SIGINT', detach); input.close(); }
|
|
132
|
+
}
|