@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/src/tui.js ADDED
@@ -0,0 +1,443 @@
1
+ import { emitKeypressEvents } from 'node:readline';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { basename, resolve } from 'node:path';
4
+ import { problem } from './errors.js';
5
+ import { request, waitOperation } from './ipc.js';
6
+ import { identity, key as processKey } from './platform.js';
7
+
8
+ const esc = '\x1b[';
9
+ const clean = value => String(value).replace(/[\p{Cc}\p{Cf}]/gu, ' ');
10
+ const segment = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
11
+ const wide = /[\p{Extended_Pictographic}\p{Regional_Indicator}\u1100-\u115f\u2329\u232a\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe6f\uff00-\uff60\uffe0-\uffe6\u{20000}-\u{3fffd}]/u;
12
+ const glyphs = value => Array.from(segment.segment(value), entry => entry.segment);
13
+ const columns = value => wide.test(value) ? 2 : 1;
14
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), Math.max(min, max));
15
+ const ports = entries => [...new Set((entries ?? []).map(row => row.port))].join(', ');
16
+ const endpoint = row => `${row.address.includes(':') ? `[${row.address}]` : row.address}:${row.port}`;
17
+ const mutations = {
18
+ freeze: ['Freeze', 'Suspend reviewed processes. Memory and listening sockets stay allocated.'],
19
+ resume: ['Resume', 'Resume the processes suspended by ProjectFreeze.'],
20
+ sleep: ['Sleep', 'Shut down reviewed processes. Shared listeners may remain open.'],
21
+ wake: ['Wake', 'Start the saved recipe. Expect a new PID and reconnect to the workload.'],
22
+ checkpoint: ['Checkpoint', 'Capture supported process state into private storage.'],
23
+ restore: ['Restore', 'Restore a verified checkpoint into an inactive workload.'],
24
+ reconcile: ['Reconcile', 'Check current process identities against the operation journal.']
25
+ };
26
+ const shortcuts = { f: 'freeze', u: 'resume', z: 'sleep', w: 'wake', c: 'checkpoint', o: 'restore', e: 'reconcile' };
27
+ const filters = ['all', 'running', 'discovered', 'sleeping', 'frozen', 'partial'];
28
+ const sorts = ['activity', 'name', 'ports', 'CPU'];
29
+
30
+ function fit(value, width) {
31
+ const parts = glyphs(clean(value));
32
+ const total = parts.reduce((sum, char) => sum + columns(char), 0);
33
+ let text = '';
34
+ let used = 0;
35
+ const limit = total > width ? Math.max(0, width - 1) : width;
36
+ for (const char of parts) {
37
+ const cells = columns(char);
38
+ if (used + cells > limit) break;
39
+ text += char;
40
+ used += cells;
41
+ }
42
+ if (total > width && width) { text += '…'; used++; }
43
+ return text + ' '.repeat(Math.max(0, width - used));
44
+ }
45
+
46
+ function available(project, action) {
47
+ if (!project) return false;
48
+ if (action === 'freeze') return project.processCount > 0 && project.state !== 'frozen';
49
+ if (action === 'resume') return ['frozen', 'partial'].includes(project.state);
50
+ if (action === 'sleep') return project.restart?.configured && !['sleeping', 'frozen', 'exited'].includes(project.state);
51
+ if (action === 'wake') return project.restart?.configured && ['sleeping', 'exited', 'partial'].includes(project.state) && project.processCount === 0;
52
+ if (action === 'checkpoint') return project.state !== 'discovered' && project.processCount > 0;
53
+ if (action === 'restore') return project.state !== 'discovered' && project.processCount === 0;
54
+ return project.state !== 'discovered';
55
+ }
56
+
57
+ export async function interactive() {
58
+ const input = process.stdin;
59
+ const output = process.stdout;
60
+ const colors = !Object.hasOwn(process.env, 'NO_COLOR');
61
+ const style = (code, text) => colors ? `${esc}${code}m${text}${esc}0m` : text;
62
+ const selected = new Set();
63
+ const scope = new AbortController();
64
+ const wasRaw = input.isRaw;
65
+ const width = () => Math.max(1, (output.columns || 80) - 1);
66
+ const height = () => Math.max(3, output.rows || 24);
67
+ let tab = 0;
68
+ let projects = [];
69
+ let listeners = [];
70
+ let history = [];
71
+ let viewKey;
72
+ let viewIndex = 0;
73
+ let search = '';
74
+ let filter = 0;
75
+ let sort = 0;
76
+ let status = 'Scanning local projects and TCP listeners…';
77
+ let detail, dialog, operation, refreshPending, timer;
78
+ let scanning = false;
79
+ let handling = false;
80
+ let lastFrame = [];
81
+ let ended = false;
82
+ let close;
83
+ const done = new Promise(resolveClose => { close = resolveClose; });
84
+ const titles = ['Projects', 'TCP listeners', 'History'];
85
+
86
+ function entries() {
87
+ const query = search.toLowerCase();
88
+ if (tab === 0) return projects.filter(project =>
89
+ (filter === 0 || project.state === filters[filter] || (filter === 1 && project.processCount > 0 && project.state !== 'frozen')) &&
90
+ `${project.name} ${project.state} ${project.cwd} ${ports(project.ports)} ${project.processes.map(row => row.pid).join(' ')}`.toLowerCase().includes(query)
91
+ ).sort((a, b) => ((sort === 0 && Number(b.processCount > 0) - Number(a.processCount > 0)) ||
92
+ (sort === 2 && b.ports.length - a.ports.length) || (sort === 3 && (b.cpuPercent ?? 0) - (a.cpuPercent ?? 0)) || a.name.localeCompare(b.name)))
93
+ .map(project => ({ id: project.id, project }));
94
+ if (tab === 1) return listeners.filter(row => `${endpoint(row)} ${row.pid} ${row.name ?? ''} ${row.image ?? ''}`.toLowerCase().includes(query))
95
+ .map(row => ({ id: `${endpoint(row)}/${row.pid}`, listener: row }));
96
+ return history.filter(row => `${row.action} ${row.status} ${row.id}`.includes(query)).map(row => ({ id: row.id, op: row }));
97
+ }
98
+
99
+ function focused(rows) {
100
+ const existing = rows.findIndex(row => row.id === viewKey);
101
+ viewIndex = existing >= 0 ? existing : clamp(viewIndex, 0, rows.length - 1);
102
+ viewKey = rows[viewIndex]?.id;
103
+ return rows[viewIndex];
104
+ }
105
+
106
+ function listLines(span, budget) {
107
+ const rows = entries();
108
+ focused(rows);
109
+ const subheading = tab === 0 ? `${projects.filter(row => row.processCount > 0).length} active / ${projects.length} projects / ${selected.size} selected` :
110
+ tab === 1 ? `${listeners.length} observed TCP endpoints / Read-only` : `${history.length} recent operations`;
111
+ const lines = [{ text: `${subheading} ${search ? `Search: ${search}` : tab === 0 ? `Filter: ${filters[filter]} Sort: ${sorts[sort]}` : ''}`, tone: '2' }];
112
+ const nameSize = clamp(Math.floor(span * 0.37), 12, 36);
113
+ if (span >= 60) lines.push({ text: tab === 0 ? ` ${fit('PROJECT', nameSize)} ${fit('STATE', 18)} ${fit('PROCS', 6)} TCP` :
114
+ tab === 1 ? ' TCP ENDPOINT PID PROJECT / PROCESS' : ' ACTION STATUS OPERATION', tone: '36' });
115
+ if (!rows.length) return [...lines, { text: scanning ? ' Scanning…' : ' No matching entries.' }];
116
+ const count = Math.max(1, budget - lines.length);
117
+ const offset = clamp(viewIndex - count + 1, 0, rows.length - count);
118
+ for (const [index, row] of rows.slice(offset, offset + count).entries()) {
119
+ const marked = index + offset === viewIndex;
120
+ const pointer = marked ? '› ' : ' ';
121
+ let text;
122
+ if (row.project) {
123
+ const p = row.project;
124
+ const choice = selected.has(p.id) ? '● ' : ' ';
125
+ text = span >= 60 ? `${choice}${fit(p.name, nameSize)} ${fit(p.state, 18)} ${fit(p.processCount, 6)} ${ports(p.ports) || '—'}` :
126
+ `${choice}${fit(p.name, Math.max(5, Math.min(nameSize, span - p.state.length - 7))).trimEnd()} ${p.state} ${ports(p.ports)}`;
127
+ } else if (row.listener) {
128
+ const p = row.listener;
129
+ text = span >= 60 ? `${fit(endpoint(p), 33)} ${fit(p.pid, 9)} ${p.name ?? basename(p.image ?? 'Unassigned')} (${p.scope})` : `${endpoint(p)} PID ${p.pid}`;
130
+ } else text = `${fit(row.op.action, 15)} ${fit(row.op.status, 14)} ${row.op.id}`;
131
+ lines.push({ text: pointer + text, tone: marked ? '7' : row.project?.state === 'partial' ? '33' : undefined });
132
+ }
133
+ return lines;
134
+ }
135
+
136
+ function projectPanel(project) {
137
+ const rows = project.processes;
138
+ const children = new Map();
139
+ for (const row of rows) {
140
+ if (!children.has(row.parent)) children.set(row.parent, []);
141
+ children.get(row.parent).push(row);
142
+ }
143
+ const branches = [];
144
+ const visited = new Set();
145
+ function branch(row, indent) {
146
+ if (visited.has(row.pid)) return;
147
+ visited.add(row.pid);
148
+ branches.push(`${' '.repeat(Math.min(indent, 4))}PID ${row.pid} ${basename(row.image ?? '')} ${ports(row.ports) ? `TCP ${ports(row.ports)}` : ''}`);
149
+ for (const child of children.get(row.pid) ?? []) branch(child, indent + 1);
150
+ }
151
+ const pids = new Set(rows.map(row => row.pid));
152
+ rows.filter(row => !pids.has(row.parent)).forEach(row => branch(row, 0));
153
+ rows.forEach(row => branch(row, 0));
154
+ const actionGuide = Object.entries(shortcuts).filter(([, action]) => available(project, action))
155
+ .map(([key, action]) => `${key} ${mutations[action][0]}`);
156
+ if (project.candidates?.length) actionGuide.unshift('a Review ownership');
157
+ return [`${project.name} [${project.state}]`, project.cwd, '',
158
+ `TCP listeners: ${project.ports.map(endpoint).join(', ') || 'none'}`,
159
+ `${project.processCount} processes CPU ${project.cpuPercent?.toFixed(1) ?? '—'}% RSS ${project.rssSum === undefined ? '—' : `${(project.rssSum / 2 ** 20).toFixed(1)} MiB`}`,
160
+ `Restart recipe: ${project.restart?.configured ? 'saved' : 'unavailable'} Candidates: ${project.candidates?.length ?? 0}`, '',
161
+ actionGuide.join(' '), '', ...branches];
162
+ }
163
+
164
+ function panelLines(budget) {
165
+ const lines = dialog?.kind === 'input' ? [dialog.title, '', `› ${glyphs(dialog.text).slice(-Math.max(1, Math.floor((width() - 3) / 2))).join('')}`, '', 'Enter Apply Esc Cancel'] :
166
+ dialog?.kind === 'confirm' ? [dialog.title, '', ...dialog.lines] :
167
+ dialog?.kind === 'adopt' ? ['Review ownership', 'Space toggles a process. Enter continues.', '',
168
+ ...dialog.rows.map(row => `${dialog.approved.has(processKey(row)) ? '[x]' : '[ ]'} PID ${row.pid} ${basename(row.image ?? '')} TCP ${ports(row.ports) || '—'}`)] :
169
+ dialog?.kind === 'help' ? ['Keyboard shortcuts', '', 'Tab / Shift+Tab or ←/→ Switch views', '↑/↓ PageUp/PageDown Move or scroll',
170
+ 'Enter Inspect / Confirm Space Select multiple Esc Back', '/ Search t Filter projects s Sort projects r Rescan',
171
+ 'l Launch recipe a Review ownership f Freeze u Resume', 'z Sleep w Wake c Checkpoint o Restore e Reconcile',
172
+ 'x Cancel operation q Quit / detach', '', 'TCP inventory is read-only until ownership has been reviewed.'] :
173
+ detail?.project ? projectPanel(detail.project) : detail?.listener ? [endpoint(detail.listener), '', `PID ${detail.listener.pid}`, detail.listener.image ?? '',
174
+ `Project: ${detail.listener.name ?? 'Unassigned'}`, `Scope: ${detail.listener.scope}`, '', ...(detail.listener.project ? ['Enter Open project'] : [])] :
175
+ [`${detail?.op.action} [${detail?.op.status}]`, detail?.op.id, '', ...(detail?.op.outcomes ?? []).map(row => `${row.status} ${row.target} ${row.message ?? ''}`)];
176
+ const panel = dialog ?? detail;
177
+ const choice = dialog?.kind === 'adopt' ? dialog.index + 3 : undefined;
178
+ const confirm = dialog?.kind === 'confirm';
179
+ const cap = Math.max(1, budget - (confirm ? 1 : 0));
180
+ panel.scroll = clamp(choice === undefined ? panel.scroll ?? 0 : Math.max(panel.scroll ?? 0, choice - cap + 1), 0, lines.length - cap);
181
+ if (choice !== undefined && choice < panel.scroll) panel.scroll = choice;
182
+ const visible = lines.slice(panel.scroll, panel.scroll + cap).map((line, index) => ({ text: line,
183
+ tone: index + panel.scroll === choice ? '7' : index + panel.scroll === 0 ? '1;36' : undefined }));
184
+ if (confirm) visible.push({ text: 'Enter Confirm Esc Cancel', tone: '1;36' });
185
+ return visible;
186
+ }
187
+
188
+ function draw() {
189
+ if (ended) return;
190
+ const span = width();
191
+ const rows = height();
192
+ const tabs = titles.map((title, index) => index === tab ? `[${title}]` : title).join(' ');
193
+ const budget = Math.max(1, rows - 5);
194
+ const body = dialog || detail ? panelLines(budget) : listLines(span, budget);
195
+ const heading = [{ text: ' ProjectFreeze ', tone: '1;36' }, { text: tabs, tone: '1' }];
196
+ const result = operation ? `${operation.action} ${operation.status ?? 'starting'} ${operation.outcomes?.length ?? 0} result(s) ${operation.pending ?? ''}` : status;
197
+ const controls = operation ? 'x Cancel operation q Detach' : dialog ? 'Esc Cancel q Quit' : detail ?
198
+ '↑↓ Scroll Esc Back Tab View ? Help q Quit' : 'Tab View ↑↓ Move Enter Inspect / Search l Launch ? Help q Quit';
199
+ const frame = [...heading, ...body.slice(0, budget)];
200
+ while (frame.length < rows - 2) frame.push({ text: '' });
201
+ frame.push({ text: result, tone: operation ? '33' : '2' }, { text: controls, tone: '36' });
202
+ const next = frame.slice(0, rows).map(line => style(line.tone ?? '0', fit(line.text, span)));
203
+ let changes = '';
204
+ for (const [index, line] of next.entries()) if (line !== lastFrame[index]) changes += `${esc}${index + 1};1H${line}${esc}K`;
205
+ lastFrame = next;
206
+ if (changes) output.write(changes);
207
+ }
208
+
209
+ async function refresh() {
210
+ if (scanning) { refreshPending = true; return; }
211
+ if (ended) return;
212
+ scanning = true;
213
+ let refreshed = false;
214
+ const screen = tab;
215
+ const inspected = detail?.project?.id;
216
+ try {
217
+ if (inspected && detail?.project?.id === inspected) {
218
+ const current = await request('inspect', { id: inspected });
219
+ if (detail?.project?.id === inspected) detail.project = current;
220
+ projects = projects.map(row => row.id === inspected ? current : row);
221
+ } else if (screen === 1) listeners = await request('listeners');
222
+ else if (screen === 2) history = await request('history');
223
+ else projects = await request('list');
224
+ refreshed = true;
225
+ } catch (error) { status = problem(error).message; }
226
+ finally {
227
+ scanning = false;
228
+ if (refreshed && status.startsWith('Scanning')) status = '';
229
+ draw();
230
+ if (refreshPending && !ended) { refreshPending = false; void refresh(); }
231
+ }
232
+ }
233
+
234
+ function switchTab(index) {
235
+ tab = (index + titles.length) % titles.length;
236
+ detail = undefined;
237
+ search = '';
238
+ viewKey = undefined;
239
+ viewIndex = 0;
240
+ void refresh();
241
+ }
242
+
243
+ async function act(action, args) {
244
+ if (operation || ended) return false;
245
+ operation = { action };
246
+ draw();
247
+ try {
248
+ const started = await request(action, args);
249
+ operation = { ...started, action };
250
+ const result = await waitOperation(started.id, item => { operation = item; draw(); }, scope.signal);
251
+ status = `${mutations[action]?.[0] ?? action} ${result.status}${result.outcomes.filter(row => row.status === 'failed').map(row => ` ${row.message}`).join('')}`;
252
+ return result.status === 'complete';
253
+ } catch (error) { status = problem(error).message; return false; }
254
+ finally { operation = undefined; void refresh(); }
255
+ }
256
+
257
+ function confirm(title, lines, run) { dialog = { kind: 'confirm', title, lines, run, scroll: 0 }; }
258
+ function edit(title, text, apply) { dialog = { kind: 'input', title, text, apply }; }
259
+ function inspectProject(id) {
260
+ const project = projects.find(row => row.id === id);
261
+ if (!project) { status = 'This project is no longer visible. Refresh and try again.'; return; }
262
+ detail = { project, scroll: 0 };
263
+ void refresh();
264
+ }
265
+
266
+ function review(action, targets, args = {}) {
267
+ if (operation || !targets.length) return;
268
+ const blocked = targets.find(project => !available(project, action));
269
+ if (blocked) { status = `${mutations[action][0]} is unavailable for ${blocked.name} (${blocked.state}).`; return; }
270
+ const display = mutations[action];
271
+ confirm(`${display[0]} ${targets.length === 1 ? targets[0].name : `${targets.length} projects`}`,
272
+ [display[1], '',
273
+ ...targets.map(p => `${p.name} ${p.processCount} process(es) TCP ${ports(p.ports) || '—'}`)],
274
+ () => act(action, { ids: targets.map(p => p.id), ...args }));
275
+ }
276
+
277
+ async function adopt(project, after) {
278
+ const live = await request('inspect', { id: project.id });
279
+ if (ended) return;
280
+ detail = { project: live, scroll: 0 };
281
+ if (!live.candidates.length) { status = 'No current candidates to review.'; return; }
282
+ dialog = { kind: 'adopt', rows: live.candidates, approved: new Set(), index: 0, scroll: 0, project: live, after };
283
+ }
284
+
285
+ async function finishAdoption(panel, recipe) {
286
+ const members = panel.rows.filter(row => panel.approved.has(processKey(row)));
287
+ confirm(`Adopt ${members.length} reviewed process(es)`, members.map(row => `PID ${row.pid} TCP ${ports(row.ports) || '—'} ${row.image ?? ''}`), async () => {
288
+ const adopted = await act('adopt', { cwd: panel.project.cwd, confirm: true, members: members.map(identity), recipe });
289
+ if (adopted && panel.after && !ended) {
290
+ projects = await request('list');
291
+ await panel.after();
292
+ }
293
+ });
294
+ }
295
+
296
+ async function freeze(ids) {
297
+ const targets = ids.map(id => projects.find(row => row.id === id)).filter(Boolean);
298
+ const observed = targets.find(project => project.state === 'discovered');
299
+ if (!observed) { review('freeze', targets); return; }
300
+ await adopt(observed, () => freeze(ids));
301
+ }
302
+
303
+ async function loadRecipe(path) {
304
+ const recipe = JSON.parse(await readFile(resolve(path.replace(/^"(.*)"$/, '$1')), 'utf8'));
305
+ recipe.cwd = resolve(recipe.cwd ?? '.');
306
+ return recipe;
307
+ }
308
+
309
+ function quit() {
310
+ if (ended) return;
311
+ ended = true;
312
+ scope.abort();
313
+ close();
314
+ }
315
+
316
+ async function handle(str, key) {
317
+ const name = key?.name;
318
+ if (key?.ctrl && name === 'c') { quit(); return; }
319
+ if (dialog?.kind === 'input') {
320
+ if (name === 'escape') dialog = undefined;
321
+ else if (name === 'return') { const { text, apply } = dialog; dialog = undefined; await apply(text.trim()); }
322
+ else if (name === 'backspace') dialog.text = glyphs(dialog.text).slice(0, -1).join('');
323
+ else if (key?.ctrl && name === 'u') dialog.text = '';
324
+ else if (str && !key?.ctrl && !key?.meta && !/[\p{Cc}]/u.test(str)) dialog.text = (dialog.text + str).slice(0, 4096);
325
+ return;
326
+ }
327
+ if (name === 'q') { quit(); return; }
328
+ if (name === 'escape') {
329
+ if (dialog) dialog = undefined;
330
+ else if (detail) { detail = undefined; void refresh(); }
331
+ else if (search) search = '';
332
+ else if (tab !== 0) switchTab(0);
333
+ return;
334
+ }
335
+ const movement = ({ up: -1, down: 1, pageup: -Math.max(1, height() - 7), pagedown: Math.max(1, height() - 7) })[name];
336
+ if (dialog?.kind === 'adopt') {
337
+ if (movement) dialog.index = clamp(dialog.index + movement, 0, dialog.rows.length - 1);
338
+ if (name === 'space') {
339
+ const id = processKey(dialog.rows[dialog.index]);
340
+ if (dialog.approved.has(id)) dialog.approved.delete(id); else dialog.approved.add(id);
341
+ }
342
+ if (name === 'return' && dialog.approved.size) {
343
+ const panel = dialog;
344
+ edit('Restart recipe path (optional)', '', async path => finishAdoption(panel, path ? await loadRecipe(path) : undefined));
345
+ }
346
+ return;
347
+ }
348
+ if (dialog) {
349
+ if (movement) dialog.scroll = Math.max(0, (dialog.scroll ?? 0) + movement);
350
+ if (name === 'return' && dialog.kind === 'confirm') { const { run } = dialog; dialog = undefined; await run(); }
351
+ return;
352
+ }
353
+ if (str === '?') { dialog = { kind: 'help', scroll: 0 }; return; }
354
+ if (name === 'x' && operation?.id) { await request('cancel', { id: operation.id }); return; }
355
+ if (name === 'r') { void refresh(); return; }
356
+ if (name === 'tab') { switchTab(tab + (key.shift ? -1 : 1)); return; }
357
+ if (name === 'p') { switchTab(1); return; }
358
+ if (name === 'h') { switchTab(2); return; }
359
+ if (str === '/') { edit(`Search ${titles[tab].toLowerCase()}`, search, text => { search = text; detail = undefined; viewKey = undefined; viewIndex = 0; }); return; }
360
+ if (name === 'l' && !operation) {
361
+ edit('Path to JSON launch recipe', '', async path => {
362
+ if (!path) return;
363
+ const recipe = await loadRecipe(path);
364
+ confirm('Launch reviewed recipe', [recipe.cwd, `Executable: ${recipe.argv?.[0] ?? '(missing)'}`, `Readiness: ${recipe.ready ? endpoint(recipe.ready) : 'none configured'}`],
365
+ () => act('run', { recipe }));
366
+ });
367
+ return;
368
+ }
369
+ if (detail) {
370
+ if (movement) detail.scroll = Math.max(0, detail.scroll + movement);
371
+ if (name === 'return' && detail.listener?.project) inspectProject(detail.listener.project);
372
+ } else {
373
+ if (name === 'left' || name === 'right') { switchTab(tab + (name === 'left' ? -1 : 1)); return; }
374
+ if (name === 's' && tab === 0) { sort = (sort + 1) % sorts.length; return; }
375
+ if (name === 't' && tab === 0) { filter = (filter + 1) % filters.length; return; }
376
+ const rows = entries();
377
+ const row = focused(rows);
378
+ if (movement || name === 'home' || name === 'end') {
379
+ viewIndex = clamp(name === 'home' ? 0 : name === 'end' ? rows.length - 1 : viewIndex + movement, 0, rows.length - 1);
380
+ viewKey = rows[viewIndex]?.id;
381
+ return;
382
+ }
383
+ if (name === 'return' && row) {
384
+ if (row.project) inspectProject(row.id);
385
+ else detail = { ...row, scroll: 0 };
386
+ return;
387
+ }
388
+ if (name === 'space' && row?.project) {
389
+ if (selected.has(row.id)) selected.delete(row.id); else selected.add(row.id);
390
+ return;
391
+ }
392
+ }
393
+ if (operation) return;
394
+ const project = detail?.project ?? (!detail && tab === 0 ? focused(entries())?.project : undefined);
395
+ if (!project) return;
396
+ if (name === 'a') { await adopt(project); return; }
397
+ const action = shortcuts[name];
398
+ if (!action) return;
399
+ const targets = !detail && selected.size ? projects.filter(row => selected.has(row.id)) : [project];
400
+ if (!targets.length) { status = 'The selected projects are no longer visible.'; return; }
401
+ if (action === 'freeze') { await freeze(targets.map(row => row.id)); return; }
402
+ if (action === 'restore') { edit('Checkpoint ID', '', checkpoint => { if (checkpoint) review(action, targets, { checkpoint }); }); return; }
403
+ review(action, targets);
404
+ }
405
+
406
+ function keypress(str, key) {
407
+ if (ended) return;
408
+ if (handling && !(key?.ctrl && key.name === 'c') && !(operation && ['x', 'q'].includes(key?.name))) return;
409
+ const locked = !handling && (key?.name === 'return' || key?.name === 'x' ||
410
+ (!dialog && ['a', 'f'].includes(key?.name)));
411
+ if (locked) handling = true;
412
+ void handle(str, key).catch(error => { status = problem(error).message; }).finally(() => {
413
+ if (locked) handling = false;
414
+ draw();
415
+ });
416
+ }
417
+ function resize() { lastFrame = []; output.write(`${esc}2J`); draw(); }
418
+ emitKeypressEvents(input);
419
+ input.setRawMode(true);
420
+ input.on('keypress', keypress);
421
+ input.once('end', quit);
422
+ output.on('resize', resize);
423
+ process.once('SIGINT', quit);
424
+ process.once('SIGTERM', quit);
425
+ try {
426
+ output.write(`${esc}?1049h${esc}?25l${esc}2J`);
427
+ input.resume();
428
+ timer = setInterval(() => void refresh(), 2500);
429
+ draw();
430
+ void refresh();
431
+ await done;
432
+ } finally {
433
+ clearInterval(timer);
434
+ input.off('keypress', keypress);
435
+ input.off('end', quit);
436
+ input.setRawMode(wasRaw);
437
+ input.pause();
438
+ output.off('resize', resize);
439
+ process.off('SIGINT', quit);
440
+ process.off('SIGTERM', quit);
441
+ output.write(`${esc}0m${esc}?25h${esc}?1049l${operation?.id ? `Operation ${operation.id} continues in the daemon.\n` : ''}`);
442
+ }
443
+ }
@@ -0,0 +1,83 @@
1
+ param([string]$Kind, [string]$Name, [string]$Action, [string]$Expected = '-')
2
+ $ErrorActionPreference = 'Stop'
3
+ [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
4
+ $user = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
5
+ function Get-Sid([string]$Account) {
6
+ if ($Account -match '^S-1-[0-9-]+$') { return $Account }
7
+ return ([Security.Principal.NTAccount]::new($Account)).Translate([Security.Principal.SecurityIdentifier]).Value
8
+ }
9
+ function Digest($Value) {
10
+ $sha = [Security.Cryptography.SHA256]::Create()
11
+ try { return [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes(($Value | ConvertTo-Json -Compress -Depth 6)))).Replace('-','').ToLowerInvariant() }
12
+ finally { $sha.Dispose() }
13
+ }
14
+ try {
15
+ if ($Kind -eq 'task') {
16
+ if ($Name -notmatch '^\\(?:[A-Za-z0-9][A-Za-z0-9_.-]{0,80}\\)*[A-Za-z0-9][A-Za-z0-9_.-]{0,80}$') { exit 2 }
17
+ $at = $Name.LastIndexOf('\')
18
+ $folder = if ($at -eq 0) { '\' } else { $Name.Substring(0,$at) }
19
+ $scheduler = New-Object -ComObject 'Schedule.Service'
20
+ $scheduler.Connect()
21
+ $task = $scheduler.GetFolder($folder).GetTask($Name.Substring($at + 1))
22
+ $definition = $task.Definition
23
+ $acl = [Security.AccessControl.RawSecurityDescriptor]::new($task.GetSecurityDescriptor(5))
24
+ if (!$acl.DiscretionaryAcl) { exit 4 }
25
+ if ($definition.Actions.Count -ne 1 -or $definition.Actions.Item(1).Type -ne 0 -or
26
+ (Get-Sid $definition.Principal.UserId) -ne $user -or $definition.Principal.RunLevel -ne 0 -or
27
+ ($acl.Owner.Value -ne $user -and $acl.Owner.Value -ne 'S-1-5-32-544')) { exit 4 }
28
+ foreach ($ace in $acl.DiscretionaryAcl) {
29
+ if ($ace.AceType -eq [Security.AccessControl.AceType]::AccessAllowed -and ($ace.AccessMask -band 0x500D0156) -ne 0 -and
30
+ $ace.SecurityIdentifier.Value -notin @($user,'S-1-5-18','S-1-5-32-544')) { exit 4 }
31
+ }
32
+ $exec = $definition.Actions.Item(1)
33
+ $xml = [xml]$task.Xml
34
+ $mutable = $xml.DocumentElement.SelectSingleNode('*[local-name()="Settings"]/*[local-name()="Enabled"]')
35
+ if ($mutable) { [void]$mutable.ParentNode.RemoveChild($mutable) }
36
+ $fingerprint = Digest @($task.Path,$xml.OuterXml,$acl.Owner.Value,$exec.Path,$exec.WorkingDirectory)
37
+ if ($Action -ne 'probe') {
38
+ if ($fingerprint -ne $Expected) { exit 4 }
39
+ if ($Action -notin @('disable','enable')) { exit 5 }
40
+ if ($Action -eq 'disable' -and ($task.GetInstances(0).Count -ne 0 -or $task.State -eq 2)) { exit 6 }
41
+ $task.Enabled = $Action -eq 'enable'
42
+ }
43
+ @{ path=$task.Path; fingerprint=$fingerprint; executable=$exec.Path; cwd=$exec.WorkingDirectory;
44
+ enabled=[bool]$task.Enabled; active=($task.GetInstances(0).Count -ne 0 -or $task.State -eq 2) } | ConvertTo-Json -Compress
45
+ } elseif ($Kind -eq 'scm') {
46
+ Add-Type -AssemblyName System.ServiceProcess
47
+ if ($Name -notmatch '^[A-Za-z0-9][A-Za-z0-9_.-]{0,100}$') { exit 2 }
48
+ $service = Get-CimInstance Win32_Service -Filter "Name='$Name'"
49
+ if (!$service) { exit 3 }
50
+ if ($service.ServiceType -ne 'Own Process' -or $service.StartMode -ne 'Manual' -or
51
+ (Get-Sid $service.StartName) -notin @($user,'S-1-5-19') -or $service.PathName -notmatch '^"([^"]+\.exe)"(?:\s|$)|^([^\s"]+\.exe)(?:\s|$)') { exit 4 }
52
+ $binary = if ($Matches[1]) { $Matches[1] } else { $Matches[2] }
53
+ $registry = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey("SYSTEM\CurrentControlSet\Services\$Name")
54
+ if (!$registry) { exit 4 }
55
+ try {
56
+ if (($registry.GetSubKeyNames() -contains 'TriggerInfo') -or $null -ne $registry.GetValue('FailureActions')) { exit 5 }
57
+ } finally { $registry.Dispose() }
58
+ $controller = [System.ServiceProcess.ServiceController]::new($Name)
59
+ try {
60
+ if ($controller.DependentServices.Count -or $controller.ServicesDependedOn.Count) { exit 4 }
61
+ $fingerprint = Digest @($service.Name,$service.PathName,$service.StartName,$service.ServiceType,$service.StartMode)
62
+ if ($Action -ne 'probe') {
63
+ if ($fingerprint -ne $Expected) { exit 4 }
64
+ $method = @{ pause='PauseService'; resume='ResumeService'; stop='StopService'; start='StartService' }[$Action]
65
+ if (!$method) { exit 5 }
66
+ $result = Invoke-CimMethod -InputObject $service -MethodName $method
67
+ if ($result.ReturnValue -eq 2) { exit 4 }
68
+ if ($result.ReturnValue -in @(6,10)) { exit 6 }
69
+ if ($result.ReturnValue -in @(1,5)) { exit 5 }
70
+ if ($result.ReturnValue -ne 0) { exit 7 }
71
+ }
72
+ @{ path=$service.Name; fingerprint=$fingerprint; executable=$binary; state=$service.State; pid=$service.ProcessId;
73
+ canPause=$service.AcceptPause; canStop=$service.AcceptStop } | ConvertTo-Json -Compress
74
+ } finally { $controller.Dispose() }
75
+ } else { exit 2 }
76
+ } catch {
77
+ if ($env:PF_SUPERVISOR_TEST_DIAGNOSTICS -eq '1') {
78
+ [Console]::Error.WriteLine(('{0}: HRESULT {1}, line {2}' -f $_.FullyQualifiedErrorId,$_.Exception.HResult,$_.InvocationInfo.ScriptLineNumber))
79
+ }
80
+ if ($_.Exception.HResult -in @(-2147024891,-2147217405)) { exit 4 }
81
+ if ($_.Exception.HResult -in @(-2147024894,-2147217406)) { exit 3 }
82
+ exit 7
83
+ }