@hasna/snapshots 0.1.0 → 0.1.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/README.md +15 -6
- package/dist/agent/index.js +0 -0
- package/dist/capture/index.d.ts.map +1 -1
- package/dist/capture/index.js +254 -22
- package/dist/capture/index.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +55 -13
- package/dist/cli/index.js.map +1 -1
- package/dist/mcp/index.js +15 -2
- package/dist/mcp/index.js.map +1 -1
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +9 -1
- package/dist/policy.js.map +1 -1
- package/dist/restore.d.ts +1 -0
- package/dist/restore.d.ts.map +1 -1
- package/dist/restore.js +637 -24
- package/dist/restore.js.map +1 -1
- package/dist/runtime.d.ts +12 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +60 -2
- package/dist/runtime.js.map +1 -1
- package/dist/server/index.js +9 -1
- package/dist/server/index.js.map +1 -1
- package/dist/service.d.ts +1 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +107 -3
- package/dist/service.js.map +1 -1
- package/dist/storage.d.ts +3 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +61 -3
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +59 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/util.d.ts +3 -0
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +15 -0
- package/dist/util.js.map +1 -1
- package/package.json +3 -3
package/dist/restore.js
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { commandExists, nowIso, sha256, stableJson } from "./util.js";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { commandExists, nowIso, runTmux, sha256, stableJson, tmuxCommand } from "./util.js";
|
|
4
4
|
import { resolvePolicy } from "./policy.js";
|
|
5
5
|
export function createRestorePlan(snapshot, resources, policies = [], options = {}) {
|
|
6
|
+
const request = normalizeRestoreRequest(options);
|
|
7
|
+
const selection = selectResources(resources, request);
|
|
8
|
+
const selectedResourceIds = new Set(selection.resources.map((resource) => resource.id));
|
|
9
|
+
const strictExistingTmuxSessions = strictExistingTmuxSessionNames(selection.resources, request);
|
|
6
10
|
const operations = [];
|
|
7
|
-
|
|
11
|
+
const planWarnings = [...selection.warnings, ...tmuxPlanWarnings(selection.resources, request)];
|
|
12
|
+
for (const resource of selection.resources) {
|
|
13
|
+
const missingParent = missingSelectedParent(resource, resources, selectedResourceIds);
|
|
14
|
+
if (missingParent) {
|
|
15
|
+
operations.push(operation(resource, "dependency.missing", `Resource requires parent ${missingParent}. Re-run with --with-dependencies to include it.`, "blocked", "Partial restore cannot safely apply a child resource without its captured parent.", undefined, {
|
|
16
|
+
dependsOn: [missingParent],
|
|
17
|
+
warnings: ["Dependency closure is incomplete."],
|
|
18
|
+
confidence: "impossible",
|
|
19
|
+
risk: "medium"
|
|
20
|
+
}));
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
8
23
|
const policy = resolvePolicy(resource, policies);
|
|
9
24
|
if (policy.mode === "ignore") {
|
|
10
25
|
operations.push(operation(resource, "ignored", "Ignored by restore policy.", "skipped", policy.reason));
|
|
@@ -18,20 +33,56 @@ export function createRestorePlan(snapshot, resources, policies = [], options =
|
|
|
18
33
|
operations.push(planProject(resource));
|
|
19
34
|
}
|
|
20
35
|
else if (resource.kind === "tmux-session") {
|
|
21
|
-
operations.push(planTmuxSession(resource));
|
|
36
|
+
operations.push(planTmuxSession(resource, resources, request));
|
|
37
|
+
operations.push(...planTmuxSessionState(resource, resources));
|
|
38
|
+
}
|
|
39
|
+
else if (resource.kind === "tmux-window") {
|
|
40
|
+
const blockedSession = tmuxSessionForResource(resource);
|
|
41
|
+
if (blockedSession && strictExistingTmuxSessions.has(blockedSession)) {
|
|
42
|
+
operations.push(blockedExistingTmuxSubtree(resource, blockedSession));
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
operations.push(planTmuxWindow(resource, request));
|
|
46
|
+
operations.push(...planTmuxWindowState(resource));
|
|
47
|
+
}
|
|
48
|
+
else if (resource.kind === "tmux-pane") {
|
|
49
|
+
const blockedSession = tmuxSessionForResource(resource);
|
|
50
|
+
if (blockedSession && strictExistingTmuxSessions.has(blockedSession)) {
|
|
51
|
+
operations.push(blockedExistingTmuxSubtree(resource, blockedSession));
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
operations.push(planTmuxPane(resource, resources, request));
|
|
55
|
+
operations.push(...planTmuxPaneState(resource));
|
|
56
|
+
}
|
|
57
|
+
else if (resource.kind === "process") {
|
|
58
|
+
operations.push(planProcess(resource));
|
|
59
|
+
}
|
|
60
|
+
else if (resource.kind === "app") {
|
|
61
|
+
operations.push(planApp(resource, policy));
|
|
22
62
|
}
|
|
23
63
|
else {
|
|
24
64
|
operations.push(operation(resource, "unsupported", "No restore adapter for this resource kind.", "skipped"));
|
|
25
65
|
}
|
|
26
66
|
}
|
|
27
|
-
const
|
|
28
|
-
id: `plan_${snapshot.id}
|
|
67
|
+
const basePlan = {
|
|
68
|
+
id: `plan_${snapshot.id}_pending`,
|
|
29
69
|
snapshotId: snapshot.id,
|
|
30
70
|
createdAt: nowIso(),
|
|
31
71
|
apply: Boolean(options.apply),
|
|
32
|
-
|
|
72
|
+
request,
|
|
73
|
+
matchedSelectors: selection.matchedSelectors,
|
|
74
|
+
unmatchedSelectors: selection.unmatchedSelectors,
|
|
75
|
+
autoAddedDependencies: selection.autoAddedDependencies,
|
|
76
|
+
warnings: planWarnings,
|
|
77
|
+
autopilot: assessAutopilot(operations),
|
|
78
|
+
operations: sortOperations(operations),
|
|
33
79
|
summary: summarizeOperations(operations)
|
|
34
80
|
};
|
|
81
|
+
basePlan.planHash = hashRestorePlan(basePlan);
|
|
82
|
+
const plan = {
|
|
83
|
+
...basePlan,
|
|
84
|
+
id: `plan_${snapshot.id}_${basePlan.planHash.slice(0, 12)}`
|
|
85
|
+
};
|
|
35
86
|
if (options.apply) {
|
|
36
87
|
return executeRestorePlan(plan, options);
|
|
37
88
|
}
|
|
@@ -47,11 +98,11 @@ export function executeRestorePlan(plan, options = {}) {
|
|
|
47
98
|
return {
|
|
48
99
|
...plan,
|
|
49
100
|
apply: true,
|
|
50
|
-
operations,
|
|
101
|
+
operations: sortOperations(operations),
|
|
51
102
|
summary: summarizeOperations(operations)
|
|
52
103
|
};
|
|
53
104
|
}
|
|
54
|
-
const operations = plan.operations.map((op) => executeOperation(op));
|
|
105
|
+
const operations = sortOperations(plan.operations).map((op) => executeOperation(op));
|
|
55
106
|
return {
|
|
56
107
|
...plan,
|
|
57
108
|
apply: true,
|
|
@@ -73,7 +124,7 @@ function planProject(resource) {
|
|
|
73
124
|
path
|
|
74
125
|
]);
|
|
75
126
|
}
|
|
76
|
-
function planTmuxSession(resource) {
|
|
127
|
+
function planTmuxSession(resource, resources, request) {
|
|
77
128
|
const name = resource.name;
|
|
78
129
|
if (!/^[A-Za-z0-9_.:-]+$/.test(name)) {
|
|
79
130
|
return operation(resource, "tmux.create-session", `Unsafe tmux session name: ${name}`, "blocked");
|
|
@@ -81,24 +132,168 @@ function planTmuxSession(resource) {
|
|
|
81
132
|
if (!commandExists("tmux")) {
|
|
82
133
|
return operation(resource, "tmux.create-session", "tmux is not installed or not on PATH.", "blocked");
|
|
83
134
|
}
|
|
84
|
-
const hasSession =
|
|
85
|
-
stdio: "ignore",
|
|
86
|
-
timeout: 2_000
|
|
87
|
-
}).status === 0;
|
|
135
|
+
const hasSession = runTmux(["has-session", "-t", name], 2_000).status === 0;
|
|
88
136
|
if (hasSession) {
|
|
89
137
|
return operation(resource, "tmux.exists", `tmux session already exists: ${name}`, "noop");
|
|
90
138
|
}
|
|
91
|
-
const
|
|
139
|
+
const firstWindow = resources
|
|
140
|
+
.filter((candidate) => candidate.kind === "tmux-window" && candidate.parentId === resource.id)
|
|
141
|
+
.sort((a, b) => Number(a.attributes.index ?? 0) - Number(b.attributes.index ?? 0))[0];
|
|
142
|
+
const cwd = typeof firstWindow?.attributes.current_path === "string"
|
|
143
|
+
? firstWindow.attributes.current_path
|
|
144
|
+
: typeof resource.attributes.cwd === "string"
|
|
145
|
+
? resource.attributes.cwd
|
|
146
|
+
: process.cwd();
|
|
147
|
+
const command = tmuxCommand(["new-session", "-d", "-s", name, "-c", cwd]);
|
|
148
|
+
if (firstWindow && typeof firstWindow.attributes.name === "string") {
|
|
149
|
+
command.push("-n", firstWindow.attributes.name);
|
|
150
|
+
}
|
|
151
|
+
const startCommand = typeof firstWindow?.attributes.start_command === "string" ? firstWindow.attributes.start_command : "";
|
|
152
|
+
if (shouldReplayTmuxCommand(firstWindow, request, startCommand)) {
|
|
153
|
+
command.push(startCommand);
|
|
154
|
+
}
|
|
92
155
|
return operation(resource, "tmux.create-session", `Create detached tmux session: ${name}`, "planned", undefined, [
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
156
|
+
...command
|
|
157
|
+
], tmuxCreateExtras(firstWindow ?? resource, request, startCommand));
|
|
158
|
+
}
|
|
159
|
+
function planTmuxSessionState(resource, resources) {
|
|
160
|
+
const name = resource.name;
|
|
161
|
+
if (!commandExists("tmux"))
|
|
162
|
+
return [];
|
|
163
|
+
if (tmuxSessionExists(name))
|
|
164
|
+
return [];
|
|
165
|
+
const firstWindow = resources
|
|
166
|
+
.filter((candidate) => candidate.kind === "tmux-window" && candidate.parentId === resource.id)
|
|
167
|
+
.sort((a, b) => Number(a.attributes.index ?? 0) - Number(b.attributes.index ?? 0))[0];
|
|
168
|
+
const windowIndex = Number(firstWindow?.attributes.index);
|
|
169
|
+
if (!Number.isFinite(windowIndex))
|
|
170
|
+
return [];
|
|
171
|
+
return [
|
|
172
|
+
operation(firstWindow ?? resource, "tmux.move-window", `Restore first tmux window index: ${name}:${windowIndex}`, "planned", undefined, tmuxCommand(["move-window", "-s", `${name}:`, "-t", `${name}:${windowIndex}`]), {
|
|
173
|
+
confidence: "best-effort",
|
|
174
|
+
warnings: ["Moves the implicit first tmux window created by new-session to the captured index when tmux base-index differs."],
|
|
175
|
+
risk: "low",
|
|
176
|
+
effects: ["restore tmux first-window index"]
|
|
177
|
+
})
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
function planTmuxWindow(resource, request) {
|
|
181
|
+
const session = typeof resource.attributes.session === "string" ? resource.attributes.session : undefined;
|
|
182
|
+
const name = typeof resource.attributes.name === "string" ? resource.attributes.name : resource.name.split(":").slice(2).join(":");
|
|
183
|
+
const windowIndex = Number(resource.attributes.index);
|
|
184
|
+
if (!session || !name)
|
|
185
|
+
return operation(resource, "tmux.create-window", "Window is missing session/name metadata.", "blocked");
|
|
186
|
+
if (!commandExists("tmux"))
|
|
187
|
+
return operation(resource, "tmux.create-window", "tmux is not installed or not on PATH.", "blocked");
|
|
188
|
+
if (tmuxWindowExists(session, name, Number.isFinite(windowIndex) ? windowIndex : undefined)) {
|
|
189
|
+
return operation(resource, "tmux.window-exists", `tmux window already exists: ${session}:${Number.isFinite(windowIndex) ? windowIndex : name}`, "noop");
|
|
190
|
+
}
|
|
191
|
+
const cwd = typeof resource.attributes.current_path === "string" ? resource.attributes.current_path : process.cwd();
|
|
192
|
+
const target = Number.isFinite(windowIndex) ? `${session}:${windowIndex}` : session;
|
|
193
|
+
const command = tmuxCommand(["new-window", "-d", "-t", target, "-n", name, "-c", cwd]);
|
|
194
|
+
const startCommand = typeof resource.attributes.start_command === "string" ? resource.attributes.start_command : "";
|
|
195
|
+
if (shouldReplayTmuxCommand(resource, request, startCommand)) {
|
|
196
|
+
command.push(startCommand);
|
|
197
|
+
}
|
|
198
|
+
return operation(resource, "tmux.create-window", `Create tmux window: ${session}:${name}`, "planned", undefined, command, tmuxCreateExtras(resource, request, startCommand));
|
|
199
|
+
}
|
|
200
|
+
function planTmuxPane(resource, resources, request) {
|
|
201
|
+
const session = typeof resource.attributes.session === "string" ? resource.attributes.session : undefined;
|
|
202
|
+
const windowIndex = Number(resource.attributes.window_index);
|
|
203
|
+
const paneIndex = Number(resource.attributes.pane_index);
|
|
204
|
+
if (!session || !Number.isFinite(windowIndex) || !Number.isFinite(paneIndex)) {
|
|
205
|
+
return operation(resource, "tmux.create-pane", "Pane is missing session/window/pane metadata.", "blocked");
|
|
206
|
+
}
|
|
207
|
+
if (paneIndex === firstPaneIndexForWindow(resource, resources)) {
|
|
208
|
+
return operation(resource, "tmux.initial-pane", "Initial pane is created with the tmux window.", "noop");
|
|
209
|
+
}
|
|
210
|
+
if (!commandExists("tmux"))
|
|
211
|
+
return operation(resource, "tmux.create-pane", "tmux is not installed or not on PATH.", "blocked");
|
|
212
|
+
if (tmuxPaneExists(session, windowIndex, paneIndex)) {
|
|
213
|
+
return operation(resource, "tmux.pane-exists", `tmux pane index already exists: ${session}:${windowIndex}.${paneIndex}`, "noop");
|
|
214
|
+
}
|
|
215
|
+
const cwd = typeof resource.attributes.current_path === "string" ? resource.attributes.current_path : process.cwd();
|
|
216
|
+
const command = tmuxCommand(["split-window", "-d", "-t", `${session}:${windowIndex}`, "-c", cwd]);
|
|
217
|
+
const startCommand = typeof resource.attributes.start_command === "string" ? resource.attributes.start_command : "";
|
|
218
|
+
if (shouldReplayTmuxCommand(resource, request, startCommand)) {
|
|
219
|
+
command.push(startCommand);
|
|
220
|
+
}
|
|
221
|
+
return operation(resource, "tmux.create-pane", `Create tmux pane: ${session}:${windowIndex}.${paneIndex}`, "planned", undefined, command, tmuxCreateExtras(resource, request, startCommand));
|
|
222
|
+
}
|
|
223
|
+
function planTmuxWindowState(resource) {
|
|
224
|
+
const session = typeof resource.attributes.session === "string" ? resource.attributes.session : undefined;
|
|
225
|
+
const windowIndex = Number(resource.attributes.index);
|
|
226
|
+
if (!session || !Number.isFinite(windowIndex) || !commandExists("tmux"))
|
|
227
|
+
return [];
|
|
228
|
+
const operations = [];
|
|
229
|
+
const layout = typeof resource.attributes.layout === "string" ? resource.attributes.layout : undefined;
|
|
230
|
+
if (layout && Number(resource.attributes.pane_count ?? 0) > 1) {
|
|
231
|
+
operations.push(operation(resource, "tmux.select-layout", `Restore tmux layout: ${session}:${windowIndex}`, "planned", undefined, tmuxCommand(["select-layout", "-t", `${session}:${windowIndex}`, layout]), {
|
|
232
|
+
confidence: "best-effort",
|
|
233
|
+
warnings: ["tmux layout restore is best-effort and does not restore shell/process state, scrollback, marks, or client size."]
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
if (resource.attributes.active === true) {
|
|
237
|
+
operations.push(operation(resource, "tmux.select-window", `Restore active tmux window: ${session}:${windowIndex}`, "planned", undefined, tmuxCommand(["select-window", "-t", `${session}:${windowIndex}`]), {
|
|
238
|
+
confidence: "best-effort",
|
|
239
|
+
warnings: ["tmux active-window selection may affect the current live client when merging into an existing session."]
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
return operations;
|
|
243
|
+
}
|
|
244
|
+
function planTmuxPaneState(resource) {
|
|
245
|
+
const session = typeof resource.attributes.session === "string" ? resource.attributes.session : undefined;
|
|
246
|
+
const windowIndex = Number(resource.attributes.window_index);
|
|
247
|
+
const paneIndex = Number(resource.attributes.pane_index);
|
|
248
|
+
if (!session || !Number.isFinite(windowIndex) || !Number.isFinite(paneIndex) || !commandExists("tmux"))
|
|
249
|
+
return [];
|
|
250
|
+
if (resource.attributes.active !== true)
|
|
251
|
+
return [];
|
|
252
|
+
return [
|
|
253
|
+
operation(resource, "tmux.select-pane", `Restore active tmux pane: ${session}:${windowIndex}.${paneIndex}`, "planned", undefined, tmuxCommand(["select-pane", "-t", `${session}:${windowIndex}.${paneIndex}`]), {
|
|
254
|
+
confidence: "best-effort",
|
|
255
|
+
warnings: ["tmux active-pane selection may affect the current live client when merging into an existing session."]
|
|
256
|
+
})
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
function planProcess(resource) {
|
|
260
|
+
if (resource.attributes.restartable !== true) {
|
|
261
|
+
return operation(resource, "process.observe", "Process lacks explicit restartable marker.", "skipped");
|
|
262
|
+
}
|
|
263
|
+
const processId = typeof resource.attributes.process_id === "string" ? resource.attributes.process_id : undefined;
|
|
264
|
+
const restartCommand = typeof resource.attributes.restart_command === "string" ? resource.attributes.restart_command : undefined;
|
|
265
|
+
if (!restartCommand) {
|
|
266
|
+
return operation(resource, "process.restart", "Restartable process is missing restart_command.", "blocked", "Captured processes require HASNA_SNAPSHOTS_RESTART_COMMAND_B64 or HASNA_SNAPSHOTS_RESTART_COMMAND_FILE for replay.");
|
|
267
|
+
}
|
|
268
|
+
if (processId && processMarkerRunning(processId)) {
|
|
269
|
+
return operation(resource, "process.exists", `Restartable process already running: ${processId}`, "noop");
|
|
270
|
+
}
|
|
271
|
+
return operation(resource, "process.restart", `Restart marked process: ${processId ?? resource.name}`, "planned", undefined, [
|
|
272
|
+
"sh",
|
|
273
|
+
"-lc",
|
|
274
|
+
restartCommand
|
|
100
275
|
]);
|
|
101
276
|
}
|
|
277
|
+
function planApp(resource, policy) {
|
|
278
|
+
const name = typeof resource.attributes.name === "string" ? resource.attributes.name : resource.name;
|
|
279
|
+
if (!name)
|
|
280
|
+
return operation(resource, "app.open", "App resource has no name.", "blocked");
|
|
281
|
+
if (policy.selector !== resource.id) {
|
|
282
|
+
return operation(resource, "app.observe", "App restore requires a per-app restore policy.", "skipped", policy.reason);
|
|
283
|
+
}
|
|
284
|
+
const restoreCommand = Array.isArray(resource.attributes.restore_command)
|
|
285
|
+
? resource.attributes.restore_command.filter((part) => typeof part === "string")
|
|
286
|
+
: undefined;
|
|
287
|
+
if (macAppRunning(name))
|
|
288
|
+
return operation(resource, "app.exists", `App already running: ${name}`, "noop");
|
|
289
|
+
if (restoreCommand?.length) {
|
|
290
|
+
return operation(resource, "app.open", `Open app: ${name}`, "planned", undefined, restoreCommand);
|
|
291
|
+
}
|
|
292
|
+
if (process.platform === "darwin") {
|
|
293
|
+
return operation(resource, "app.open", `Open macOS app: ${name}`, "planned", undefined, ["open", "-a", name]);
|
|
294
|
+
}
|
|
295
|
+
return operation(resource, "app.open", "App restore requires an explicit restore_command on this platform.", "skipped");
|
|
296
|
+
}
|
|
102
297
|
function executeOperation(op) {
|
|
103
298
|
if (op.status !== "planned")
|
|
104
299
|
return op;
|
|
@@ -125,9 +320,68 @@ function executeOperation(op) {
|
|
|
125
320
|
reason: result.stderr?.trim() || result.error?.message || `Command exited with ${result.status}`
|
|
126
321
|
};
|
|
127
322
|
}
|
|
323
|
+
if (op.kind === "tmux.create-window" && op.command) {
|
|
324
|
+
const session = String(op.resource?.attributes.session ?? "");
|
|
325
|
+
const name = String(op.resource?.attributes.name ?? "");
|
|
326
|
+
if (session && name && tmuxWindowExists(session, name))
|
|
327
|
+
return { ...op, status: "noop", reason: "Window already exists at execution time." };
|
|
328
|
+
const [command, ...args] = op.command;
|
|
329
|
+
const result = spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5_000 });
|
|
330
|
+
if (result.status === 0)
|
|
331
|
+
return { ...op, status: "applied" };
|
|
332
|
+
return { ...op, status: "failed", reason: result.stderr?.trim() || result.error?.message || `Command exited with ${result.status}` };
|
|
333
|
+
}
|
|
334
|
+
if (op.kind === "tmux.create-pane" && op.command) {
|
|
335
|
+
const session = String(op.resource?.attributes.session ?? "");
|
|
336
|
+
const windowIndex = Number(op.resource?.attributes.window_index);
|
|
337
|
+
const paneIndex = Number(op.resource?.attributes.pane_index);
|
|
338
|
+
if (session && Number.isFinite(windowIndex) && Number.isFinite(paneIndex) && tmuxPaneExists(session, windowIndex, paneIndex)) {
|
|
339
|
+
return { ...op, status: "noop", reason: "Pane already exists at execution time." };
|
|
340
|
+
}
|
|
341
|
+
const [command, ...args] = op.command;
|
|
342
|
+
const result = spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5_000 });
|
|
343
|
+
if (result.status === 0)
|
|
344
|
+
return { ...op, status: "applied" };
|
|
345
|
+
return { ...op, status: "failed", reason: result.stderr?.trim() || result.error?.message || `Command exited with ${result.status}` };
|
|
346
|
+
}
|
|
347
|
+
if (op.kind === "tmux.move-window" && op.command) {
|
|
348
|
+
const session = String(op.resource?.attributes.session ?? "");
|
|
349
|
+
const windowIndex = Number(op.resource?.attributes.index);
|
|
350
|
+
if (session && Number.isFinite(windowIndex)) {
|
|
351
|
+
const current = runTmux(["display-message", "-p", "-t", `${session}:`, "#{window_index}"], 2_000);
|
|
352
|
+
if (current.status === 0 && Number(current.stdout.trim()) === windowIndex) {
|
|
353
|
+
return { ...op, status: "noop", reason: "First window already has the captured index." };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const [command, ...args] = op.command;
|
|
357
|
+
const result = spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5_000 });
|
|
358
|
+
if (result.status === 0)
|
|
359
|
+
return { ...op, status: "applied" };
|
|
360
|
+
return { ...op, status: "failed", reason: result.stderr?.trim() || result.error?.message || `Command exited with ${result.status}` };
|
|
361
|
+
}
|
|
362
|
+
if ((op.kind === "tmux.select-layout" || op.kind === "tmux.select-pane" || op.kind === "tmux.select-window") && op.command) {
|
|
363
|
+
const [command, ...args] = op.command;
|
|
364
|
+
const result = spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5_000 });
|
|
365
|
+
if (result.status === 0)
|
|
366
|
+
return { ...op, status: "applied" };
|
|
367
|
+
return { ...op, status: "failed", reason: result.stderr?.trim() || result.error?.message || `Command exited with ${result.status}` };
|
|
368
|
+
}
|
|
369
|
+
if (op.kind === "process.restart" && op.command) {
|
|
370
|
+
const [command, ...args] = op.command;
|
|
371
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
372
|
+
child.unref();
|
|
373
|
+
return { ...op, status: "applied" };
|
|
374
|
+
}
|
|
375
|
+
if (op.kind === "app.open" && op.command) {
|
|
376
|
+
const [command, ...args] = op.command;
|
|
377
|
+
const result = spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 });
|
|
378
|
+
if (result.status === 0)
|
|
379
|
+
return { ...op, status: "applied" };
|
|
380
|
+
return { ...op, status: "failed", reason: result.stderr?.trim() || result.error?.message || `Command exited with ${result.status}` };
|
|
381
|
+
}
|
|
128
382
|
return { ...op, status: "blocked", reason: "No executor for operation kind." };
|
|
129
383
|
}
|
|
130
|
-
function operation(resource, kind, summary, status, reason, command) {
|
|
384
|
+
function operation(resource, kind, summary, status, reason, command, extra = {}) {
|
|
131
385
|
return {
|
|
132
386
|
id: `${kind}:${resource.id}`,
|
|
133
387
|
kind,
|
|
@@ -137,9 +391,274 @@ function operation(resource, kind, summary, status, reason, command) {
|
|
|
137
391
|
status,
|
|
138
392
|
reason,
|
|
139
393
|
command,
|
|
140
|
-
resource
|
|
394
|
+
resource,
|
|
395
|
+
...extra
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function normalizeRestoreRequest(options) {
|
|
399
|
+
const request = {
|
|
400
|
+
dependencyMode: options.dependencyMode ?? "none",
|
|
401
|
+
targetMode: options.targetMode ?? "strict",
|
|
402
|
+
tmuxMode: options.tmuxMode ?? "layout-only"
|
|
403
|
+
};
|
|
404
|
+
if (options.include?.length)
|
|
405
|
+
request.include = uniqueStrings(options.include);
|
|
406
|
+
if (options.exclude?.length)
|
|
407
|
+
request.exclude = uniqueStrings(options.exclude);
|
|
408
|
+
if (options.applyPlanId)
|
|
409
|
+
request.applyPlanId = options.applyPlanId;
|
|
410
|
+
if (options.planHash)
|
|
411
|
+
request.planHash = options.planHash;
|
|
412
|
+
return request;
|
|
413
|
+
}
|
|
414
|
+
function selectResources(resources, request) {
|
|
415
|
+
const include = request.include ?? [];
|
|
416
|
+
const exclude = request.exclude ?? [];
|
|
417
|
+
const matchedSelectors = [];
|
|
418
|
+
const unmatchedSelectors = [];
|
|
419
|
+
const warnings = [];
|
|
420
|
+
const autoAddedDependencies = [];
|
|
421
|
+
const byId = new Map(resources.map((resource) => [resource.id, resource]));
|
|
422
|
+
const childrenByParent = new Map();
|
|
423
|
+
for (const resource of resources) {
|
|
424
|
+
if (!resource.parentId)
|
|
425
|
+
continue;
|
|
426
|
+
const children = childrenByParent.get(resource.parentId) ?? [];
|
|
427
|
+
children.push(resource);
|
|
428
|
+
childrenByParent.set(resource.parentId, children);
|
|
429
|
+
}
|
|
430
|
+
const selectedIds = new Set();
|
|
431
|
+
const includeSelectors = include.length ? include : ["*"];
|
|
432
|
+
for (const selector of includeSelectors) {
|
|
433
|
+
const matches = matchResources(resources, selector).map((resource) => resource.id);
|
|
434
|
+
matchedSelectors.push({ selector, matchedResourceIds: matches });
|
|
435
|
+
if (!matches.length && selector !== "*")
|
|
436
|
+
unmatchedSelectors.push(selector);
|
|
437
|
+
for (const id of matches)
|
|
438
|
+
selectedIds.add(id);
|
|
439
|
+
}
|
|
440
|
+
if ((request.dependencyMode === "parents" || request.dependencyMode === "full") && include.length) {
|
|
441
|
+
for (const id of [...selectedIds])
|
|
442
|
+
addParentDependencies(id, id, byId, selectedIds, autoAddedDependencies);
|
|
443
|
+
}
|
|
444
|
+
if (request.dependencyMode === "full" && include.length) {
|
|
445
|
+
for (const id of [...selectedIds])
|
|
446
|
+
addChildDependencies(id, id, childrenByParent, selectedIds, autoAddedDependencies);
|
|
447
|
+
}
|
|
448
|
+
for (const selector of exclude) {
|
|
449
|
+
const matches = matchResources(resources, selector).map((resource) => resource.id);
|
|
450
|
+
matchedSelectors.push({ selector: `!${selector}`, matchedResourceIds: matches });
|
|
451
|
+
if (!matches.length)
|
|
452
|
+
unmatchedSelectors.push(`!${selector}`);
|
|
453
|
+
for (const id of matches)
|
|
454
|
+
selectedIds.delete(id);
|
|
455
|
+
}
|
|
456
|
+
if (include.length && request.dependencyMode === "none") {
|
|
457
|
+
warnings.push("Partial restore requested without dependency closure; child resources with omitted parents will be blocked.");
|
|
458
|
+
}
|
|
459
|
+
return {
|
|
460
|
+
resources: resources.filter((resource) => selectedIds.has(resource.id)),
|
|
461
|
+
matchedSelectors,
|
|
462
|
+
unmatchedSelectors,
|
|
463
|
+
autoAddedDependencies: autoAddedDependencies.filter((entry) => selectedIds.has(entry.resourceId)),
|
|
464
|
+
warnings
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
function matchResources(resources, selector) {
|
|
468
|
+
const trimmed = selector.trim();
|
|
469
|
+
if (!trimmed || trimmed === "*")
|
|
470
|
+
return resources;
|
|
471
|
+
const [prefix, ...rest] = trimmed.split(":");
|
|
472
|
+
const value = rest.join(":");
|
|
473
|
+
if (!value)
|
|
474
|
+
return resources.filter((resource) => resource.id === trimmed);
|
|
475
|
+
if (prefix === "id")
|
|
476
|
+
return resources.filter((resource) => resource.id === value);
|
|
477
|
+
if (prefix === "kind")
|
|
478
|
+
return resources.filter((resource) => resource.kind === value);
|
|
479
|
+
if (prefix === "source")
|
|
480
|
+
return resources.filter((resource) => resource.source === value);
|
|
481
|
+
if (prefix === "parent")
|
|
482
|
+
return resources.filter((resource) => resource.parentId === value);
|
|
483
|
+
if (prefix === "name") {
|
|
484
|
+
const normalized = value.toLowerCase();
|
|
485
|
+
return resources.filter((resource) => resource.name.toLowerCase().includes(normalized));
|
|
486
|
+
}
|
|
487
|
+
if (prefix === "path") {
|
|
488
|
+
return resources.filter((resource) => resourcePaths(resource).some((path) => path === value || path.startsWith(`${value}/`)));
|
|
489
|
+
}
|
|
490
|
+
return resources.filter((resource) => resource.id === trimmed);
|
|
491
|
+
}
|
|
492
|
+
function resourcePaths(resource) {
|
|
493
|
+
return ["path", "current_path", "app_path"]
|
|
494
|
+
.map((key) => resource.attributes[key])
|
|
495
|
+
.filter((value) => typeof value === "string");
|
|
496
|
+
}
|
|
497
|
+
function addParentDependencies(resourceId, requiredBy, byId, selectedIds, added) {
|
|
498
|
+
const resource = byId.get(resourceId);
|
|
499
|
+
if (!resource?.parentId)
|
|
500
|
+
return;
|
|
501
|
+
if (!selectedIds.has(resource.parentId)) {
|
|
502
|
+
selectedIds.add(resource.parentId);
|
|
503
|
+
added.push({ resourceId: resource.parentId, requiredBy, reason: "parent dependency" });
|
|
504
|
+
}
|
|
505
|
+
addParentDependencies(resource.parentId, requiredBy, byId, selectedIds, added);
|
|
506
|
+
}
|
|
507
|
+
function addChildDependencies(resourceId, requiredBy, childrenByParent, selectedIds, added) {
|
|
508
|
+
for (const child of childrenByParent.get(resourceId) ?? []) {
|
|
509
|
+
if (!selectedIds.has(child.id)) {
|
|
510
|
+
selectedIds.add(child.id);
|
|
511
|
+
added.push({ resourceId: child.id, requiredBy, reason: "child dependency" });
|
|
512
|
+
}
|
|
513
|
+
addChildDependencies(child.id, requiredBy, childrenByParent, selectedIds, added);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
function missingSelectedParent(resource, allResources, selectedResourceIds) {
|
|
517
|
+
if (!resource.parentId)
|
|
518
|
+
return undefined;
|
|
519
|
+
if (selectedResourceIds.has(resource.parentId))
|
|
520
|
+
return undefined;
|
|
521
|
+
return allResources.some((candidate) => candidate.id === resource.parentId) ? resource.parentId : undefined;
|
|
522
|
+
}
|
|
523
|
+
function strictExistingTmuxSessionNames(resources, request) {
|
|
524
|
+
if (request.targetMode === "merge-existing")
|
|
525
|
+
return new Set();
|
|
526
|
+
if (!commandExists("tmux"))
|
|
527
|
+
return new Set();
|
|
528
|
+
const names = new Set();
|
|
529
|
+
for (const resource of resources) {
|
|
530
|
+
if (resource.kind !== "tmux-session")
|
|
531
|
+
continue;
|
|
532
|
+
if (tmuxSessionExists(resource.name))
|
|
533
|
+
names.add(resource.name);
|
|
534
|
+
}
|
|
535
|
+
return names;
|
|
536
|
+
}
|
|
537
|
+
function shouldReplayTmuxCommand(resource, request, startCommand) {
|
|
538
|
+
return request.tmuxMode === "resume-marked" && resource?.attributes.restartable === true && Boolean(startCommand);
|
|
539
|
+
}
|
|
540
|
+
function tmuxCreateExtras(resource, request, startCommand) {
|
|
541
|
+
const warnings = [
|
|
542
|
+
"tmux restore recreates layout/cwd best-effort; it cannot restore shell internals, scrollback, process memory, or client attachment."
|
|
543
|
+
];
|
|
544
|
+
if (startCommand && resource.attributes.restartable === true && request.tmuxMode !== "resume-marked") {
|
|
545
|
+
warnings.push("Captured restartable command was not replayed because tmux mode is layout-only.");
|
|
546
|
+
}
|
|
547
|
+
if (startCommand && resource.attributes.restartable !== true) {
|
|
548
|
+
warnings.push("Captured command is forensic-only because it lacks a restartable marker.");
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
warnings,
|
|
552
|
+
confidence: "best-effort",
|
|
553
|
+
risk: shouldReplayTmuxCommand(resource, request, startCommand) ? "high" : "low",
|
|
554
|
+
effects: shouldReplayTmuxCommand(resource, request, startCommand)
|
|
555
|
+
? ["create tmux structure", "replay restartable command"]
|
|
556
|
+
: ["create tmux structure"]
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
function tmuxPlanWarnings(resources, request) {
|
|
560
|
+
const warnings = [];
|
|
561
|
+
if (resources.some((resource) => resource.kind === "tmux-session" && resource.attributes.attached === true)) {
|
|
562
|
+
warnings.push("tmux client attachment is captured for context but restore creates detached sessions.");
|
|
563
|
+
}
|
|
564
|
+
if (request.tmuxMode === "layout-only" && resources.some((resource) => resource.source === "tmux" && typeof resource.attributes.start_command === "string" && resource.attributes.start_command)) {
|
|
565
|
+
warnings.push("tmux restore mode is layout-only; captured start commands are preserved as forensic data but not replayed.");
|
|
566
|
+
}
|
|
567
|
+
return warnings;
|
|
568
|
+
}
|
|
569
|
+
function tmuxSessionExists(name) {
|
|
570
|
+
return runTmux(["has-session", "-t", name], 2_000).status === 0;
|
|
571
|
+
}
|
|
572
|
+
function tmuxSessionForResource(resource) {
|
|
573
|
+
if (resource.kind === "tmux-session")
|
|
574
|
+
return resource.name;
|
|
575
|
+
return typeof resource.attributes.session === "string" ? resource.attributes.session : undefined;
|
|
576
|
+
}
|
|
577
|
+
function blockedExistingTmuxSubtree(resource, session) {
|
|
578
|
+
return operation(resource, "tmux.blocked-existing-session", `Blocked restore into existing tmux session: ${session}`, "blocked", "Existing tmux sessions are not merged by default. Re-run with --merge-existing to opt into live-session mutation.", undefined, {
|
|
579
|
+
preconditions: [`tmux session must be absent or --merge-existing must be set: ${session}`],
|
|
580
|
+
warnings: ["Default strict restore avoids mutating live tmux sessions."],
|
|
581
|
+
risk: "high",
|
|
582
|
+
confidence: "impossible"
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
function assessAutopilot(operations) {
|
|
586
|
+
const allowedOperationIds = [];
|
|
587
|
+
const approvalRequiredOperationIds = [];
|
|
588
|
+
const forbiddenOperationIds = [];
|
|
589
|
+
const reasons = [];
|
|
590
|
+
for (const op of operations) {
|
|
591
|
+
if (op.status === "blocked" || op.status === "failed") {
|
|
592
|
+
forbiddenOperationIds.push(op.id);
|
|
593
|
+
reasons.push(`${op.id}: ${op.status} operation prevents autopilot apply.`);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (op.status !== "planned")
|
|
597
|
+
continue;
|
|
598
|
+
if (op.kind === "project.mkdir") {
|
|
599
|
+
allowedOperationIds.push(op.id);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (op.command?.[0] === "sh" && op.command?.[1] === "-lc") {
|
|
603
|
+
forbiddenOperationIds.push(op.id);
|
|
604
|
+
reasons.push(`${op.id}: shell command replay is forbidden for autopilot.`);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (op.kind.startsWith("tmux.") || op.kind === "app.open" || op.kind === "process.restart") {
|
|
608
|
+
approvalRequiredOperationIds.push(op.id);
|
|
609
|
+
reasons.push(`${op.id}: ${op.kind} requires human approval.`);
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
approvalRequiredOperationIds.push(op.id);
|
|
613
|
+
reasons.push(`${op.id}: operation kind is not autopilot-allowlisted.`);
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
safeToApply: approvalRequiredOperationIds.length === 0 && forbiddenOperationIds.length === 0,
|
|
617
|
+
allowedOperationIds,
|
|
618
|
+
approvalRequiredOperationIds,
|
|
619
|
+
forbiddenOperationIds,
|
|
620
|
+
reasons
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
function hashRestorePlan(plan) {
|
|
624
|
+
return sha256(stableJson({
|
|
625
|
+
snapshotId: plan.snapshotId,
|
|
626
|
+
request: {
|
|
627
|
+
include: plan.request?.include ?? [],
|
|
628
|
+
exclude: plan.request?.exclude ?? [],
|
|
629
|
+
dependencyMode: plan.request?.dependencyMode ?? "none",
|
|
630
|
+
targetMode: plan.request?.targetMode ?? "strict",
|
|
631
|
+
tmuxMode: plan.request?.tmuxMode ?? "layout-only",
|
|
632
|
+
applyPlanId: plan.request?.applyPlanId ?? null,
|
|
633
|
+
planHash: plan.request?.planHash ?? null
|
|
634
|
+
},
|
|
635
|
+
operations: plan.operations.map((op) => ({
|
|
636
|
+
id: op.id,
|
|
637
|
+
kind: op.kind,
|
|
638
|
+
resourceId: op.resourceId,
|
|
639
|
+
resourceKind: op.resourceKind,
|
|
640
|
+
status: op.status,
|
|
641
|
+
command: op.command ?? [],
|
|
642
|
+
reason: op.reason ?? null,
|
|
643
|
+
resourceHash: op.resource?.hash ?? null
|
|
644
|
+
}))
|
|
645
|
+
}));
|
|
646
|
+
}
|
|
647
|
+
export function prepareRestorePlanForExecution(plan) {
|
|
648
|
+
const operations = plan.operations.map((op) => op.status === "blocked" && op.reason === "Restore execution requires --apply --yes."
|
|
649
|
+
? { ...op, status: "planned", reason: undefined }
|
|
650
|
+
: op);
|
|
651
|
+
return {
|
|
652
|
+
...plan,
|
|
653
|
+
apply: false,
|
|
654
|
+
operations,
|
|
655
|
+
summary: summarizeOperations(operations),
|
|
656
|
+
autopilot: assessAutopilot(operations)
|
|
141
657
|
};
|
|
142
658
|
}
|
|
659
|
+
function uniqueStrings(values) {
|
|
660
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
661
|
+
}
|
|
143
662
|
function summarizeOperations(operations) {
|
|
144
663
|
const summary = {
|
|
145
664
|
planned: 0,
|
|
@@ -154,4 +673,98 @@ function summarizeOperations(operations) {
|
|
|
154
673
|
}
|
|
155
674
|
return summary;
|
|
156
675
|
}
|
|
676
|
+
function sortOperations(operations) {
|
|
677
|
+
const priority = {
|
|
678
|
+
"project.mkdir": 10,
|
|
679
|
+
"tmux.create-session": 20,
|
|
680
|
+
"tmux.move-window": 25,
|
|
681
|
+
"tmux.create-window": 30,
|
|
682
|
+
"tmux.create-pane": 35,
|
|
683
|
+
"tmux.select-layout": 36,
|
|
684
|
+
"tmux.select-pane": 37,
|
|
685
|
+
"tmux.select-window": 38,
|
|
686
|
+
"process.restart": 40,
|
|
687
|
+
"app.open": 50
|
|
688
|
+
};
|
|
689
|
+
return [...operations].sort((a, b) => (priority[a.kind] ?? 100) - (priority[b.kind] ?? 100)
|
|
690
|
+
|| operationOrderKey(a).localeCompare(operationOrderKey(b), undefined, { numeric: true }));
|
|
691
|
+
}
|
|
692
|
+
function operationOrderKey(op) {
|
|
693
|
+
const resource = op.resource;
|
|
694
|
+
const session = typeof resource?.attributes.session === "string" ? resource.attributes.session : resource?.name ?? "";
|
|
695
|
+
const windowIndex = Number(resource?.attributes.index ?? resource?.attributes.window_index ?? 0);
|
|
696
|
+
const paneIndex = Number(resource?.attributes.pane_index ?? 0);
|
|
697
|
+
return `${session}:${Number.isFinite(windowIndex) ? windowIndex : 0}:${Number.isFinite(paneIndex) ? paneIndex : 0}:${op.id}`;
|
|
698
|
+
}
|
|
699
|
+
function tmuxWindowExists(session, name, index) {
|
|
700
|
+
const result = runTmux(["list-windows", "-t", session, "-F", "#{window_index}\t#{window_name}"], 2_000);
|
|
701
|
+
if (result.status !== 0)
|
|
702
|
+
return false;
|
|
703
|
+
return result.stdout.split("\n").some((line) => {
|
|
704
|
+
const [windowIndex, windowName] = line.trim().split("\t");
|
|
705
|
+
if (typeof index === "number")
|
|
706
|
+
return Number(windowIndex) === index;
|
|
707
|
+
return windowName === name;
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
function firstPaneIndexForWindow(resource, resources) {
|
|
711
|
+
const session = resource.attributes.session;
|
|
712
|
+
const windowIndex = resource.attributes.window_index;
|
|
713
|
+
const indexes = resources
|
|
714
|
+
.filter((candidate) => candidate.kind === "tmux-pane"
|
|
715
|
+
&& candidate.attributes.session === session
|
|
716
|
+
&& candidate.attributes.window_index === windowIndex)
|
|
717
|
+
.map((candidate) => Number(candidate.attributes.pane_index))
|
|
718
|
+
.filter(Number.isFinite);
|
|
719
|
+
return indexes.length ? Math.min(...indexes) : 0;
|
|
720
|
+
}
|
|
721
|
+
function tmuxPaneExists(session, windowIndex, paneIndex) {
|
|
722
|
+
const result = runTmux(["list-panes", "-t", `${session}:${windowIndex}`, "-F", "#{pane_index}"], 2_000);
|
|
723
|
+
if (result.status !== 0)
|
|
724
|
+
return false;
|
|
725
|
+
return result.stdout.split("\n").map((line) => Number(line.trim())).includes(paneIndex);
|
|
726
|
+
}
|
|
727
|
+
function processMarkerRunning(processId) {
|
|
728
|
+
const result = spawnSync("ps", ["-axo", "pid=,args="], {
|
|
729
|
+
encoding: "utf8",
|
|
730
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
731
|
+
timeout: 2_000
|
|
732
|
+
});
|
|
733
|
+
if (result.status !== 0)
|
|
734
|
+
return false;
|
|
735
|
+
return result.stdout.split("\n").some((line) => {
|
|
736
|
+
const trimmed = line.trim();
|
|
737
|
+
if (!trimmed)
|
|
738
|
+
return false;
|
|
739
|
+
const match = trimmed.match(/^(\d+)\s+(.*)$/);
|
|
740
|
+
if (!match)
|
|
741
|
+
return false;
|
|
742
|
+
const [, pid, args] = match;
|
|
743
|
+
return Number(pid) !== process.pid
|
|
744
|
+
&& args.includes("HASNA_SNAPSHOTS_RESTARTABLE=1")
|
|
745
|
+
&& args.includes(`HASNA_SNAPSHOTS_PROCESS_ID=${processId}`);
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
function macAppRunning(name) {
|
|
749
|
+
if (process.platform !== "darwin")
|
|
750
|
+
return false;
|
|
751
|
+
const result = spawnSync("osascript", ["-e", `tell application "System Events" to exists application process "${escapeAppleScript(name)}"`], {
|
|
752
|
+
encoding: "utf8",
|
|
753
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
754
|
+
timeout: 5_000
|
|
755
|
+
});
|
|
756
|
+
if (result.status === 0)
|
|
757
|
+
return result.stdout.trim() === "true";
|
|
758
|
+
const ps = spawnSync("ps", ["-axo", "args="], {
|
|
759
|
+
encoding: "utf8",
|
|
760
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
761
|
+
timeout: 2_000
|
|
762
|
+
});
|
|
763
|
+
if (ps.status !== 0)
|
|
764
|
+
return false;
|
|
765
|
+
return ps.stdout.split("\n").some((line) => line.includes(`/${name}.app/Contents/MacOS/`));
|
|
766
|
+
}
|
|
767
|
+
function escapeAppleScript(value) {
|
|
768
|
+
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
|
769
|
+
}
|
|
157
770
|
//# sourceMappingURL=restore.js.map
|