gentle-pi 4.4.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 +2 -2
- package/bin/gentle-shell.mjs +37 -4
- package/docs/gentle-music.md +24 -3
- package/docs/gentle-shell.md +5 -5
- package/docs/readme-reference.md +58 -45
- package/extensions/gentle-agents.ts +14 -2
- package/extensions/gentle-ai.ts +1672 -1237
- package/extensions/gentle-music.ts +73 -8
- package/extensions/gentle-shell.ts +88 -8
- package/lib/command-palette-catalog.ts +1 -2
- package/lib/gentle-shell-bundled.ts +18 -8
- package/lib/gentle-shell-launcher.ts +2 -0
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/shell-bar.ts +2 -4
- package/lib/shell-sidebar-layout.ts +1 -1
- package/lib/vim-editor-adapter.ts +3 -2
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +18 -8
- package/runtime/gentle-shell-launcher.mjs +2 -0
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/installer-downloads.mjs +24 -0
- package/scripts/installer-preflight.mjs +1 -1
- package/scripts/pi-update.mjs +3 -1
- package/tests/bundled-install.test.ts +273 -2
- package/tests/command-palette.test.ts +3 -3
- package/tests/gentle-agents.test.ts +4 -4
- package/tests/gentle-ai.test.ts +810 -609
- package/tests/gentle-music.test.ts +117 -0
- package/tests/gentle-shell-bundled.test.ts +20 -0
- package/tests/gentle-shell-launcher.test.ts +1 -0
- package/tests/gentle-shell.test.ts +44 -19
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/package-manifest.test.ts +2 -2
- package/tests/pi-update.test.ts +5 -0
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/shell-bar.test.ts +4 -4
- package/tests/shell-sidebar-layout.test.ts +17 -0
- package/tests/vim-editor-adapter.test.ts +2 -2
|
@@ -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.
|
|
@@ -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 }) => {
|
|
@@ -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",
|
|
@@ -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");
|
|
@@ -3473,7 +3473,7 @@ test("session_start freezes the inherited pin: a later pin change does not move
|
|
|
3473
3473
|
assert.equal(await session.launch(), "openai/alpha:minimal");
|
|
3474
3474
|
assert.equal(await session.launch(), "openai/alpha:minimal");
|
|
3475
3475
|
assert.deepEqual(session.driftNotices(), [
|
|
3476
|
-
'notify:el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local).
|
|
3476
|
+
'notify:el Gentleman: the default profile changed to "local" (local), this session keeps "frontier" (local). Open /gentle:profiles and choose Use in this session to adopt one.',
|
|
3477
3477
|
], "one notice for one change, across two launches");
|
|
3478
3478
|
base.writeStore(DRIFT_PROFILES, "local");
|
|
3479
3479
|
base.writeDeclaration("local");
|
|
@@ -3502,7 +3502,7 @@ test("a session with no profile anywhere keeps today's routing even after a pin
|
|
|
3502
3502
|
base.writePin("frontier");
|
|
3503
3503
|
assert.equal(await session.launch(), "openai-codex/gpt-5.6-terra:low");
|
|
3504
3504
|
assert.deepEqual(session.driftNotices(), [
|
|
3505
|
-
'notify:el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile.
|
|
3505
|
+
'notify:el Gentleman: the default profile changed to "frontier" (local), this session keeps no profile. Open /gentle:profiles and choose Use in this session to adopt one.',
|
|
3506
3506
|
]);
|
|
3507
3507
|
});
|
|
3508
3508
|
|
|
@@ -6654,7 +6654,7 @@ test("/gentle:jobs opens this session's jobs overlay, stops the selected job, an
|
|
|
6654
6654
|
gentleAgents(pi, {}, { ...deps().deps, jobShell: jobs.shell, jobOutputDir: () => jobs.dir });
|
|
6655
6655
|
const { ctx, overlays } = fakeContext();
|
|
6656
6656
|
const statuses: Array<string | undefined> = [];
|
|
6657
|
-
const snapshots: Array<{ sessionId: string; snapshot: { jobs: Array<{ id: string; label: string; startedAt: number }> } }> = [];
|
|
6657
|
+
const snapshots: Array<{ sessionId: string; snapshot: { jobs: Array<{ id: string; label: string; startedAt: number; status?: string }> } }> = [];
|
|
6658
6658
|
const unsubscribe = pi.events.on("gentle-ai:jobs-sidebar", (value) => { snapshots.push(value as typeof snapshots[number]); });
|
|
6659
6659
|
Object.assign(ctx.ui, { setStatus: (key: string, value: string | undefined) => { if (key === "gentle-jobs") statuses.push(value); } });
|
|
6660
6660
|
try {
|
|
@@ -6672,7 +6672,7 @@ test("/gentle:jobs opens this session's jobs overlay, stops the selected job, an
|
|
|
6672
6672
|
view.handleInput("s");
|
|
6673
6673
|
assert.equal(jobs.runs[0]!.signal?.aborted, true, "s stops the selected job");
|
|
6674
6674
|
assert.equal(statuses.at(-1), undefined, "the footer count clears when nothing runs");
|
|
6675
|
-
assert.deepEqual(snapshots.at(-1)?.snapshot.jobs, [], "the
|
|
6675
|
+
assert.deepEqual(snapshots.at(-1)?.snapshot.jobs.map(({ status }) => status), ["stopped"], "the finished tail keeps the stopped job visible once the count clears");
|
|
6676
6676
|
await jobIo();
|
|
6677
6677
|
const notices = sent.filter((entry) => entry.message.customType === "gentle-jobs.notice");
|
|
6678
6678
|
assert.equal(notices.length, 1, "the agent was told it would be notified, so a human stop is reported once");
|