gentle-pi 3.4.0 → 3.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.
@@ -0,0 +1,99 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+
8
+ // isPiManagedInstall gates the POSTINSTALL entry point only (scripts/install-gentle-ai.mjs):
9
+ // it decides whether the running gentle-pi package sits under a directory
10
+ // pattern Pi's own package manager creates (npm-backed or git-backed), so an
11
+ // `npm install -g gentle-pi`, a plain development git checkout, or an npx
12
+ // cache directory never writes to the user's global Pi settings. It is
13
+ // deliberately unrelated to installTuiModeSetting's own ownership check
14
+ // (which compares the package location against one specific resolved agent
15
+ // home) and to installIsolatedTuiModeSetting (gentle-shell's own bootstrap of
16
+ // a directory it just created), covered in the other two test files.
17
+
18
+ const helperUrl = new URL("../scripts/install-tui-mode-setting.mjs", import.meta.url);
19
+ const { isPiManagedInstall } = await import(helperUrl.href);
20
+
21
+ test("isPiManagedInstall recognizes the user-scope npm-managed layout", () => {
22
+ assert.equal(isPiManagedInstall(join("/home/alan", ".pi", "agent", "npm", "node_modules", "gentle-pi")), true);
23
+ });
24
+
25
+ test("isPiManagedInstall recognizes the project-scope npm-managed layout", () => {
26
+ assert.equal(isPiManagedInstall(join("/repo", ".pi", "npm", "node_modules", "gentle-pi")), true);
27
+ });
28
+
29
+ test("isPiManagedInstall recognizes Pi's git-managed layout", () => {
30
+ assert.equal(isPiManagedInstall(join("/home/alan", ".pi", "agent", "git", "github.com", "Gentleman-Programming", "gentle-pi")), true);
31
+ });
32
+
33
+ test("isPiManagedInstall rejects a global npm -g install", () => {
34
+ assert.equal(isPiManagedInstall("/usr/local/lib/node_modules/gentle-pi"), false);
35
+ });
36
+
37
+ test("isPiManagedInstall rejects a plain development git checkout", () => {
38
+ assert.equal(isPiManagedInstall("/Users/alan/work/gentle-pi"), false);
39
+ });
40
+
41
+ test("isPiManagedInstall rejects an npx cache directory", () => {
42
+ assert.equal(isPiManagedInstall(join("/home/alan", ".npm", "_npx", "abc123", "node_modules", "gentle-pi")), false);
43
+ });
44
+
45
+ test("isPiManagedInstall rejects a pnpm content-addressable store", () => {
46
+ assert.equal(isPiManagedInstall(join("/home/alan", ".pnpm", "gentle-pi@1.0.0", "node_modules", "gentle-pi")), false);
47
+ });
48
+
49
+ test("isPiManagedInstall resolves a relative path before checking", () => {
50
+ const cwd = process.cwd();
51
+ try {
52
+ process.chdir(join("/tmp"));
53
+ assert.equal(isPiManagedInstall(join(".", "npm", "node_modules", "gentle-pi")), true);
54
+ } finally {
55
+ process.chdir(cwd);
56
+ }
57
+ });
58
+
59
+ // --- postinstall entry integration --------------------------------------------
60
+
61
+ function fixture(t: test.TestContext, packageSegments: readonly string[]) {
62
+ const root = mkdtempSync(join(tmpdir(), "gentle-pi-guard-"));
63
+ t.after(() => rmSync(root, { recursive: true, force: true }));
64
+ const home = join(root, "agent");
65
+ const packageRoot = join(home, ...packageSegments);
66
+ const scripts = join(packageRoot, "scripts");
67
+ mkdirSync(scripts, { recursive: true });
68
+ copyFileSync(helperUrl, join(scripts, "install-tui-mode-setting.mjs"));
69
+ copyFileSync(new URL("../scripts/install-gentle-ai.mjs", import.meta.url), join(scripts, "install-gentle-ai.mjs"));
70
+ writeFileSync(
71
+ join(scripts, "gentle-ai-installer.mjs"),
72
+ 'export const INSTALLER_VERSION = "test"; export async function installGentleAi() { return { installed: true, binaryPath: "fixture" }; }',
73
+ );
74
+ const settings = join(home, "settings.json");
75
+ const env = { ...process.env, GENTLE_PI_AGENT_HOME: home, GENTLE_PI_SKIP_GENTLE_AI_INSTALL: "0" };
76
+ return { root, home, packageRoot, scripts, settings, env };
77
+ }
78
+
79
+ test("postinstall skips the global settings write outside a pi-managed install", (t) => {
80
+ const f = fixture(t, ["node_modules", "gentle-pi"]);
81
+ const result = spawnSync(process.execPath, [join(f.scripts, "install-gentle-ai.mjs")], { encoding: "utf8", env: f.env });
82
+ assert.equal(result.status, 0, result.stderr);
83
+ assert.match(`${result.stdout}${result.stderr}`, /skip/i);
84
+ assert.equal(existsSync(f.settings), false);
85
+ });
86
+
87
+ test("postinstall still writes fullscreen for a pi-managed npm install", (t) => {
88
+ const f = fixture(t, ["npm", "node_modules", "gentle-pi"]);
89
+ const result = spawnSync(process.execPath, [join(f.scripts, "install-gentle-ai.mjs")], { encoding: "utf8", env: f.env });
90
+ assert.equal(result.status, 0, result.stderr);
91
+ assert.equal(JSON.parse(readFileSync(f.settings, "utf8")).tuiMode, "fullscreen");
92
+ });
93
+
94
+ test("postinstall still writes fullscreen for a pi-managed git install", (t) => {
95
+ const f = fixture(t, ["git", "github.com", "Gentleman-Programming", "gentle-pi"]);
96
+ const result = spawnSync(process.execPath, [join(f.scripts, "install-gentle-ai.mjs")], { encoding: "utf8", env: f.env });
97
+ assert.equal(result.status, 0, result.stderr);
98
+ assert.equal(JSON.parse(readFileSync(f.settings, "utf8")).tuiMode, "fullscreen");
99
+ });
@@ -7,7 +7,7 @@ import { join } from "node:path";
7
7
  import { test } from "node:test";
