@kubilaycakmak/projectfreeze 0.0.0-stage → 0.1.0-dev.5

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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -2
  3. package/bin/projectfreeze.js +4 -0
  4. package/docs/interfaces.md +98 -0
  5. package/docs/operations.md +42 -0
  6. package/examples/demo.js +72 -0
  7. package/examples/stream-test.mjs +71 -0
  8. package/examples/workload.js +29 -0
  9. package/helpers/.gitkeep +0 -0
  10. package/helpers/projectfreeze-darwin-arm64 +0 -0
  11. package/helpers/projectfreeze-darwin-arm64.sha256 +1 -0
  12. package/helpers/projectfreeze-darwin-x64 +0 -0
  13. package/helpers/projectfreeze-darwin-x64.sha256 +1 -0
  14. package/helpers/projectfreeze-linux-arm64 +0 -0
  15. package/helpers/projectfreeze-linux-arm64.sha256 +1 -0
  16. package/helpers/projectfreeze-linux-x64 +0 -0
  17. package/helpers/projectfreeze-linux-x64.sha256 +1 -0
  18. package/helpers/projectfreeze-win32-arm64.exe +0 -0
  19. package/helpers/projectfreeze-win32-arm64.exe.sha256 +1 -0
  20. package/helpers/projectfreeze-win32-x64.exe +0 -0
  21. package/helpers/projectfreeze-win32-x64.exe.sha256 +1 -0
  22. package/package.json +17 -4
  23. package/src/cli.js +135 -0
  24. package/src/contracts.d.ts +28 -0
  25. package/src/discovery.js +112 -0
  26. package/src/engine.js +820 -0
  27. package/src/errors.js +20 -0
  28. package/src/integrations.js +195 -0
  29. package/src/inventory.js +37 -0
  30. package/src/ipc.js +258 -0
  31. package/src/line-menu.js +147 -0
  32. package/src/mcp.js +113 -0
  33. package/src/platform.js +320 -0
  34. package/src/recipe.js +69 -0
  35. package/src/store.js +61 -0
  36. package/src/supervisors.js +163 -0
  37. package/src/systemd.js +104 -0
  38. package/src/tui.js +596 -0
  39. package/src/windows-supervisors.ps1 +83 -0
