@howaboua/pi-pet 0.1.1
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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/SECURITY.md +24 -0
- package/authoring/PET-GUIDE.md +65 -0
- package/authoring/hatch/HATCH-GUIDE.md +136 -0
- package/authoring/hatch/LICENSE.txt +201 -0
- package/authoring/hatch/NOTICE.md +5 -0
- package/authoring/hatch/pyproject.toml +8 -0
- package/authoring/hatch/references/animation-rows.md +29 -0
- package/authoring/hatch/references/final-assembly.md +67 -0
- package/authoring/hatch/references/look-directions.md +35 -0
- package/authoring/hatch/references/pet-contract.md +40 -0
- package/authoring/hatch/references/qa-rubric.md +34 -0
- package/authoring/hatch/references/reviewer-protocol.md +72 -0
- package/authoring/hatch/references/standard-assembly.md +21 -0
- package/authoring/hatch/references/visual-generation.md +59 -0
- package/authoring/hatch/scripts/approve_cardinals.py +110 -0
- package/authoring/hatch/scripts/assemble_extended_atlas.py +684 -0
- package/authoring/hatch/scripts/combine_direction_blind_verdicts.py +59 -0
- package/authoring/hatch/scripts/compose_atlas.py +171 -0
- package/authoring/hatch/scripts/compose_cardinal_anchor_strip.py +45 -0
- package/authoring/hatch/scripts/derive_running_left_from_running_right.py +164 -0
- package/authoring/hatch/scripts/despill_chroma_edges.py +312 -0
- package/authoring/hatch/scripts/extract_cardinal_anchors.py +153 -0
- package/authoring/hatch/scripts/extract_strip_frames.py +409 -0
- package/authoring/hatch/scripts/inspect_frames.py +278 -0
- package/authoring/hatch/scripts/make_contact_sheet.py +116 -0
- package/authoring/hatch/scripts/make_direction_blind_qa_sheet.py +179 -0
- package/authoring/hatch/scripts/make_direction_qa_sheet.py +172 -0
- package/authoring/hatch/scripts/measure_direction_continuity.py +201 -0
- package/authoring/hatch/scripts/prepare_pet_run.py +1207 -0
- package/authoring/hatch/scripts/render_animation_previews.py +80 -0
- package/authoring/hatch/scripts/validate_atlas.py +299 -0
- package/authoring/hatch/scripts/validate_direction_blind_verdicts.py +100 -0
- package/authoring/hatch/uv.lock +99 -0
- package/authoring/references/import-existing-pet.md +47 -0
- package/authoring/references/pet-format.md +88 -0
- package/changelog.ts +327 -0
- package/deploy/pi-pet-hyprland.conf +10 -0
- package/desktop/build.mjs +194 -0
- package/desktop/package-lock.json +887 -0
- package/desktop/package.json +16 -0
- package/dist/authoring/prepare.mjs +3 -0
- package/dist/authoring/rebuild.mjs +4 -0
- package/dist/authoring/validate.mjs +2 -0
- package/dist/desktop/main.mjs +5 -0
- package/dist/desktop/preload.cjs +1 -0
- package/dist/web/app.js +1 -0
- package/dist/web/catalog.json +1 -0
- package/dist/web/index.html +100 -0
- package/dist/web/manifest.webmanifest +16 -0
- package/dist/web/pet-icon.svg +6 -0
- package/dist/web/spritesheet.webp +0 -0
- package/dist/web/styles.css +592 -0
- package/docs/architecture.md +41 -0
- package/docs/pi-pet.png +0 -0
- package/docs/research.md +17 -0
- package/extensions/desktop-command.ts +249 -0
- package/extensions/desktop-config.ts +152 -0
- package/extensions/desktop-launcher.ts +380 -0
- package/extensions/gippity.ts +24 -0
- package/extensions/index.ts +110 -0
- package/extensions/remote-app.ts +61 -0
- package/package.json +101 -0
- package/pets/clawa/PET.md +27 -0
- package/pets/clawa/pet.json +7 -0
- package/pets/clawa/spritesheet.webp +0 -0
- package/pets/clawa/validation.json +808 -0
- package/src/desktop/attention.ts +87 -0
- package/src/desktop/bridge.ts +31 -0
- package/src/desktop/config.ts +145 -0
- package/src/desktop/cursor-provider.ts +150 -0
- package/src/desktop/main.ts +459 -0
- package/src/desktop/preload.ts +19 -0
- package/src/pet-image-size.ts +72 -0
- package/src/pet-loader.ts +278 -0
- package/src/pet-storage.ts +240 -0
- package/src/protocol/index.ts +81 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
CONFIG_DIR_NAME,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
type ExtensionCommandContext,
|
|
8
|
+
type ExtensionContext,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { parseDesktopConfig, parseDeviceName, parseSshTarget } from "../src/desktop/config.ts";
|
|
11
|
+
import { type RepositoryPetConfig, readRepositoryPetConfig, writeRepositoryPetConfig } from "../src/pet-storage.ts";
|
|
12
|
+
import {
|
|
13
|
+
type DeviceRegistryConfig,
|
|
14
|
+
type PetDevice,
|
|
15
|
+
readDeviceRegistry,
|
|
16
|
+
writeDeviceRegistry,
|
|
17
|
+
} from "./desktop-config.ts";
|
|
18
|
+
import { DesktopDeviceFleet } from "./desktop-launcher.ts";
|
|
19
|
+
import { GippityUnavailableError, isMissingGippity } from "./gippity.ts";
|
|
20
|
+
|
|
21
|
+
const ACTIONS = ["attach", "detach", "restart", "status"] as const;
|
|
22
|
+
const ARGUMENT_SEPARATOR = /\s+/;
|
|
23
|
+
const MAX_AUTHORING_REQUEST_BYTES = 8 * 1024;
|
|
24
|
+
const AUTHORING_GUIDE = fileURLToPath(new URL("../authoring/PET-GUIDE.md", import.meta.url));
|
|
25
|
+
|
|
26
|
+
type PetCommand =
|
|
27
|
+
| { action: "attach"; device: string; gippityUrl?: string | undefined }
|
|
28
|
+
| { action: "detach"; device: string }
|
|
29
|
+
| { action: "restart" }
|
|
30
|
+
| { action: "status" }
|
|
31
|
+
| { action: "author"; request: string };
|
|
32
|
+
|
|
33
|
+
function usage(): string {
|
|
34
|
+
return "Usage: /pet attach <local|device> [gippity-url] | detach <device> | restart | status";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function projectConfigPath(cwd: string): string {
|
|
38
|
+
return join(cwd, CONFIG_DIR_NAME, "pi-pet.json");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function selectedDeviceNames(registry: DeviceRegistryConfig, project: RepositoryPetConfig | undefined): string[] {
|
|
42
|
+
const names = project?.devices ?? registry.defaultDevices;
|
|
43
|
+
const missing = names.find((name) => !Object.hasOwn(registry.devices, name));
|
|
44
|
+
if (missing) throw new Error(`Pi Pet device is selected but not registered: ${missing}.`);
|
|
45
|
+
return names;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatStatus(names: string[], fleet: DesktopDeviceFleet): string {
|
|
49
|
+
const running = new Set(fleet.deviceNames());
|
|
50
|
+
if (names.length === 0) return "Pi Pet is active with no devices attached for this folder.";
|
|
51
|
+
return [...names]
|
|
52
|
+
.sort()
|
|
53
|
+
.map((name) => `${name}: ${running.has(name) ? "running" : "stopped"}`)
|
|
54
|
+
.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function showDevicePhase(context: ExtensionContext | undefined, phases: Map<string, string>): void {
|
|
58
|
+
const current = [...phases.entries()].at(-1);
|
|
59
|
+
context?.ui.setStatus("pi-pet-device", current ? `Pi Pet ${current[0]}: ${current[1]}` : undefined);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function authoringCommand(request: string): PetCommand {
|
|
63
|
+
if (Buffer.byteLength(request) > MAX_AUTHORING_REQUEST_BYTES) {
|
|
64
|
+
throw new Error(`Pi Pet authoring request exceeds ${MAX_AUTHORING_REQUEST_BYTES} bytes.`);
|
|
65
|
+
}
|
|
66
|
+
return { action: "author", request };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parsePetCommand(rawArgs: string): PetCommand {
|
|
70
|
+
const request = rawArgs.trim();
|
|
71
|
+
const tokens = request ? request.split(ARGUMENT_SEPARATOR) : ["status"];
|
|
72
|
+
const [rawAction, rawDevice, rawUrl, ...extra] = tokens;
|
|
73
|
+
const action = rawAction?.toLowerCase();
|
|
74
|
+
if (!(action && ACTIONS.includes(action as (typeof ACTIONS)[number]))) {
|
|
75
|
+
return authoringCommand(request);
|
|
76
|
+
}
|
|
77
|
+
if (extra.length > 0) throw new Error(usage());
|
|
78
|
+
const validAction = action as (typeof ACTIONS)[number];
|
|
79
|
+
if (validAction === "status" || validAction === "restart") {
|
|
80
|
+
if (rawDevice || rawUrl) throw new Error(usage());
|
|
81
|
+
return { action: validAction };
|
|
82
|
+
}
|
|
83
|
+
if (!rawDevice) throw new Error(usage());
|
|
84
|
+
const device = parseDeviceName(rawDevice);
|
|
85
|
+
if (validAction === "detach") {
|
|
86
|
+
if (rawUrl) throw new Error(usage());
|
|
87
|
+
return { action: validAction, device };
|
|
88
|
+
}
|
|
89
|
+
const gippityUrl = rawUrl ? parseDesktopConfig({ schemaVersion: 1, gippityUrl: rawUrl }).gippityUrl : undefined;
|
|
90
|
+
return { action: "attach", device, ...(gippityUrl ? { gippityUrl } : {}) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function petAuthoringPrompt(request: string): string {
|
|
94
|
+
return `Read and follow the Pi Pet guide at ${JSON.stringify(AUTHORING_GUIDE)} for this request:\n\n${request}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function deviceForAttach(name: string, existing: PetDevice | undefined, gippityUrl?: string): PetDevice {
|
|
98
|
+
if (name === "local") {
|
|
99
|
+
if (existing?.kind === "ssh") throw new Error("The reserved local device cannot be an SSH device.");
|
|
100
|
+
const url = gippityUrl ?? existing?.gippityUrl;
|
|
101
|
+
return { kind: "local", ...(url ? { gippityUrl: url } : {}) };
|
|
102
|
+
}
|
|
103
|
+
if (existing) {
|
|
104
|
+
const url = gippityUrl ?? existing.gippityUrl;
|
|
105
|
+
return { ...existing, ...(url ? { gippityUrl: url } : {}) };
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
kind: "ssh",
|
|
109
|
+
target: parseSshTarget(name),
|
|
110
|
+
...(gippityUrl ? { gippityUrl } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function startDevices(
|
|
115
|
+
pi: ExtensionAPI,
|
|
116
|
+
ctx: ExtensionContext,
|
|
117
|
+
fleet: DesktopDeviceFleet,
|
|
118
|
+
registry: DeviceRegistryConfig,
|
|
119
|
+
names: string[],
|
|
120
|
+
): Promise<string> {
|
|
121
|
+
let status: { running: boolean; urls: string[] };
|
|
122
|
+
try {
|
|
123
|
+
const { ensureGippityLan } = await import("@howaboua/pi-gippity-control/lan-service");
|
|
124
|
+
status = await ensureGippityLan(pi, ctx);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (isMissingGippity(error)) throw new GippityUnavailableError();
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
const automaticUrl = parseDesktopConfig({ schemaVersion: 1, gippityUrl: status.urls[0] }).gippityUrl;
|
|
130
|
+
await Promise.all(names.map((name) => fleet.start(name, registry.devices[name] as PetDevice, automaticUrl)));
|
|
131
|
+
return automaticUrl;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function executeCommand(
|
|
135
|
+
pi: ExtensionAPI,
|
|
136
|
+
command: PetCommand,
|
|
137
|
+
fleet: DesktopDeviceFleet,
|
|
138
|
+
ctx: ExtensionCommandContext,
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
if (command.action === "author") {
|
|
141
|
+
await access(AUTHORING_GUIDE);
|
|
142
|
+
await ctx.waitForIdle();
|
|
143
|
+
pi.sendUserMessage(petAuthoringPrompt(command.request));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const path = projectConfigPath(ctx.cwd);
|
|
147
|
+
const registry = await readDeviceRegistry();
|
|
148
|
+
const project = readRepositoryPetConfig(path);
|
|
149
|
+
const selected = selectedDeviceNames(registry, project);
|
|
150
|
+
if (command.action === "status") {
|
|
151
|
+
ctx.ui.notify(formatStatus(selected, fleet), "info");
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (command.action === "restart") {
|
|
155
|
+
await fleet.stopAll();
|
|
156
|
+
if (selected.length > 0) await startDevices(pi, ctx, fleet, registry, selected);
|
|
157
|
+
ctx.ui.notify("Pi Pet devices for this folder restarted.", "info");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (command.action === "detach") {
|
|
161
|
+
if (!selected.includes(command.device)) {
|
|
162
|
+
throw new Error(`Pi Pet device ${command.device} is not attached to this folder.`);
|
|
163
|
+
}
|
|
164
|
+
const devices = selected.filter((name) => name !== command.device);
|
|
165
|
+
await writeRepositoryPetConfig({ ...project, schemaVersion: 1, devices }, path);
|
|
166
|
+
await fleet.stop(command.device);
|
|
167
|
+
ctx.ui.notify(`Pi Pet device ${command.device} detached from this folder.`, "info");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const device = deviceForAttach(command.device, registry.devices[command.device], command.gippityUrl);
|
|
171
|
+
const nextRegistry: DeviceRegistryConfig = {
|
|
172
|
+
...registry,
|
|
173
|
+
devices: { ...registry.devices, [command.device]: device },
|
|
174
|
+
};
|
|
175
|
+
const automaticUrl = await startDevices(pi, ctx, fleet, nextRegistry, [command.device]);
|
|
176
|
+
try {
|
|
177
|
+
await writeDeviceRegistry(nextRegistry);
|
|
178
|
+
await writeRepositoryPetConfig(
|
|
179
|
+
{
|
|
180
|
+
...project,
|
|
181
|
+
schemaVersion: 1,
|
|
182
|
+
devices: [...new Set([...selected, command.device])],
|
|
183
|
+
},
|
|
184
|
+
path,
|
|
185
|
+
);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
await fleet.stop(command.device);
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
ctx.ui.notify(
|
|
191
|
+
`Pi Pet device ${command.device} attached to this folder through ${command.gippityUrl ?? automaticUrl}.`,
|
|
192
|
+
"info",
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function registerPetDevices(pi: ExtensionAPI): void {
|
|
197
|
+
let context: ExtensionContext | undefined;
|
|
198
|
+
const phases = new Map<string, string>();
|
|
199
|
+
const fleet = new DesktopDeviceFleet({
|
|
200
|
+
onPhase(device, phase) {
|
|
201
|
+
phases.delete(device);
|
|
202
|
+
phases.set(device, phase);
|
|
203
|
+
showDevicePhase(context, phases);
|
|
204
|
+
},
|
|
205
|
+
onExit(device, error) {
|
|
206
|
+
phases.delete(device);
|
|
207
|
+
showDevicePhase(context, phases);
|
|
208
|
+
if (error && context?.hasUI) context.ui.notify(`Pi Pet device ${device}: ${error.message}`, "warning");
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
pi.registerCommand("pet", {
|
|
213
|
+
description: "Control devices or start Pi Pet authoring",
|
|
214
|
+
getArgumentCompletions: (prefix) =>
|
|
215
|
+
ACTIONS.filter((action) => action.startsWith(prefix.trim().toLowerCase())).map((value) => ({
|
|
216
|
+
label: value,
|
|
217
|
+
value,
|
|
218
|
+
})),
|
|
219
|
+
handler: async (rawArgs, ctx) => {
|
|
220
|
+
try {
|
|
221
|
+
await executeCommand(pi, parsePetCommand(rawArgs), fleet, ctx);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
229
|
+
context = ctx;
|
|
230
|
+
try {
|
|
231
|
+
const registry = await readDeviceRegistry();
|
|
232
|
+
const project = readRepositoryPetConfig(projectConfigPath(ctx.cwd));
|
|
233
|
+
const selected = selectedDeviceNames(registry, project);
|
|
234
|
+
if (selected.length > 0) await startDevices(pi, ctx, fleet, registry, selected);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (!(error instanceof GippityUnavailableError)) {
|
|
237
|
+
ctx.ui.notify(
|
|
238
|
+
`Pi Pet devices could not start: ${error instanceof Error ? error.message : String(error)}`,
|
|
239
|
+
"warning",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
pi.on("session_shutdown", async () => {
|
|
245
|
+
context = undefined;
|
|
246
|
+
phases.clear();
|
|
247
|
+
await fleet.stopAll();
|
|
248
|
+
});
|
|
249
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { type FileHandle, mkdir, open, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { MAX_PET_DEVICES, parseDesktopConfig, parseDeviceName, parseSshTarget } from "../src/desktop/config.ts";
|
|
7
|
+
|
|
8
|
+
export type PetDevice =
|
|
9
|
+
| { kind: "local"; gippityUrl?: string | undefined }
|
|
10
|
+
| { kind: "ssh"; target: string; gippityUrl?: string | undefined };
|
|
11
|
+
|
|
12
|
+
export interface DeviceRegistryConfig {
|
|
13
|
+
schemaVersion: 1;
|
|
14
|
+
devices: Record<string, PetDevice>;
|
|
15
|
+
defaultDevices: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ROOT_KEYS = new Set(["schemaVersion", "devices", "defaultDevices"]);
|
|
19
|
+
const DEVICE_KEYS = new Set(["kind", "target", "gippityUrl"]);
|
|
20
|
+
const LEGACY_ROOT_KEYS = new Set(["schemaVersion", "displays"]);
|
|
21
|
+
const LEGACY_DISPLAY_KEYS = new Set(["gippityUrl"]);
|
|
22
|
+
const MAX_CONFIG_BYTES = 32 * 1024;
|
|
23
|
+
|
|
24
|
+
function agentDirectory(env: NodeJS.ProcessEnv = process.env, home = homedir()): string {
|
|
25
|
+
return env["PI_CODING_AGENT_DIR"]?.trim() || join(home, ".pi", "agent");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function deviceRegistryPath(env: NodeJS.ProcessEnv = process.env, home = homedir()): string {
|
|
29
|
+
return join(agentDirectory(env, home), "pi-pet.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseOptionalUrl(value: unknown): string | undefined {
|
|
33
|
+
return value === undefined ? undefined : parseDesktopConfig({ schemaVersion: 1, gippityUrl: value }).gippityUrl;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseDevice(name: string, value: unknown): PetDevice {
|
|
37
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
38
|
+
throw new Error(`Pi Pet device ${name} must be an object.`);
|
|
39
|
+
}
|
|
40
|
+
const input = value as Record<string, unknown>;
|
|
41
|
+
const unknown = Object.keys(input).find((key) => !DEVICE_KEYS.has(key));
|
|
42
|
+
if (unknown) throw new Error(`Pi Pet device ${name} has unknown field: ${unknown}.`);
|
|
43
|
+
const gippityUrl = parseOptionalUrl(input["gippityUrl"]);
|
|
44
|
+
if (input["kind"] === "local") {
|
|
45
|
+
if (name !== "local" || input["target"] !== undefined)
|
|
46
|
+
throw new Error('The local Pi Pet device must be named "local" and cannot have an SSH target.');
|
|
47
|
+
return { kind: "local", ...(gippityUrl ? { gippityUrl } : {}) };
|
|
48
|
+
}
|
|
49
|
+
if (input["kind"] !== "ssh") throw new Error(`Pi Pet device ${name} kind must be local or ssh.`);
|
|
50
|
+
const target = parseSshTarget(input["target"]);
|
|
51
|
+
return { kind: "ssh", target, ...(gippityUrl ? { gippityUrl } : {}) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseDeviceNames(value: unknown, label: string): string[] {
|
|
55
|
+
if (!Array.isArray(value) || value.length > MAX_PET_DEVICES) {
|
|
56
|
+
throw new Error(`${label} must be an array of at most ${MAX_PET_DEVICES} device names.`);
|
|
57
|
+
}
|
|
58
|
+
const names = value.map(parseDeviceName);
|
|
59
|
+
if (new Set(names).size !== names.length) throw new Error(`${label} must contain unique device names.`);
|
|
60
|
+
return names;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseLegacyRegistry(input: Record<string, unknown>): DeviceRegistryConfig {
|
|
64
|
+
const unknownRoot = Object.keys(input).find((key) => !LEGACY_ROOT_KEYS.has(key));
|
|
65
|
+
if (unknownRoot) throw new Error(`Pi Pet device registry has unknown field: ${unknownRoot}.`);
|
|
66
|
+
const rawDisplays = input["displays"];
|
|
67
|
+
if (!rawDisplays || typeof rawDisplays !== "object" || Array.isArray(rawDisplays)) {
|
|
68
|
+
throw new Error("Legacy Pi Pet displays must be an object.");
|
|
69
|
+
}
|
|
70
|
+
const entries = Object.entries(rawDisplays);
|
|
71
|
+
if (entries.length > MAX_PET_DEVICES) throw new Error(`Pi Pet supports at most ${MAX_PET_DEVICES} devices.`);
|
|
72
|
+
const devices: DeviceRegistryConfig["devices"] = {};
|
|
73
|
+
for (const [rawName, rawDisplay] of entries) {
|
|
74
|
+
const name = parseDeviceName(rawName);
|
|
75
|
+
if (name === "local") throw new Error("Legacy SSH display local conflicts with the reserved local device.");
|
|
76
|
+
if (!rawDisplay || typeof rawDisplay !== "object" || Array.isArray(rawDisplay)) {
|
|
77
|
+
throw new Error(`Legacy Pi Pet display ${name} must be an object.`);
|
|
78
|
+
}
|
|
79
|
+
const display = rawDisplay as Record<string, unknown>;
|
|
80
|
+
const unknown = Object.keys(display).find((key) => !LEGACY_DISPLAY_KEYS.has(key));
|
|
81
|
+
if (unknown) throw new Error(`Legacy Pi Pet display ${name} has unknown field: ${unknown}.`);
|
|
82
|
+
const gippityUrl = parseOptionalUrl(display["gippityUrl"]);
|
|
83
|
+
devices[name] = { kind: "ssh", target: parseSshTarget(name), ...(gippityUrl ? { gippityUrl } : {}) };
|
|
84
|
+
}
|
|
85
|
+
return { schemaVersion: 1, devices, defaultDevices: Object.keys(devices) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function parseDeviceRegistry(value: unknown): DeviceRegistryConfig {
|
|
89
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
90
|
+
throw new Error("Pi Pet device registry must be an object.");
|
|
91
|
+
}
|
|
92
|
+
const input = value as Record<string, unknown>;
|
|
93
|
+
if (input["schemaVersion"] !== 1) throw new Error("Unsupported Pi Pet device registry schemaVersion.");
|
|
94
|
+
if (input["displays"] !== undefined) return parseLegacyRegistry(input);
|
|
95
|
+
const unknownRoot = Object.keys(input).find((key) => !ROOT_KEYS.has(key));
|
|
96
|
+
if (unknownRoot) throw new Error(`Pi Pet device registry has unknown field: ${unknownRoot}.`);
|
|
97
|
+
const rawDevices = input["devices"];
|
|
98
|
+
if (!rawDevices || typeof rawDevices !== "object" || Array.isArray(rawDevices)) {
|
|
99
|
+
throw new Error("Pi Pet devices must be an object.");
|
|
100
|
+
}
|
|
101
|
+
const entries = Object.entries(rawDevices);
|
|
102
|
+
if (entries.length > MAX_PET_DEVICES) throw new Error(`Pi Pet supports at most ${MAX_PET_DEVICES} devices.`);
|
|
103
|
+
const devices: DeviceRegistryConfig["devices"] = {};
|
|
104
|
+
for (const [rawName, rawDevice] of entries) {
|
|
105
|
+
const name = parseDeviceName(rawName);
|
|
106
|
+
devices[name] = parseDevice(name, rawDevice);
|
|
107
|
+
}
|
|
108
|
+
const defaultDevices = parseDeviceNames(input["defaultDevices"] ?? [], "Pi Pet defaultDevices");
|
|
109
|
+
const missing = defaultDevices.find((name) => !Object.hasOwn(devices, name));
|
|
110
|
+
if (missing) throw new Error(`Pi Pet default device is not registered: ${missing}.`);
|
|
111
|
+
return { schemaVersion: 1, devices, defaultDevices };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function readDeviceRegistry(path = deviceRegistryPath()): Promise<DeviceRegistryConfig> {
|
|
115
|
+
let handle: FileHandle;
|
|
116
|
+
try {
|
|
117
|
+
handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
120
|
+
return { schemaVersion: 1, devices: {}, defaultDevices: [] };
|
|
121
|
+
if ((error as NodeJS.ErrnoException).code === "ELOOP") {
|
|
122
|
+
throw new Error(`Pi Pet device registry must not be a symbolic link: ${path}`);
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const info = await handle.stat();
|
|
128
|
+
if (!info.isFile() || info.size > MAX_CONFIG_BYTES) {
|
|
129
|
+
throw new Error(`Pi Pet device registry must be a bounded regular file: ${path}`);
|
|
130
|
+
}
|
|
131
|
+
if (process.platform !== "win32" && (info.mode & 0o077) !== 0) {
|
|
132
|
+
throw new Error(`Pi Pet device registry must have mode 0600: ${path}`);
|
|
133
|
+
}
|
|
134
|
+
return parseDeviceRegistry(JSON.parse(await handle.readFile("utf8")) as unknown);
|
|
135
|
+
} finally {
|
|
136
|
+
await handle.close();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function writeDeviceRegistry(config: DeviceRegistryConfig, path = deviceRegistryPath()): Promise<void> {
|
|
141
|
+
const normalized = parseDeviceRegistry(config);
|
|
142
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
143
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
144
|
+
try {
|
|
145
|
+
await rm(temporary, { force: true });
|
|
146
|
+
await writeFile(temporary, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
147
|
+
await rename(temporary, path);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
await rm(temporary, { force: true });
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|