8
8
 
9
9
  const helperUrl = new URL("../scripts/install-tui-mode-setting.mjs", import.meta.url);
10
- const { installTuiModeSetting } = await import(helperUrl.href);
10
+ const { installTuiModeSetting, installIsolatedTuiModeSetting } = await import(helperUrl.href);
11
11
 
12
12
  function fixture(t: { after(fn: () => void): void }, packagePath: readonly string[] = ["npm", "node_modules", "gentle-pi"]) {
13
13
  const root = mkdtempSync(join(tmpdir(), "gentle-tui-test-"));
@@ -269,6 +269,44 @@ for (const fault of ["write", "rename", "concurrent-change"]) {
269
269
  });
270
270
  }
271
271
 
272
+ // --- installIsolatedTuiModeSetting -------------------------------------------
273
+ // gentle-shell's own isolated-home bootstrap (T2): unlike installTuiModeSetting,
274
+ // there is no "physically installed under this home's npm/node_modules"
275
+ // ownership check to satisfy, because gentle-shell just created `dir` itself
276
+ // moments ago; a plain agent-home-shaped temp directory is enough.
277
+
278
+ test("installIsolatedTuiModeSetting writes fullscreen into a freshly created directory", async (t) => {
279
+ const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
280
+ t.after(() => rmSync(root, { recursive: true, force: true }));
281
+ const dir = join(root, "gentle-shell", "agent");
282
+ mkdirSync(dir, { recursive: true });
283
+ assert.deepEqual(await installIsolatedTuiModeSetting(dir), { changed: true, recognized: true });
284
+ assert.deepEqual(JSON.parse(readFileSync(join(dir, "settings.json"), "utf8")), { tuiMode: "fullscreen" });
285
+ });
286
+
287
+ test("installIsolatedTuiModeSetting preserves other settings fields already present", async (t) => {
288
+ const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
289
+ t.after(() => rmSync(root, { recursive: true, force: true }));
290
+ writeFileSync(join(root, "settings.json"), JSON.stringify({ tuiMode: "regular", theme: "rose" }));
291
+ assert.deepEqual(await installIsolatedTuiModeSetting(root), { changed: true, recognized: true });
292
+ assert.deepEqual(JSON.parse(readFileSync(join(root, "settings.json"), "utf8")), { tuiMode: "fullscreen", theme: "rose" });
293
+ });
294
+
295
+ test("installIsolatedTuiModeSetting is a no-op when already fullscreen", async (t) => {
296
+ const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
297
+ t.after(() => rmSync(root, { recursive: true, force: true }));
298
+ writeFileSync(join(root, "settings.json"), JSON.stringify({ tuiMode: "fullscreen" }));
299
+ const before = lstatSync(join(root, "settings.json"));
300
+ assert.deepEqual(await installIsolatedTuiModeSetting(root), { changed: false, recognized: true });
301
+ assert.equal(lstatSync(join(root, "settings.json")).ino, before.ino);
302
+ });
303
+
304
+ test("installIsolatedTuiModeSetting rejects a missing directory", async (t) => {
305
+ const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
306
+ t.after(() => rmSync(root, { recursive: true, force: true }));
307
+ await assert.rejects(installIsolatedTuiModeSetting(join(root, "absent")));
308
+ });
309
+
272
310
  type PostinstallRoute = "skip" | "success" | "failure";
273
311
 
