gentle-pi 4.3.0 → 4.4.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 +8 -5
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +101 -151
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell.md +77 -7
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +19 -15
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +173 -38
- package/extensions/gentle-ai.ts +214 -69
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +332 -73
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +123 -49
- package/lib/gentle-shell-launcher.ts +9 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +4 -3
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +122 -48
- package/runtime/gentle-shell-launcher.mjs +9 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +34 -6
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +3 -3
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +1 -1
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +365 -20
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bundled.test.ts +301 -248
- package/tests/gentle-shell-launcher.test.ts +28 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +183 -8
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +99 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +14 -2
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { extname, join, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
// The single owner of design/<slug>/comments.json (S9). The canvas server and
|
|
6
|
+
// the agent tool both write through this module, so every write to one file
|
|
7
|
+
// runs in a per-file in-process queue, re-reads the file just before writing,
|
|
8
|
+
// and replaces it atomically with a temp file + rename. A file this module
|
|
9
|
+
// cannot parse is reported and never overwritten.
|
|
10
|
+
|
|
11
|
+
export const DESIGN_COMMENTS_FILE = "comments.json";
|
|
12
|
+
export const MAX_DESIGN_COMMENTS = 500;
|
|
13
|
+
export const DESIGN_COMMENT_LIMITS = Object.freeze({ comment: 2000, selector: 500, excerpt: 300 });
|
|
14
|
+
|
|
15
|
+
const SLUG = /^[a-z0-9][a-z0-9-]*$/;
|
|
16
|
+
const ID = /^[A-Za-z0-9_-]{1,32}$/;
|
|
17
|
+
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
18
|
+
const ID_ATTEMPTS = 8;
|
|
19
|
+
const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
/** One screenshot per comment, stored as design/<slug>/.screenshots/<id>.<ext>. */
|
|
22
|
+
export const MAX_DESIGN_SCREENSHOT_BYTES = 5 * 1024 * 1024;
|
|
23
|
+
export const DESIGN_SCREENSHOTS_DIR = ".screenshots";
|
|
24
|
+
export const DESIGN_SCREENSHOT_TYPES: Readonly<Record<string, string>> = Object.freeze({
|
|
25
|
+
"image/png": "png",
|
|
26
|
+
"image/jpeg": "jpg",
|
|
27
|
+
"image/webp": "webp",
|
|
28
|
+
"image/gif": "gif",
|
|
29
|
+
});
|
|
30
|
+
const SCREENSHOT_PATH = /^\.screenshots\/[A-Za-z0-9_-]{1,32}\.(png|jpg|webp|gif)$/;
|
|
31
|
+
|
|
32
|
+
export type DesignCommentStatus = "open" | "resolved";
|
|
33
|
+
|
|
34
|
+
export type StoredDesignComment = {
|
|
35
|
+
id: string;
|
|
36
|
+
file: string;
|
|
37
|
+
selector: string;
|
|
38
|
+
excerpt: string;
|
|
39
|
+
comment: string;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
status: DesignCommentStatus;
|
|
42
|
+
sentAt: string | null;
|
|
43
|
+
resolvedAt: string | null;
|
|
44
|
+
/** `.screenshots/<id>.<ext>`, relative to the design directory; absent when the comment has none. */
|
|
45
|
+
screenshot?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type DesignCommentsDocument = { version: 1; comments: StoredDesignComment[] };
|
|
49
|
+
|
|
50
|
+
export type DesignScreenshotInput = { mimeType: string; data: Buffer };
|
|
51
|
+
|
|
52
|
+
export type DesignCommentInput = { file: string; selector: string; excerpt: string; comment: string; screenshot?: DesignScreenshotInput };
|
|
53
|
+
|
|
54
|
+
export type DesignScreenshot = { path: string; mimeType: string; data: Buffer };
|
|
55
|
+
|
|
56
|
+
export type DesignCommentsErrorCode = "not-found" | "invalid-file" | "invalid-input" | "limit" | "id-exhausted";
|
|
57
|
+
|
|
58
|
+
export class DesignCommentsError extends Error {
|
|
59
|
+
readonly code: DesignCommentsErrorCode;
|
|
60
|
+
constructor(code: DesignCommentsErrorCode, message: string) {
|
|
61
|
+
super(message);
|
|
62
|
+
this.name = "DesignCommentsError";
|
|
63
|
+
this.code = code;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface DesignCommentStoreDeps {
|
|
68
|
+
now?: () => Date;
|
|
69
|
+
randomId?: () => string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface DesignCommentStore {
|
|
73
|
+
/** The design's comments; a missing comments.json is an empty document. */
|
|
74
|
+
read(slug: string): Promise<DesignCommentsDocument>;
|
|
75
|
+
add(slug: string, input: DesignCommentInput): Promise<StoredDesignComment>;
|
|
76
|
+
setStatus(slug: string, ids: readonly string[], status: DesignCommentStatus): Promise<{ updated: StoredDesignComment[]; unknown: string[] }>;
|
|
77
|
+
/** Stamps sentAt on comments that have none yet. */
|
|
78
|
+
markSent(slug: string, ids: readonly string[]): Promise<StoredDesignComment[]>;
|
|
79
|
+
listOpen(slug: string, file?: string): Promise<StoredDesignComment[]>;
|
|
80
|
+
/** The screenshot of comment `id`, or undefined when there is no such comment, it has none, or the file is gone. */
|
|
81
|
+
readScreenshot(slug: string, id: string): Promise<DesignScreenshot | undefined>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Keyed by the real path of each design file (comments.json, handoffs.json),
|
|
85
|
+
// shared by every store in this process.
|
|
86
|
+
const queues = new Map<string, Promise<unknown>>();
|
|
87
|
+
|
|
88
|
+
/** Runs `task` after every earlier task queued for `key`, the real path of the file it writes. */
|
|
89
|
+
export function serializeDesignWrite<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
90
|
+
const previous = queues.get(key) ?? Promise.resolve();
|
|
91
|
+
const run = previous.then(task, task);
|
|
92
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
93
|
+
queues.set(key, settled);
|
|
94
|
+
void settled.then(() => { if (queues.get(key) === settled) queues.delete(key); });
|
|
95
|
+
return run;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function within(root: string, target: string): boolean {
|
|
99
|
+
return target.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isMissing(error: unknown): boolean {
|
|
103
|
+
const code = (error as NodeJS.ErrnoException)?.code;
|
|
104
|
+
return code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Real `<root>/<slug>/<name>` path of a design-owned file, refusing a slug that
|
|
109
|
+
* escapes the real design root and a `name` that is not a regular file (a link
|
|
110
|
+
* would make the atomic rename replace the link, not its target). `notFound`
|
|
111
|
+
* builds the caller's typed error from the message.
|
|
112
|
+
*/
|
|
113
|
+
export async function locateDesignFile(designRoot: string, slug: string, name: string, notFound: (message: string) => Error): Promise<{ dir: string; path: string }> {
|
|
114
|
+
const missing = notFound(`No design "${slug}" in design/.`);
|
|
115
|
+
if (typeof slug !== "string" || !SLUG.test(slug)) throw missing;
|
|
116
|
+
let root: string;
|
|
117
|
+
let dir: string;
|
|
118
|
+
try {
|
|
119
|
+
root = await realpath(designRoot);
|
|
120
|
+
dir = await realpath(join(root, slug));
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (isMissing(error)) throw missing;
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
if (!within(root, dir) || !(await lstat(dir)).isDirectory()) throw missing;
|
|
126
|
+
const path = join(dir, name);
|
|
127
|
+
try {
|
|
128
|
+
if (!(await lstat(path)).isFile()) throw notFound(`design/${slug}/${name} must be a regular file.`);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (!isMissing(error)) throw error;
|
|
131
|
+
}
|
|
132
|
+
return { dir, path };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The UTF-8 content of `path`, or undefined when it does not exist; `tooLarge` is thrown past `maxBytes`. */
|
|
136
|
+
export async function readDesignFile(path: string, maxBytes: number, tooLarge: () => Error): Promise<string | undefined> {
|
|
137
|
+
try {
|
|
138
|
+
const handle = await open(path, "r");
|
|
139
|
+
try {
|
|
140
|
+
if ((await handle.stat()).size > maxBytes) throw tooLarge();
|
|
141
|
+
return await handle.readFile("utf8");
|
|
142
|
+
} finally {
|
|
143
|
+
await handle.close();
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (isMissing(error)) return undefined;
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Replaces `<dir>/<name>` (at `path`) atomically: a synced temp file in the same directory, then rename. */
|
|
152
|
+
export async function replaceDesignFile(dir: string, path: string, name: string, content: string | Buffer): Promise<void> {
|
|
153
|
+
const temp = join(dir, `.${name}.${randomBytes(6).toString("hex")}.tmp`);
|
|
154
|
+
const handle = await open(temp, "wx", 0o644);
|
|
155
|
+
try {
|
|
156
|
+
await handle.writeFile(content);
|
|
157
|
+
await handle.sync();
|
|
158
|
+
} finally {
|
|
159
|
+
await handle.close();
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
await rename(temp, path);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
await unlink(temp).catch(() => {});
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A relative `.html` path of plain segments, such as `sub/01-a.html`. */
|
|
170
|
+
export function isArtboardPath(value: unknown): value is string {
|
|
171
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 512) return false;
|
|
172
|
+
const parts = value.split("/");
|
|
173
|
+
if (parts.some((part) => part === "" || part.startsWith(".") || /[\\\0:]/.test(part) || /[\u0000-\u001f\u007f]/.test(part))) return false;
|
|
174
|
+
return extname(parts[parts.length - 1]).toLowerCase() === ".html";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `value` cut to at most `max` UTF-16 code units, the unit every comment limit
|
|
179
|
+
* uses, dropping a surrogate pair whole rather than splitting it.
|
|
180
|
+
*/
|
|
181
|
+
export function truncateUtf16(value: string, max: number): string {
|
|
182
|
+
const cut = value.length > max ? value.slice(0, max) : value;
|
|
183
|
+
// A trailing high surrogate is half a pair, whether this cut or the canvas's split it.
|
|
184
|
+
return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function isDesignCommentId(value: unknown): value is string {
|
|
188
|
+
return typeof value === "string" && ID.test(value);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function inputProblem(input: DesignCommentInput): string | undefined {
|
|
192
|
+
if (!input || typeof input !== "object") return "a comment must be an object";
|
|
193
|
+
if (!isArtboardPath(input.file)) return "file must be a relative .html path inside the design";
|
|
194
|
+
if (typeof input.selector !== "string" || input.selector.length < 1 || input.selector.length > DESIGN_COMMENT_LIMITS.selector) return `selector must have 1 to ${DESIGN_COMMENT_LIMITS.selector} characters`;
|
|
195
|
+
if (typeof input.excerpt !== "string" || input.excerpt.length > DESIGN_COMMENT_LIMITS.excerpt) return `excerpt must have at most ${DESIGN_COMMENT_LIMITS.excerpt} characters`;
|
|
196
|
+
if (typeof input.comment !== "string" || input.comment.length < 1 || input.comment.length > DESIGN_COMMENT_LIMITS.comment) return `comment must have 1 to ${DESIGN_COMMENT_LIMITS.comment} characters`;
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function hasSignature(data: Buffer, mimeType: string): boolean {
|
|
201
|
+
const ascii = (start: number, end: number) => data.subarray(start, end).toString("latin1");
|
|
202
|
+
if (mimeType === "image/png") return data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
|
203
|
+
if (mimeType === "image/jpeg") return data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
|
|
204
|
+
if (mimeType === "image/gif") return ascii(0, 6) === "GIF87a" || ascii(0, 6) === "GIF89a";
|
|
205
|
+
if (mimeType === "image/webp") return ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP";
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function screenshotProblem(screenshot: DesignScreenshotInput): string | undefined {
|
|
210
|
+
if (!screenshot || typeof screenshot !== "object") return "screenshot must be an object";
|
|
211
|
+
if (typeof screenshot.mimeType !== "string" || !Object.hasOwn(DESIGN_SCREENSHOT_TYPES, screenshot.mimeType)) return "screenshot must be a PNG, JPEG, WebP or GIF image";
|
|
212
|
+
if (!Buffer.isBuffer(screenshot.data) || screenshot.data.length === 0) return "screenshot is empty";
|
|
213
|
+
if (screenshot.data.length > MAX_DESIGN_SCREENSHOT_BYTES) return `screenshot must be at most ${MAX_DESIGN_SCREENSHOT_BYTES} bytes`;
|
|
214
|
+
if (!hasSignature(screenshot.data, screenshot.mimeType)) return `screenshot content is not ${screenshot.mimeType}`;
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isTimestamp(value: unknown): boolean {
|
|
219
|
+
return typeof value === "string" && value.length <= 64;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function parseDocument(raw: string): DesignCommentsDocument {
|
|
223
|
+
let value: unknown;
|
|
224
|
+
try {
|
|
225
|
+
value = JSON.parse(raw);
|
|
226
|
+
} catch {
|
|
227
|
+
throw new DesignCommentsError("invalid-file", "comments.json is not valid JSON");
|
|
228
|
+
}
|
|
229
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new DesignCommentsError("invalid-file", "comments.json must be a JSON object");
|
|
230
|
+
const record = value as Record<string, unknown>;
|
|
231
|
+
if (record.version !== 1) throw new DesignCommentsError("invalid-file", "comments.json version must be 1");
|
|
232
|
+
if (!Array.isArray(record.comments)) throw new DesignCommentsError("invalid-file", "comments.json comments must be an array");
|
|
233
|
+
const seen = new Set<string>();
|
|
234
|
+
const comments = record.comments.map((entry: unknown, index: number): StoredDesignComment => {
|
|
235
|
+
const where = `comments[${index}]`;
|
|
236
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new DesignCommentsError("invalid-file", `${where} must be an object`);
|
|
237
|
+
const item = entry as Record<string, unknown>;
|
|
238
|
+
if (!isDesignCommentId(item.id) || seen.has(item.id)) throw new DesignCommentsError("invalid-file", `${where}.id must be a unique id of letters, digits, _ or -`);
|
|
239
|
+
seen.add(item.id);
|
|
240
|
+
const problem = inputProblem(item as unknown as DesignCommentInput);
|
|
241
|
+
if (problem) throw new DesignCommentsError("invalid-file", `${where}: ${problem}`);
|
|
242
|
+
if (!isTimestamp(item.createdAt)) throw new DesignCommentsError("invalid-file", `${where}.createdAt must be a string`);
|
|
243
|
+
if (item.status !== "open" && item.status !== "resolved") throw new DesignCommentsError("invalid-file", `${where}.status must be "open" or "resolved"`);
|
|
244
|
+
for (const key of ["sentAt", "resolvedAt"] as const) {
|
|
245
|
+
if (item[key] !== null && !isTimestamp(item[key])) throw new DesignCommentsError("invalid-file", `${where}.${key} must be a string or null`);
|
|
246
|
+
}
|
|
247
|
+
if (item.screenshot !== undefined && (typeof item.screenshot !== "string" || !SCREENSHOT_PATH.test(item.screenshot))) {
|
|
248
|
+
throw new DesignCommentsError("invalid-file", `${where}.screenshot must be a .screenshots/<id>.<png|jpg|webp|gif> path`);
|
|
249
|
+
}
|
|
250
|
+
const comment: StoredDesignComment = {
|
|
251
|
+
id: item.id,
|
|
252
|
+
file: item.file as string,
|
|
253
|
+
selector: item.selector as string,
|
|
254
|
+
excerpt: item.excerpt as string,
|
|
255
|
+
comment: item.comment as string,
|
|
256
|
+
createdAt: item.createdAt as string,
|
|
257
|
+
status: item.status,
|
|
258
|
+
sentAt: item.sentAt as string | null,
|
|
259
|
+
resolvedAt: item.resolvedAt as string | null,
|
|
260
|
+
};
|
|
261
|
+
if (typeof item.screenshot === "string") comment.screenshot = item.screenshot;
|
|
262
|
+
return comment;
|
|
263
|
+
});
|
|
264
|
+
return { version: 1, comments };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function defaultRandomId(): string {
|
|
268
|
+
return Array.from(randomBytes(8), (byte) => ID_ALPHABET[byte % ID_ALPHABET.length]).join("");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function createDesignCommentStore(designRoot: string, deps: DesignCommentStoreDeps = {}): DesignCommentStore {
|
|
272
|
+
const now = deps.now ?? (() => new Date());
|
|
273
|
+
const randomId = deps.randomId ?? defaultRandomId;
|
|
274
|
+
|
|
275
|
+
/** Real comments.json path inside `<root>/<slug>`, refusing any escape or a non-file comments.json. */
|
|
276
|
+
function locate(slug: string): Promise<{ dir: string; path: string }> {
|
|
277
|
+
return locateDesignFile(designRoot, slug, DESIGN_COMMENTS_FILE, (message) => new DesignCommentsError("not-found", message));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function load(path: string): Promise<DesignCommentsDocument> {
|
|
281
|
+
const raw = await readDesignFile(path, MAX_FILE_BYTES, () => new DesignCommentsError("invalid-file", "comments.json is too large"));
|
|
282
|
+
return raw === undefined ? { version: 1, comments: [] } : parseDocument(raw);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function save(dir: string, path: string, document: DesignCommentsDocument): Promise<void> {
|
|
286
|
+
return replaceDesignFile(dir, path, DESIGN_COMMENTS_FILE, `${JSON.stringify(document, null, 2)}\n`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Read-modify-write in the file's queue; `change` sets `write: false` when nothing changed. */
|
|
290
|
+
async function mutate<T>(slug: string, change: (document: DesignCommentsDocument) => { result: T; write: boolean }): Promise<T> {
|
|
291
|
+
const { dir, path } = await locate(slug);
|
|
292
|
+
return serializeDesignWrite(path, async () => {
|
|
293
|
+
const document = await load(path);
|
|
294
|
+
const { result, write } = change(document);
|
|
295
|
+
if (write) await save(dir, path, document);
|
|
296
|
+
return result;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* `<dir>/.screenshots` when it is a real directory (never a link), created when
|
|
302
|
+
* `create` is set; undefined when it is missing and not created.
|
|
303
|
+
*/
|
|
304
|
+
async function screenshotsDir(dir: string, slug: string, create: boolean): Promise<string | undefined> {
|
|
305
|
+
const shots = join(dir, DESIGN_SCREENSHOTS_DIR);
|
|
306
|
+
const refused = new DesignCommentsError("invalid-file", `design/${slug}/${DESIGN_SCREENSHOTS_DIR} must be a directory`);
|
|
307
|
+
try {
|
|
308
|
+
if (!(await lstat(shots)).isDirectory()) throw refused;
|
|
309
|
+
return shots;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
if (error === refused || !isMissing(error)) throw error;
|
|
312
|
+
}
|
|
313
|
+
if (!create) return undefined;
|
|
314
|
+
await mkdir(shots).catch((error: NodeJS.ErrnoException) => { if (error?.code !== "EEXIST") throw error; });
|
|
315
|
+
if (!(await lstat(shots)).isDirectory()) throw refused;
|
|
316
|
+
return shots;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function read(slug: string): Promise<DesignCommentsDocument> {
|
|
320
|
+
return load((await locate(slug)).path);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return {
|
|
324
|
+
read,
|
|
325
|
+
|
|
326
|
+
async add(slug, input) {
|
|
327
|
+
const problem = inputProblem(input) ?? (input.screenshot === undefined ? undefined : screenshotProblem(input.screenshot));
|
|
328
|
+
if (problem) throw new DesignCommentsError("invalid-input", problem);
|
|
329
|
+
const { dir, path } = await locate(slug);
|
|
330
|
+
return serializeDesignWrite(path, async () => {
|
|
331
|
+
const document = await load(path);
|
|
332
|
+
if (document.comments.length >= MAX_DESIGN_COMMENTS) {
|
|
333
|
+
throw new DesignCommentsError("limit", `design/${slug} already has ${MAX_DESIGN_COMMENTS} comments, the most a design can hold. Delete resolved comments from comments.json to add more.`);
|
|
334
|
+
}
|
|
335
|
+
const taken = new Set(document.comments.map((comment) => comment.id));
|
|
336
|
+
let id: string | undefined;
|
|
337
|
+
for (let attempt = 0; attempt < ID_ATTEMPTS && id === undefined; attempt++) {
|
|
338
|
+
const candidate = randomId();
|
|
339
|
+
if (isDesignCommentId(candidate) && !taken.has(candidate)) id = candidate;
|
|
340
|
+
}
|
|
341
|
+
if (id === undefined) throw new DesignCommentsError("id-exhausted", "No unique comment id could be generated.");
|
|
342
|
+
const comment: StoredDesignComment = {
|
|
343
|
+
id,
|
|
344
|
+
file: input.file,
|
|
345
|
+
selector: input.selector,
|
|
346
|
+
excerpt: input.excerpt,
|
|
347
|
+
comment: input.comment,
|
|
348
|
+
createdAt: now().toISOString(),
|
|
349
|
+
status: "open",
|
|
350
|
+
sentAt: null,
|
|
351
|
+
resolvedAt: null,
|
|
352
|
+
};
|
|
353
|
+
let image: string | undefined;
|
|
354
|
+
if (input.screenshot) {
|
|
355
|
+
const name = `${id}.${DESIGN_SCREENSHOT_TYPES[input.screenshot.mimeType]}`;
|
|
356
|
+
const shots = (await screenshotsDir(dir, slug, true))!;
|
|
357
|
+
image = join(shots, name);
|
|
358
|
+
await replaceDesignFile(shots, image, name, input.screenshot.data);
|
|
359
|
+
comment.screenshot = `${DESIGN_SCREENSHOTS_DIR}/${name}`;
|
|
360
|
+
}
|
|
361
|
+
document.comments.push(comment);
|
|
362
|
+
try {
|
|
363
|
+
await save(dir, path, document);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (image) await unlink(image).catch(() => {});
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
return comment;
|
|
369
|
+
});
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
async setStatus(slug, ids, status) {
|
|
373
|
+
if (status !== "open" && status !== "resolved") throw new DesignCommentsError("invalid-input", 'status must be "open" or "resolved"');
|
|
374
|
+
if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string")) throw new DesignCommentsError("invalid-input", "ids must be an array of strings");
|
|
375
|
+
return mutate(slug, (document) => {
|
|
376
|
+
const wanted = new Set(ids);
|
|
377
|
+
const updated: StoredDesignComment[] = [];
|
|
378
|
+
let write = false;
|
|
379
|
+
for (const comment of document.comments) {
|
|
380
|
+
if (!wanted.has(comment.id)) continue;
|
|
381
|
+
if (comment.status !== status) {
|
|
382
|
+
comment.status = status;
|
|
383
|
+
comment.resolvedAt = status === "resolved" ? now().toISOString() : null;
|
|
384
|
+
write = true;
|
|
385
|
+
}
|
|
386
|
+
updated.push(comment);
|
|
387
|
+
}
|
|
388
|
+
const found = new Set(updated.map((comment) => comment.id));
|
|
389
|
+
return { result: { updated, unknown: [...wanted].filter((id) => !found.has(id)) }, write };
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
async markSent(slug, ids) {
|
|
394
|
+
return mutate(slug, (document) => {
|
|
395
|
+
const wanted = new Set(ids);
|
|
396
|
+
const updated: StoredDesignComment[] = [];
|
|
397
|
+
for (const comment of document.comments) {
|
|
398
|
+
if (!wanted.has(comment.id) || comment.sentAt !== null) continue;
|
|
399
|
+
comment.sentAt = now().toISOString();
|
|
400
|
+
updated.push(comment);
|
|
401
|
+
}
|
|
402
|
+
return { result: updated, write: updated.length > 0 };
|
|
403
|
+
});
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
async readScreenshot(slug, id) {
|
|
407
|
+
const { dir, path } = await locate(slug);
|
|
408
|
+
const stored = (await load(path)).comments.find((comment) => comment.id === id);
|
|
409
|
+
if (!stored?.screenshot) return undefined;
|
|
410
|
+
const shots = await screenshotsDir(dir, slug, false);
|
|
411
|
+
if (!shots) return undefined;
|
|
412
|
+
const name = stored.screenshot.slice(DESIGN_SCREENSHOTS_DIR.length + 1);
|
|
413
|
+
const target = join(shots, name);
|
|
414
|
+
const ext = extname(name).slice(1);
|
|
415
|
+
const mimeType = Object.keys(DESIGN_SCREENSHOT_TYPES).find((type) => DESIGN_SCREENSHOT_TYPES[type] === ext)!;
|
|
416
|
+
try {
|
|
417
|
+
if (!(await lstat(target)).isFile()) return undefined;
|
|
418
|
+
const handle = await open(target, "r");
|
|
419
|
+
try {
|
|
420
|
+
const info = await handle.stat();
|
|
421
|
+
if (!info.isFile() || info.size > MAX_DESIGN_SCREENSHOT_BYTES) return undefined;
|
|
422
|
+
return { path: stored.screenshot, mimeType, data: await handle.readFile() };
|
|
423
|
+
} finally {
|
|
424
|
+
await handle.close();
|
|
425
|
+
}
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (isMissing(error)) return undefined;
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
async listOpen(slug, file) {
|
|
433
|
+
const { comments } = await read(slug);
|
|
434
|
+
return comments.filter((comment) => comment.status === "open" && (file === undefined || comment.file === file));
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|