pi-webdesk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -0
- package/dist/apps/daemon/src/appearance-preferences.js +218 -0
- package/dist/apps/daemon/src/auth.js +88 -0
- package/dist/apps/daemon/src/bin.js +123 -0
- package/dist/apps/daemon/src/cli.js +48 -0
- package/dist/apps/daemon/src/event-hub.js +155 -0
- package/dist/apps/daemon/src/index.js +102 -0
- package/dist/apps/daemon/src/launcher-control.js +114 -0
- package/dist/apps/daemon/src/launcher.js +73 -0
- package/dist/apps/daemon/src/pi-auth.js +290 -0
- package/dist/apps/daemon/src/pi-resources.js +182 -0
- package/dist/apps/daemon/src/pi-runtime-factory.js +19 -0
- package/dist/apps/daemon/src/pi-sessions.js +265 -0
- package/dist/apps/daemon/src/runtime-process.js +241 -0
- package/dist/apps/daemon/src/secret.js +71 -0
- package/dist/apps/daemon/src/server.js +1662 -0
- package/dist/apps/daemon/src/session-projection.js +117 -0
- package/dist/apps/daemon/src/state-lock.js +31 -0
- package/dist/apps/daemon/src/static-web.js +53 -0
- package/dist/apps/daemon/src/task-archive.js +152 -0
- package/dist/apps/daemon/src/task-commit.js +503 -0
- package/dist/apps/daemon/src/task-merge.js +912 -0
- package/dist/apps/daemon/src/task-review.js +204 -0
- package/dist/apps/daemon/src/task-runtime.js +1124 -0
- package/dist/apps/daemon/src/task-validation.js +352 -0
- package/dist/apps/daemon/src/workspace-store.js +140 -0
- package/dist/apps/daemon/src/workspace.js +795 -0
- package/dist/extensions/webdesk.js +34 -0
- package/dist/packages/git/src/commit.js +675 -0
- package/dist/packages/git/src/errors.js +55 -0
- package/dist/packages/git/src/fingerprint.js +286 -0
- package/dist/packages/git/src/index.js +123 -0
- package/dist/packages/git/src/merge.js +1008 -0
- package/dist/packages/git/src/paths.js +58 -0
- package/dist/packages/git/src/repository.js +77 -0
- package/dist/packages/git/src/review.js +396 -0
- package/dist/packages/git/src/runner.js +110 -0
- package/dist/packages/git/src/validation.js +263 -0
- package/dist/packages/git/src/worktree.js +233 -0
- package/dist/packages/pi-bridge/extensions/pita-policy.js +117 -0
- package/dist/packages/pi-bridge/src/auth.js +80 -0
- package/dist/packages/pi-bridge/src/errors.js +19 -0
- package/dist/packages/pi-bridge/src/handshake.js +43 -0
- package/dist/packages/pi-bridge/src/index.js +76 -0
- package/dist/packages/pi-bridge/src/jsonl.js +105 -0
- package/dist/packages/pi-bridge/src/policy-approval.js +62 -0
- package/dist/packages/pi-bridge/src/resolve.js +59 -0
- package/dist/packages/pi-bridge/src/resources-child.mjs +23 -0
- package/dist/packages/pi-bridge/src/resources.js +481 -0
- package/dist/packages/pi-bridge/src/rpc/client.js +480 -0
- package/dist/packages/pi-bridge/src/rpc/runtime.js +496 -0
- package/dist/packages/pi-bridge/src/rpc/supervisor.mjs +129 -0
- package/dist/packages/pi-bridge/src/rpc/tool-events.js +78 -0
- package/dist/packages/pi-bridge/src/rpc/wire.js +263 -0
- package/dist/packages/pi-bridge/src/runtime.js +0 -0
- package/dist/packages/pi-bridge/src/sessions-child.mjs +38 -0
- package/dist/packages/pi-bridge/src/sessions.js +314 -0
- package/dist/packages/pi-bridge/src/tool-activity.js +56 -0
- package/dist/packages/protocol/src/index.js +1863 -0
- package/dist/web/assets/index-BOw_fhvO.css +2 -0
- package/dist/web/assets/index-oXs7yAAo.js +119 -0
- package/dist/web/index.html +14 -0
- package/package.json +69 -0
- package/scripts/prepare.mjs +7 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// apps/daemon/src/event-hub.ts
|
|
2
|
+
import {
|
|
3
|
+
PROTOCOL_VERSION,
|
|
4
|
+
taskEventsErrorSchema,
|
|
5
|
+
taskEventsReplaySchema
|
|
6
|
+
} from "../../../packages/protocol/src/index.js";
|
|
7
|
+
import { TaskRuntimeOperationError } from "./task-runtime.js";
|
|
8
|
+
var DEFAULT_EVENT_BUFFER_CAPACITY = 512;
|
|
9
|
+
var DEFAULT_TASK_BUFFER_LIMIT = 64;
|
|
10
|
+
function createTaskEventHub(options) {
|
|
11
|
+
const capacity = options.bufferCapacity ?? DEFAULT_EVENT_BUFFER_CAPACITY;
|
|
12
|
+
const taskBufferLimit = options.taskBufferLimit ?? DEFAULT_TASK_BUFFER_LIMIT;
|
|
13
|
+
if (!Number.isSafeInteger(capacity) || capacity < 1) {
|
|
14
|
+
throw new Error("Event buffer capacity must be a positive safe integer");
|
|
15
|
+
}
|
|
16
|
+
if (!Number.isSafeInteger(taskBufferLimit) || taskBufferLimit < 1) {
|
|
17
|
+
throw new Error("Task buffer limit must be a positive safe integer");
|
|
18
|
+
}
|
|
19
|
+
const buffers = /* @__PURE__ */ new Map();
|
|
20
|
+
const snapshotsInFlight = /* @__PURE__ */ new Map();
|
|
21
|
+
const connections = /* @__PURE__ */ new Set();
|
|
22
|
+
function record(event) {
|
|
23
|
+
const buffer = buffers.get(event.taskId);
|
|
24
|
+
if (buffer === void 0 || event.runtimeGeneration > buffer.generation) {
|
|
25
|
+
if (buffer === void 0 && buffers.size >= taskBufferLimit) {
|
|
26
|
+
const oldestTaskId = buffers.keys().next().value;
|
|
27
|
+
if (oldestTaskId !== void 0) buffers.delete(oldestTaskId);
|
|
28
|
+
}
|
|
29
|
+
if (buffer !== void 0) buffers.delete(event.taskId);
|
|
30
|
+
buffers.set(event.taskId, {
|
|
31
|
+
generation: event.runtimeGeneration,
|
|
32
|
+
events: [event]
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (event.runtimeGeneration < buffer.generation) return;
|
|
37
|
+
buffer.events.push(event);
|
|
38
|
+
if (buffer.events.length > capacity) buffer.events.shift();
|
|
39
|
+
buffers.delete(event.taskId);
|
|
40
|
+
buffers.set(event.taskId, buffer);
|
|
41
|
+
}
|
|
42
|
+
function sharedSnapshot(taskId) {
|
|
43
|
+
const existing = snapshotsInFlight.get(taskId);
|
|
44
|
+
if (existing !== void 0) return existing;
|
|
45
|
+
const created = options.snapshot(taskId);
|
|
46
|
+
snapshotsInFlight.set(taskId, created);
|
|
47
|
+
void created.finally(() => {
|
|
48
|
+
if (snapshotsInFlight.get(taskId) === created) {
|
|
49
|
+
snapshotsInFlight.delete(taskId);
|
|
50
|
+
}
|
|
51
|
+
}).catch(() => void 0);
|
|
52
|
+
return created;
|
|
53
|
+
}
|
|
54
|
+
function replayableRange(taskId, cursor) {
|
|
55
|
+
const buffer = buffers.get(taskId);
|
|
56
|
+
if (buffer === void 0 || buffer.generation !== cursor.runtimeGeneration) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const first = buffer.events[0];
|
|
60
|
+
const last = buffer.events[buffer.events.length - 1];
|
|
61
|
+
if (first === void 0 || last === void 0) return null;
|
|
62
|
+
if (first.sequence > cursor.lastSequence + 1) return null;
|
|
63
|
+
if (cursor.lastSequence > last.sequence) return null;
|
|
64
|
+
return buffer.events.filter((event) => event.sequence > cursor.lastSequence);
|
|
65
|
+
}
|
|
66
|
+
function deliver(state, event) {
|
|
67
|
+
const subscription = state.subscription;
|
|
68
|
+
if (subscription === null || subscription.taskId !== event.taskId) return;
|
|
69
|
+
if (subscription.mode === "live") {
|
|
70
|
+
state.send(event);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
subscription.queue.push(event);
|
|
74
|
+
if (subscription.queue.length > capacity) subscription.queue.shift();
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
publish(event) {
|
|
78
|
+
record(event);
|
|
79
|
+
for (const state of connections) deliver(state, event);
|
|
80
|
+
},
|
|
81
|
+
connect(send) {
|
|
82
|
+
const state = { send, subscription: null, closed: false };
|
|
83
|
+
connections.add(state);
|
|
84
|
+
return {
|
|
85
|
+
subscribe(message) {
|
|
86
|
+
if (state.closed) return;
|
|
87
|
+
state.subscription = null;
|
|
88
|
+
if (message.cursor !== null) {
|
|
89
|
+
const replayed = replayableRange(message.taskId, message.cursor);
|
|
90
|
+
if (replayed !== null) {
|
|
91
|
+
const lastReplayed = replayed[replayed.length - 1];
|
|
92
|
+
state.send(
|
|
93
|
+
taskEventsReplaySchema.parse({
|
|
94
|
+
protocol: PROTOCOL_VERSION,
|
|
95
|
+
type: "task-events-replay",
|
|
96
|
+
taskId: message.taskId,
|
|
97
|
+
runtimeGeneration: message.cursor.runtimeGeneration,
|
|
98
|
+
afterSequence: message.cursor.lastSequence,
|
|
99
|
+
lastSequence: lastReplayed?.sequence ?? message.cursor.lastSequence
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
for (const event of replayed) state.send(event);
|
|
103
|
+
state.subscription = { taskId: message.taskId, mode: "live" };
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const pending = {
|
|
108
|
+
taskId: message.taskId,
|
|
109
|
+
mode: "pending",
|
|
110
|
+
queue: []
|
|
111
|
+
};
|
|
112
|
+
state.subscription = pending;
|
|
113
|
+
void sharedSnapshot(message.taskId).then(
|
|
114
|
+
(snapshot) => {
|
|
115
|
+
if (state.closed || state.subscription !== pending) return;
|
|
116
|
+
state.send(snapshot);
|
|
117
|
+
const coveredSequence = snapshot.lastSequence ?? -1;
|
|
118
|
+
for (const event of pending.queue) {
|
|
119
|
+
if (event.runtimeGeneration < snapshot.runtimeGeneration) continue;
|
|
120
|
+
if (event.runtimeGeneration === snapshot.runtimeGeneration && event.sequence <= coveredSequence) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
state.send(event);
|
|
124
|
+
}
|
|
125
|
+
state.subscription = { taskId: message.taskId, mode: "live" };
|
|
126
|
+
},
|
|
127
|
+
(error) => {
|
|
128
|
+
if (state.closed || state.subscription !== pending) return;
|
|
129
|
+
state.subscription = null;
|
|
130
|
+
state.send(
|
|
131
|
+
taskEventsErrorSchema.parse({
|
|
132
|
+
protocol: PROTOCOL_VERSION,
|
|
133
|
+
type: "task-events-error",
|
|
134
|
+
taskId: message.taskId,
|
|
135
|
+
code: error instanceof TaskRuntimeOperationError && error.code === "task-not-found" ? "task-not-found" : "snapshot-failed",
|
|
136
|
+
message: error instanceof TaskRuntimeOperationError && error.code === "task-not-found" ? "The selected task no longer exists." : "Webdesk could not synchronize this task. It will retry automatically."
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
close() {
|
|
143
|
+
state.closed = true;
|
|
144
|
+
state.subscription = null;
|
|
145
|
+
connections.delete(state);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
export {
|
|
152
|
+
DEFAULT_EVENT_BUFFER_CAPACITY,
|
|
153
|
+
DEFAULT_TASK_BUFFER_LIMIT,
|
|
154
|
+
createTaskEventHub
|
|
155
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// apps/daemon/src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
DaemonConfigError,
|
|
4
|
+
createDaemonServer
|
|
5
|
+
} from "./server.js";
|
|
6
|
+
import {
|
|
7
|
+
SESSION_COOKIE_NAME,
|
|
8
|
+
createSessionToken,
|
|
9
|
+
generateBootstrapToken,
|
|
10
|
+
readCookie,
|
|
11
|
+
tokensMatch,
|
|
12
|
+
verifySessionToken
|
|
13
|
+
} from "./auth.js";
|
|
14
|
+
import {
|
|
15
|
+
DaemonStateError,
|
|
16
|
+
SESSION_SECRET_FILE,
|
|
17
|
+
loadOrCreateSessionSecret,
|
|
18
|
+
sessionSecretPath
|
|
19
|
+
} from "./secret.js";
|
|
20
|
+
import { DaemonStateLockError, acquireDaemonStateLock } from "./state-lock.js";
|
|
21
|
+
import {
|
|
22
|
+
RuntimeProcessRecoveryError,
|
|
23
|
+
captureRuntimeProcess,
|
|
24
|
+
createRuntimeProcessInspector,
|
|
25
|
+
recoverRuntimeProcess
|
|
26
|
+
} from "./runtime-process.js";
|
|
27
|
+
import {
|
|
28
|
+
WorkspaceStateError,
|
|
29
|
+
WORKSPACE_STATE_FILE,
|
|
30
|
+
createWorkspaceStore
|
|
31
|
+
} from "./workspace-store.js";
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_PITA_APPEARANCE,
|
|
34
|
+
PITA_PREFERENCES_FILE,
|
|
35
|
+
PitaPreferencesOperationError,
|
|
36
|
+
createPitaPreferencesService
|
|
37
|
+
} from "./appearance-preferences.js";
|
|
38
|
+
import {
|
|
39
|
+
WorkspaceOperationError,
|
|
40
|
+
createWorkspaceService
|
|
41
|
+
} from "./workspace.js";
|
|
42
|
+
import {
|
|
43
|
+
TaskReviewOperationError,
|
|
44
|
+
createTaskReviewService
|
|
45
|
+
} from "./task-review.js";
|
|
46
|
+
import {
|
|
47
|
+
TaskRuntimeOperationError,
|
|
48
|
+
createTaskRuntimeCoordinator
|
|
49
|
+
} from "./task-runtime.js";
|
|
50
|
+
import { createPiTaskRuntimeFactory } from "./pi-runtime-factory.js";
|
|
51
|
+
import {
|
|
52
|
+
PiAuthOperationError,
|
|
53
|
+
createPiAuthCoordinator
|
|
54
|
+
} from "./pi-auth.js";
|
|
55
|
+
import {
|
|
56
|
+
PiConfigurationOperationError,
|
|
57
|
+
createPiConfigurationService
|
|
58
|
+
} from "./pi-resources.js";
|
|
59
|
+
import {
|
|
60
|
+
PiSessionCatalogOperationError,
|
|
61
|
+
createPiSessionCatalogService
|
|
62
|
+
} from "./pi-sessions.js";
|
|
63
|
+
export {
|
|
64
|
+
DEFAULT_PITA_APPEARANCE,
|
|
65
|
+
DaemonConfigError,
|
|
66
|
+
DaemonStateError,
|
|
67
|
+
DaemonStateLockError,
|
|
68
|
+
PITA_PREFERENCES_FILE,
|
|
69
|
+
PiAuthOperationError,
|
|
70
|
+
PiConfigurationOperationError,
|
|
71
|
+
PiSessionCatalogOperationError,
|
|
72
|
+
PitaPreferencesOperationError,
|
|
73
|
+
RuntimeProcessRecoveryError,
|
|
74
|
+
SESSION_COOKIE_NAME,
|
|
75
|
+
SESSION_SECRET_FILE,
|
|
76
|
+
TaskReviewOperationError,
|
|
77
|
+
TaskRuntimeOperationError,
|
|
78
|
+
WORKSPACE_STATE_FILE,
|
|
79
|
+
WorkspaceOperationError,
|
|
80
|
+
WorkspaceStateError,
|
|
81
|
+
acquireDaemonStateLock,
|
|
82
|
+
captureRuntimeProcess,
|
|
83
|
+
createDaemonServer,
|
|
84
|
+
createPiAuthCoordinator,
|
|
85
|
+
createPiConfigurationService,
|
|
86
|
+
createPiSessionCatalogService,
|
|
87
|
+
createPiTaskRuntimeFactory,
|
|
88
|
+
createPitaPreferencesService,
|
|
89
|
+
createRuntimeProcessInspector,
|
|
90
|
+
createSessionToken,
|
|
91
|
+
createTaskReviewService,
|
|
92
|
+
createTaskRuntimeCoordinator,
|
|
93
|
+
createWorkspaceService,
|
|
94
|
+
createWorkspaceStore,
|
|
95
|
+
generateBootstrapToken,
|
|
96
|
+
loadOrCreateSessionSecret,
|
|
97
|
+
readCookie,
|
|
98
|
+
recoverRuntimeProcess,
|
|
99
|
+
sessionSecretPath,
|
|
100
|
+
tokensMatch,
|
|
101
|
+
verifySessionToken
|
|
102
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// apps/daemon/src/launcher-control.ts
|
|
2
|
+
import { chmodSync, lstatSync, unlinkSync } from "node:fs";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
var launcherCommandSchema = z.enum(["open", "status", "stop"]);
|
|
7
|
+
var replySchema = z.object({
|
|
8
|
+
version: z.literal(1),
|
|
9
|
+
status: z.enum(["running", "stopping"]),
|
|
10
|
+
origin: z.string().url().refine((value) => {
|
|
11
|
+
const url = new URL(value);
|
|
12
|
+
return url.protocol === "http:" && ["127.0.0.1", "localhost"].includes(url.hostname) && url.origin === value;
|
|
13
|
+
}),
|
|
14
|
+
token: z.string().regex(/^[A-Za-z0-9_-]{32,128}$/).optional()
|
|
15
|
+
}).strict();
|
|
16
|
+
function controlSocketPath(stateDir) {
|
|
17
|
+
return path.join(stateDir, "launcher-v1.sock");
|
|
18
|
+
}
|
|
19
|
+
async function listenLauncherControl(stateDir, handle, stop) {
|
|
20
|
+
const socketPath = controlSocketPath(stateDir);
|
|
21
|
+
try {
|
|
22
|
+
if (!lstatSync(socketPath).isSocket()) throw new Error(`Expected a local socket at ${socketPath}`);
|
|
23
|
+
unlinkSync(socketPath);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error.code !== "ENOENT") throw error;
|
|
26
|
+
}
|
|
27
|
+
const clients = /* @__PURE__ */ new Set();
|
|
28
|
+
const server = net.createServer((socket) => {
|
|
29
|
+
clients.add(socket);
|
|
30
|
+
socket.once("close", () => clients.delete(socket));
|
|
31
|
+
socket.on("error", () => socket.destroy());
|
|
32
|
+
socket.setTimeout(2e3, () => socket.destroy());
|
|
33
|
+
socket.setEncoding("utf8");
|
|
34
|
+
let input = "";
|
|
35
|
+
let handled = false;
|
|
36
|
+
socket.on("data", (chunk) => {
|
|
37
|
+
if (handled) return;
|
|
38
|
+
input += chunk;
|
|
39
|
+
if (input.length > 64) {
|
|
40
|
+
socket.destroy();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!input.endsWith("\n")) return;
|
|
44
|
+
handled = true;
|
|
45
|
+
const command = launcherCommandSchema.safeParse(input.trim());
|
|
46
|
+
if (!command.success) {
|
|
47
|
+
socket.destroy();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const reply = replySchema.parse(handle(command.data));
|
|
52
|
+
socket.end(`${JSON.stringify(reply)}
|
|
53
|
+
`, () => {
|
|
54
|
+
if (command.data === "stop") stop();
|
|
55
|
+
});
|
|
56
|
+
} catch {
|
|
57
|
+
socket.destroy();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
await new Promise((resolve, reject) => {
|
|
62
|
+
server.once("error", reject);
|
|
63
|
+
server.listen(socketPath, () => {
|
|
64
|
+
server.removeListener("error", reject);
|
|
65
|
+
resolve();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
chmodSync(socketPath, 384);
|
|
69
|
+
return () => new Promise((resolve, reject) => {
|
|
70
|
+
for (const socket of clients) socket.destroy();
|
|
71
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async function requestLauncherControl(stateDir, command) {
|
|
75
|
+
try {
|
|
76
|
+
const state = lstatSync(stateDir);
|
|
77
|
+
if (!state.isDirectory() || state.isSymbolicLink() || (state.mode & 63) !== 0) {
|
|
78
|
+
throw new Error(`Webdesk state must be a private directory (mode 700): ${stateDir}`);
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error.code === "ENOENT") return null;
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const socket = net.createConnection(controlSocketPath(stateDir));
|
|
86
|
+
socket.setEncoding("utf8");
|
|
87
|
+
socket.setTimeout(2e3, () => socket.destroy(new Error("Webdesk's local control socket did not respond.")));
|
|
88
|
+
let response = "";
|
|
89
|
+
socket.once("connect", () => socket.write(`${command}
|
|
90
|
+
`));
|
|
91
|
+
socket.on("data", (chunk) => {
|
|
92
|
+
response += chunk;
|
|
93
|
+
if (response.length > 4096) socket.destroy(new Error("Invalid Webdesk control response."));
|
|
94
|
+
});
|
|
95
|
+
socket.once("error", (error) => {
|
|
96
|
+
if (error.code === "ENOENT" || error.code === "ECONNREFUSED") resolve(null);
|
|
97
|
+
else reject(error);
|
|
98
|
+
});
|
|
99
|
+
socket.once("end", () => {
|
|
100
|
+
socket.destroy();
|
|
101
|
+
try {
|
|
102
|
+
resolve(replySchema.parse(JSON.parse(response)));
|
|
103
|
+
} catch {
|
|
104
|
+
reject(new Error("Invalid Webdesk control response."));
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
export {
|
|
110
|
+
controlSocketPath,
|
|
111
|
+
launcherCommandSchema,
|
|
112
|
+
listenLauncherControl,
|
|
113
|
+
requestLauncherControl
|
|
114
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// apps/daemon/src/launcher.ts
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { closeSync, constants, fstatSync, openSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { requestLauncherControl } from "./launcher-control.js";
|
|
9
|
+
import { loadOrCreateSessionSecret } from "./secret.js";
|
|
10
|
+
function launcherStateDir() {
|
|
11
|
+
return path.resolve(process.env.PITA_STATE_DIR ?? path.join(os.homedir(), ".pita"));
|
|
12
|
+
}
|
|
13
|
+
function bootstrapUrl(reply) {
|
|
14
|
+
if (!reply.token) throw new Error("Webdesk did not return a browser login link.");
|
|
15
|
+
return `${reply.origin}/?pita_bootstrap=${reply.token}`;
|
|
16
|
+
}
|
|
17
|
+
async function openBrowser(url) {
|
|
18
|
+
const command = process.platform === "darwin" ? "open" : "xdg-open";
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const child = spawn(command, [url], { stdio: "ignore" });
|
|
21
|
+
const timeout = setTimeout(() => {
|
|
22
|
+
child.kill();
|
|
23
|
+
resolve(false);
|
|
24
|
+
}, 5e3);
|
|
25
|
+
const finish = (success) => {
|
|
26
|
+
clearTimeout(timeout);
|
|
27
|
+
resolve(success);
|
|
28
|
+
};
|
|
29
|
+
child.once("error", () => finish(false));
|
|
30
|
+
child.once("exit", (code) => finish(code === 0));
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async function launchPita(stateDir = launcherStateDir()) {
|
|
34
|
+
const existing = await requestLauncherControl(stateDir, "open");
|
|
35
|
+
if (existing) return existing;
|
|
36
|
+
loadOrCreateSessionSecret(stateDir);
|
|
37
|
+
const logPath = path.join(stateDir, "daemon.log");
|
|
38
|
+
const log = openSync(logPath, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW, 384);
|
|
39
|
+
let child;
|
|
40
|
+
try {
|
|
41
|
+
const info = fstatSync(log);
|
|
42
|
+
if (!info.isFile() || (info.mode & 63) !== 0) throw new Error(`Webdesk log must be a private regular file: ${logPath}`);
|
|
43
|
+
const cli = fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "./cli.ts" : "./cli.js", import.meta.url));
|
|
44
|
+
child = spawn(process.execPath, [cli, "serve", "--no-browser"], {
|
|
45
|
+
cwd: process.cwd(),
|
|
46
|
+
env: { ...process.env, PITA_STATE_DIR: stateDir, PITA_BACKGROUND: "1" },
|
|
47
|
+
detached: true,
|
|
48
|
+
stdio: ["ignore", log, log]
|
|
49
|
+
});
|
|
50
|
+
} finally {
|
|
51
|
+
closeSync(log);
|
|
52
|
+
}
|
|
53
|
+
let failure;
|
|
54
|
+
child.once("error", (error) => {
|
|
55
|
+
failure = error;
|
|
56
|
+
});
|
|
57
|
+
child.unref();
|
|
58
|
+
const deadline = Date.now() + 2e4;
|
|
59
|
+
while (Date.now() < deadline) {
|
|
60
|
+
const reply = await requestLauncherControl(stateDir, "open");
|
|
61
|
+
if (reply) return reply;
|
|
62
|
+
if (failure || child.exitCode !== null || child.signalCode !== null) break;
|
|
63
|
+
await delay(100);
|
|
64
|
+
}
|
|
65
|
+
child.kill("SIGTERM");
|
|
66
|
+
throw new Error(`Webdesk could not start. Check ${logPath} for the startup error.`);
|
|
67
|
+
}
|
|
68
|
+
export {
|
|
69
|
+
bootstrapUrl,
|
|
70
|
+
launchPita,
|
|
71
|
+
launcherStateDir,
|
|
72
|
+
openBrowser
|
|
73
|
+
};
|