274
312
  function writePostinstallFixture(f: ReturnType<typeof fixture>, route: PostinstallRoute) {
@@ -1563,9 +1563,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in
1563
1563
  assert.match(wrapper, /quietToolsEnabled/);
1564
1564
  });
1565
1565
 
1566
- test("Gentle Shell v3.4.0 package and runtime stop before publication", () => {
1566
+ test("Gentle Shell v3.5.0 package and runtime stop before publication", () => {
1567
1567
  const packageJson = readPackageJson();
1568
- assert.equal(packageJson.version, "3.4.0", "the release manifest must remain explicitly pinned to v3.4.0");
1568
+ assert.equal(packageJson.version, "3.5.0", "the release manifest must remain explicitly pinned to v3.5.0");
1569
1569
  assert.equal(
1570
1570
  packageJson.scripts?.test,
1571
1571
  "node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
@@ -0,0 +1,77 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ INTERACTIVE_HOST_ENV,
5
+ isInteractiveMode,
6
+ isInteractiveRpcHost,
7
+ withoutInteractiveHost,
8
+ } from "../lib/rpc-host.ts";
9
+
10
+ // Interactive host signal: the desktop app sets GENTLE_SHELL_INTERACTIVE_HOST=1
11
+ // on the pi process it spawns directly (`--mode rpc`). Subagent children
12
+ // spawned by lib/agents-runner.ts must never see it, so rpc without the
13
+ // variable stays byte-identical to today's headless behaviour.
14
+
15
+ test("isInteractiveRpcHost is true only for rpc mode with the variable set to \"1\"", () => {
16
+ assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "1" }), true);
17
+ });
18
+
19
+ test("isInteractiveRpcHost is false for rpc mode without the variable", () => {
20
+ assert.equal(isInteractiveRpcHost("rpc", {}), false);
21
+ });
22
+
23
+ test("isInteractiveRpcHost is false for rpc mode when the variable is not exactly \"1\"", () => {
24
+ assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "true" }), false);
25
+ assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "0" }), false);
26
+ assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "" }), false);
27
+ });
28
+
29
+ test("isInteractiveRpcHost is false for every non-rpc mode even with the variable set", () => {
30
+ for (const mode of ["tui", "print", "json"]) {
31
+ assert.equal(isInteractiveRpcHost(mode, { [INTERACTIVE_HOST_ENV]: "1" }), false, `mode ${mode}`);
32
+ }
33
+ });
34
+
35
+ test("isInteractiveRpcHost defaults to process.env when no env is supplied", (t) => {
36
+ const previous = process.env[INTERACTIVE_HOST_ENV];
37
+ process.env[INTERACTIVE_HOST_ENV] = "1";
38
+ t.after(() => {
39
+ if (previous === undefined) delete process.env[INTERACTIVE_HOST_ENV];
40
+ else process.env[INTERACTIVE_HOST_ENV] = previous;
41
+ });
42
+
43
+ assert.equal(isInteractiveRpcHost("rpc"), true);
44
+ });
45
+
46
+ test("isInteractiveMode is true for tui regardless of the variable", () => {
47
+ assert.equal(isInteractiveMode("tui", {}), true);
48
+ assert.equal(isInteractiveMode("tui", { [INTERACTIVE_HOST_ENV]: "1" }), true);
49
+ });
50
+
51
+ test("isInteractiveMode is true for an interactive rpc host", () => {
52
+ assert.equal(isInteractiveMode("rpc", { [INTERACTIVE_HOST_ENV]: "1" }), true);
53
+ });
54
+
55
+ test("isInteractiveMode is false for rpc without the variable, and for print/json", () => {
56
+ assert.equal(isInteractiveMode("rpc", {}), false);
57
+ assert.equal(isInteractiveMode("print", { [INTERACTIVE_HOST_ENV]: "1" }), false);
58
+ assert.equal(isInteractiveMode("json", { [INTERACTIVE_HOST_ENV]: "1" }), false);
59
+ });
60
+
61
+ test("withoutInteractiveHost returns a copy without the variable, preserving the rest", () => {
62
+ const source = { PATH: "/bin", [INTERACTIVE_HOST_ENV]: "1", KEEP: "yes" };
63
+
64
+ const result = withoutInteractiveHost(source);
65
+
66
+ assert.deepEqual(result, { PATH: "/bin", KEEP: "yes" });
67
+ assert.deepEqual(source, { PATH: "/bin", [INTERACTIVE_HOST_ENV]: "1", KEEP: "yes" }, "the source env is not mutated");
68
+ });
69
+
70
+ test("withoutInteractiveHost is a no-op copy when the variable is absent", () => {
71
+ const source = { PATH: "/bin" };
72
+
73
+ const result = withoutInteractiveHost(source);
74
+
75
+ assert.deepEqual(result, { PATH: "/bin" });
76
+ assert.notEqual(result, source, "a fresh object is returned, not the same reference");
77
+ });