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
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Pi Webdesk
|
|
2
|
+
|
|
3
|
+
An opinionated web UI for Pi.
|
|
4
|
+
|
|
5
|
+
Webdesk is a local-first browser workspace built specifically for [Pi](https://pi.dev/). It gives a solo developer a calm, worktree-first place to run several coding tasks, inspect changes, validate them, and explicitly decide what gets committed or merged.
|
|
6
|
+
|
|
7
|
+
Pi remains the agent runtime and the source of truth for sessions, models, authentication, settings, skills, extensions, prompts, themes, and packages.
|
|
8
|
+
|
|
9
|
+
## Status
|
|
10
|
+
|
|
11
|
+
The core vertical slice is runnable locally, including through Pi's package manager: an authenticated localhost browser can register repositories, create isolated task worktrees, run supervised Pi runtimes with policy approvals, stream and steer prompts, and reconnect with exact event replay or a Pi-reconstructed canonical snapshot. Tasks provide live bounded Git review, explicit validation, snapshot commit approval, local merge approval, and non-destructive archive/restore. Durable runtime ownership lets a restarted daemon terminate verified orphan process groups before accepting traffic; ambiguous ownership blocks only the affected task, and interrupted prompts are never resumed automatically. The current UI includes formatted conversations, expandable tool and skill-file activity with canonical reload recovery, proposed edit previews, compact session branches, persistent task selection, read-only Pi configuration/resource discovery, and schema-bound Webdesk appearance preferences.
|
|
12
|
+
|
|
13
|
+
- [Product brief](docs/product-brief.md)
|
|
14
|
+
- [Architecture](docs/architecture.md)
|
|
15
|
+
- [Roadmap](ROADMAP.md)
|
|
16
|
+
- [Runtime boundary decision](docs/decisions/0001-pi-runtime-boundary.md)
|
|
17
|
+
|
|
18
|
+
## Install in Pi
|
|
19
|
+
|
|
20
|
+
Install the public preview through Pi's package manager:
|
|
21
|
+
|
|
22
|
+
Requires Node.js **22.19+**, npm, Git **2.38+**, and an existing Pi setup. macOS is the primary platform; Linux uses `xdg-open` when available.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pi install npm:pi-webdesk
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Restart Pi, or run `/reload` in an existing interactive session. Then:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
/webdesk Start Webdesk in the background and open its browser workspace
|
|
32
|
+
/webdesk status Show whether the daemon is running
|
|
33
|
+
/webdesk stop Gracefully stop the daemon and its active task runtimes
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The npm package includes the compiled browser app and daemon. Pi loads only the launcher extension; loading or reloading it never starts a daemon. `/webdesk` opens a fresh one-time login link to `http://127.0.0.1:4517` (valid for 15 minutes). If the browser cannot open automatically, copy the link from Pi. Repeating `/webdesk` reuses the running daemon and replaces any unused login link.
|
|
37
|
+
|
|
38
|
+
The daemon keeps running when Pi or the browser closes. `/webdesk stop` preserves task worktrees, metadata, and Pi sessions; it stops active execution without replaying interrupted prompts. Use it before updating or removing the package, then restart Pi or `/reload` to load the updated launcher. Removing the Pi package does not remove `~/.pita` or Pi's files. Webdesk opens its workspace; your current terminal conversation is not moved automatically. Existing sessions can be continued through Webdesk's session workflow.
|
|
39
|
+
|
|
40
|
+
After stopping Webdesk, update it from your terminal:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pi update npm:pi-webdesk
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then restart Pi or `/reload`, and run `/webdesk` again. Package updates are managed by Pi; Webdesk's resource UI is read-only.
|
|
47
|
+
|
|
48
|
+
You can also install the current GitHub source with `pi install https://github.com/luka-zivkovic/pi-webdesk`. This runs `npm install --omit=dev` and builds the package automatically. If switching from a Git installation to npm, stop Webdesk and run `pi remove https://github.com/luka-zivkovic/pi-webdesk` before the npm install to avoid duplicate launcher extensions. Your worktrees and sessions are preserved.
|
|
49
|
+
|
|
50
|
+
Configuration is inherited from the environment that starts Pi:
|
|
51
|
+
|
|
52
|
+
| Variable | Default | Purpose |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `PITA_PORT` | `4517` | Loopback HTTP port |
|
|
55
|
+
| `PITA_STATE_DIR` | `~/.pita` | Private state directory, worktrees, and background log (`daemon.log`) |
|
|
56
|
+
| `PITA_PI_EXECUTABLE` | Package's tested Pi runtime | Explicit Pi executable override |
|
|
57
|
+
|
|
58
|
+
Webdesk keeps its tested Pi runtime dependency alongside the application and uses Pi's existing configuration, authentication, resources, and session formats. Set Pi's own `PI_CODING_AGENT_DIR` to use a different Pi configuration directory. Binding to other interfaces is not supported.
|
|
59
|
+
|
|
60
|
+
To install a local development checkout:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
pnpm install
|
|
64
|
+
pnpm build
|
|
65
|
+
pi install "$PWD"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The distribution also includes a `pi-webdesk` executable for npm installations. From a built checkout, run `node dist/apps/daemon/src/cli.js --no-browser` to serve the UI in the foreground; Ctrl+C stops it. `open`, `status`, and `stop` are also available as CLI commands. Pi package installation does not add this executable to your shell's global PATH.
|
|
69
|
+
|
|
70
|
+
### Existing Pita installations
|
|
71
|
+
|
|
72
|
+
Webdesk was previously named Pita. `/pita` remains an alias for `/webdesk`. The existing `~/.pita` data directory and `PITA_*` environment variables retain their names, so tasks, worktrees, sessions, preferences, and launch configuration continue to work without moving data. Existing browser storage and internal protocol identifiers are also preserved.
|
|
73
|
+
|
|
74
|
+
## Development and verification
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
pnpm install
|
|
78
|
+
pnpm --filter @pita/daemon start # terminal 1
|
|
79
|
+
pnpm --filter @pita/web dev # terminal 2
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The development daemon prints a login link for the Vite origin, `http://localhost:5173`. `PITA_ALLOWED_ORIGIN` configures that development origin; `PITA_DAEMON_PORT` configures Vite's proxy when using a different daemon port.
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
pnpm test
|
|
86
|
+
pnpm typecheck
|
|
87
|
+
pnpm build
|
|
88
|
+
pnpm test:package
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The package check performs a fresh npm source install, packs and installs production dependencies into an isolated directory, registers the package with an isolated Pi configuration, verifies the launcher and real Pi policy handshake, and checks browser authentication and daemon restart. It makes no model calls. `npm pack` rebuilds the distribution. Only the root `pi-webdesk` package is published; internal workspace packages stay private.
|
|
92
|
+
|
|
93
|
+
For a release, run the checks above plus `pnpm test:process`, then pack and test the exact archive before publishing it:
|
|
94
|
+
|
|
95
|
+
```sh
|
|
96
|
+
npm pack
|
|
97
|
+
WEBDESK_PACKAGE_TARBALL="$PWD/pi-webdesk-0.1.0.tgz" pnpm test:package
|
|
98
|
+
npm publish ./pi-webdesk-0.1.0.tgz --access public --ignore-scripts
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Use the version from `package.json` in the archive name. `WEBDESK_PACKAGE_TARBALL` makes the package check install that archive while still verifying a fresh Git-source build.
|
|
102
|
+
|
|
103
|
+
## Locked technology direction
|
|
104
|
+
|
|
105
|
+
- Browser UI: React, TypeScript, Vite
|
|
106
|
+
- Styling and components: Tailwind CSS v4, shadcn/ui on Base UI, Lucide icons
|
|
107
|
+
- Local service: Node.js/TypeScript daemon bound to localhost
|
|
108
|
+
- Runtime: one supervised Pi runtime per active task
|
|
109
|
+
- Repository model: one Git worktree per task
|
|
110
|
+
|
|
111
|
+
The first implementation milestone is a thin vertical slice: open a repository, create a task worktree, talk to Pi with streamed events, survive a browser reconnect, and inspect the resulting diff.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// apps/daemon/src/appearance-preferences.ts
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { copyFile, mkdir, open, readFile, readdir, rename, unlink } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import {
|
|
7
|
+
PITA_PREFERENCES_SCHEMA_VERSION,
|
|
8
|
+
PROTOCOL_VERSION,
|
|
9
|
+
pitaAppearancePreferencesSchema,
|
|
10
|
+
pitaPreferencesSnapshotSchema
|
|
11
|
+
} from "../../../packages/protocol/src/index.js";
|
|
12
|
+
var PITA_PREFERENCES_FILE = "preferences-v1.json";
|
|
13
|
+
var STALE_TEMP_FILE = /^\.preferences-v1\.json\.\d+\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/i;
|
|
14
|
+
var MAX_REPOSITORY_PREFERENCES = 1e3;
|
|
15
|
+
var DEFAULT_PITA_APPEARANCE = {
|
|
16
|
+
palette: "warm-sand",
|
|
17
|
+
mode: "system",
|
|
18
|
+
density: "comfortable",
|
|
19
|
+
sidebarWidth: 264,
|
|
20
|
+
sidebarVisible: true,
|
|
21
|
+
contextPanel: "right"
|
|
22
|
+
};
|
|
23
|
+
var preferencesDocumentSchema = z.object({
|
|
24
|
+
schemaVersion: z.literal(PITA_PREFERENCES_SCHEMA_VERSION),
|
|
25
|
+
global: pitaAppearancePreferencesSchema,
|
|
26
|
+
repositories: z.record(z.uuid(), pitaAppearancePreferencesSchema)
|
|
27
|
+
}).strict().refine(
|
|
28
|
+
(document) => Object.keys(document.repositories).length <= MAX_REPOSITORY_PREFERENCES,
|
|
29
|
+
"Too many repository appearance overrides."
|
|
30
|
+
);
|
|
31
|
+
var PitaPreferencesOperationError = class extends Error {
|
|
32
|
+
name = "PitaPreferencesOperationError";
|
|
33
|
+
code;
|
|
34
|
+
status;
|
|
35
|
+
constructor(code, message, status) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.status = status;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
function initialDocument() {
|
|
42
|
+
return {
|
|
43
|
+
schemaVersion: PITA_PREFERENCES_SCHEMA_VERSION,
|
|
44
|
+
global: { ...DEFAULT_PITA_APPEARANCE },
|
|
45
|
+
repositories: {}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function errorCode(error) {
|
|
49
|
+
return error !== null && typeof error === "object" && "code" in error ? String(error.code) : void 0;
|
|
50
|
+
}
|
|
51
|
+
function createPitaPreferencesService(options) {
|
|
52
|
+
const stateDir = path.resolve(options.stateDir);
|
|
53
|
+
const filePath = path.join(stateDir, PITA_PREFERENCES_FILE);
|
|
54
|
+
let document = null;
|
|
55
|
+
let initialization = null;
|
|
56
|
+
let mutationQueue = Promise.resolve();
|
|
57
|
+
let invalidDocument = false;
|
|
58
|
+
async function persist(next) {
|
|
59
|
+
await mkdir(stateDir, { recursive: true, mode: 448 });
|
|
60
|
+
const tempPath = path.join(
|
|
61
|
+
stateDir,
|
|
62
|
+
`.${PITA_PREFERENCES_FILE}.${process.pid}.${randomUUID()}.tmp`
|
|
63
|
+
);
|
|
64
|
+
let handle = null;
|
|
65
|
+
try {
|
|
66
|
+
handle = await open(tempPath, "wx", 384);
|
|
67
|
+
await handle.writeFile(`${JSON.stringify(next, null, 2)}
|
|
68
|
+
`, "utf8");
|
|
69
|
+
await handle.sync();
|
|
70
|
+
await handle.close();
|
|
71
|
+
handle = null;
|
|
72
|
+
await rename(tempPath, filePath);
|
|
73
|
+
let directory = null;
|
|
74
|
+
try {
|
|
75
|
+
directory = await open(stateDir, "r");
|
|
76
|
+
await directory.sync();
|
|
77
|
+
} catch {
|
|
78
|
+
} finally {
|
|
79
|
+
await directory?.close().catch(() => void 0);
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
await handle?.close().catch(() => void 0);
|
|
83
|
+
await unlink(tempPath).catch(() => void 0);
|
|
84
|
+
throw new PitaPreferencesOperationError(
|
|
85
|
+
"preferences-unavailable",
|
|
86
|
+
`Could not persist Webdesk appearance preferences: ${errorCode(error) ?? "write failed"}.`,
|
|
87
|
+
503
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function load() {
|
|
92
|
+
await mkdir(stateDir, { recursive: true, mode: 448 });
|
|
93
|
+
for (const name of await readdir(stateDir)) {
|
|
94
|
+
if (STALE_TEMP_FILE.test(name)) {
|
|
95
|
+
await unlink(path.join(stateDir, name)).catch(() => void 0);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
let raw;
|
|
99
|
+
try {
|
|
100
|
+
raw = await readFile(filePath, "utf8");
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (errorCode(error) === "ENOENT") {
|
|
103
|
+
const initial = initialDocument();
|
|
104
|
+
await persist(initial);
|
|
105
|
+
document = initial;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
throw new PitaPreferencesOperationError(
|
|
109
|
+
"preferences-unavailable",
|
|
110
|
+
"Webdesk appearance preferences could not be read. The existing file was left untouched.",
|
|
111
|
+
503
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
document = preferencesDocumentSchema.parse(JSON.parse(raw));
|
|
116
|
+
} catch {
|
|
117
|
+
invalidDocument = true;
|
|
118
|
+
throw new PitaPreferencesOperationError(
|
|
119
|
+
"preferences-unavailable",
|
|
120
|
+
"Webdesk appearance preferences are invalid. The existing file was preserved for recovery.",
|
|
121
|
+
503
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
async function read() {
|
|
126
|
+
initialization ??= load();
|
|
127
|
+
await initialization;
|
|
128
|
+
return preferencesDocumentSchema.parse(document);
|
|
129
|
+
}
|
|
130
|
+
function toSnapshot(current, repositoryId) {
|
|
131
|
+
const repository = repositoryId === null ? null : current.repositories[repositoryId] ?? null;
|
|
132
|
+
return pitaPreferencesSnapshotSchema.parse({
|
|
133
|
+
protocol: PROTOCOL_VERSION,
|
|
134
|
+
type: "pita-preferences-snapshot",
|
|
135
|
+
schemaVersion: PITA_PREFERENCES_SCHEMA_VERSION,
|
|
136
|
+
repositoryId,
|
|
137
|
+
global: current.global,
|
|
138
|
+
repository,
|
|
139
|
+
effective: repository ?? current.global
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
async function update(request) {
|
|
143
|
+
const operation = mutationQueue.then(async () => {
|
|
144
|
+
let current;
|
|
145
|
+
try {
|
|
146
|
+
current = await read();
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (!invalidDocument || request.scope !== "global" || request.action !== "reset") {
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
const recoveryPath = path.join(
|
|
152
|
+
stateDir,
|
|
153
|
+
`${PITA_PREFERENCES_FILE}.invalid.${Date.now()}.${randomUUID()}.json`
|
|
154
|
+
);
|
|
155
|
+
try {
|
|
156
|
+
await copyFile(filePath, recoveryPath, 0);
|
|
157
|
+
} catch (copyError) {
|
|
158
|
+
throw new PitaPreferencesOperationError(
|
|
159
|
+
"preferences-unavailable",
|
|
160
|
+
`Could not preserve the invalid appearance file for recovery: ${errorCode(copyError) ?? "copy failed"}.`,
|
|
161
|
+
503
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const recovered = initialDocument();
|
|
165
|
+
await persist(recovered);
|
|
166
|
+
document = recovered;
|
|
167
|
+
invalidDocument = false;
|
|
168
|
+
initialization = Promise.resolve();
|
|
169
|
+
return toSnapshot(recovered, request.repositoryId);
|
|
170
|
+
}
|
|
171
|
+
const repositories = { ...current.repositories };
|
|
172
|
+
let global = current.global;
|
|
173
|
+
if (request.scope === "global") {
|
|
174
|
+
global = request.action === "reset" ? { ...DEFAULT_PITA_APPEARANCE } : pitaAppearancePreferencesSchema.parse(request.preferences);
|
|
175
|
+
} else {
|
|
176
|
+
const repositoryId = request.repositoryId;
|
|
177
|
+
if (repositoryId === null) {
|
|
178
|
+
throw new PitaPreferencesOperationError(
|
|
179
|
+
"invalid-request",
|
|
180
|
+
"Choose a repository before changing repository appearance.",
|
|
181
|
+
400
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (request.action === "reset") {
|
|
185
|
+
delete repositories[repositoryId];
|
|
186
|
+
} else {
|
|
187
|
+
repositories[repositoryId] = pitaAppearancePreferencesSchema.parse(request.preferences);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const next = preferencesDocumentSchema.parse({
|
|
191
|
+
schemaVersion: PITA_PREFERENCES_SCHEMA_VERSION,
|
|
192
|
+
global,
|
|
193
|
+
repositories
|
|
194
|
+
});
|
|
195
|
+
await persist(next);
|
|
196
|
+
document = next;
|
|
197
|
+
return toSnapshot(next, request.repositoryId);
|
|
198
|
+
});
|
|
199
|
+
mutationQueue = operation.then(
|
|
200
|
+
() => void 0,
|
|
201
|
+
() => void 0
|
|
202
|
+
);
|
|
203
|
+
return operation;
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
filePath,
|
|
207
|
+
async snapshot(repositoryId) {
|
|
208
|
+
return toSnapshot(await read(), repositoryId);
|
|
209
|
+
},
|
|
210
|
+
update
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
export {
|
|
214
|
+
DEFAULT_PITA_APPEARANCE,
|
|
215
|
+
PITA_PREFERENCES_FILE,
|
|
216
|
+
PitaPreferencesOperationError,
|
|
217
|
+
createPitaPreferencesService
|
|
218
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// apps/daemon/src/auth.ts
|
|
2
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
var SESSION_COOKIE_NAME = "pita_session";
|
|
5
|
+
var TOKEN_VERSION = "v1";
|
|
6
|
+
var MAC_CONTEXT = `pita-session-${TOKEN_VERSION}.`;
|
|
7
|
+
var sessionClaimsSchema = z.object({
|
|
8
|
+
sid: z.string().min(1),
|
|
9
|
+
iat: z.number().int().min(0),
|
|
10
|
+
exp: z.number().int().min(0)
|
|
11
|
+
});
|
|
12
|
+
function generateBootstrapToken() {
|
|
13
|
+
return randomBytes(32).toString("hex");
|
|
14
|
+
}
|
|
15
|
+
function tokensMatch(expected, provided) {
|
|
16
|
+
const a = createHash("sha256").update(expected, "utf8").digest();
|
|
17
|
+
const b = createHash("sha256").update(provided, "utf8").digest();
|
|
18
|
+
return timingSafeEqual(a, b);
|
|
19
|
+
}
|
|
20
|
+
function sign(secret, payload) {
|
|
21
|
+
return createHmac("sha256", secret).update(MAC_CONTEXT + payload, "utf8").digest("base64url");
|
|
22
|
+
}
|
|
23
|
+
function createSessionToken(secret, init) {
|
|
24
|
+
const expiresAt = init.nowMs + init.ttlMs;
|
|
25
|
+
const payload = Buffer.from(
|
|
26
|
+
JSON.stringify({
|
|
27
|
+
sid: randomBytes(16).toString("hex"),
|
|
28
|
+
iat: init.nowMs,
|
|
29
|
+
exp: expiresAt
|
|
30
|
+
}),
|
|
31
|
+
"utf8"
|
|
32
|
+
).toString("base64url");
|
|
33
|
+
return {
|
|
34
|
+
token: `${TOKEN_VERSION}.${payload}.${sign(secret, payload)}`,
|
|
35
|
+
expiresAt
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function verifySessionToken(secret, token, nowMs) {
|
|
39
|
+
const parts = token.split(".");
|
|
40
|
+
if (parts.length !== 3 || parts[0] !== TOKEN_VERSION) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const [, payload, mac] = parts;
|
|
44
|
+
if (payload === void 0 || mac === void 0) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const expected = sign(secret, payload);
|
|
48
|
+
const macBuffer = Buffer.from(mac, "utf8");
|
|
49
|
+
const expectedBuffer = Buffer.from(expected, "utf8");
|
|
50
|
+
if (macBuffer.length !== expectedBuffer.length || !timingSafeEqual(macBuffer, expectedBuffer)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
let claims;
|
|
54
|
+
try {
|
|
55
|
+
claims = sessionClaimsSchema.parse(
|
|
56
|
+
JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
|
|
57
|
+
);
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (claims.exp <= nowMs) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return { sid: claims.sid, expiresAt: claims.exp };
|
|
65
|
+
}
|
|
66
|
+
function readCookie(header, name) {
|
|
67
|
+
if (header === void 0) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
for (const part of header.split(";")) {
|
|
71
|
+
const separator = part.indexOf("=");
|
|
72
|
+
if (separator === -1) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (part.slice(0, separator).trim() === name) {
|
|
76
|
+
return part.slice(separator + 1).trim();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
export {
|
|
82
|
+
SESSION_COOKIE_NAME,
|
|
83
|
+
createSessionToken,
|
|
84
|
+
generateBootstrapToken,
|
|
85
|
+
readCookie,
|
|
86
|
+
tokensMatch,
|
|
87
|
+
verifySessionToken
|
|
88
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// apps/daemon/src/bin.ts
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { accessSync } from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { createDaemonServer, DaemonConfigError } from "./server.js";
|
|
9
|
+
import { DaemonStateError } from "./secret.js";
|
|
10
|
+
import { DaemonStateLockError } from "./state-lock.js";
|
|
11
|
+
import { RuntimeProcessRecoveryError } from "./runtime-process.js";
|
|
12
|
+
import { WorkspaceStateError } from "./workspace-store.js";
|
|
13
|
+
import { PitaPreferencesOperationError } from "./appearance-preferences.js";
|
|
14
|
+
import { listenLauncherControl } from "./launcher-control.js";
|
|
15
|
+
import { openBrowser } from "./launcher.js";
|
|
16
|
+
var DEFAULT_PORT = 4517;
|
|
17
|
+
var DEFAULT_ORIGIN = "http://localhost:5173";
|
|
18
|
+
function readPort(raw) {
|
|
19
|
+
if (raw === void 0 || raw === "") {
|
|
20
|
+
return DEFAULT_PORT;
|
|
21
|
+
}
|
|
22
|
+
const port = Number(raw);
|
|
23
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
24
|
+
throw new DaemonConfigError(`PITA_PORT must be an integer in 1-65535, got ${raw}`);
|
|
25
|
+
}
|
|
26
|
+
return port;
|
|
27
|
+
}
|
|
28
|
+
function readOrigin(raw, fallback = DEFAULT_ORIGIN) {
|
|
29
|
+
if (raw === void 0 || raw === "") {
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
let url;
|
|
33
|
+
try {
|
|
34
|
+
url = new URL(raw);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new DaemonConfigError(`PITA_ALLOWED_ORIGIN must be a URL origin, got ${raw}`);
|
|
37
|
+
}
|
|
38
|
+
if (url.origin !== raw) {
|
|
39
|
+
throw new DaemonConfigError(
|
|
40
|
+
`PITA_ALLOWED_ORIGIN must be exactly an origin (no path), e.g. ${url.origin}`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return url.origin;
|
|
44
|
+
}
|
|
45
|
+
async function runDaemon(options = {}) {
|
|
46
|
+
const stateDir = path.resolve(
|
|
47
|
+
process.env.PITA_STATE_DIR ?? path.join(os.homedir(), ".pita")
|
|
48
|
+
);
|
|
49
|
+
const port = readPort(process.env.PITA_PORT);
|
|
50
|
+
const allowedOrigin = readOrigin(process.env.PITA_ALLOWED_ORIGIN, options.packaged ? `http://127.0.0.1:${port}` : DEFAULT_ORIGIN);
|
|
51
|
+
const webDirectory = options.packaged ? fileURLToPath(new URL(import.meta.url.endsWith(".ts") ? "../../../dist/web/" : "../../../web/", import.meta.url)) : void 0;
|
|
52
|
+
if (webDirectory) accessSync(path.join(webDirectory, "index.html"));
|
|
53
|
+
const server = createDaemonServer({
|
|
54
|
+
stateDir,
|
|
55
|
+
allowedOrigin,
|
|
56
|
+
port,
|
|
57
|
+
...webDirectory === void 0 ? {} : { webDirectory }
|
|
58
|
+
});
|
|
59
|
+
const bound = await server.listen();
|
|
60
|
+
const printBootstrapLink = (token) => {
|
|
61
|
+
process.stderr.write(
|
|
62
|
+
`open this one-time link in your browser (valid for 15 minutes):
|
|
63
|
+
${allowedOrigin}/?pita_bootstrap=${token}
|
|
64
|
+
`
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
process.stderr.write(
|
|
68
|
+
`Webdesk daemon listening on http://${bound.address}:${bound.port} (state: ${stateDir})
|
|
69
|
+
run \`kill -USR2 ${process.pid}\` to print a fresh one-time link
|
|
70
|
+
`
|
|
71
|
+
);
|
|
72
|
+
let closing = false;
|
|
73
|
+
let closeControl;
|
|
74
|
+
const shutdown = () => {
|
|
75
|
+
if (closing) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
closing = true;
|
|
79
|
+
void (async () => {
|
|
80
|
+
try {
|
|
81
|
+
await closeControl?.();
|
|
82
|
+
} finally {
|
|
83
|
+
await server.close();
|
|
84
|
+
}
|
|
85
|
+
})().then(
|
|
86
|
+
() => process.exit(0),
|
|
87
|
+
() => process.exit(1)
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
closeControl = await listenLauncherControl(stateDir, (command) => ({
|
|
92
|
+
version: 1,
|
|
93
|
+
status: command === "stop" ? "stopping" : "running",
|
|
94
|
+
origin: allowedOrigin,
|
|
95
|
+
...command === "open" ? { token: server.rotateBootstrapToken() } : {}
|
|
96
|
+
}), shutdown);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
await server.close();
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
process.on("SIGINT", shutdown);
|
|
102
|
+
process.on("SIGTERM", shutdown);
|
|
103
|
+
process.on("SIGUSR2", () => {
|
|
104
|
+
if (!closing) {
|
|
105
|
+
printBootstrapLink(server.rotateBootstrapToken());
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
if (process.env.PITA_BACKGROUND !== "1") {
|
|
109
|
+
printBootstrapLink(server.bootstrapToken);
|
|
110
|
+
if (options.open && !await openBrowser(`${allowedOrigin}/?pita_bootstrap=${server.bootstrapToken}`)) {
|
|
111
|
+
process.stderr.write("Could not open a browser automatically. Use the link above.\n");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) runDaemon().catch((error) => {
|
|
116
|
+
const message = error instanceof DaemonStateError || error instanceof DaemonStateLockError || error instanceof DaemonConfigError || error instanceof RuntimeProcessRecoveryError || error instanceof WorkspaceStateError || error instanceof PitaPreferencesOperationError ? error.message : String(error);
|
|
117
|
+
process.stderr.write(`Webdesk daemon failed to start: ${message}
|
|
118
|
+
`);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
});
|
|
121
|
+
export {
|
|
122
|
+
runDaemon
|
|
123
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// apps/daemon/src/cli.ts
|
|
4
|
+
import { bootstrapUrl, launchPita, launcherStateDir, openBrowser } from "./launcher.js";
|
|
5
|
+
import { requestLauncherControl } from "./launcher-control.js";
|
|
6
|
+
var HELP = `Usage: pi-webdesk [serve|open|status|stop] [--no-browser]
|
|
7
|
+
|
|
8
|
+
serve Run in this terminal (default); Ctrl+C stops the daemon and runtimes
|
|
9
|
+
open Start a background daemon or open the running workspace
|
|
10
|
+
status Show whether Webdesk is running
|
|
11
|
+
stop Gracefully stop the daemon and its task runtimes
|
|
12
|
+
|
|
13
|
+
Environment: PITA_PORT (4517), PITA_STATE_DIR (~/.pita), PITA_PI_EXECUTABLE
|
|
14
|
+
Webdesk listens only on 127.0.0.1. State and Pi sessions survive stopping.
|
|
15
|
+
`;
|
|
16
|
+
async function main() {
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
19
|
+
process.stdout.write(HELP);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const noBrowser = args.includes("--no-browser");
|
|
23
|
+
const positional = args.filter((arg) => arg !== "--no-browser");
|
|
24
|
+
const command = positional[0] ?? "serve";
|
|
25
|
+
if (positional.length > 1 || !["serve", "open", "status", "stop"].includes(command)) throw new Error(HELP);
|
|
26
|
+
if (command === "serve") {
|
|
27
|
+
const { runDaemon } = await import("./bin.js");
|
|
28
|
+
await runDaemon({ packaged: true, open: !noBrowser });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (command === "open") {
|
|
32
|
+
const reply2 = await launchPita();
|
|
33
|
+
const url = bootstrapUrl(reply2);
|
|
34
|
+
process.stdout.write(`Open this one-time link (valid for 15 minutes):
|
|
35
|
+
${url}
|
|
36
|
+
`);
|
|
37
|
+
if (!noBrowser && !await openBrowser(url)) process.stderr.write("Could not open a browser automatically. Use the link above.\n");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const reply = await requestLauncherControl(launcherStateDir(), command);
|
|
41
|
+
process.stdout.write(reply ? `Webdesk ${reply.status} at ${reply.origin}
|
|
42
|
+
` : "Webdesk is not running.\n");
|
|
43
|
+
}
|
|
44
|
+
main().catch((error) => {
|
|
45
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
46
|
+
`);
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
});
|