gentle-pi 4.3.0 → 4.5.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 +10 -7
- 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 +138 -155
- 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 +82 -0
- package/docs/gentle-shell.md +80 -10
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +72 -55
- 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 +187 -40
- package/extensions/gentle-ai.ts +1765 -1185
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +354 -0
- package/extensions/gentle-shell.ts +418 -79
- 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 +3 -2
- 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 +141 -57
- package/lib/gentle-shell-launcher.ts +11 -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/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- 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-freeze.ts +2 -2
- 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 +6 -7
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +27 -7
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +14 -10
- 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 +140 -56
- package/runtime/gentle-shell-launcher.mjs +11 -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/bundled-install.mjs +209 -13
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +31 -7
- package/scripts/installer-preflight.mjs +6 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +37 -7
- 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 +276 -5
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +4 -4
- 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 +369 -24
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +928 -448
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +452 -0
- package/tests/gentle-shell-bundled.test.ts +321 -248
- package/tests/gentle-shell-launcher.test.ts +29 -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 +227 -27
- 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-downloads.test.ts +32 -0
- 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/jobs-widget.test.ts +138 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/model-profiles-preservation.test.ts +2 -2
- 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 +104 -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/runtime-harness.mjs +188 -211
- 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-profile-freeze.test.ts +6 -4
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +18 -6
- 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 +67 -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/vim-editor-adapter.test.ts +2 -2
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { mkdtempSync, mkdirSync, symlinkSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, mkdirSync, symlinkSync, linkSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { AutoPermissionRuntime } from "../lib/auto-permission-runtime.ts";
|
|
@@ -258,6 +258,90 @@ test("Native consent, human questions and delegation remain with their original
|
|
|
258
258
|
f.setMode("auto");assert.equal((await f.execute("custom_tool",{},false)).executions,1);assert.equal(f.requests.length,0);
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
+
test("Companion update files are not permission authority and still receive ordinary checks", async t=>{
|
|
262
|
+
const f=fixture(t);f.input("Update the installed companion tools from main.");
|
|
263
|
+
for(const mode of ["normal","auto","yolo"]) {
|
|
264
|
+
f.setMode(mode);
|
|
265
|
+
for(const path of ["dev-binary.json","channel.json","main/gentle-ai","tools/go/bin/go"]) {
|
|
266
|
+
const input={path:join(f.cwd,"config",path),content:"updated companion data"};
|
|
267
|
+
for(const toolName of ["write","edit"]) {
|
|
268
|
+
assert.equal(f.runtime.guard({toolName,input},f.ctx),undefined,`${mode}: ${path} is not policy`);
|
|
269
|
+
assert.equal((await f.execute(toolName,input)).executions,1);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
assert.equal(f.requests.length,8,"only Auto requests are classified; other modes keep their own behavior");
|
|
274
|
+
f.setMode("auto");f.setResponse({status:"available",decision:"deny",reason:"Update not authorized"});
|
|
275
|
+
assert.equal((await f.execute("write",{path:join(f.cwd,"config","dev-binary.json"),content:"unapproved"})).executions,0);
|
|
276
|
+
assert.equal(f.requests.length,9,"an update path is not an automatic allow");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("Permission authority, aliases and parent directories remain protected in every mode", async t=>{
|
|
280
|
+
const f=fixture(t);f.input("Update companion tools without changing permissions.");
|
|
281
|
+
const home=join(f.cwd,"config");mkdirSync(home);
|
|
282
|
+
const projectHome=join(f.cwd,".pi","gentle-ai");mkdirSync(projectHome,{recursive:true});
|
|
283
|
+
for(const name of ["permission-mode.json","runtime-guardrails.json"])writeFileSync(join(home,name),"{}");
|
|
284
|
+
writeFileSync(join(projectHome,"runtime-guardrails.json"),"{}");
|
|
285
|
+
symlinkSync(home,join(f.cwd,"config-alias"));
|
|
286
|
+
symlinkSync(join(home,"permission-mode.json"),join(f.cwd,"policy-alias"));
|
|
287
|
+
symlinkSync(join(home,"runtime-guardrails.json"),join(home,"dev-binary.json"));
|
|
288
|
+
for(const mode of ["normal","auto","yolo"]) {
|
|
289
|
+
f.setMode(mode);
|
|
290
|
+
for(const path of [join(home,"permission-mode.json"),join(home,"runtime-guardrails.json"),
|
|
291
|
+
join(f.cwd,"config-alias","permission-mode.json"),join(f.cwd,"policy-alias"),join(home,"dev-binary.json"),
|
|
292
|
+
join(projectHome,"runtime-guardrails.json"),projectHome,home,f.cwd,
|
|
293
|
+
...["profiles.json","models.json","background-subagents.json","persona.json","builtin-codemode-optout.json",
|
|
294
|
+
"history-capture.json","scratch/session.json","tools/other-tool"].map(path=>join(home,path))]) {
|
|
295
|
+
for(const toolName of ["write","edit"]) {
|
|
296
|
+
const input={path,content:"{}"};
|
|
297
|
+
assert.equal(f.runtime.guard({toolName,input},f.ctx)?.block,true,`${mode}: ${path} stays protected`);
|
|
298
|
+
assert.equal((await f.execute(toolName,input)).executions,0);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
for(const toolName of ["bash","bash_background","monitor"]) {
|
|
302
|
+
for(const command of [`rm -rf '${home}'`,`rm -rf '${home}/'`,`rm -rf '${home}'/*`,
|
|
303
|
+
`printf '{}' > '${join(home,"permission-mode.json")}'`,`printf '{}' > '${join(home,"runtime-guardrails.json")}'`,
|
|
304
|
+
`printf '{}' > '${join(projectHome,"runtime-guardrails.json")}'`]) {
|
|
305
|
+
assert.equal(f.runtime.guard({toolName,input:{command}},f.ctx)?.block,true);
|
|
306
|
+
assert.equal((await f.execute(toolName,{command})).executions,0);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
assert.equal(f.requests.length,0,"a classifier allow never overrides the policy guard");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("Updater exceptions cannot overwrite authority through hardlinks", async t=>{
|
|
314
|
+
const f=fixture(t);f.input("Update companion tools without changing permission or routing policy.");
|
|
315
|
+
const home=join(f.cwd,"config");mkdirSync(join(home,"main"),{recursive:true});
|
|
316
|
+
for(const [policy,alias] of [["permission-mode.json","dev-binary.json"],["models.json","main/routing-alias"]]) {
|
|
317
|
+
writeFileSync(join(home,policy),"{}");linkSync(join(home,policy),join(home,alias));
|
|
318
|
+
for(const toolName of ["write","edit"]) {
|
|
319
|
+
const input={path:join(home,alias),content:"{}"};
|
|
320
|
+
assert.equal(f.runtime.guard({toolName,input},f.ctx)?.block,true,alias);
|
|
321
|
+
assert.equal((await f.execute(toolName,input)).executions,0);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
assert.equal(f.requests.length,0);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("Dangling updater aliases cannot create absent permission authority", async t=>{
|
|
328
|
+
const f=fixture(t);f.input("Update companion tools without creating or changing permission policy.");
|
|
329
|
+
const home=join(f.cwd,"config");mkdirSync(join(home,"tools"),{recursive:true});
|
|
330
|
+
for(const [alias,policy] of [["dev-binary.json","permission-mode.json"],["channel.json","runtime-guardrails.json"],
|
|
331
|
+
["main","permission-mode.json"],["tools/go","runtime-guardrails.json"]]) {
|
|
332
|
+
symlinkSync(join(home,policy),join(home,alias));
|
|
333
|
+
for(const mode of ["normal","auto","yolo"]) {
|
|
334
|
+
f.setMode(mode);
|
|
335
|
+
for(const toolName of ["write","edit"]) {
|
|
336
|
+
const input={path:join(home,alias),content:"{}"};
|
|
337
|
+
assert.equal(f.runtime.guard({toolName,input},f.ctx)?.block,true,`${mode}: ${alias} stays protected`);
|
|
338
|
+
assert.equal((await f.execute(toolName,input)).executions,0);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
assert.equal(f.requests.length,0,"unresolved aliases never reach an allowing classifier");
|
|
343
|
+
});
|
|
344
|
+
|
|
261
345
|
test("Policy files cannot be modified by model file tools; symlink escapes are reviewed", async t=>{
|
|
262
346
|
const f=fixture(t);f.input("Edit source code");mkdirSync(join(f.cwd,"config"));
|
|
263
347
|
assert.equal((await f.execute("write",{path:join(f.cwd,"config","permission-mode.json"),content:"yolo"})).executions,0);
|
|
@@ -910,3 +910,23 @@ test("loadRuntimeGuardrailsConfig: autonomousMode:{} (object) in JSON does NOT a
|
|
|
910
910
|
rmSync(dir, { recursive: true, force: true });
|
|
911
911
|
}
|
|
912
912
|
});
|
|
913
|
+
|
|
914
|
+
test("evaluateSuperYoloCommand: waives data-loss and configured confirmations, keeps only catastrophic denies", () => {
|
|
915
|
+
const { evaluateSuperYoloCommand } = __testing;
|
|
916
|
+
// Ordinary data-loss commands run without confirmation.
|
|
917
|
+
assert.equal(evaluateSuperYoloCommand("rm -rf node_modules build"), "allow");
|
|
918
|
+
assert.equal(evaluateSuperYoloCommand("git rebase origin/main"), "allow");
|
|
919
|
+
assert.equal(evaluateSuperYoloCommand("git branch -D feature/x"), "allow");
|
|
920
|
+
assert.equal(evaluateSuperYoloCommand("npm publish"), "allow");
|
|
921
|
+
assert.equal(evaluateSuperYoloCommand("git push -u origin feature"), "allow");
|
|
922
|
+
// Code-level catastrophic denies survive: root/home removal and hard-coded
|
|
923
|
+
// destructive Git forms (reset --hard, forced clean, force push).
|
|
924
|
+
assert.equal(evaluateSuperYoloCommand("rm -rf /"), "block");
|
|
925
|
+
assert.equal(evaluateSuperYoloCommand("rm -rf ~"), "block");
|
|
926
|
+
assert.equal(evaluateSuperYoloCommand("rm -rf --no-preserve-root /"), "block");
|
|
927
|
+
assert.equal(evaluateSuperYoloCommand("git reset --hard HEAD~1"), "block");
|
|
928
|
+
assert.equal(evaluateSuperYoloCommand("git push --force origin main"), "block");
|
|
929
|
+
assert.equal(evaluateSuperYoloCommand("git clean -fd"), "block");
|
|
930
|
+
// Harmless commands are unaffected.
|
|
931
|
+
assert.equal(evaluateSuperYoloCommand("git status && npm test"), "allow");
|
|
932
|
+
});
|
|
@@ -6,9 +6,9 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { dirname, join, posix, win32 } from "node:path";
|
|
7
7
|
import test, { after, type TestContext } from "node:test";
|
|
8
8
|
import { crc32, gzipSync } from "node:zlib";
|
|
9
|
-
import { activateVersion, activeVersion, applyPathEntry, claimPrefix, prefixRoot, distributionFiles, engramExecutable, ensureEngram, ensureLauncher, ensureRuntime, filterNpmrc, installVersion, nodeExecutable,
|
|
9
|
+
import { activateVersion, activeVersion, applyPathEntry, claimPrefix, prefixRoot, distributionFiles, engramExecutable, ensureEngram, ensureLauncher, ensureRuntime, ensureUv, filterNpmrc, installMusicPlayer, installedMusicPlayer, installVersion, nodeExecutable, uvExecutable,
|
|
10
10
|
nodeRuntimeFiles, pathEntryPlan, pnpmEnvironment, prefixLayout, pruneVersions, removePathEntry, rollbackBundled, setupEnvironment, bundledNpmCommand, ensureNpmShim, upgradeBundled, userNpmrc, writeNpmrcAuth,
|
|
11
|
-
describePathReceipt, readPathReceipt, scheduleWindowsRemoval, writePathReceipt }
|
|
11
|
+
publishedPi, upgradePiLatest, describePathReceipt, readPathReceipt, scheduleWindowsRemoval, writePathReceipt }
|
|
12
12
|
from "../scripts/bundled-install.mjs";
|
|
13
13
|
|
|
14
14
|
// Real directories, modes, symlinks and uids need a POSIX host.
|
|
@@ -17,7 +17,7 @@ const posixTest = (name: string, fn: (t: TestContext) => void | Promise<void>) =
|
|
|
17
17
|
const NODE = "24.21.0";
|
|
18
18
|
const PNPM = "11.1.1";
|
|
19
19
|
const NPM = "11.19.0";
|
|
20
|
-
const ENGRAM = "3.
|
|
20
|
+
const ENGRAM = "3.5.2";
|
|
21
21
|
|
|
22
22
|
// Every temporary home this file creates is removed when the file's tests end.
|
|
23
23
|
const roots: string[] = [];
|
|
@@ -373,8 +373,8 @@ posixTest("ensureEngram downloads nothing when the prefix is missing", async ()
|
|
|
373
373
|
});
|
|
374
374
|
|
|
375
375
|
test("engramExecutable takes only the top-level engram from the tarball and engram.exe from the zip, regular files only", () => {
|
|
376
|
-
const tarball = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.
|
|
377
|
-
const zipped = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.
|
|
376
|
+
const tarball = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.5.2/engram_3.5.2_linux_amd64.tar.gz" };
|
|
377
|
+
const zipped = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.5.2/engram_3.5.2_windows_amd64.zip" };
|
|
378
378
|
assert.deepEqual(engramExecutable(engramArchive, tarball), engramBytes);
|
|
379
379
|
const exe = Buffer.from("MZ engram");
|
|
380
380
|
assert.deepEqual(engramExecutable(zip([["tools/cloud-sync-projects.ps1", Buffer.from("x")], ["engram.exe", exe]]), zipped), exe);
|
|
@@ -383,6 +383,166 @@ test("engramExecutable takes only the top-level engram from the tarball and engr
|
|
|
383
383
|
assert.throws(() => engramExecutable(zip([["engram", exe]]), zipped), /Engram archive rejected/);
|
|
384
384
|
});
|
|
385
385
|
|
|
386
|
+
// S5: the /gentle:music offer. Only uv is a new runtime; it, the uv-managed
|
|
387
|
+
// Python and gentle-stream all live inside the prefix.
|
|
388
|
+
const UV = "0.13.6";
|
|
389
|
+
const uvBytes = Buffer.from("#!/bin/sh\necho fixture uv\n");
|
|
390
|
+
const uvUrl = `https://github.com/astral-sh/uv/releases/download/${UV}/uv-aarch64-apple-darwin.tar.gz`;
|
|
391
|
+
const uvArchive = tarGz([
|
|
392
|
+
tarEntry("uv-aarch64-apple-darwin/", { type: "5" }),
|
|
393
|
+
tarEntry("uv-aarch64-apple-darwin/uvx", { data: Buffer.from("#!/bin/sh\n"), mode: "0000755" }),
|
|
394
|
+
tarEntry("uv-aarch64-apple-darwin/uv", { data: uvBytes, mode: "0000755" }),
|
|
395
|
+
]);
|
|
396
|
+
const PLAYER_ARCHIVE = "https://github.com/Gentleman-Programming/gentle-stream/archive/381290451544ad2ca3348a68a81b3056bdac73b7.tar.gz";
|
|
397
|
+
// A uv fixture: `--version` answers the pin; `tool install` writes the
|
|
398
|
+
// executable into UV_TOOL_BIN_DIR unless `fail(spec)` says the spec fails.
|
|
399
|
+
function uvAdapters({ version = `uv ${UV} (fixture 2026-10-26 aarch64-apple-darwin)`, fail = (_spec: string): boolean => false, writes = "inside" }: { version?: string; fail?: (spec: string) => boolean; writes?: "inside" | "outside" | "none" } = {}) {
|
|
400
|
+
const calls: { downloads: string[]; runs: { command: string; args: string[]; cwd: string; env: Record<string, string>; timeout?: number }[] } = { downloads: [], runs: [] };
|
|
401
|
+
return {
|
|
402
|
+
calls,
|
|
403
|
+
adapters: {
|
|
404
|
+
artifact: (name: string) => {
|
|
405
|
+
assert.equal(name, "uv");
|
|
406
|
+
return { name, version: UV, maxBytes: 1 << 20, integrity: `sha256-${sha256(uvArchive)}`, url: uvUrl };
|
|
407
|
+
},
|
|
408
|
+
download: async (descriptor: { name: string }) => { calls.downloads.push(descriptor.name); return uvArchive; },
|
|
409
|
+
run: (command: string, args: string[], options: { cwd: string; env: Record<string, string>; timeout?: number }) => {
|
|
410
|
+
calls.runs.push({ command, args, ...options });
|
|
411
|
+
if (args.length === 1 && args[0] === "--version") return { status: 0, stdout: `${version}\n`, stderr: "" };
|
|
412
|
+
if (args[0] === "tool" && args[1] === "install") {
|
|
413
|
+
const spec = args.at(-1)!;
|
|
414
|
+
if (fail(spec)) return { status: 1, stdout: "", stderr: "error: Failed to build `pyfluidsynth`" };
|
|
415
|
+
// As uv does on POSIX: the executable in the tool's venv, linked from the bin folder.
|
|
416
|
+
if (writes !== "none") {
|
|
417
|
+
const target = writes === "inside" ? join(options.env.UV_TOOL_DIR, "gentle-stream", "bin", "gentle-stream") : join(dirname(options.cwd), "elsewhere");
|
|
418
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
419
|
+
writeFileSync(target, "#!/bin/sh\n", { mode: 0o755 });
|
|
420
|
+
mkdirSync(options.env.UV_TOOL_BIN_DIR, { recursive: true });
|
|
421
|
+
symlinkSync(target, join(options.env.UV_TOOL_BIN_DIR, "gentle-stream"));
|
|
422
|
+
}
|
|
423
|
+
return { status: 0, stdout: "Installed 1 executable: gentle-stream\n", stderr: "" };
|
|
424
|
+
}
|
|
425
|
+
if (args[0] === "cache" && args[1] === "clean") return { status: 0, stdout: "", stderr: "" };
|
|
426
|
+
return { status: 1, stdout: "", stderr: "unexpected" };
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
test("prefixLayout keeps uv, the managed Python and the music player inside the prefix", () => {
|
|
433
|
+
const layout = prefixLayout({ platform: "linux", env: {}, home: "/home/me" });
|
|
434
|
+
const root = "/home/me/.gentle-shell";
|
|
435
|
+
assert.equal(layout.uvDir, `${root}/runtime/uv-${UV}`);
|
|
436
|
+
assert.equal(layout.uv, `${root}/runtime/uv-${UV}/uv`);
|
|
437
|
+
assert.equal(layout.music, `${root}/music`);
|
|
438
|
+
assert.equal(layout.gentleStream, `${root}/music/bin/gentle-stream`);
|
|
439
|
+
const windows = prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\Users\\me\\AppData\\Local" }, home: "C:\\Users\\me" });
|
|
440
|
+
assert.equal(windows.uv, `C:\\Users\\me\\AppData\\Local\\gentle-shell\\runtime\\uv-${UV}\\uv.exe`);
|
|
441
|
+
assert.equal(windows.gentleStream, "C:\\Users\\me\\AppData\\Local\\gentle-shell\\music\\bin\\gentle-stream.exe");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("uvExecutable takes only <stem>/uv from the tarball and uv.exe from the zip, regular files only", () => {
|
|
445
|
+
assert.deepEqual(uvExecutable(uvArchive, { url: uvUrl }), uvBytes);
|
|
446
|
+
const zipped = { url: `https://github.com/astral-sh/uv/releases/download/${UV}/uv-x86_64-pc-windows-msvc.zip` };
|
|
447
|
+
const exe = Buffer.from("MZ uv");
|
|
448
|
+
assert.deepEqual(uvExecutable(zip([["uvx.exe", Buffer.from("MZ uvx")], ["uv.exe", exe], ["uvw.exe", Buffer.from("MZ uvw")]]), zipped), exe);
|
|
449
|
+
assert.throws(() => uvExecutable(tarGz([tarEntry("uv-aarch64-apple-darwin/uv", { type: "2", link: "/bin/sh" })]), { url: uvUrl }), /uv archive rejected/);
|
|
450
|
+
assert.throws(() => uvExecutable(tarGz([tarEntry("uv", { data: uvBytes })]), { url: uvUrl }), /uv archive rejected/);
|
|
451
|
+
assert.throws(() => uvExecutable(zip([["uv", exe]]), zipped), /uv archive rejected/);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
posixTest("ensureUv publishes only the verified uv after running it, once", async () => {
|
|
455
|
+
const { layout } = claimed();
|
|
456
|
+
const { calls, adapters: fixture } = uvAdapters();
|
|
457
|
+
assert.equal(await ensureUv({ layout, platform: "darwin", arch: "arm64", adapters: fixture }), true);
|
|
458
|
+
assert.deepEqual(readdirSync(layout.uvDir).sort(), [".gentle-shell-runtime", "uv"]);
|
|
459
|
+
assert.deepEqual(readFileSync(layout.uv), uvBytes);
|
|
460
|
+
assert.equal(lstatSync(layout.uv).mode & 0o777, 0o700);
|
|
461
|
+
assert.equal(readFileSync(join(layout.uvDir, ".gentle-shell-runtime"), "utf8"), `${uvUrl}\n`);
|
|
462
|
+
assert.match(calls.runs[0]!.command, /\/runtime\/\.stage-[^/]+\/uv$/);
|
|
463
|
+
assert.equal(await ensureUv({ layout, platform: "darwin", arch: "arm64", adapters: fixture }), false, "reused");
|
|
464
|
+
assert.deepEqual(calls.downloads, ["uv"]);
|
|
465
|
+
// Another version's answer, or a tampered archive, publishes nothing.
|
|
466
|
+
for (const broken of [uvAdapters({ version: "uv 0.9.0" }).adapters, { ...uvAdapters().adapters, download: async () => Buffer.from("x") }]) {
|
|
467
|
+
const other = claimed().layout;
|
|
468
|
+
await assert.rejects(ensureUv({ layout: other, platform: "darwin", arch: "arm64", adapters: broken }), /uv|integrity|size/);
|
|
469
|
+
assert.deepEqual(readdirSync(other.runtime), []);
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
posixTest("installedMusicPlayer accepts only a player that resolves to a regular file inside music/", () => {
|
|
474
|
+
const { layout } = claimed();
|
|
475
|
+
assert.equal(installedMusicPlayer(layout), false, "missing");
|
|
476
|
+
mkdirSync(dirname(layout.gentleStream), { recursive: true });
|
|
477
|
+
const outside = join(mkdtempSync(join(tmpdir(), "gs-player-")), "gentle-stream");
|
|
478
|
+
writeFileSync(outside, "#!/bin/sh\n");
|
|
479
|
+
symlinkSync(outside, layout.gentleStream);
|
|
480
|
+
assert.equal(installedMusicPlayer(layout), false, "a link out of music/");
|
|
481
|
+
rmSync(layout.gentleStream);
|
|
482
|
+
const tool = join(layout.music, "tools", "gentle-stream", "bin", "gentle-stream");
|
|
483
|
+
mkdirSync(dirname(tool), { recursive: true });
|
|
484
|
+
writeFileSync(tool, "#!/bin/sh\n");
|
|
485
|
+
symlinkSync(tool, layout.gentleStream);
|
|
486
|
+
assert.equal(installedMusicPlayer(layout), true, "uv's link into music/tools");
|
|
487
|
+
rmSync(dirname(outside), { recursive: true, force: true });
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
posixTest("installMusicPlayer confines uv, Python and gentle-stream to the prefix and installs from the pinned archive", async () => {
|
|
491
|
+
const { layout } = claimed();
|
|
492
|
+
const { calls, adapters: fixture } = uvAdapters();
|
|
493
|
+
const out: string[] = [];
|
|
494
|
+
const env = { HOME: "/home/me", PATH: "/usr/bin", UV_TOOL_DIR: "/home/me/.local/share/uv/tools", UV_INDEX_URL: "https://example.invalid", VIRTUAL_ENV: "/venv", HTTPS_PROXY: "http://proxy:3128",
|
|
495
|
+
PYTHONUSERBASE: "/home/me/.pybase", PYTHONSTARTUP: "/home/me/startup.py", PIP_INDEX_URL: "https://pip.invalid" };
|
|
496
|
+
const result = await installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env, adapters: fixture, out: (line: string) => out.push(line) });
|
|
497
|
+
assert.deepEqual(result, { gentleStream: layout.gentleStream, samples: true, installed: true });
|
|
498
|
+
const install = calls.runs.find((run) => run.args[0] === "tool")!;
|
|
499
|
+
assert.equal(install.command, layout.uv);
|
|
500
|
+
assert.deepEqual(install.args, ["tool", "install", "--python", "3.13", `gentle-stream[samples] @ ${PLAYER_ARCHIVE}`]);
|
|
501
|
+
assert.ok(!install.args.join(" ").includes("git+"), "an archive URL, so git is never needed");
|
|
502
|
+
assert.ok(install.cwd.startsWith(layout.root));
|
|
503
|
+
assert.equal(install.env.UV_TOOL_DIR, join(layout.music, "tools"));
|
|
504
|
+
assert.equal(install.env.UV_TOOL_BIN_DIR, join(layout.music, "bin"));
|
|
505
|
+
assert.equal(install.env.UV_PYTHON_INSTALL_DIR, join(layout.music, "python"));
|
|
506
|
+
assert.equal(install.env.UV_PYTHON_BIN_DIR, join(layout.music, "python", "bin"));
|
|
507
|
+
assert.equal(install.env.UV_CACHE_DIR, join(layout.music, "cache"));
|
|
508
|
+
assert.equal(install.env.UV_PYTHON, "3.13");
|
|
509
|
+
assert.equal(install.env.UV_PYTHON_PREFERENCE, "only-managed");
|
|
510
|
+
assert.equal(install.env.UV_NO_CONFIG, "1");
|
|
511
|
+
// The user's uv settings and virtualenv never reach it; the network settings do.
|
|
512
|
+
assert.equal(install.env.UV_INDEX_URL, undefined);
|
|
513
|
+
assert.equal(install.env.VIRTUAL_ENV, undefined);
|
|
514
|
+
for (const key of ["PYTHONUSERBASE", "PYTHONSTARTUP", "PIP_INDEX_URL"]) assert.equal(install.env[key], undefined, key);
|
|
515
|
+
assert.equal(install.env.HTTPS_PROXY, "http://proxy:3128");
|
|
516
|
+
assert.ok(out.length >= 2, "progress is reported");
|
|
517
|
+
assert.deepEqual(calls.runs.at(-1)!.args, ["cache", "clean"], "the download cache is emptied afterwards");
|
|
518
|
+
// Idempotent: a second call downloads and runs nothing.
|
|
519
|
+
const before = calls.runs.length;
|
|
520
|
+
assert.deepEqual(await installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env, adapters: fixture }), { gentleStream: layout.gentleStream, samples: true, installed: false });
|
|
521
|
+
assert.equal(calls.runs.length, before);
|
|
522
|
+
assert.deepEqual(calls.downloads, ["uv"]);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
posixTest("installMusicPlayer retries without the samples extra when it cannot be installed, and says so", async () => {
|
|
526
|
+
const { layout } = claimed();
|
|
527
|
+
const { calls, adapters: fixture } = uvAdapters({ fail: (spec) => spec.includes("[samples]") });
|
|
528
|
+
const out: string[] = [];
|
|
529
|
+
const result = await installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env: {}, adapters: fixture, out: (line: string) => out.push(line) });
|
|
530
|
+
assert.deepEqual(result, { gentleStream: layout.gentleStream, samples: false, installed: true });
|
|
531
|
+
assert.deepEqual(calls.runs.filter((run) => run.args[0] === "tool").map((run) => run.args.at(-1)), [`gentle-stream[samples] @ ${PLAYER_ARCHIVE}`, `gentle-stream @ ${PLAYER_ARCHIVE}`]);
|
|
532
|
+
assert.ok(out.some((line) => /without the samples extra/i.test(line) && /FluidSynth/.test(line)));
|
|
533
|
+
// Reused without samples too, so no retry loop on every /gentle:music.
|
|
534
|
+
assert.deepEqual(await installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env: {}, adapters: fixture }), { gentleStream: layout.gentleStream, samples: false, installed: false });
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
posixTest("installMusicPlayer fails clearly when uv cannot install the player at all", async () => {
|
|
538
|
+
// uv failing twice, no executable, or one that leaves <prefix>/music.
|
|
539
|
+
for (const fixture of [uvAdapters({ fail: () => true }), uvAdapters({ writes: "none" }), uvAdapters({ writes: "outside" })]) {
|
|
540
|
+
const { layout } = claimed();
|
|
541
|
+
await assert.rejects(installMusicPlayer({ layout, platform: "darwin", arch: "arm64", env: {}, adapters: fixture.adapters }), /gentle-stream/);
|
|
542
|
+
assert.equal(existsSync(join(layout.music, ".gentle-shell-music")), false);
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
|
|
386
546
|
test("nodeExecutable takes only node.exe from the Windows zip and only bin/node from the tarball", () => {
|
|
387
547
|
const stem = `node-v${NODE}-win-x64`;
|
|
388
548
|
const exe = Buffer.from("MZ fixture");
|
|
@@ -1175,6 +1335,117 @@ posixTest("upgradeBundled reports success with a warning when only pruning fails
|
|
|
1175
1335
|
assert.match(lines.join("\n"), /Upgraded to Gentle Shell 4\.2\.0.*warning: older versions were not removed \(EBUSY\)/s);
|
|
1176
1336
|
});
|
|
1177
1337
|
|
|
1338
|
+
posixTest("upgradeBundled never downgrades a newer Pi installed with update --latest, and a newer Gentle Shell release still upgrades", async () => {
|
|
1339
|
+
const { user, layout } = await activeInstall();
|
|
1340
|
+
await installed(layout, [["4.1.0", "1.0.5"]]);
|
|
1341
|
+
activateVersion(layout, "4.1.0-1.0.5");
|
|
1342
|
+
const same = upgradeAdapters(layout, { release: { shell: "4.1.0", pi: "1.0.1" } });
|
|
1343
|
+
const lines: string[] = [];
|
|
1344
|
+
const result = await upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: same.adapters, out: (line: string) => lines.push(line) });
|
|
1345
|
+
assert.deepEqual(result, { id: "4.1.0-1.0.5", changed: false });
|
|
1346
|
+
assert.deepEqual(lines, ["Gentle Shell 4.1.0 is current; Pi 1.0.5 (installed with 'gentle-shell update --latest') is newer than this release's verified Pi 1.0.1."]);
|
|
1347
|
+
assert.deepEqual([same.calls.ensureRuntime.length, same.calls.verify.length, same.calls.loaded.length], [0, 0, 0]);
|
|
1348
|
+
assert.equal(activeVersion(layout), "4.1.0-1.0.5");
|
|
1349
|
+
const newer = upgradeAdapters(layout);
|
|
1350
|
+
const upgraded = await upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: newer.adapters });
|
|
1351
|
+
assert.equal(upgraded.id, "4.2.0-1.0.2");
|
|
1352
|
+
assert.equal(activeVersion(layout), "4.2.0-1.0.2");
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
function latestAdapters(layout: ReturnType<typeof prefixLayout>, { latestPi = "1.0.5", reported = { shell: "4.1.0", pi: latestPi } } = {}) {
|
|
1356
|
+
const upgrade = upgradeAdapters(layout, { release: { shell: "4.1.0", pi: latestPi }, reported });
|
|
1357
|
+
const pnpm = fakePnpm({ shell: "4.1.0", pi: latestPi });
|
|
1358
|
+
const { latest: _latest, distribution: _distribution, ...rest } = upgrade.adapters;
|
|
1359
|
+
return { calls: upgrade.calls, pnpm, adapters: { ...rest, install: { run: pnpm.run }, latestPi: async () => latestPi } };
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
posixTest("upgradePiLatest changes nothing when the active version already has the latest published Pi", async () => {
|
|
1363
|
+
const { user, layout } = await activeInstall();
|
|
1364
|
+
const { adapters, calls, pnpm } = latestAdapters(layout, { latestPi: "1.0.1" });
|
|
1365
|
+
const lines: string[] = [];
|
|
1366
|
+
const result = await upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters, out: (line: string) => lines.push(line) });
|
|
1367
|
+
assert.deepEqual(result, { id: "4.1.0-1.0.1", changed: false });
|
|
1368
|
+
assert.deepEqual(lines, ["Pi 1.0.1 is already the latest published Pi."]);
|
|
1369
|
+
assert.deepEqual([pnpm.runs.length, calls.ensureRuntime.length, calls.verify.length], [0, 0, 0]);
|
|
1370
|
+
assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]);
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
posixTest("upgradePiLatest never downgrades when the latest published Pi is older than the active Pi", async () => {
|
|
1374
|
+
const { user, layout } = await activeInstall();
|
|
1375
|
+
const { adapters, calls, pnpm } = latestAdapters(layout, { latestPi: "1.0.0" });
|
|
1376
|
+
const lines: string[] = [];
|
|
1377
|
+
const result = await upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters, out: (line: string) => lines.push(line) });
|
|
1378
|
+
assert.deepEqual(result, { id: "4.1.0-1.0.1", changed: false });
|
|
1379
|
+
assert.deepEqual(lines, ["Pi 1.0.1 is newer than the latest published Pi (1.0.0); nothing changed."]);
|
|
1380
|
+
assert.deepEqual([pnpm.runs.length, calls.ensureRuntime.length, calls.verify.length], [0, 0, 0]);
|
|
1381
|
+
assert.equal(activeVersion(layout), "4.1.0-1.0.1");
|
|
1382
|
+
assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]);
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
posixTest("upgradePiLatest warns, installs the newest Pi without a lockfile beside the active version, verifies and switches current", async () => {
|
|
1386
|
+
const { user, layout } = await activeInstall();
|
|
1387
|
+
const { adapters, calls, pnpm } = latestAdapters(layout);
|
|
1388
|
+
const lines: string[] = [];
|
|
1389
|
+
adapters.install.run = (command: string, args: string[], options: { cwd: string; env: Record<string, string> }) => {
|
|
1390
|
+
lines.push("<install>");
|
|
1391
|
+
return fakePnpm({ shell: "4.1.0", pi: "1.0.5" }).run(command, args, options);
|
|
1392
|
+
};
|
|
1393
|
+
const result = await upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters, out: (line: string) => lines.push(line) });
|
|
1394
|
+
assert.deepEqual(result, { id: "4.1.0-1.0.5", changed: true, previous: "4.1.0-1.0.1", removed: ["4.0.0-1.0.0"] });
|
|
1395
|
+
assert.equal(activeVersion(layout), "4.1.0-1.0.5");
|
|
1396
|
+
assert.deepEqual(lines, [
|
|
1397
|
+
"warning: Pi 1.0.5 is newer than the Pi verified for this Gentle Shell release (1.0.1); it installs without the release's verified lockfile, and Gentle Shell features that rely on audited Pi internals (such as the Vim editor) may turn off until a Gentle Shell release verifies it.",
|
|
1398
|
+
"<install>",
|
|
1399
|
+
"Installed Pi 1.0.5 with Gentle Shell 4.1.0 (from 4.1.0-1.0.1). Run 'gentle-shell upgrade --rollback' to switch back.",
|
|
1400
|
+
]);
|
|
1401
|
+
const folder = join(layout.versions, "4.1.0-1.0.5");
|
|
1402
|
+
assert.equal(existsSync(join(folder, "pnpm-lock.yaml")), false);
|
|
1403
|
+
for (const [name, text] of Object.entries(distributionFiles({ shell: "4.1.0", pi: "1.0.5" }))) assert.equal(readFileSync(join(folder, name), "utf8"), text, name);
|
|
1404
|
+
assert.deepEqual(calls.loaded, [releaseModuleFile(layout, "4.1.0-1.0.5")]);
|
|
1405
|
+
assert.equal(calls.verify[0].env.GENTLE_SHELL_VERIFY_VERSION, "4.1.0-1.0.5");
|
|
1406
|
+
assert.equal(pnpm.runs.length, 0);
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
posixTest("upgradePiLatest installs with plain pnpm install, never the frozen lockfile", async () => {
|
|
1410
|
+
const { user, layout } = await activeInstall();
|
|
1411
|
+
const { adapters, pnpm } = latestAdapters(layout);
|
|
1412
|
+
await upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters });
|
|
1413
|
+
assert.equal(pnpm.runs.length, 1);
|
|
1414
|
+
assert.equal(pnpm.runs[0].command, layout.node);
|
|
1415
|
+
assert.equal(pnpm.runs[0].args[0], layout.pnpm);
|
|
1416
|
+
assert.equal(pnpm.runs[0].args[1], "install");
|
|
1417
|
+
assert.equal(pnpm.runs[0].args.includes("--frozen-lockfile"), false);
|
|
1418
|
+
assert.deepEqual(pnpm.runs[0].args.slice(1), ["install", "--no-frozen-lockfile"]);
|
|
1419
|
+
assert.equal(pnpm.runs[0].env.pnpm_config_store_dir, layout.store);
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
posixTest("upgradePiLatest leaves current untouched and removes the new folder when verification fails, and refuses a prerelease", async () => {
|
|
1423
|
+
const { user, layout } = await activeInstall();
|
|
1424
|
+
const wrong = latestAdapters(layout, { reported: { shell: "4.1.0", pi: "1.0.1" } });
|
|
1425
|
+
await assert.rejects(upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters: wrong.adapters }), /reported.*current stays at 4\.1\.0-1\.0\.1/s);
|
|
1426
|
+
assert.equal(activeVersion(layout), "4.1.0-1.0.1");
|
|
1427
|
+
assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]);
|
|
1428
|
+
const beta = latestAdapters(layout, { latestPi: "1.1.0-beta.1" });
|
|
1429
|
+
await assert.rejects(upgradePiLatest({ layout, home: user, shell: "4.1.0", platform: "darwin", arch: "arm64", env: {}, adapters: beta.adapters }), /latest published Pi.*rejected/);
|
|
1430
|
+
assert.equal(beta.pnpm.runs.length, 0);
|
|
1431
|
+
assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]);
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
posixTest("publishedPi asks our pnpm for Pi's latest dist-tag with the prefix npmrc and accepts only an exact stable version", async () => {
|
|
1435
|
+
const { layout } = claimed();
|
|
1436
|
+
const runs: { command: string; args: string[]; env: Record<string, string> }[] = [];
|
|
1437
|
+
const run = (stdout: string, status = 0) => async (command: string, args: string[], options: { env: Record<string, string> }) => {
|
|
1438
|
+
runs.push({ command, args, env: options.env });
|
|
1439
|
+
return { status, stdout, stderr: "" };
|
|
1440
|
+
};
|
|
1441
|
+
assert.equal(await publishedPi(layout, { platform: "darwin", env: {}, run: run("1.4.0\n") }), "1.4.0");
|
|
1442
|
+
assert.equal(runs[0].command, layout.node);
|
|
1443
|
+
assert.deepEqual(runs[0].args, [layout.pnpm, "view", "@earendil-works/pi-coding-agent@latest", "version"]);
|
|
1444
|
+
assert.equal(runs[0].env.pnpm_config_npmrc_auth_file, layout.npmrc);
|
|
1445
|
+
for (const stdout of ["1.4.0-rc.1\n", "", "v1.4.0\n", "1.4\n"]) await assert.rejects(publishedPi(layout, { platform: "darwin", env: {}, run: run(stdout) }), /rejected/, stdout);
|
|
1446
|
+
await assert.rejects(publishedPi(layout, { platform: "darwin", env: {}, run: run("", 1) }), /exit 1/);
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1178
1449
|
function rollbackAdapters(module: Record<string, unknown> = {}) {
|
|
1179
1450
|
const calls: { loaded: string[]; runtimes: unknown[]; engram: unknown[]; launchers: unknown[] } = { loaded: [], runtimes: [], engram: [], launchers: [] };
|
|
1180
1451
|
const releaseModule = { prefixLayout, ensureRuntime: async (options: { layout: unknown; engram?: boolean }) => {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { isWsl, readClipboardImage, sniffImageMimeType, type ClipboardCommandRunner } from "../lib/clipboard-image.ts";
|
|
4
|
+
|
|
5
|
+
// A 2×1 PNG header: signature, IHDR length/type, width 2, height 1.
|
|
6
|
+
const PNG_2X1 = new Uint8Array([
|
|
7
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
8
|
+
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
9
|
+
0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x01,
|
|
10
|
+
0x08, 0x06, 0x00, 0x00, 0x00,
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
type Call = { command: string; args: readonly string[] };
|
|
14
|
+
type Reply = Buffer | undefined | ((call: Call) => Buffer | undefined);
|
|
15
|
+
|
|
16
|
+
// Scripted backends keyed by `command firstArg`; anything unscripted is "not installed".
|
|
17
|
+
function runner(replies: Record<string, Reply>): { run: ClipboardCommandRunner; calls: Call[] } {
|
|
18
|
+
const calls: Call[] = [];
|
|
19
|
+
const run: ClipboardCommandRunner = async (command, args) => {
|
|
20
|
+
const call = { command, args };
|
|
21
|
+
calls.push(call);
|
|
22
|
+
const reply = replies[`${command} ${args[0] ?? ""}`.trim()] ?? replies[command];
|
|
23
|
+
return typeof reply === "function" ? reply(call) : reply;
|
|
24
|
+
};
|
|
25
|
+
return { run, calls };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const BMP = new Uint8Array([0x42, 0x4d, 0x00, 0x00]);
|
|
29
|
+
const noProc = () => undefined;
|
|
30
|
+
|
|
31
|
+
test("WSL reads a Windows screenshot through PowerShell when the Linux clipboard has none", async () => {
|
|
32
|
+
const { run, calls } = runner({
|
|
33
|
+
"wl-paste --list-types": Buffer.from("text/plain\n"),
|
|
34
|
+
"powershell.exe": Buffer.from(`${Buffer.from(PNG_2X1).toString("base64")}\r\n`),
|
|
35
|
+
});
|
|
36
|
+
const image = await readClipboardImage({ env: { WSL_DISTRO_NAME: "Ubuntu" }, platform: "linux", run, native: () => undefined, procVersion: noProc });
|
|
37
|
+
assert.deepEqual(image && { mimeType: image.mimeType, bytes: [...image.bytes] }, { mimeType: "image/png", bytes: [...PNG_2X1] });
|
|
38
|
+
const powershell = calls.find((call) => call.command === "powershell.exe");
|
|
39
|
+
assert.ok(powershell, "PowerShell was consulted");
|
|
40
|
+
// The image travels on stdout as base64: no temp file path is ever passed.
|
|
41
|
+
const script = powershell.args.at(-1) ?? "";
|
|
42
|
+
assert.match(script, /ToBase64String/);
|
|
43
|
+
assert.doesNotMatch(script, /\$path|Save\('|tmp/i);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("WSL falls back to PowerShell when wl-paste and xclip are not installed", async () => {
|
|
47
|
+
const { run, calls } = runner({ "powershell.exe": Buffer.from(Buffer.from(PNG_2X1).toString("base64")) });
|
|
48
|
+
const image = await readClipboardImage({ env: { WSL_INTEROP: "/run/WSL/1_interop" }, platform: "linux", run, native: () => undefined, procVersion: noProc });
|
|
49
|
+
assert.equal(image?.mimeType, "image/png");
|
|
50
|
+
assert.deepEqual(calls.map((call) => call.command), ["wl-paste", "xclip", "powershell.exe"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("Wayland prefers PNG and never asks Windows on a native Linux desktop", async () => {
|
|
54
|
+
const { run, calls } = runner({
|
|
55
|
+
"wl-paste --list-types": Buffer.from("image/jpeg\nimage/png\ntext/plain\n"),
|
|
56
|
+
"wl-paste --type": (call) => call.args[1] === "image/png" ? Buffer.from(PNG_2X1) : undefined,
|
|
57
|
+
});
|
|
58
|
+
const image = await readClipboardImage({ env: { WAYLAND_DISPLAY: "wayland-0" }, platform: "linux", run, native: () => undefined, procVersion: noProc });
|
|
59
|
+
assert.equal(image?.mimeType, "image/png");
|
|
60
|
+
assert.ok(!calls.some((call) => call.command === "powershell.exe"));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("an empty Windows clipboard, an unsupported format, or Termux leave the paste to Pi", async () => {
|
|
64
|
+
const empty = runner({ "powershell.exe": Buffer.from("\r\n") });
|
|
65
|
+
assert.equal(await readClipboardImage({ env: { WSL_DISTRO_NAME: "Ubuntu" }, platform: "linux", run: empty.run, native: () => undefined, procVersion: noProc }), null);
|
|
66
|
+
const bmp = runner({ "xclip -selection": (call) => call.args.includes("TARGETS") ? Buffer.from("image/png\n") : Buffer.from(BMP) });
|
|
67
|
+
assert.equal(await readClipboardImage({ env: {}, platform: "linux", run: bmp.run, native: () => undefined, procVersion: noProc }), null);
|
|
68
|
+
const termux = runner({});
|
|
69
|
+
assert.equal(await readClipboardImage({ env: { TERMUX_VERSION: "0.118" }, platform: "linux", run: termux.run, procVersion: noProc }), null);
|
|
70
|
+
assert.equal(termux.calls.length, 0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("macOS reads the native clipboard but leaves copied files to Pi", async () => {
|
|
74
|
+
const native = (files: string[] | null) => () => ({
|
|
75
|
+
getText: async () => null,
|
|
76
|
+
getImage: async () => PNG_2X1,
|
|
77
|
+
getFilePaths: async () => files,
|
|
78
|
+
});
|
|
79
|
+
const image = await readClipboardImage({ env: {}, platform: "darwin", native: native(null) });
|
|
80
|
+
assert.equal(image?.mimeType, "image/png");
|
|
81
|
+
assert.equal(await readClipboardImage({ env: {}, platform: "darwin", native: native(["/Users/me/shot.png"]) }), null);
|
|
82
|
+
assert.equal(await readClipboardImage({ env: {}, platform: "win32", native: () => undefined }), null);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a failed wl-paste read falls through to xclip, as Pi does", async () => {
|
|
86
|
+
const { run, calls } = runner({
|
|
87
|
+
"wl-paste --list-types": Buffer.from("image/png\n"),
|
|
88
|
+
"xclip -selection": (call) => call.args.includes("TARGETS") ? Buffer.from("image/png\n") : Buffer.from(PNG_2X1),
|
|
89
|
+
});
|
|
90
|
+
const image = await readClipboardImage({ env: { WAYLAND_DISPLAY: "wayland-0" }, platform: "linux", run, native: () => undefined, procVersion: noProc });
|
|
91
|
+
assert.equal(image?.mimeType, "image/png");
|
|
92
|
+
assert.deepEqual(calls.map((call) => call.command), ["wl-paste", "wl-paste", "xclip", "xclip"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("files copied in a Linux file manager stay Pi's even when an image is offered too", async () => {
|
|
96
|
+
const { run, calls } = runner({ "wl-paste --list-types": Buffer.from("image/png\ntext/uri-list\n") });
|
|
97
|
+
const native = () => ({ getText: async () => null, getImage: async () => PNG_2X1, getFilePaths: async () => ["/home/me/shot.png"] });
|
|
98
|
+
assert.equal(await readClipboardImage({ env: { WAYLAND_DISPLAY: "wayland-0" }, platform: "linux", run, native, procVersion: noProc }), null);
|
|
99
|
+
assert.equal(calls.length, 0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("image formats are identified by signature", () => {
|
|
103
|
+
assert.equal(sniffImageMimeType(PNG_2X1), "image/png");
|
|
104
|
+
assert.equal(sniffImageMimeType(new Uint8Array([0xff, 0xd8, 0xff, 0xe0])), "image/jpeg");
|
|
105
|
+
assert.equal(sniffImageMimeType(new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])), "image/gif");
|
|
106
|
+
assert.equal(sniffImageMimeType(new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50])), "image/webp");
|
|
107
|
+
assert.equal(sniffImageMimeType(BMP), undefined);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("WSL is detected from its environment or the kernel version", () => {
|
|
111
|
+
assert.equal(isWsl({ WSL_DISTRO_NAME: "Ubuntu" }, noProc), true);
|
|
112
|
+
assert.equal(isWsl({}, () => "Linux version 6.6.87.2-microsoft-standard-WSL2"), true);
|
|
113
|
+
assert.equal(isWsl({}, () => "Linux version 6.8.0-generic"), false);
|
|
114
|
+
});
|
|
@@ -362,7 +362,6 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
|
|
|
362
362
|
);
|
|
363
363
|
const byTitle = (title: string) => COMMAND_PALETTE_CATALOG.find((group) => group.title === title)?.items.map((item) => item.command);
|
|
364
364
|
assert.deepEqual(byTitle("Configuration"), [
|
|
365
|
-
"gentle:models",
|
|
366
365
|
"gentle:profiles",
|
|
367
366
|
"gentle:persona",
|
|
368
367
|
"gentle:review-mode",
|
|
@@ -374,7 +373,7 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
|
|
|
374
373
|
"gentle:telemetry",
|
|
375
374
|
"gentle:dev-binary",
|
|
376
375
|
]);
|
|
377
|
-
assert.deepEqual(byTitle("Session"), ["gentle:yolo", "gentle:changes", "gentle:agents", "gentle:jobs", "gentle:usage", "gentle:review-session-permission"]);
|
|
376
|
+
assert.deepEqual(byTitle("Session"), ["gentle:yolo", "gentle:changes", "gentle:agents", "gentle:jobs", "gentle:usage", "gentle:review-session-permission", "gentle:voice", "gentle:design"]);
|
|
378
377
|
assert.deepEqual(byTitle("Diagnostics"), ["gentle:status", "gentle:doctor"]);
|
|
379
378
|
assert.equal(byTitle("SDD"), undefined);
|
|
380
379
|
assert.deepEqual(byTitle("Skills"), ["skill-registry:refresh"]);
|
|
@@ -407,7 +406,7 @@ test("retired SDD commands are absent from the palette and extension registratio
|
|
|
407
406
|
|
|
408
407
|
test("buildCommandPaletteGroups keeps only registered commands, attaches descriptions and shortcuts, drops empty groups, preserves catalog order", () => {
|
|
409
408
|
const registered = [
|
|
410
|
-
{ name: "gentle:
|
|
409
|
+
{ name: "gentle:profiles", description: "Configure models" },
|
|
411
410
|
{ name: "gentle:changes", description: "Browse changes" },
|
|
412
411
|
{ name: "gentle:status", description: "Show status" },
|
|
413
412
|
{ name: "skill-registry:refresh", description: "Refresh registry" },
|
|
@@ -419,7 +418,8 @@ test("buildCommandPaletteGroups keeps only registered commands, attaches descrip
|
|
|
419
418
|
["Configuration", "Session", "Diagnostics", "Skills"],
|
|
420
419
|
);
|
|
421
420
|
const configuration = groups.find((group) => group.title === "Configuration");
|
|
422
|
-
assert.deepEqual(configuration?.items.map((item) => item.command), ["gentle:
|
|
421
|
+
assert.deepEqual(configuration?.items.map((item) => item.command), ["gentle:profiles"]);
|
|
422
|
+
assert.equal(configuration?.items[0]?.label, "Models and profiles");
|
|
423
423
|
assert.equal(configuration?.items[0]?.description, "Configure models");
|
|
424
424
|
assert.equal(configuration?.items[0]?.shortcut, undefined);
|
|
425
425
|
const session = groups.find((group) => group.title === "Session");
|