pennyrouter 0.3.41 → 0.3.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.41",
3
+ "version": "0.3.42",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -0,0 +1,92 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="referrer" content="no-referrer">
7
+ <title>Demo artifact preview · Penny Compare</title>
8
+ <style>
9
+ :root { color-scheme: dark; font: 14px/1.4 system-ui, sans-serif; }
10
+ * { box-sizing: border-box; }
11
+ html, body { margin: 0; height: 100%; background: #111116; color: #e3e3e8; }
12
+ body { display: flex; flex-direction: column; }
13
+ header { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 12px 16px;
14
+ background: #24242b; border-bottom: 1px solid #44444d; }
15
+ strong { color: #9ded83; }
16
+ #filename { overflow-wrap: anywhere; flex: 1; }
17
+ button { font: inherit; color: inherit; background: transparent; border: 1px solid #74747c;
18
+ padding: 5px 12px; cursor: pointer; }
19
+ button:disabled { opacity: .45; cursor: default; }
20
+ #status { margin: 0; padding: 8px 16px; color: #b8b8c3; }
21
+ #status:empty { display: none; }
22
+ iframe { flex: 1; width: 100%; min-height: 0; border: 0; background: white; }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <header><strong id="side">PENNY COMPARE</strong><span id="filename">Artifact preview</span>
27
+ <button id="refresh" disabled>Refresh</button><button id="download" disabled>Download</button></header>
28
+ <p id="status" role="status">Connecting to your demo…</p>
29
+ <iframe id="preview" title="Generated HTML preview" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
30
+ <script>
31
+ let session;
32
+ let content;
33
+ let loading = false;
34
+ const status = document.getElementById('status');
35
+ const refresh = document.getElementById('refresh');
36
+ const download = document.getElementById('download');
37
+ async function load() {
38
+ if (!session || loading) return;
39
+ loading = true;
40
+ refresh.disabled = true;
41
+ status.textContent = 'Loading…';
42
+ try {
43
+ const params = new URLSearchParams({ side: session.side, path: session.path });
44
+ const response = await fetch(`/artifact?${params}`, {
45
+ headers: { Authorization: `Bearer ${session.token}` }, cache: 'no-store',
46
+ signal: AbortSignal.timeout(15000),
47
+ });
48
+ if (!response.ok) throw new Error('File is unavailable. It may have been moved or this demo session has ended.');
49
+ const artifact = await response.json();
50
+ content = artifact.content;
51
+ // This policy precedes generated markup and cannot be relaxed by its own meta tags.
52
+ // HTTPS resources support chart libraries; network API calls and nested frames stay disabled.
53
+ const policy = "default-src https: data: blob: 'unsafe-inline' 'unsafe-eval'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'";
54
+ document.getElementById('preview').srcdoc =
55
+ `<meta http-equiv="Content-Security-Policy" content="${policy}">` + content;
56
+ download.disabled = false;
57
+ status.textContent = '';
58
+ } catch (error) {
59
+ status.textContent = error instanceof TypeError || error.name === 'TimeoutError'
60
+ ? 'Could not reach your demo. The session may have ended. You can still download the loaded file.'
61
+ : error.message || 'This demo session has ended.';
62
+ } finally { loading = false; refresh.disabled = false; }
63
+ }
64
+ refresh.onclick = load;
65
+ download.onclick = () => {
66
+ if (content === undefined) return;
67
+ const url = URL.createObjectURL(new Blob([content], { type: 'text/html;charset=utf-8' }));
68
+ const link = document.createElement('a');
69
+ link.href = url; link.download = session.path.split('/').pop(); link.click();
70
+ setTimeout(() => URL.revokeObjectURL(url), 10000);
71
+ };
72
+ window.addEventListener('message', (event) => {
73
+ if (session || event.origin !== location.origin || event.source !== window.opener ||
74
+ event.data?.type !== 'compare-artifact-session' || !['left', 'right'].includes(event.data.side) ||
75
+ typeof event.data.path !== 'string' || typeof event.data.token !== 'string') return;
76
+ session = event.data;
77
+ clearInterval(handshake);
78
+ document.getElementById('side').textContent = session.side === 'left' ? 'NATIVE' : 'PENNY';
79
+ document.getElementById('filename').textContent = session.path;
80
+ document.title = `${session.path.split('/').pop()} · ${session.side === 'left' ? 'Native' : 'Penny'}`;
81
+ load();
82
+ });
83
+ const requestSession = () => window.opener?.postMessage({ type: 'compare-artifact-ready' }, location.origin);
84
+ const handshake = setInterval(requestSession, 500);
85
+ requestSession();
86
+ setTimeout(() => {
87
+ clearInterval(handshake);
88
+ if (!session) status.textContent = 'Open this preview from the Files list in an active demo.';
89
+ }, 10000);
90
+ </script>
91
+ </body>
92
+ </html>
@@ -0,0 +1,67 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, readdir, realpath } from 'node:fs/promises';
3
+ import { extname, join, relative, resolve, sep } from 'node:path';
4
+
5
+ export const maxArtifactBytes = 5 * 1024 * 1024;
6
+ const excluded = new Set(['node_modules', 'vendor']);
7
+ // Keep the type mapping separate so additional preview formats can be added here.
8
+ export const artifactType = (path) => ['.html', '.htm'].includes(extname(path).toLowerCase()) ? 'html' : null;
9
+ const inside = (root, path) => path.startsWith(root + sep);
10
+
11
+ export async function listArtifacts(root) {
12
+ const files = [];
13
+ let inspected = 0;
14
+ async function walk(directory, depth) {
15
+ if (depth > 8 || files.length >= 200 || inspected >= 5000) return;
16
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
17
+ for (const entry of entries) {
18
+ if (++inspected > 5000 || files.length >= 200) break;
19
+ if (entry.name.startsWith('.') || excluded.has(entry.name) || entry.isSymbolicLink()) continue;
20
+ const path = join(directory, entry.name);
21
+ if (entry.isDirectory()) await walk(path, depth + 1);
22
+ else if (entry.isFile() && artifactType(path)) {
23
+ const stat = await lstat(path).catch(() => null);
24
+ if (stat?.isFile() && stat.size <= maxArtifactBytes) {
25
+ files.push({ path: relative(root, path).split(sep).join('/'), type: artifactType(path),
26
+ size: stat.size, modifiedAt: stat.mtimeMs });
27
+ }
28
+ }
29
+ }
30
+ }
31
+ await walk(root, 0);
32
+ return files.sort((a, b) => a.path.localeCompare(b.path));
33
+ }
34
+
35
+ export async function readArtifact(root, path) {
36
+ if (typeof path !== 'string' || !path || path.includes('\\') || path.includes('\0') ||
37
+ path.split('/').some((part) => !part || part === '..' || part.startsWith('.') || excluded.has(part)) ||
38
+ !artifactType(path)) throw new Error('Invalid artifact path');
39
+ const canonicalRoot = await realpath(root);
40
+ const target = resolve(canonicalRoot, path);
41
+ if (!inside(canonicalRoot, target)) throw new Error('Invalid artifact path');
42
+ let current = canonicalRoot;
43
+ for (const part of path.split('/')) {
44
+ current = join(current, part);
45
+ if ((await lstat(current)).isSymbolicLink()) throw new Error('Invalid artifact path');
46
+ }
47
+ const canonicalTarget = await realpath(target);
48
+ if (!inside(canonicalRoot, canonicalTarget)) throw new Error('Invalid artifact path');
49
+ const handle = await open(canonicalTarget, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
50
+ try {
51
+ // On Linux, verify the opened file too, in case a directory was swapped during traversal.
52
+ if (process.platform === 'linux' && !inside(canonicalRoot, await realpath(`/proc/self/fd/${handle.fd}`))) {
53
+ throw new Error('Invalid artifact path');
54
+ }
55
+ const stat = await handle.stat();
56
+ if (!stat.isFile() || stat.size > maxArtifactBytes) throw new Error('Artifact is too large or unavailable');
57
+ const buffer = Buffer.alloc(maxArtifactBytes + 1);
58
+ let length = 0;
59
+ while (length < buffer.length) {
60
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
61
+ if (!bytesRead) break;
62
+ length += bytesRead;
63
+ }
64
+ if (length > maxArtifactBytes) throw new Error('Artifact is too large');
65
+ return buffer.subarray(0, length).toString('utf8');
66
+ } finally { await handle.close(); }
67
+ }