@@ -0,0 +1,163 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile, realpath } from 'node:fs/promises';
3
+ import { isAbsolute, join, relative, sep } from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { Fault } from './errors.js';
7
+ import { command, control, matches, scan, windows } from './platform.js';
8
+
9
+ const mac = process.platform === 'darwin';
10
+ const launchLabel = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,119}$/;
11
+ const taskPath = /^\\(?:[A-Za-z0-9][A-Za-z0-9_.-]{0,80}\\)*[A-Za-z0-9][A-Za-z0-9_.-]{0,80}$/;
12
+ const ps = windows ? join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') : '';
13
+ const script = fileURLToPath(new URL('./windows-supervisors.ps1', import.meta.url));
14
+ const ctl = (args, timeout) => command('/bin/launchctl', args, { timeout });
15
+ const digest = value => createHash('sha256').update(value).digest('hex');
16
+ const id = item => `${item.kind}:${windows ? item.name.toLowerCase() : item.name}`;
17
+
18
+ export function supervisorPolicies(value) {
19
+ if (!Array.isArray(value) || value.length > 12 || value.some(item => !item || typeof item.name !== 'string' ||
20
+ typeof item.shared !== 'boolean' || !(
21
+ (mac && item.kind === 'launchd' && launchLabel.test(item.name) && typeof item.plist === 'string' && isAbsolute(item.plist) && !/[\r\n\0]/.test(item.plist)) ||
22
+ (windows && item.kind === 'scm' && launchLabel.test(item.name)) || (windows && item.kind === 'task' && taskPath.test(item.name))
23
+ )) || new Set(value.map(id)).size !== value.length) throw new Fault('USAGE', 'Supply exact supervisor identities and explicit sharing for this platform.');
24
+ return value.map(({ kind, name, shared, plist }) => ({ kind, name, shared, ...(kind === 'launchd' ? { plist } : {}) }));
25
+ }
26
+
27
+ async function projectFile(cwd, path) {
28
+ if (typeof path !== 'string' || !isAbsolute(path)) throw new Fault('DENIED', 'The supervisor must use an absolute executable or definition path.');
29
+ const real = await realpath(path).catch(() => '');
30
+ const rel = relative(cwd, real);
31
+ if (!real || !rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
32
+ throw new Fault('DENIED', 'The supervisor definition or executable must be inside the project directory.');
33
+ }
34
+ const info = await lstat(real);
35
+ if (!info.isFile() || (!windows && (info.uid !== process.getuid() || (info.mode & 0o022)))) {
36
+ throw new Fault('DENIED', 'The supervisor definition must be an owner-controlled regular file.');
37
+ }
38
+ return real;
39
+ }
40
+
41
+ function property(output, name) {
42
+ return output.split('\n').map(line => line.match(new RegExp(`^\\s*${name} = (.+)$`))).find(Boolean)?.[1]?.trim();
43
+ }
44
+
45
+ async function launchdProbe(item, project) {
46
+ const plist = await projectFile(project.cwd, item.plist);
47
+ const json = JSON.parse(await command('/usr/bin/plutil', ['-convert', 'json', '-o', '-', '--', plist]));
48
+ const executable = json.Program ?? json.ProgramArguments?.[0];
49
+ if (json.Label !== item.name || typeof executable !== 'string' || !isAbsolute(executable) ||
50
+ await realpath(json.WorkingDirectory ?? '/').catch(() => '') !== project.cwd ||
51
+ ['UserName', 'GroupName', 'RootDirectory', 'Sockets', 'MachServices', 'WatchPaths', 'QueueDirectories', 'StartInterval',
52
+ 'StartCalendarInterval', 'LaunchEvents', 'KeepAlive'].some(name => Object.hasOwn(json, name) && json[name] !== false)) {
53
+ throw new Fault('DENIED', 'Use a project-owned, single-process LaunchAgent with a matching working directory and no auto-activation.');
54
+ }
55
+ const domain = `gui/${process.getuid()}`;
56
+ const service = `${domain}/${item.name}`;
57
+ const output = await ctl(['print', service], 5000);
58
+ const actual = property(output, 'path');
59
+ if (!actual || await realpath(actual).catch(() => '') !== plist || property(output, 'program') !== executable) {
60
+ throw new Fault('DENIED', 'The loaded LaunchAgent path and executable must match the reviewed definition.');
61
+ }
62
+ return { fingerprint: digest(await readFile(plist)), service, pid: Number(property(output, 'pid') ?? 0), executable };
63
+ }
64
+
65
+ async function windowsProbe(item, action = 'probe', fingerprint = '-') {
66
+ try {
67
+ const env = { ...process.env };
68
+ for (const name of Object.keys(env)) if (name.toUpperCase() === 'PSMODULEPATH') delete env[name];
69
+ Object.assign(env, { PF_SUPERVISOR_SCRIPT: script, PF_SUPERVISOR_KIND: item.kind, PF_SUPERVISOR_NAME: item.name,
70
+ PF_SUPERVISOR_ACTION: action, PF_SUPERVISOR_FINGERPRINT: fingerprint });
71
+ const invoke = '& $env:PF_SUPERVISOR_SCRIPT -Kind $env:PF_SUPERVISOR_KIND -Name $env:PF_SUPERVISOR_NAME -Action $env:PF_SUPERVISOR_ACTION -Expected $env:PF_SUPERVISOR_FINGERPRINT; exit $LASTEXITCODE';
72
+ const encoded = Buffer.from(invoke, 'utf16le').toString('base64');
73
+ return JSON.parse(await command(ps, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded], { env }));
74
+ } catch (error) {
75
+ if (error.details?.exit === 7) throw new Fault('PARTIAL', `The Windows supervisor did not complete ${action}. Inspect the exact configured service or task.`);
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ async function probe(item, project) {
81
+ if (item.kind === 'launchd') return launchdProbe(item, project);
82
+ const state = await windowsProbe(item);
83
+ if (state.path.toLowerCase() !== item.name.toLowerCase()) throw new Fault('DENIED', 'The Windows supervisor returned a different identity.');
84
+ await projectFile(project.cwd, state.executable);
85
+ if (item.kind === 'task' && (typeof state.cwd !== 'string' || !isAbsolute(state.cwd) ||
86
+ (await realpath(state.cwd).catch(() => '')).toLowerCase() !== project.cwd.toLowerCase())) {
87
+ throw new Fault('DENIED', 'The scheduled task must use the registered project working directory.');
88
+ }
89
+ return state;
90
+ }
91
+
92
+ export async function supervisorTargets(project, all, enroll = false) {
93
+ const result = [];
94
+ for (const item of project.recipe?.supervisors ?? []) {
95
+ const key = id(item);
96
+ const shared = item.shared || all.some(other => other.id !== project.id && other.recipe?.supervisors?.some(s => id(s) === key));
97
+ if (shared) { result.push({ ...item, key, shared }); continue; }
98
+ const observed = await probe(item, project);
99
+ project.supervisorPins ??= {};
100
+ if (enroll && !project.supervisorPins[key]) project.supervisorPins[key] = observed.fingerprint;
101
+ if (project.supervisorPins[key] !== observed.fingerprint) throw new Fault('DENIED', `The reviewed definition of ${item.name} changed.`);
102
+ result.push({ ...item, ...observed, key, shared: false });
103
+ }
104
+ return result;
105
+ }
106
+
107
+ async function launchdProcess(target, rows, transition = false) {
108
+ if (!target.pid) return undefined;
109
+ const process = rows.find(row => row.pid === target.pid);
110
+ const executable = await realpath(target.executable).catch(() => '');
111
+ const observed = process?.image ? await realpath(process.image).catch(() => '') : '';
112
+ if (transition && (!observed || observed !== executable)) return undefined;
113
+ if (!observed || observed !== executable || rows.some(row => row.parent === target.pid)) {
114
+ throw new Fault('UNSUPPORTED', 'LaunchAgent control requires one observable process with the reviewed executable and no live children.');
115
+ }
116
+ return process;
117
+ }
118
+
119
+ export async function observeSupervisors(project, all, rows, transition = false) {
120
+ const targets = await supervisorTargets(project, all);
121
+ for (const target of targets.filter(t => !t.shared)) {
122
+ if (target.kind === 'launchd') {
123
+ target.process = await launchdProcess(target, rows, transition);
124
+ target.state = target.process?.state === 'T' ? 'Paused' : target.process ? 'Running' : target.pid ? 'Pending' : 'Stopped';
125
+ } else if (target.kind === 'scm' && target.pid) {
126
+ target.process = rows.find(row => row.pid === target.pid);
127
+ }
128
+ }
129
+ return targets;
130
+ }
131
+
132
+ export async function supervisorAction(action, target, project) {
133
+ if (target.shared) throw new Fault('DENIED', 'Shared supervisors are observational.');
134
+ const before = await probe(target, project);
135
+ if (before.fingerprint !== target.fingerprint) throw new Fault('DENIED', 'Supervisor scope changed before control.');
136
+ if (['pause', 'resume', 'stop'].includes(action) && (!target.pid || before.pid !== target.pid)) {
137
+ throw new Fault('DENIED', 'The supervisor process changed before control.');
138
+ }
139
+ const timeout = project.recipe?.timeout ?? 10000;
140
+ if (target.kind === 'launchd') {
141
+ if (action === 'start') {
142
+ if (!before.pid) await ctl(['kickstart', before.service], timeout);
143
+ } else {
144
+ const process = await launchdProcess(before, await scan(false));
145
+ if (!process || !matches(process, target.process)) throw new Fault('DENIED', 'The LaunchAgent process was replaced or could not be verified.');
146
+ await control(({ pause: 'freeze', resume: 'resume', stop: 'stop' })[action], process);
147
+ }
148
+ } else {
149
+ if (target.kind === 'scm' && ((action === 'pause' && !before.canPause) || (action === 'stop' && !before.canStop))) {
150
+ throw new Fault('UNSUPPORTED', 'The Windows service did not advertise this control.');
151
+ }
152
+ await windowsProbe(target, action, target.fingerprint);
153
+ }
154
+ const deadline = Date.now() + timeout;
155
+ do {
156
+ const [current] = await observeSupervisors({ ...project, recipe: { ...project.recipe, supervisors: [target] } }, [], await scan(false), true);
157
+ if (action === 'enable' ? current.enabled : action === 'disable' ? !current.enabled && !current.active :
158
+ current.state === ({ pause: 'Paused', resume: 'Running', start: 'Running', stop: 'Stopped' })[action] &&
159
+ (!['pause','resume'].includes(action) || current.pid === before.pid)) return;
160
+ await delay(125);
161
+ } while (Date.now() < deadline);
162
+ throw new Fault('PARTIAL', 'The supervisor did not reach the requested state before the deadline.');
163
+ }
package/src/systemd.js ADDED
@@ -0,0 +1,104 @@
1
+ import { lstat, realpath, readFile } from 'node:fs/promises';
2
+ import { join, relative, isAbsolute, sep } from 'node:path';
3
+ import { Fault } from './errors.js';
4
+ import { command, linux } from './platform.js';
5
+
6
+ const unitName = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,120}\.(service|timer)$/;
7
+ const keys = ['Id', 'LoadState', 'ActiveState', 'FreezerState', 'FragmentPath', 'DropInPaths', 'WorkingDirectory',
8
+ 'ControlGroup', 'TriggeredBy', 'Triggers', 'RequiredBy', 'WantedBy', 'BoundBy', 'PartOf', 'ConsistsOf',
9
+ 'Restart', 'SendSIGKILL', 'RefuseManualStop', 'RefuseManualStart', 'KillMode'];
10
+
11
+ export function validSystemd(policy) {
12
+ return Array.isArray(policy) && policy.length <= 24 && policy.every(item => item && typeof item.unit === 'string' &&
13
+ unitName.test(item.unit) && typeof item.shared === 'boolean') && new Set(policy.map(item => item.unit)).size === policy.length;
14
+ }
15
+
16
+ const systemctl = (args, options = {}) => {
17
+ if (!linux) throw new Fault('UNSUPPORTED', 'User systemd units require a Linux systemd user manager.');
18
+ return command('/usr/bin/systemctl', ['--user', '--no-pager', '--no-ask-password', ...args], options).catch(error => {
19
+ throw new Fault(error.code === 'UNAVAILABLE' ? 'UNAVAILABLE' : 'PARTIAL', 'The user systemd manager did not complete its command. Inspect unit status locally.');
20
+ });
21
+ };
22
+
23
+ function below(parent, path) {
24
+ const rel = relative(parent, path);
25
+ return Boolean(rel && !isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
26
+ }
27
+
28
+ async function approvedFile(path, cwd) {
29
+ try {
30
+ const actual = await realpath(path);
31
+ const file = await lstat(actual);
32
+ return below(cwd, actual) && file.isFile() && file.uid === process.getuid() && (file.mode & 0o022) === 0;
33
+ } catch { return false; }
34
+ }
35
+
36
+ export async function unitState(unit) {
37
+ if (!unitName.test(unit)) throw new Fault('USAGE', 'Use an exact systemd user service or timer name.');
38
+ const output = await systemctl(['show', ...keys.flatMap(key => ['-p', key]), '--', unit]);
39
+ const state = Object.fromEntries(output.trim().split('\n').map(line => {
40
+ const at = line.indexOf('=');
41
+ return [line.slice(0, at), line.slice(at + 1)];
42
+ }));
43
+ if (state.Id !== unit || state.LoadState !== 'loaded') throw new Fault('NOT_FOUND', `The exact user unit ${unit} is not loaded.`);
44
+ return state;
45
+ }
46
+
47
+ export async function serviceTargets(project, projects) {
48
+ const policies = project.recipe?.systemd ?? [];
49
+ if (!policies.length) return [];
50
+ const output = [];
51
+ for (const policy of policies) {
52
+ const shared = policy.shared || projects.some(other => other.id !== project.id && other.recipe?.systemd?.some(s => s.unit === policy.unit));
53
+ if (shared) { output.push({ ...policy, shared }); continue; }
54
+ const state = await unitState(policy.unit);
55
+ const timer = policy.unit.endsWith('.timer');
56
+ if (!await approvedFile(state.FragmentPath, project.cwd) || state.DropInPaths ||
57
+ (!timer && await realpath(state.WorkingDirectory).catch(() => '') !== project.cwd)) {
58
+ throw new Fault('DENIED', `The linked unit ${policy.unit} must use a user-owned fragment inside the project, with no drop-ins.`);
59
+ }
60
+ if (state.RefuseManualStop === 'yes' || state.RefuseManualStart === 'yes' ||
61
+ (!timer && (state.SendSIGKILL !== 'no' || !['control-group', 'mixed'].includes(state.KillMode)))) {
62
+ throw new Fault('UNSUPPORTED', `The unit ${policy.unit} needs manual start and stop, KillMode=control-group or mixed, and SendSIGKILL=no.`);
63
+ }
64
+ const linked = [state.TriggeredBy, state.Triggers, state.RequiredBy, state.BoundBy, state.PartOf, state.ConsistsOf]
65
+ .join(' ').trim().split(/\s+/).filter(Boolean);
66
+ if (linked.some(name => !policies.some(item => item.unit === name && !item.shared) ||
67
+ projects.some(other => other.id !== project.id && other.recipe?.systemd?.some(item => item.unit === name)))) {
68
+ throw new Fault('DENIED', `The unit ${policy.unit} has activation or lifecycle dependencies outside the exclusive reviewed scope.`);
69
+ }
70
+ const fragment = await realpath(state.FragmentPath);
71
+ const info = await lstat(fragment);
72
+ output.push({ ...policy, state, timer, definition: `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}` });
73
+ }
74
+ return output;
75
+ }
76
+
77
+ export async function serviceAction(action, target, timeout) {
78
+ if (!['freeze', 'thaw', 'stop', 'start'].includes(action) || target.shared) throw new Fault('DENIED', 'The unit is outside the approved action scope.');
79
+ const current = await unitState(target.unit);
80
+ const info = await lstat(await realpath(current.FragmentPath));
81
+ if (`${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}` !== target.definition || current.FragmentPath !== target.state.FragmentPath ||
82
+ current.DropInPaths !== target.state.DropInPaths || current.TriggeredBy !== target.state.TriggeredBy ||
83
+ current.Triggers !== target.state.Triggers || current.SendSIGKILL !== target.state.SendSIGKILL) {
84
+ throw new Fault('DENIED', 'Systemd unit definition or activation metadata changed during the operation.');
85
+ }
86
+ if (action === 'freeze' && (!current.ControlGroup || current.ControlGroup === '/' || current.ActiveState !== 'active')) {
87
+ throw new Fault('BUSY', 'The unit no longer has an active dedicated cgroup.');
88
+ }
89
+ const options = ['start', 'stop'].includes(action) ? ['--job-mode=ignore-dependencies'] : [];
90
+ await systemctl([...options, action, '--', target.unit], { timeout });
91
+ const actual = await unitState(target.unit);
92
+ const valid = {
93
+ freeze: actual.FreezerState === 'frozen', thaw: actual.FreezerState === 'running',
94
+ start: actual.ActiveState === 'active', stop: ['inactive', 'failed'].includes(actual.ActiveState) && !actual.ControlGroup
95
+ }[action];
96
+ if (!valid) throw new Fault('PARTIAL', `The unit ${target.unit} did not reach the requested state.`);
97
+ }
98
+
99
+ export async function systemdPids(target) {
100
+ if (!linux || !target.state?.ControlGroup?.startsWith('/')) return [];
101
+ const path = join('/sys/fs/cgroup', target.state.ControlGroup, 'cgroup.procs');
102
+ const values = await readFile(path, 'utf8').catch(() => '');
103
+ return values.trim().split(/\s+/).filter(Boolean).map(Number);
104
+ }