pennyrouter 0.3.41 → 0.3.43
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.
|
|
3
|
+
"version": "0.3.43",
|
|
4
4
|
"description": "Install and manage PennyRouter local coding-agent integrations.",
|
|
5
5
|
"homepage": "https://pennyrouter.com",
|
|
6
6
|
"bugs": {
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@xterm/addon-fit": "^0.10.0",
|
|
32
|
+
"@xterm/addon-unicode11": "^0.8.0",
|
|
32
33
|
"@xterm/xterm": "^5.5.0",
|
|
33
34
|
"node-pty": "^1.0.0",
|
|
34
35
|
"ws": "^8.18.0"
|
package/src/launch.js
CHANGED
|
@@ -315,7 +315,7 @@ export async function installPennyCompare() {
|
|
|
315
315
|
await mkdir(join(destination, "reports"), { recursive: true, mode: 0o700 });
|
|
316
316
|
await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
|
|
317
317
|
const modules = join(destination, "web-demo", "node_modules");
|
|
318
|
-
for (const name of ["@xterm/addon-fit", "@xterm/xterm", "node-pty", "ws"]) {
|
|
318
|
+
for (const name of ["@xterm/addon-fit", "@xterm/addon-unicode11", "@xterm/xterm", "node-pty", "ws"]) {
|
|
319
319
|
const target = join(modules, name);
|
|
320
320
|
await mkdir(dirname(target), { recursive: true });
|
|
321
321
|
await cp(await findInstalledPackage(name), target, { recursive: true });
|
package/src/launch.test.js
CHANGED
|
@@ -106,6 +106,10 @@ async function main() {
|
|
|
106
106
|
assert.equal(second.changed, false);
|
|
107
107
|
assert.equal(await fileMode(pennyShimPath()), 0o755);
|
|
108
108
|
assert.ok(fs.existsSync(path.join(pennyRuntimeDir(), "bin", "penny.js")));
|
|
109
|
+
for (const asset of ["terminal-unicode.js", "artifacts.mjs", "artifact-preview.html",
|
|
110
|
+
"node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js"]) {
|
|
111
|
+
assert.ok(fs.existsSync(path.join(pennyCompareDir(), "web-demo", asset)), `compare installs ${asset}`);
|
|
112
|
+
}
|
|
109
113
|
assert.equal(
|
|
110
114
|
fs.realpathSync(path.join(pennyBinDir(), "penny-compare")),
|
|
111
115
|
fs.realpathSync(path.join(pennyCompareDir(), "penny-compare")),
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Keep the terminal's widths aligned with Claude's status-line cursor movements.
|
|
2
|
+
// Unicode 11 predates the coin emoji (Unicode 13), which Claude counts as two cells.
|
|
3
|
+
globalThis.configureCompareUnicode = function (terminal, Unicode11Addon) {
|
|
4
|
+
const addon = new Unicode11Addon();
|
|
5
|
+
addon.activate({ unicode: { register(base) {
|
|
6
|
+
const provider = Object.create(base);
|
|
7
|
+
provider.version = '11-penny';
|
|
8
|
+
provider.wcwidth = function (codepoint) {
|
|
9
|
+
return codepoint === 0x1fa99 ? 2 : base.wcwidth(codepoint);
|
|
10
|
+
};
|
|
11
|
+
terminal.unicode.register(provider);
|
|
12
|
+
} } });
|
|
13
|
+
terminal.unicode.activeVersion = '11-penny';
|
|
14
|
+
};
|