gentle-pi 3.5.1 → 3.7.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 +8 -1
- package/assets/orchestrator-delegation.md +2 -0
- package/bin/gentle-shell.mjs +900 -23
- package/docs/gentle-shell.md +5 -4
- package/docs/readme-reference.md +53 -39
- package/extensions/gentle-agents.ts +89 -22
- package/extensions/gentle-ai.ts +27 -48
- package/lib/agents-runner.ts +9 -0
- package/lib/foreign-target-grants.ts +32 -0
- package/lib/gentle-shell-launcher.ts +314 -2
- package/lib/inprocess-reviewer.ts +54 -6
- package/lib/native-review-cli.ts +16 -0
- package/lib/session-change-capture.ts +10 -1
- package/package.json +1 -1
- package/runtime/gentle-shell-launcher.mjs +313 -1
- package/runtime/native-review-cli.mjs +16 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-tui-mode-setting.mjs +21 -2
- package/scripts/verify-package-files.mjs +2 -2
- package/tests/agents-runner.test.ts +22 -0
- package/tests/foreign-target-grants.test.ts +58 -0
- package/tests/gentle-agents.test.ts +362 -2
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +54 -49
- package/tests/gentle-ai.test.ts +147 -2
- package/tests/gentle-shell-bin.test.ts +1739 -4
- package/tests/gentle-shell-launcher.test.ts +394 -0
- package/tests/inprocess-reviewer.test.ts +179 -0
- package/tests/install-tui-mode-setting.test.ts +22 -4
- package/tests/native-review-capability-contract.test.ts +34 -1
- package/tests/odd-runtime-delegation-gate.test.ts +18 -197
- package/tests/package-manifest.test.ts +6 -6
- package/tests/runtime-harness.mjs +1 -2
- package/tests/session-change-capture.test.ts +12 -1
- package/lib/odd-runtime-delegation-gate.ts +0 -88
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import {
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
chmodSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
statSync,
|
|
13
|
+
symlinkSync,
|
|
14
|
+
utimesSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
4
17
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { dirname, join } from "node:path";
|
|
18
|
+
import { delimiter, dirname, join } from "node:path";
|
|
6
19
|
import { fileURLToPath } from "node:url";
|
|
7
20
|
import test from "node:test";
|
|
8
21
|
|
|
@@ -16,6 +29,21 @@ const binUrl = new URL("../bin/gentle-shell.mjs", import.meta.url);
|
|
|
16
29
|
const binPath = fileURLToPath(binUrl);
|
|
17
30
|
const packageRoot = dirname(dirname(binPath));
|
|
18
31
|
|
|
32
|
+
// The launcher's own gentle-pi version, exactly as `ownPackageVersion()` in
|
|
33
|
+
// bin/gentle-shell.mjs reads it (package.json at packageRoot) — used to
|
|
34
|
+
// assert the post-install gentle-pi removal message and the provisioning
|
|
35
|
+
// marker's `gentlePi` field without hardcoding this package's own version.
|
|
36
|
+
function ownGentlePiVersion(): string {
|
|
37
|
+
return JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")).version;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// GENTLE_SHELL_NO_AUTO_SETUP=1 is in the base fixture env so every test that
|
|
41
|
+
// is not itself about auto-provisioning (S7) keeps today's plain-launch
|
|
42
|
+
// behavior: without it, every fixture-driven test against a fresh isolated
|
|
43
|
+
// home would trigger a real `gentle-ai install --agent pi` (network, tens of
|
|
44
|
+
// seconds, mutating state) unless it also happened to set
|
|
45
|
+
// GENTLE_SHELL_GENTLE_AI_BIN. The auto-provision test section below opts
|
|
46
|
+
// back in per test via enableAutoProvision.
|
|
19
47
|
function fixture(t: test.TestContext) {
|
|
20
48
|
const root = mkdtempSync(join(tmpdir(), "gentle-shell-bin-"));
|
|
21
49
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
@@ -30,17 +58,38 @@ function fixture(t: test.TestContext) {
|
|
|
30
58
|
USERPROFILE: home,
|
|
31
59
|
GENTLE_SHELL_HOME: gentleShellHome,
|
|
32
60
|
GENTLE_SHELL_PI: piScript,
|
|
61
|
+
GENTLE_SHELL_NO_AUTO_SETUP: "1",
|
|
33
62
|
};
|
|
34
63
|
return { root, home, gentleShellHome, piScript, env };
|
|
35
64
|
}
|
|
36
65
|
|
|
37
|
-
|
|
66
|
+
// Removes the fixture's default GENTLE_SHELL_NO_AUTO_SETUP=1 opt-out, for a
|
|
67
|
+
// test that specifically exercises auto-provisioning (S7).
|
|
68
|
+
function enableAutoProvision(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
69
|
+
const { GENTLE_SHELL_NO_AUTO_SETUP, ...rest } = env;
|
|
70
|
+
return rest;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// `removeExitCode` controls only the `pi remove ...` branch exercised by the
|
|
74
|
+
// setup-subcommand conflict-cleanup tests below; every other pi invocation
|
|
75
|
+
// (including the version probe) keeps exiting 0, unchanged for every
|
|
76
|
+
// existing caller that does not pass it.
|
|
77
|
+
function writePiScript(path: string, version: string, removeExitCode = 0) {
|
|
38
78
|
writeFileSync(
|
|
39
79
|
path,
|
|
40
80
|
[
|
|
41
81
|
"#!/usr/bin/env node",
|
|
42
82
|
"const args = process.argv.slice(2);",
|
|
43
83
|
`if (args.includes("--version")) { console.log(${JSON.stringify(version)}); process.exit(0); }`,
|
|
84
|
+
"if (args[0] === 'remove') {",
|
|
85
|
+
" console.log(JSON.stringify({",
|
|
86
|
+
" args,",
|
|
87
|
+
" PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,",
|
|
88
|
+
" GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME,",
|
|
89
|
+
" PATH: process.env.PATH,",
|
|
90
|
+
" }));",
|
|
91
|
+
` process.exit(${removeExitCode});`,
|
|
92
|
+
"}",
|
|
44
93
|
"console.log(JSON.stringify({",
|
|
45
94
|
" args,",
|
|
46
95
|
" PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,",
|
|
@@ -57,6 +106,269 @@ function run(env: NodeJS.ProcessEnv, args: string[], options: { cwd?: string } =
|
|
|
57
106
|
return spawnSync(process.execPath, [binPath, ...args], { encoding: "utf8", env, ...options });
|
|
58
107
|
}
|
|
59
108
|
|
|
109
|
+
// Test/development-only stub for the setup subcommand's gentle-ai binary:
|
|
110
|
+
// records its argv and the environment gentle-shell sets around it, instead
|
|
111
|
+
// of running the real package-local gentle-ai (whose supply-chain integrity
|
|
112
|
+
// checks a test cannot cheaply satisfy). Pointed to via GENTLE_SHELL_GENTLE_AI_BIN,
|
|
113
|
+
// documented as test/development-only in docs/readme-reference.md.
|
|
114
|
+
function writeGentleAiScript(path: string, exitCode = 0) {
|
|
115
|
+
writeFileSync(
|
|
116
|
+
path,
|
|
117
|
+
[
|
|
118
|
+
"#!/usr/bin/env node",
|
|
119
|
+
"const args = process.argv.slice(2);",
|
|
120
|
+
"process.stdout.write(JSON.stringify({",
|
|
121
|
+
" args,",
|
|
122
|
+
" PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,",
|
|
123
|
+
" GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME,",
|
|
124
|
+
" PATH: process.env.PATH,",
|
|
125
|
+
"}));",
|
|
126
|
+
`process.exit(${exitCode});`,
|
|
127
|
+
"",
|
|
128
|
+
].join("\n"),
|
|
129
|
+
);
|
|
130
|
+
chmodSync(path, 0o755);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Test/development-only stub for the setup subcommand's gentle-ai binary
|
|
134
|
+
// that also records each invocation's args to `counterPath` (one JSON line
|
|
135
|
+
// per run), so an auto-provision test can prove the flow ran exactly once
|
|
136
|
+
// (or not at all) across several gentle-shell invocations, independent of
|
|
137
|
+
// what lands on stdout/stderr.
|
|
138
|
+
function writeGentleAiScriptCountingRuns(path: string, counterPath: string, exitCode = 0) {
|
|
139
|
+
writeFileSync(
|
|
140
|
+
path,
|
|
141
|
+
[
|
|
142
|
+
"#!/usr/bin/env node",
|
|
143
|
+
"import { appendFileSync } from 'node:fs';",
|
|
144
|
+
"const args = process.argv.slice(2);",
|
|
145
|
+
`appendFileSync(${JSON.stringify(counterPath)}, JSON.stringify(args) + '\\n');`,
|
|
146
|
+
"process.stdout.write(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }));",
|
|
147
|
+
`process.exit(${exitCode});`,
|
|
148
|
+
"",
|
|
149
|
+
].join("\n"),
|
|
150
|
+
);
|
|
151
|
+
chmodSync(path, 0o755);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Test/development-only stub for setup's self-heal installer seam
|
|
155
|
+
// (GENTLE_SHELL_GENTLE_AI_INSTALLER): writes a working gentle-ai stub at
|
|
156
|
+
// process.env.GENTLE_SHELL_GENTLE_AI_BIN — the same path setup itself
|
|
157
|
+
// resolved the missing binary from — so "the installer created the pinned
|
|
158
|
+
// binary" is provable by setup's own retry check finding it, exactly like a
|
|
159
|
+
// real `node scripts/install-gentle-ai.mjs` run would leave a real binary at
|
|
160
|
+
// gentleAiBinaryPath().
|
|
161
|
+
function writeInstallerScriptThatCreatesTheBinary(path: string) {
|
|
162
|
+
writeFileSync(
|
|
163
|
+
path,
|
|
164
|
+
[
|
|
165
|
+
"#!/usr/bin/env node",
|
|
166
|
+
"import { writeFileSync, chmodSync, mkdirSync } from 'node:fs';",
|
|
167
|
+
"import { dirname } from 'node:path';",
|
|
168
|
+
"const target = process.env.GENTLE_SHELL_GENTLE_AI_BIN;",
|
|
169
|
+
"mkdirSync(dirname(target), { recursive: true });",
|
|
170
|
+
"writeFileSync(target, [",
|
|
171
|
+
" '#!/usr/bin/env node',",
|
|
172
|
+
" 'const args = process.argv.slice(2);',",
|
|
173
|
+
" 'process.stdout.write(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR, GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME, PATH: process.env.PATH }));',",
|
|
174
|
+
" 'process.exit(0);',",
|
|
175
|
+
" '',",
|
|
176
|
+
"].join('\\n'));",
|
|
177
|
+
"chmodSync(target, 0o755);",
|
|
178
|
+
"process.exit(0);",
|
|
179
|
+
"",
|
|
180
|
+
].join("\n"),
|
|
181
|
+
);
|
|
182
|
+
chmodSync(path, 0o755);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Test/development-only stub for setup's self-heal installer seam that
|
|
186
|
+
// deliberately never creates the binary, simulating a real installer run
|
|
187
|
+
// that failed (e.g. a network or verification failure) — setup's retry
|
|
188
|
+
// check must still find the binary missing afterward.
|
|
189
|
+
function writeInstallerScriptThatFails(path: string) {
|
|
190
|
+
writeFileSync(path, ["#!/usr/bin/env node", "process.stderr.write('simulated installer failure\\n');", "process.exit(1);", ""].join("\n"));
|
|
191
|
+
chmodSync(path, 0o755);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Test/development-only stub for the setup subcommand's gentle-ai binary,
|
|
195
|
+
// simulating the real gentle-ai's managed Pi stack declaring the conflicting
|
|
196
|
+
// npm:@juicesharp/rpiv-ask-user-question package (gentle-ai #4820,
|
|
197
|
+
// gentle-shell #1277) into the provisioned home's settings.json, the same
|
|
198
|
+
// file the isolated-home bootstrap already created before `setup` spawns
|
|
199
|
+
// this stub. Its own stdout line ends with "\n" so a test can tell it apart
|
|
200
|
+
// from a later `pi remove` line on the same inherited stdout.
|
|
201
|
+
function writeGentleAiScriptDeclaringConflict(path: string, exitCode = 0) {
|
|
202
|
+
writeFileSync(
|
|
203
|
+
path,
|
|
204
|
+
[
|
|
205
|
+
"#!/usr/bin/env node",
|
|
206
|
+
"import { readFileSync, writeFileSync } from 'node:fs';",
|
|
207
|
+
"import { join } from 'node:path';",
|
|
208
|
+
"const args = process.argv.slice(2);",
|
|
209
|
+
"const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');",
|
|
210
|
+
"const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));",
|
|
211
|
+
"settings.packages = ['npm:@juicesharp/rpiv-ask-user-question@1.2.3'];",
|
|
212
|
+
"writeFileSync(settingsPath, JSON.stringify(settings));",
|
|
213
|
+
"process.stdout.write(JSON.stringify({ args }) + '\\n');",
|
|
214
|
+
`process.exit(${exitCode});`,
|
|
215
|
+
"",
|
|
216
|
+
].join("\n"),
|
|
217
|
+
);
|
|
218
|
+
chmodSync(path, 0o755);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Test/development-only stub for the setup subcommand's gentle-ai binary
|
|
222
|
+
// that also rewrites the provisioned home's own settings.json `theme` field,
|
|
223
|
+
// simulating gentle-ai's managed Pi install writing its own theme into
|
|
224
|
+
// settings.json — the case gentle-shell's own theme-restore logic
|
|
225
|
+
// (bin/gentle-shell.mjs's runSetupFlow, enforceDefaultThemeField) must undo.
|
|
226
|
+
function writeGentleAiScriptSettingTheme(path: string, theme: string, exitCode = 0) {
|
|
227
|
+
writeFileSync(
|
|
228
|
+
path,
|
|
229
|
+
[
|
|
230
|
+
"#!/usr/bin/env node",
|
|
231
|
+
"import { readFileSync, writeFileSync } from 'node:fs';",
|
|
232
|
+
"import { join } from 'node:path';",
|
|
233
|
+
"const args = process.argv.slice(2);",
|
|
234
|
+
"const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');",
|
|
235
|
+
"const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));",
|
|
236
|
+
`settings.theme = ${JSON.stringify(theme)};`,
|
|
237
|
+
"writeFileSync(settingsPath, JSON.stringify(settings));",
|
|
238
|
+
"process.stdout.write(JSON.stringify({ args }) + '\\n');",
|
|
239
|
+
`process.exit(${exitCode});`,
|
|
240
|
+
"",
|
|
241
|
+
].join("\n"),
|
|
242
|
+
);
|
|
243
|
+
chmodSync(path, 0o755);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Test/development-only stub for the setup subcommand's gentle-ai binary,
|
|
247
|
+
// simulating the real gentle-ai's managed Pi stack always declaring
|
|
248
|
+
// npm:gentle-pi itself into the provisioned home's settings.json (the
|
|
249
|
+
// problem this fix addresses: the home must never keep running that
|
|
250
|
+
// declared copy instead of the launcher's own). `extraSources` lets a test
|
|
251
|
+
// also declare the conflicting rpiv package alongside it, in the given
|
|
252
|
+
// order, to prove both post-install removals run together.
|
|
253
|
+
function writeGentleAiScriptDeclaringGentlePi(path: string, extraSources: string[] = [], exitCode = 0) {
|
|
254
|
+
writeFileSync(
|
|
255
|
+
path,
|
|
256
|
+
[
|
|
257
|
+
"#!/usr/bin/env node",
|
|
258
|
+
"import { readFileSync, writeFileSync } from 'node:fs';",
|
|
259
|
+
"import { join } from 'node:path';",
|
|
260
|
+
"const args = process.argv.slice(2);",
|
|
261
|
+
"const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');",
|
|
262
|
+
"const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));",
|
|
263
|
+
`settings.packages = [...${JSON.stringify(extraSources)}, 'npm:gentle-pi@3.5.1'];`,
|
|
264
|
+
"writeFileSync(settingsPath, JSON.stringify(settings));",
|
|
265
|
+
"process.stdout.write(JSON.stringify({ args }) + '\\n');",
|
|
266
|
+
`process.exit(${exitCode});`,
|
|
267
|
+
"",
|
|
268
|
+
].join("\n"),
|
|
269
|
+
);
|
|
270
|
+
chmodSync(path, 0o755);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Test/development-only fake pi runtime whose `remove <source>` branch also
|
|
274
|
+
// rewrites the target home's settings.json, dropping the matching `packages`
|
|
275
|
+
// entry — unlike the plain `writePiScript` stub above (which only records
|
|
276
|
+
// the invocation), this models what a real `pi remove` does to settings.json
|
|
277
|
+
// closely enough to prove a later plain launch sees the declaration gone and
|
|
278
|
+
// resumes the launcher's own injection.
|
|
279
|
+
function writePiScriptEditingSettingsOnRemove(path: string, version = "0.85.1") {
|
|
280
|
+
writeFileSync(
|
|
281
|
+
path,
|
|
282
|
+
[
|
|
283
|
+
"#!/usr/bin/env node",
|
|
284
|
+
"import { readFileSync, writeFileSync } from 'node:fs';",
|
|
285
|
+
"import { join } from 'node:path';",
|
|
286
|
+
"const args = process.argv.slice(2);",
|
|
287
|
+
`if (args.includes("--version")) { console.log(${JSON.stringify(version)}); process.exit(0); }`,
|
|
288
|
+
"if (args[0] === 'remove') {",
|
|
289
|
+
" const settingsPath = join(process.env.PI_CODING_AGENT_DIR, 'settings.json');",
|
|
290
|
+
" const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));",
|
|
291
|
+
" const target = args[1];",
|
|
292
|
+
" settings.packages = (settings.packages || []).filter((entry) => {",
|
|
293
|
+
" const source = typeof entry === 'string' ? entry : entry.source;",
|
|
294
|
+
" return source !== target && !source.startsWith(target + '@');",
|
|
295
|
+
" });",
|
|
296
|
+
" writeFileSync(settingsPath, JSON.stringify(settings));",
|
|
297
|
+
" console.log(JSON.stringify({",
|
|
298
|
+
" args,",
|
|
299
|
+
" PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,",
|
|
300
|
+
" GENTLE_PI_AGENT_HOME: process.env.GENTLE_PI_AGENT_HOME,",
|
|
301
|
+
" PATH: process.env.PATH,",
|
|
302
|
+
" }));",
|
|
303
|
+
" process.exit(0);",
|
|
304
|
+
"}",
|
|
305
|
+
"console.log(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }));",
|
|
306
|
+
"process.exit(0);",
|
|
307
|
+
"",
|
|
308
|
+
].join("\n"),
|
|
309
|
+
);
|
|
310
|
+
chmodSync(path, 0o755);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Test/development-only stub for the setup subcommand's gentle-ai binary
|
|
314
|
+
// that also rewrites the shared Pi persona file at
|
|
315
|
+
// `$HOME/.pi/gentle-ai/persona.json` to the default preset, exactly like the
|
|
316
|
+
// real gentle-ai does regardless of `PI_CODING_AGENT_DIR` (gentle-ai's
|
|
317
|
+
// `PiPersonaConfigPath` always resolves against the OS home). Reads
|
|
318
|
+
// `homedir()` inside the child so it honors the fixture's isolated `HOME`,
|
|
319
|
+
// never `PI_CODING_AGENT_DIR` — that mismatch is exactly the bug the
|
|
320
|
+
// snapshot/restore wrap in `runSetupFlow` guards against.
|
|
321
|
+
function writeGentleAiScriptRewritingPersona(path: string, exitCode = 0) {
|
|
322
|
+
writeFileSync(
|
|
323
|
+
path,
|
|
324
|
+
[
|
|
325
|
+
"#!/usr/bin/env node",
|
|
326
|
+
"import { writeFileSync, mkdirSync } from 'node:fs';",
|
|
327
|
+
"import { homedir } from 'node:os';",
|
|
328
|
+
"import { join } from 'node:path';",
|
|
329
|
+
"const personaDir = join(homedir(), '.pi', 'gentle-ai');",
|
|
330
|
+
"mkdirSync(personaDir, { recursive: true });",
|
|
331
|
+
"writeFileSync(join(personaDir, 'persona.json'), JSON.stringify({ mode: 'gentleman' }));",
|
|
332
|
+
`process.exit(${exitCode});`,
|
|
333
|
+
"",
|
|
334
|
+
].join("\n"),
|
|
335
|
+
);
|
|
336
|
+
chmodSync(path, 0o755);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Test/development-only stub for the setup subcommand's gentle-ai binary
|
|
340
|
+
// that also rewrites the shared `$HOME/.gentle-ai/state.json` the way the
|
|
341
|
+
// real gentle-ai does on every install: it changes managed_asset_digest (the
|
|
342
|
+
// field this fix restores) and one unrelated field (installed_agents), so a
|
|
343
|
+
// test can prove the digest gets restored while the unrelated field is left
|
|
344
|
+
// exactly as the child wrote it. `create` controls whether it merges into an
|
|
345
|
+
// existing state.json or writes a brand-new one (for the "no pre-existing
|
|
346
|
+
// file" case, where the stub's file must be left alone entirely).
|
|
347
|
+
function writeGentleAiScriptRewritingManagedAssetDigest(path: string, opts: { digest?: string; create?: boolean } = {}, exitCode = 0) {
|
|
348
|
+
const digest = opts.digest ?? "digest-from-pinned-gentle-ai";
|
|
349
|
+
const create = opts.create ?? false;
|
|
350
|
+
writeFileSync(
|
|
351
|
+
path,
|
|
352
|
+
[
|
|
353
|
+
"#!/usr/bin/env node",
|
|
354
|
+
"import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';",
|
|
355
|
+
"import { homedir } from 'node:os';",
|
|
356
|
+
"import { join } from 'node:path';",
|
|
357
|
+
"const stateDir = join(homedir(), '.gentle-ai');",
|
|
358
|
+
"mkdirSync(stateDir, { recursive: true });",
|
|
359
|
+
"const statePath = join(stateDir, 'state.json');",
|
|
360
|
+
`const create = ${JSON.stringify(create)};`,
|
|
361
|
+
"const state = !create && existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf8')) : {};",
|
|
362
|
+
`state.managed_asset_digest = ${JSON.stringify(digest)};`,
|
|
363
|
+
"state.installed_agents = [...(state.installed_agents || []), 'claude'];",
|
|
364
|
+
"writeFileSync(statePath, JSON.stringify(state, null, 2) + '\\n');",
|
|
365
|
+
`process.exit(${exitCode});`,
|
|
366
|
+
"",
|
|
367
|
+
].join("\n"),
|
|
368
|
+
);
|
|
369
|
+
chmodSync(path, 0o755);
|
|
370
|
+
}
|
|
371
|
+
|
|
60
372
|
test("--help exits 0 and prints usage", (t) => {
|
|
61
373
|
const f = fixture(t);
|
|
62
374
|
const result = run(f.env, ["--help"]);
|
|
@@ -93,6 +405,26 @@ test("home <path> persists a custom directory", (t) => {
|
|
|
93
405
|
assert.equal(check.stdout.trim(), `path ${target}`);
|
|
94
406
|
});
|
|
95
407
|
|
|
408
|
+
// Test/development-only: GENTLE_SHELL_CONFIG overrides the launcher
|
|
409
|
+
// config.json path (normally <homedir>/.gentle-shell/config.json), so a test
|
|
410
|
+
// or a field run against the real HOME (for example the packed-artifact E2E
|
|
411
|
+
// script, which needs `--link` probes against the real pi home) never
|
|
412
|
+
// touches the real ~/.gentle-shell/config.json. Documented as
|
|
413
|
+
// test/development-only in docs/readme-reference.md.
|
|
414
|
+
test("GENTLE_SHELL_CONFIG redirects the config.json path used by 'home' and auto-provisioning", (t) => {
|
|
415
|
+
const f = fixture(t);
|
|
416
|
+
const overridePath = join(f.root, "alt-config.json");
|
|
417
|
+
const env = { ...f.env, GENTLE_SHELL_CONFIG: overridePath };
|
|
418
|
+
|
|
419
|
+
const save = run(env, ["home", "link"]);
|
|
420
|
+
assert.equal(save.status, 0, save.stderr);
|
|
421
|
+
assert.deepEqual(JSON.parse(readFileSync(overridePath, "utf8")), { home: "link" });
|
|
422
|
+
assert.equal(existsSync(join(f.home, ".gentle-shell", "config.json")), false);
|
|
423
|
+
|
|
424
|
+
const check = run(env, ["home"]);
|
|
425
|
+
assert.match(check.stdout, /^link /);
|
|
426
|
+
});
|
|
427
|
+
|
|
96
428
|
test("first isolated run bootstraps the home, writes fullscreen, and prints the hint once", (t) => {
|
|
97
429
|
const f = fixture(t);
|
|
98
430
|
assert.equal(existsSync(f.gentleShellHome), false);
|
|
@@ -149,6 +481,74 @@ test("--link skips injection and leaves settings.json byte-identical when it alr
|
|
|
149
481
|
assert.equal(existsSync(join(piAgentDir, ".gentle-shell")), false);
|
|
150
482
|
});
|
|
151
483
|
|
|
484
|
+
// --- injection skip for isolated/--home homes once they declare gentle-pi (S3) ---
|
|
485
|
+
//
|
|
486
|
+
// `gentle-shell setup` installs npm:gentle-pi into the home's settings.json;
|
|
487
|
+
// once that declaration exists, isolated and --home homes must stop
|
|
488
|
+
// injecting the launcher's own copy on top of it, the same way --link
|
|
489
|
+
// already does. Homes without a declaration keep the plain injection.
|
|
490
|
+
|
|
491
|
+
test("an isolated home whose settings.json declares npm:gentle-pi gets no injection", (t) => {
|
|
492
|
+
const f = fixture(t);
|
|
493
|
+
mkdirSync(f.gentleShellHome, { recursive: true });
|
|
494
|
+
writeFileSync(join(f.gentleShellHome, "settings.json"), JSON.stringify({ packages: ["npm:gentle-pi@3.5.1"], tuiMode: "fullscreen" }));
|
|
495
|
+
|
|
496
|
+
const result = run(f.env, ["--mode", "rpc"]);
|
|
497
|
+
assert.equal(result.status, 0, result.stderr);
|
|
498
|
+
const payload = JSON.parse(result.stdout);
|
|
499
|
+
assert.deepEqual(payload.args, ["--mode", "rpc"]);
|
|
500
|
+
assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("an isolated home whose settings.json does not declare gentle-pi keeps the plain injection", (t) => {
|
|
504
|
+
const f = fixture(t);
|
|
505
|
+
mkdirSync(f.gentleShellHome, { recursive: true });
|
|
506
|
+
writeFileSync(join(f.gentleShellHome, "settings.json"), JSON.stringify({ tuiMode: "fullscreen" }));
|
|
507
|
+
|
|
508
|
+
const result = run(f.env, ["--mode", "rpc"]);
|
|
509
|
+
assert.equal(result.status, 0, result.stderr);
|
|
510
|
+
const payload = JSON.parse(result.stdout);
|
|
511
|
+
assert.deepEqual(payload.args, [
|
|
512
|
+
"-e",
|
|
513
|
+
packageRoot,
|
|
514
|
+
"--theme",
|
|
515
|
+
join(packageRoot, "themes"),
|
|
516
|
+
"--skill",
|
|
517
|
+
join(packageRoot, "skills"),
|
|
518
|
+
"--prompt-template",
|
|
519
|
+
join(packageRoot, "prompts"),
|
|
520
|
+
"--mode",
|
|
521
|
+
"rpc",
|
|
522
|
+
]);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("an isolated home whose settings.json declares a path-based gentle-pi takes over, same as --link", (t) => {
|
|
526
|
+
const f = fixture(t);
|
|
527
|
+
mkdirSync(f.gentleShellHome, { recursive: true });
|
|
528
|
+
const otherGentlePiDir = join(f.root, "other-gentle-pi");
|
|
529
|
+
mkdirSync(otherGentlePiDir, { recursive: true });
|
|
530
|
+
writeFileSync(join(otherGentlePiDir, "package.json"), JSON.stringify({ name: "gentle-pi" }));
|
|
531
|
+
writeFileSync(join(f.gentleShellHome, "settings.json"), JSON.stringify({ packages: ["../other-gentle-pi"], tuiMode: "fullscreen" }));
|
|
532
|
+
|
|
533
|
+
const result = run(f.env, ["--mode", "rpc"], { cwd: f.root });
|
|
534
|
+
assert.equal(result.status, 0, result.stderr);
|
|
535
|
+
assert.match(result.stderr, /taking over gentle-pi from/);
|
|
536
|
+
const payload = JSON.parse(result.stdout);
|
|
537
|
+
assert.deepEqual(payload.args, [
|
|
538
|
+
"--no-extensions",
|
|
539
|
+
"-e",
|
|
540
|
+
packageRoot,
|
|
541
|
+
"--theme",
|
|
542
|
+
join(packageRoot, "themes"),
|
|
543
|
+
"--skill",
|
|
544
|
+
join(packageRoot, "skills"),
|
|
545
|
+
"--prompt-template",
|
|
546
|
+
join(packageRoot, "prompts"),
|
|
547
|
+
"--mode",
|
|
548
|
+
"rpc",
|
|
549
|
+
]);
|
|
550
|
+
});
|
|
551
|
+
|
|
152
552
|
test("gentle-shell list forwards to pi as a bare subcommand, with no injected extension flags", (t) => {
|
|
153
553
|
const f = fixture(t);
|
|
154
554
|
const result = run(f.env, ["list"]);
|
|
@@ -187,6 +587,1341 @@ test("--version prints three lines", (t) => {
|
|
|
187
587
|
assert.match(lines[2], /^home isolated /);
|
|
188
588
|
});
|
|
189
589
|
|
|
590
|
+
// --- setup subcommand ------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
test("gentle-shell setup provisions the resolved home through the pinned gentle-ai binary", (t) => {
|
|
593
|
+
const f = fixture(t);
|
|
594
|
+
assert.equal(existsSync(f.gentleShellHome), false);
|
|
595
|
+
|
|
596
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
597
|
+
writeGentleAiScript(gentleAiScript);
|
|
598
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
599
|
+
|
|
600
|
+
const result = run(env, ["setup"]);
|
|
601
|
+
assert.equal(result.status, 0, result.stderr);
|
|
602
|
+
assert.match(result.stderr, /provisioning/);
|
|
603
|
+
assert.match(result.stderr, new RegExp(f.gentleShellHome.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
604
|
+
|
|
605
|
+
const payload = JSON.parse(result.stdout);
|
|
606
|
+
assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]);
|
|
607
|
+
assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome);
|
|
608
|
+
assert.equal(payload.GENTLE_PI_AGENT_HOME, f.gentleShellHome);
|
|
609
|
+
assert.ok(payload.PATH.startsWith(`${dirname(f.piScript)}${delimiter}`), payload.PATH);
|
|
610
|
+
|
|
611
|
+
// Home resolution runs exactly as a normal run: the isolated home gets
|
|
612
|
+
// created with its bootstrap TUI setting, same as a plain `gentle-shell`.
|
|
613
|
+
const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8"));
|
|
614
|
+
assert.equal(settings.tuiMode, "fullscreen");
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("gentle-shell setup forwards --dry-run to gentle-ai", (t) => {
|
|
618
|
+
const f = fixture(t);
|
|
619
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
620
|
+
writeGentleAiScript(gentleAiScript);
|
|
621
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
622
|
+
|
|
623
|
+
const result = run(env, ["setup", "--dry-run"]);
|
|
624
|
+
assert.equal(result.status, 0, result.stderr);
|
|
625
|
+
const payload = JSON.parse(result.stdout);
|
|
626
|
+
assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global", "--dry-run"]);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("gentle-shell setup accepts a home selector before it and provisions that home", (t) => {
|
|
630
|
+
const f = fixture(t);
|
|
631
|
+
const target = join(f.root, "custom-home");
|
|
632
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
633
|
+
writeGentleAiScript(gentleAiScript);
|
|
634
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
635
|
+
|
|
636
|
+
const result = run(env, ["--home", target, "setup"]);
|
|
637
|
+
assert.equal(result.status, 0, result.stderr);
|
|
638
|
+
const payload = JSON.parse(result.stdout);
|
|
639
|
+
assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]);
|
|
640
|
+
assert.equal(payload.PI_CODING_AGENT_DIR, target);
|
|
641
|
+
assert.equal(payload.GENTLE_PI_AGENT_HOME, target);
|
|
642
|
+
assert.equal(existsSync(join(target, "settings.json")), true);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
test("gentle-shell setup passes through the gentle-ai exit code", (t) => {
|
|
646
|
+
const f = fixture(t);
|
|
647
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
648
|
+
writeGentleAiScript(gentleAiScript, 3);
|
|
649
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
650
|
+
|
|
651
|
+
const result = run(env, ["setup"]);
|
|
652
|
+
assert.equal(result.status, 3);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// --- setup subcommand's self-heal for a missing package-local binary -------
|
|
656
|
+
//
|
|
657
|
+
// `npm install -g <tarball>` on a machine whose npm config disables lifecycle
|
|
658
|
+
// scripts (`ignore-scripts=true`) never runs the package's own postinstall,
|
|
659
|
+
// so .gentle-ai/v<pin>/gentle-ai is missing even though the package itself
|
|
660
|
+
// installed fine. `gentle-shell setup` self-heals by running the installer
|
|
661
|
+
// in place before giving up. GENTLE_SHELL_GENTLE_AI_INSTALLER is a
|
|
662
|
+
// test/development-only override for the installer script path, next to
|
|
663
|
+
// GENTLE_SHELL_GENTLE_AI_BIN; documented as test/development-only in
|
|
664
|
+
// docs/readme-reference.md.
|
|
665
|
+
|
|
666
|
+
test("gentle-shell setup installs the missing package-local gentle-ai via the installer seam and then continues", (t) => {
|
|
667
|
+
const f = fixture(t);
|
|
668
|
+
const missingBinary = join(f.root, "does-not-exist", "gentle-ai");
|
|
669
|
+
const installerScript = join(f.root, "fake-installer-creates-binary.mjs");
|
|
670
|
+
writeInstallerScriptThatCreatesTheBinary(installerScript);
|
|
671
|
+
const env = {
|
|
672
|
+
...f.env,
|
|
673
|
+
GENTLE_SHELL_GENTLE_AI_BIN: missingBinary,
|
|
674
|
+
GENTLE_SHELL_GENTLE_AI_INSTALLER: installerScript,
|
|
675
|
+
GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0",
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
const result = run(env, ["setup"]);
|
|
679
|
+
assert.equal(result.status, 0, result.stderr);
|
|
680
|
+
assert.match(
|
|
681
|
+
result.stderr,
|
|
682
|
+
/gentle-shell: the package-local gentle-ai v3\.6\.0 is missing \(npm lifecycle scripts may be disabled\); installing it now/,
|
|
683
|
+
);
|
|
684
|
+
const payload = JSON.parse(result.stdout);
|
|
685
|
+
assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]);
|
|
686
|
+
assert.equal(payload.PI_CODING_AGENT_DIR, f.gentleShellHome);
|
|
687
|
+
assert.equal(payload.GENTLE_PI_AGENT_HOME, f.gentleShellHome);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
test("gentle-shell setup exits 1 with an actionable message when the pinned gentle-ai binary is still missing after the installer runs", (t) => {
|
|
691
|
+
const f = fixture(t);
|
|
692
|
+
const missingBinary = join(f.root, "does-not-exist", "gentle-ai");
|
|
693
|
+
const installerScript = join(f.root, "fake-installer-fails.mjs");
|
|
694
|
+
writeInstallerScriptThatFails(installerScript);
|
|
695
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: missingBinary, GENTLE_SHELL_GENTLE_AI_INSTALLER: installerScript };
|
|
696
|
+
|
|
697
|
+
const result = run(env, ["setup"]);
|
|
698
|
+
assert.equal(result.status, 1);
|
|
699
|
+
assert.match(result.stderr, /gentle-shell: the package-local gentle-ai v.+ is missing \(npm lifecycle scripts may be disabled\); installing it now/);
|
|
700
|
+
assert.match(result.stderr, /package-local-binary-missing/);
|
|
701
|
+
assert.match(result.stderr, new RegExp(missingBinary.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("gentle-shell setup skips the self-heal install and exits 1 when GENTLE_PI_SKIP_GENTLE_AI_INSTALL is set", (t) => {
|
|
705
|
+
const f = fixture(t);
|
|
706
|
+
const missingBinary = join(f.root, "does-not-exist", "gentle-ai");
|
|
707
|
+
// This installer would prove itself by creating the binary if it ever ran;
|
|
708
|
+
// it must not run at all when the skip variable is set.
|
|
709
|
+
const installerScript = join(f.root, "installer-should-not-run.mjs");
|
|
710
|
+
writeInstallerScriptThatCreatesTheBinary(installerScript);
|
|
711
|
+
const env = {
|
|
712
|
+
...f.env,
|
|
713
|
+
GENTLE_SHELL_GENTLE_AI_BIN: missingBinary,
|
|
714
|
+
GENTLE_SHELL_GENTLE_AI_INSTALLER: installerScript,
|
|
715
|
+
GENTLE_PI_SKIP_GENTLE_AI_INSTALL: "1",
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
const result = run(env, ["setup"]);
|
|
719
|
+
assert.equal(result.status, 1);
|
|
720
|
+
assert.match(result.stderr, /package-local-binary-missing/);
|
|
721
|
+
assert.match(result.stderr, /GENTLE_PI_SKIP_GENTLE_AI_INSTALL/);
|
|
722
|
+
assert.doesNotMatch(result.stderr, /installing it now/);
|
|
723
|
+
assert.equal(existsSync(missingBinary), false);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
// --- setup subcommand's gentle-ai pin gate (R3/R4 advisory findings) -------
|
|
727
|
+
//
|
|
728
|
+
// `gentle-shell setup` points PI_CODING_AGENT_DIR at the resolved home
|
|
729
|
+
// before spawning the package-local pinned gentle-ai; only gentle-ai >=
|
|
730
|
+
// 3.6.0 honors that variable in its own provisioning. An older pin would
|
|
731
|
+
// silently provision the caller's real ~/.pi/agent instead, so setup must
|
|
732
|
+
// refuse to spawn it. GENTLE_SHELL_GENTLE_AI_PIN is a test/development-only
|
|
733
|
+
// override for the reported pin, next to GENTLE_SHELL_GENTLE_AI_BIN;
|
|
734
|
+
// documented as test/development-only in docs/readme-reference.md.
|
|
735
|
+
|
|
736
|
+
test("gentle-shell setup refuses to run an older-than-3.6.0 pin and spawns nothing", (t) => {
|
|
737
|
+
const f = fixture(t);
|
|
738
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
739
|
+
writeGentleAiScript(gentleAiScript);
|
|
740
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.5.0" };
|
|
741
|
+
|
|
742
|
+
const result = run(env, ["setup"]);
|
|
743
|
+
assert.equal(result.status, 1);
|
|
744
|
+
assert.match(result.stderr, /gentle-shell: setup needs the package-local gentle-ai v3\.6\.0 or newer \(pinned: 3\.5\.0\); this build cannot provision a home without touching ~\/\.pi\/agent/);
|
|
745
|
+
// The isolated-home bootstrap runs before command dispatch on every run
|
|
746
|
+
// (same as a successful setup); the pin gate only refuses to spawn
|
|
747
|
+
// gentle-ai, so no gentle-ai invocation is recorded on stdout.
|
|
748
|
+
assert.equal(result.stdout, "");
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("gentle-shell setup proceeds when the reported pin is exactly 3.6.0", (t) => {
|
|
752
|
+
const f = fixture(t);
|
|
753
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
754
|
+
writeGentleAiScript(gentleAiScript);
|
|
755
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" };
|
|
756
|
+
|
|
757
|
+
const result = run(env, ["setup"]);
|
|
758
|
+
assert.equal(result.status, 0, result.stderr);
|
|
759
|
+
const payload = JSON.parse(result.stdout);
|
|
760
|
+
assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// --- setup subcommand conflict cleanup (gentle-ai #4820 / gentle-shell #1277) ---
|
|
764
|
+
//
|
|
765
|
+
// gentle-ai's managed Pi stack still installs npm:@juicesharp/rpiv-ask-user-question,
|
|
766
|
+
// which conflicts with gentle-pi's own first-party ask_user_question tool
|
|
767
|
+
// (Pi refuses two providers for the same tool name). Until the gentle-ai fix
|
|
768
|
+
// lands, `gentle-shell setup` removes the conflicting package itself once it
|
|
769
|
+
// finds gentle-ai declared it in the provisioned home's settings.json.
|
|
770
|
+
|
|
771
|
+
test("gentle-shell setup removes the conflicting ask-user-question package gentle-ai declared", (t) => {
|
|
772
|
+
const f = fixture(t);
|
|
773
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
774
|
+
writeGentleAiScriptDeclaringConflict(gentleAiScript);
|
|
775
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
776
|
+
|
|
777
|
+
const result = run(env, ["setup"]);
|
|
778
|
+
assert.equal(result.status, 0, result.stderr);
|
|
779
|
+
assert.match(
|
|
780
|
+
result.stderr,
|
|
781
|
+
/gentle-shell: removing npm:@juicesharp\/rpiv-ask-user-question from .+: gentle-pi ships ask_user_question and Pi refuses two providers \(gentle-ai #4820\)/,
|
|
782
|
+
);
|
|
783
|
+
|
|
784
|
+
const lines = result.stdout.trim().split("\n").filter((line) => line.length > 0);
|
|
785
|
+
assert.equal(lines.length, 2, result.stdout);
|
|
786
|
+
assert.deepEqual(JSON.parse(lines[0]).args, ["install", "--agent", "pi", "--scope", "global"]);
|
|
787
|
+
const removePayload = JSON.parse(lines[1]);
|
|
788
|
+
assert.deepEqual(removePayload.args, ["remove", "npm:@juicesharp/rpiv-ask-user-question"]);
|
|
789
|
+
assert.equal(removePayload.PI_CODING_AGENT_DIR, f.gentleShellHome);
|
|
790
|
+
assert.equal(removePayload.GENTLE_PI_AGENT_HOME, f.gentleShellHome);
|
|
791
|
+
assert.ok(removePayload.PATH.startsWith(`${dirname(f.piScript)}${delimiter}`), removePayload.PATH);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test("gentle-shell setup runs no pi remove when gentle-ai did not declare the conflicting package", (t) => {
|
|
795
|
+
const f = fixture(t);
|
|
796
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
797
|
+
writeGentleAiScript(gentleAiScript);
|
|
798
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
799
|
+
|
|
800
|
+
const result = run(env, ["setup"]);
|
|
801
|
+
assert.equal(result.status, 0, result.stderr);
|
|
802
|
+
assert.doesNotMatch(result.stderr, /removing npm:/);
|
|
803
|
+
const payload = JSON.parse(result.stdout);
|
|
804
|
+
assert.deepEqual(payload.args, ["install", "--agent", "pi", "--scope", "global"]);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
// --- setup subcommand keeps the home on the launcher's own gentle-pi -------
|
|
808
|
+
//
|
|
809
|
+
// gentle-ai's managed Pi stack always declares npm:gentle-pi itself into the
|
|
810
|
+
// provisioned home's settings.json. That declaration must never survive
|
|
811
|
+
// setup: this launcher always loads its own gentle-pi, so leaving it in
|
|
812
|
+
// place would let the home drift onto whatever gentle-pi npm last installed
|
|
813
|
+
// (or, worse, onto the published npm package instead of a developer's source
|
|
814
|
+
// checkout) instead of the running launcher's own copy.
|
|
815
|
+
|
|
816
|
+
test("gentle-shell setup removes npm:gentle-pi that gentle-ai declared, naming this launcher's own version", (t) => {
|
|
817
|
+
const f = fixture(t);
|
|
818
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
819
|
+
writeGentleAiScriptDeclaringGentlePi(gentleAiScript);
|
|
820
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
821
|
+
|
|
822
|
+
const result = run(env, ["setup"]);
|
|
823
|
+
assert.equal(result.status, 0, result.stderr);
|
|
824
|
+
assert.match(
|
|
825
|
+
result.stderr,
|
|
826
|
+
new RegExp(
|
|
827
|
+
`gentle-shell: removing npm:gentle-pi from .+: this launcher loads its own gentle-pi ${ownGentlePiVersion().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}, so the home always matches it`,
|
|
828
|
+
),
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
const lines = result.stdout.trim().split("\n").filter((line) => line.length > 0);
|
|
832
|
+
assert.equal(lines.length, 2, result.stdout);
|
|
833
|
+
assert.deepEqual(JSON.parse(lines[0]).args, ["install", "--agent", "pi", "--scope", "global"]);
|
|
834
|
+
const removePayload = JSON.parse(lines[1]);
|
|
835
|
+
assert.deepEqual(removePayload.args, ["remove", "npm:gentle-pi"]);
|
|
836
|
+
assert.equal(removePayload.PI_CODING_AGENT_DIR, f.gentleShellHome);
|
|
837
|
+
assert.equal(removePayload.GENTLE_PI_AGENT_HOME, f.gentleShellHome);
|
|
838
|
+
assert.ok(removePayload.PATH.startsWith(`${dirname(f.piScript)}${delimiter}`), removePayload.PATH);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
test("gentle-shell setup removes both the conflicting rpiv package and npm:gentle-pi, in declaration order", (t) => {
|
|
842
|
+
const f = fixture(t);
|
|
843
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
844
|
+
writeGentleAiScriptDeclaringGentlePi(gentleAiScript, ["npm:@juicesharp/rpiv-ask-user-question@1.2.3"]);
|
|
845
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
846
|
+
|
|
847
|
+
const result = run(env, ["setup"]);
|
|
848
|
+
assert.equal(result.status, 0, result.stderr);
|
|
849
|
+
|
|
850
|
+
const lines = result.stdout.trim().split("\n").filter((line) => line.length > 0);
|
|
851
|
+
assert.equal(lines.length, 3, result.stdout);
|
|
852
|
+
assert.deepEqual(JSON.parse(lines[0]).args, ["install", "--agent", "pi", "--scope", "global"]);
|
|
853
|
+
assert.deepEqual(JSON.parse(lines[1]).args, ["remove", "npm:@juicesharp/rpiv-ask-user-question"]);
|
|
854
|
+
assert.deepEqual(JSON.parse(lines[2]).args, ["remove", "npm:gentle-pi"]);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// A --dry-run gentle-ai install writes nothing, so reading settings.json
|
|
858
|
+
// afterwards would only ever report whatever pre-existed the run (e.g. the
|
|
859
|
+
// isolated-home bootstrap this fixture's fake-gentle-ai script never
|
|
860
|
+
// touches), never what the skipped install would have declared. Setup must
|
|
861
|
+
// therefore print the pending-removal message unconditionally, without
|
|
862
|
+
// reading settings.json — proven here with a fresh home that never declares
|
|
863
|
+
// the conflicting package at all.
|
|
864
|
+
test("gentle-shell setup --dry-run prints the pending removal unconditionally, without reading settings.json", (t) => {
|
|
865
|
+
const f = fixture(t);
|
|
866
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
867
|
+
writeGentleAiScript(gentleAiScript);
|
|
868
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
869
|
+
|
|
870
|
+
const result = run(env, ["setup", "--dry-run"]);
|
|
871
|
+
assert.equal(result.status, 0, result.stderr);
|
|
872
|
+
assert.match(
|
|
873
|
+
result.stderr,
|
|
874
|
+
/gentle-shell: setup would then remove npm:@juicesharp\/rpiv-ask-user-question if the install declares it \(gentle-ai #4820\)/,
|
|
875
|
+
);
|
|
876
|
+
assert.match(
|
|
877
|
+
result.stderr,
|
|
878
|
+
new RegExp(
|
|
879
|
+
`gentle-shell: setup would then remove npm:gentle-pi if the install declares it: this launcher loads its own gentle-pi ${ownGentlePiVersion().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}, so the home always matches it`,
|
|
880
|
+
),
|
|
881
|
+
);
|
|
882
|
+
assert.doesNotMatch(result.stderr, /gentle-shell: removing/);
|
|
883
|
+
|
|
884
|
+
const lines = result.stdout.trim().split("\n").filter((line) => line.length > 0);
|
|
885
|
+
assert.equal(lines.length, 1, result.stdout);
|
|
886
|
+
assert.deepEqual(JSON.parse(lines[0]).args, ["install", "--agent", "pi", "--scope", "global", "--dry-run"]);
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
test("a later normal launch injects the launcher's own package root once gentle-pi is removed from settings.json", (t) => {
|
|
890
|
+
const f = fixture(t);
|
|
891
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
892
|
+
writeGentleAiScriptDeclaringGentlePi(gentleAiScript);
|
|
893
|
+
writePiScriptEditingSettingsOnRemove(f.piScript);
|
|
894
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
895
|
+
|
|
896
|
+
const setupResult = run(env, ["setup"]);
|
|
897
|
+
assert.equal(setupResult.status, 0, setupResult.stderr);
|
|
898
|
+
const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8"));
|
|
899
|
+
assert.deepEqual(settings.packages, []);
|
|
900
|
+
|
|
901
|
+
const result = run(env, ["--mode", "rpc"]);
|
|
902
|
+
assert.equal(result.status, 0, result.stderr);
|
|
903
|
+
const payload = JSON.parse(result.stdout);
|
|
904
|
+
assert.deepEqual(payload.args, [
|
|
905
|
+
"-e",
|
|
906
|
+
packageRoot,
|
|
907
|
+
"--theme",
|
|
908
|
+
join(packageRoot, "themes"),
|
|
909
|
+
"--skill",
|
|
910
|
+
join(packageRoot, "skills"),
|
|
911
|
+
"--prompt-template",
|
|
912
|
+
join(packageRoot, "prompts"),
|
|
913
|
+
"--mode",
|
|
914
|
+
"rpc",
|
|
915
|
+
]);
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
test("gentle-shell setup propagates a non-zero pi remove exit code with an actionable message", (t) => {
|
|
919
|
+
const f = fixture(t);
|
|
920
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
921
|
+
writeGentleAiScriptDeclaringConflict(gentleAiScript);
|
|
922
|
+
const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
|
|
923
|
+
writePiScript(failingPiScript, "0.85.1", 7);
|
|
924
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
|
|
925
|
+
|
|
926
|
+
const result = run(env, ["setup"]);
|
|
927
|
+
assert.equal(result.status, 7);
|
|
928
|
+
assert.match(
|
|
929
|
+
result.stderr,
|
|
930
|
+
/gentle-shell: could not remove npm:@juicesharp\/rpiv-ask-user-question; run `gentle-shell remove npm:@juicesharp\/rpiv-ask-user-question` before starting/,
|
|
931
|
+
);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// The remediation message must include whatever home selector setup was run
|
|
935
|
+
// with, so a copy-pasted `gentle-shell remove <source>` doesn't silently
|
|
936
|
+
// fall back to the isolated default and miss the package in the home the
|
|
937
|
+
// user actually set up (gentle-shell #1277 follow-up).
|
|
938
|
+
test("gentle-shell setup --home <dir> includes --home <dir> in the failing-removal remediation command", (t) => {
|
|
939
|
+
const f = fixture(t);
|
|
940
|
+
const target = join(f.root, "custom-home");
|
|
941
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
942
|
+
writeGentleAiScriptDeclaringConflict(gentleAiScript);
|
|
943
|
+
const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
|
|
944
|
+
writePiScript(failingPiScript, "0.85.1", 7);
|
|
945
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
|
|
946
|
+
|
|
947
|
+
const result = run(env, ["--home", target, "setup"]);
|
|
948
|
+
assert.equal(result.status, 7);
|
|
949
|
+
assert.match(
|
|
950
|
+
result.stderr,
|
|
951
|
+
new RegExp(
|
|
952
|
+
`gentle-shell: could not remove npm:@juicesharp/rpiv-ask-user-question; run \`gentle-shell --home ${target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} remove npm:@juicesharp/rpiv-ask-user-question\` before starting`,
|
|
953
|
+
),
|
|
954
|
+
);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
// A --home path containing a space (or another shell metacharacter) must be
|
|
958
|
+
// single-quoted in the remediation command, or a copy-pasted
|
|
959
|
+
// `gentle-shell --home <dir> remove <source>` silently splits into extra
|
|
960
|
+
// shell words instead of naming the actual home setup provisioned
|
|
961
|
+
// (gentle-shell #1277 follow-up).
|
|
962
|
+
test("gentle-shell setup shell-quotes a --home path containing a space in the failing-removal remediation command", (t) => {
|
|
963
|
+
const f = fixture(t);
|
|
964
|
+
const target = join(f.root, "custom home");
|
|
965
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
966
|
+
writeGentleAiScriptDeclaringConflict(gentleAiScript);
|
|
967
|
+
const failingPiScript = join(f.root, "fake-pi-remove-fails.mjs");
|
|
968
|
+
writePiScript(failingPiScript, "0.85.1", 7);
|
|
969
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_PI: failingPiScript };
|
|
970
|
+
|
|
971
|
+
const result = run(env, ["--home", target, "setup"]);
|
|
972
|
+
assert.equal(result.status, 7);
|
|
973
|
+
assert.match(
|
|
974
|
+
result.stderr,
|
|
975
|
+
new RegExp(
|
|
976
|
+
`gentle-shell: could not remove npm:@juicesharp/rpiv-ask-user-question; run \`gentle-shell --home '${target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}' remove npm:@juicesharp/rpiv-ask-user-question\` before starting`,
|
|
977
|
+
),
|
|
978
|
+
);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
// --- persona snapshot/restore -----------------------------------------------
|
|
982
|
+
//
|
|
983
|
+
// gentle-ai's persona file always lives at the shared `~/.pi/gentle-ai/persona.json`
|
|
984
|
+
// regardless of `PI_CODING_AGENT_DIR` (see docs/readme-reference.md's setup
|
|
985
|
+
// "Known limitation"), so `setup` snapshots it before spawning gentle-ai and
|
|
986
|
+
// restores it afterward — in every mode gentle-ai gets spawned in: manual,
|
|
987
|
+
// `--dry-run`, and automatic first-run provisioning.
|
|
988
|
+
|
|
989
|
+
function personaPathFor(f: { home: string }) {
|
|
990
|
+
return join(f.home, ".pi", "gentle-ai", "persona.json");
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
test("gentle-shell setup restores the user's Pi persona file when gentle-ai rewrites it", (t) => {
|
|
994
|
+
const f = fixture(t);
|
|
995
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
996
|
+
writeGentleAiScriptRewritingPersona(gentleAiScript);
|
|
997
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
998
|
+
|
|
999
|
+
const personaPath = personaPathFor(f);
|
|
1000
|
+
mkdirSync(dirname(personaPath), { recursive: true });
|
|
1001
|
+
const originalBytes = JSON.stringify({ mode: "neutral" });
|
|
1002
|
+
writeFileSync(personaPath, originalBytes);
|
|
1003
|
+
chmodSync(personaPath, 0o600);
|
|
1004
|
+
|
|
1005
|
+
const result = run(env, ["setup"]);
|
|
1006
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1007
|
+
assert.equal(readFileSync(personaPath, "utf8"), originalBytes);
|
|
1008
|
+
assert.equal(statSync(personaPath).mode & 0o777, 0o600);
|
|
1009
|
+
assert.match(
|
|
1010
|
+
result.stderr,
|
|
1011
|
+
new RegExp(
|
|
1012
|
+
`gentle-shell: kept your Pi persona unchanged \\(gentle-ai rewrote ${personaPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}; tracked upstream\\)`,
|
|
1013
|
+
),
|
|
1014
|
+
);
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
test("gentle-shell setup removes a Pi persona file gentle-ai created where none existed", (t) => {
|
|
1018
|
+
const f = fixture(t);
|
|
1019
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1020
|
+
writeGentleAiScriptRewritingPersona(gentleAiScript);
|
|
1021
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
1022
|
+
|
|
1023
|
+
const personaPath = personaPathFor(f);
|
|
1024
|
+
assert.equal(existsSync(personaPath), false);
|
|
1025
|
+
|
|
1026
|
+
const result = run(env, ["setup"]);
|
|
1027
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1028
|
+
assert.equal(existsSync(personaPath), false);
|
|
1029
|
+
assert.match(result.stderr, /gentle-shell: kept your Pi persona unchanged/);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
test("gentle-shell setup prints no persona notice when gentle-ai leaves the persona file alone", (t) => {
|
|
1033
|
+
const f = fixture(t);
|
|
1034
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1035
|
+
writeGentleAiScript(gentleAiScript);
|
|
1036
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
1037
|
+
|
|
1038
|
+
const personaPath = personaPathFor(f);
|
|
1039
|
+
mkdirSync(dirname(personaPath), { recursive: true });
|
|
1040
|
+
writeFileSync(personaPath, JSON.stringify({ mode: "neutral" }));
|
|
1041
|
+
|
|
1042
|
+
const result = run(env, ["setup"]);
|
|
1043
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1044
|
+
assert.doesNotMatch(result.stderr, /kept your Pi persona unchanged/);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
test("gentle-shell setup restores the persona file even when gentle-ai exits non-zero after rewriting it", (t) => {
|
|
1048
|
+
const f = fixture(t);
|
|
1049
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1050
|
+
writeGentleAiScriptRewritingPersona(gentleAiScript, 3);
|
|
1051
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
1052
|
+
|
|
1053
|
+
const personaPath = personaPathFor(f);
|
|
1054
|
+
mkdirSync(dirname(personaPath), { recursive: true });
|
|
1055
|
+
const originalBytes = JSON.stringify({ mode: "neutral" });
|
|
1056
|
+
writeFileSync(personaPath, originalBytes);
|
|
1057
|
+
|
|
1058
|
+
const result = run(env, ["setup"]);
|
|
1059
|
+
assert.equal(result.status, 3);
|
|
1060
|
+
assert.equal(readFileSync(personaPath, "utf8"), originalBytes);
|
|
1061
|
+
assert.match(result.stderr, /gentle-shell: kept your Pi persona unchanged/);
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
test("gentle-shell setup --dry-run also restores the persona file gentle-ai rewrites", (t) => {
|
|
1065
|
+
const f = fixture(t);
|
|
1066
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1067
|
+
writeGentleAiScriptRewritingPersona(gentleAiScript);
|
|
1068
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
1069
|
+
|
|
1070
|
+
const personaPath = personaPathFor(f);
|
|
1071
|
+
mkdirSync(dirname(personaPath), { recursive: true });
|
|
1072
|
+
const originalBytes = JSON.stringify({ mode: "neutral" });
|
|
1073
|
+
writeFileSync(personaPath, originalBytes);
|
|
1074
|
+
|
|
1075
|
+
const result = run(env, ["setup", "--dry-run"]);
|
|
1076
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1077
|
+
assert.equal(readFileSync(personaPath, "utf8"), originalBytes);
|
|
1078
|
+
assert.match(result.stderr, /gentle-shell: kept your Pi persona unchanged/);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
test("automatic first-run provisioning also restores the persona file gentle-ai rewrites", (t) => {
|
|
1082
|
+
const f = fixture(t);
|
|
1083
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1084
|
+
writeGentleAiScriptRewritingPersona(gentleAiScript);
|
|
1085
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1086
|
+
|
|
1087
|
+
const personaPath = personaPathFor(f);
|
|
1088
|
+
mkdirSync(dirname(personaPath), { recursive: true });
|
|
1089
|
+
const originalBytes = JSON.stringify({ mode: "neutral" });
|
|
1090
|
+
writeFileSync(personaPath, originalBytes);
|
|
1091
|
+
|
|
1092
|
+
const result = run(env, []);
|
|
1093
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1094
|
+
assert.equal(readFileSync(personaPath, "utf8"), originalBytes);
|
|
1095
|
+
assert.match(result.stderr, /gentle-shell: kept your Pi persona unchanged/);
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
// --- managed-asset digest snapshot/restore ----------------------------------
|
|
1099
|
+
//
|
|
1100
|
+
// gentle-ai tracks whether its own binary's managed-asset bundle matches the
|
|
1101
|
+
// shared `~/.gentle-ai/state.json`'s managed_asset_digest field, regardless
|
|
1102
|
+
// of which home it was installing into. The pinned package-local gentle-ai
|
|
1103
|
+
// this setup flow spawns writes its own digest into that same shared file,
|
|
1104
|
+
// so afterward the user's own (unrelated, on-PATH) gentle-ai reports its
|
|
1105
|
+
// managed assets as outdated and demands `gentle-ai sync`, even though
|
|
1106
|
+
// nothing about the user's install changed. `setup` restores just that field
|
|
1107
|
+
// afterward — never the whole file, unlike persona.json, since state.json
|
|
1108
|
+
// also carries fields (like installed_agents) the pinned gentle-ai is
|
|
1109
|
+
// supposed to update.
|
|
1110
|
+
|
|
1111
|
+
function stateJsonPathFor(f: { home: string }) {
|
|
1112
|
+
return join(f.home, ".gentle-ai", "state.json");
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
test("gentle-shell setup restores managed_asset_digest while keeping other state.json fields as the child left them", (t) => {
|
|
1116
|
+
const f = fixture(t);
|
|
1117
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1118
|
+
writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "digest-from-pinned-gentle-ai" });
|
|
1119
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
1120
|
+
|
|
1121
|
+
const statePath = stateJsonPathFor(f);
|
|
1122
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
1123
|
+
writeFileSync(statePath, `${JSON.stringify({ managed_asset_digest: "users-own-digest", installed_agents: ["pi"] }, null, 2)}\n`);
|
|
1124
|
+
|
|
1125
|
+
const result = run(env, ["setup"]);
|
|
1126
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1127
|
+
const state = JSON.parse(readFileSync(statePath, "utf8"));
|
|
1128
|
+
assert.equal(state.managed_asset_digest, "users-own-digest");
|
|
1129
|
+
assert.deepEqual(state.installed_agents, ["pi", "claude"]);
|
|
1130
|
+
assert.match(
|
|
1131
|
+
result.stderr,
|
|
1132
|
+
new RegExp(
|
|
1133
|
+
`gentle-shell: kept your Gentle AI managed-asset record unchanged \\(the pinned gentle-ai rewrote ${statePath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}; tracked upstream\\)`,
|
|
1134
|
+
),
|
|
1135
|
+
);
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
test("gentle-shell setup prints no managed-asset notice when the digest is unchanged", (t) => {
|
|
1139
|
+
const f = fixture(t);
|
|
1140
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1141
|
+
writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "same-digest" });
|
|
1142
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
1143
|
+
|
|
1144
|
+
const statePath = stateJsonPathFor(f);
|
|
1145
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
1146
|
+
writeFileSync(statePath, `${JSON.stringify({ managed_asset_digest: "same-digest", installed_agents: ["pi"] }, null, 2)}\n`);
|
|
1147
|
+
|
|
1148
|
+
const result = run(env, ["setup"]);
|
|
1149
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1150
|
+
assert.doesNotMatch(result.stderr, /kept your Gentle AI managed-asset record unchanged/);
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
test("gentle-shell setup leaves a state.json the pinned gentle-ai created where none existed before", (t) => {
|
|
1154
|
+
const f = fixture(t);
|
|
1155
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1156
|
+
writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "digest-from-pinned-gentle-ai", create: true });
|
|
1157
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
1158
|
+
|
|
1159
|
+
const statePath = stateJsonPathFor(f);
|
|
1160
|
+
assert.equal(existsSync(statePath), false);
|
|
1161
|
+
|
|
1162
|
+
const result = run(env, ["setup"]);
|
|
1163
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1164
|
+
const state = JSON.parse(readFileSync(statePath, "utf8"));
|
|
1165
|
+
assert.equal(state.managed_asset_digest, "digest-from-pinned-gentle-ai");
|
|
1166
|
+
assert.doesNotMatch(result.stderr, /kept your Gentle AI managed-asset record unchanged/);
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
test("gentle-shell setup --dry-run also restores managed_asset_digest gentle-ai rewrites", (t) => {
|
|
1170
|
+
const f = fixture(t);
|
|
1171
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1172
|
+
writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "digest-from-pinned-gentle-ai" });
|
|
1173
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript };
|
|
1174
|
+
|
|
1175
|
+
const statePath = stateJsonPathFor(f);
|
|
1176
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
1177
|
+
writeFileSync(statePath, `${JSON.stringify({ managed_asset_digest: "users-own-digest", installed_agents: ["pi"] }, null, 2)}\n`);
|
|
1178
|
+
|
|
1179
|
+
const result = run(env, ["setup", "--dry-run"]);
|
|
1180
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1181
|
+
const state = JSON.parse(readFileSync(statePath, "utf8"));
|
|
1182
|
+
assert.equal(state.managed_asset_digest, "users-own-digest");
|
|
1183
|
+
assert.match(result.stderr, /kept your Gentle AI managed-asset record unchanged/);
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
test("automatic first-run provisioning also restores managed_asset_digest gentle-ai rewrites", (t) => {
|
|
1187
|
+
const f = fixture(t);
|
|
1188
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1189
|
+
writeGentleAiScriptRewritingManagedAssetDigest(gentleAiScript, { digest: "digest-from-pinned-gentle-ai" });
|
|
1190
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1191
|
+
|
|
1192
|
+
const statePath = stateJsonPathFor(f);
|
|
1193
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
1194
|
+
writeFileSync(statePath, `${JSON.stringify({ managed_asset_digest: "users-own-digest", installed_agents: ["pi"] }, null, 2)}\n`);
|
|
1195
|
+
|
|
1196
|
+
const result = run(env, []);
|
|
1197
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1198
|
+
const state = JSON.parse(readFileSync(statePath, "utf8"));
|
|
1199
|
+
assert.equal(state.managed_asset_digest, "users-own-digest");
|
|
1200
|
+
assert.match(result.stderr, /kept your Gentle AI managed-asset record unchanged/);
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
// --- automatic first-run provisioning (S7) ---------------------------------
|
|
1204
|
+
//
|
|
1205
|
+
// A plain `gentle-shell` in an isolated or `--home` home now runs the same
|
|
1206
|
+
// flow as `gentle-shell setup` automatically, before launching pi, when the
|
|
1207
|
+
// home has never been provisioned or was provisioned with a different
|
|
1208
|
+
// gentle-ai pin. GENTLE_SHELL_GENTLE_AI_PIN pins the reported pin to a fixed
|
|
1209
|
+
// value so these tests are independent of the real installed pin.
|
|
1210
|
+
|
|
1211
|
+
test("first launch auto-provisions the isolated home, writes the marker, then launches pi; stdout carries only pi's output", (t) => {
|
|
1212
|
+
const f = fixture(t);
|
|
1213
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1214
|
+
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1215
|
+
writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
|
|
1216
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1217
|
+
|
|
1218
|
+
const result = run(env, ["--mode", "rpc", "-p", "hi"]);
|
|
1219
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1220
|
+
|
|
1221
|
+
// The flow ran exactly once, before pi.
|
|
1222
|
+
const runs = readFileSync(counterPath, "utf8").trim().split("\n");
|
|
1223
|
+
assert.equal(runs.length, 1, counterPath);
|
|
1224
|
+
assert.deepEqual(JSON.parse(runs[0]), ["install", "--agent", "pi", "--scope", "global"]);
|
|
1225
|
+
|
|
1226
|
+
// stdout carries only the final pi invocation's own output (the flow's
|
|
1227
|
+
// stdout and stderr, and the launcher's own notices, all went to stderr).
|
|
1228
|
+
const stdoutLines = result.stdout.trim().split("\n").filter((line) => line.length > 0);
|
|
1229
|
+
assert.equal(stdoutLines.length, 1, result.stdout);
|
|
1230
|
+
const payload = JSON.parse(stdoutLines[0]);
|
|
1231
|
+
assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]);
|
|
1232
|
+
|
|
1233
|
+
assert.match(result.stderr, /gentle-shell: first run in .+: installing the Gentle AI companion packages \(one time; set GENTLE_SHELL_NO_AUTO_SETUP=1 to skip\)/);
|
|
1234
|
+
assert.match(result.stderr, new RegExp(JSON.stringify({ args: ["install", "--agent", "pi", "--scope", "global"], PI_CODING_AGENT_DIR: f.gentleShellHome }).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
1235
|
+
|
|
1236
|
+
const config = JSON.parse(readFileSync(join(f.home, ".gentle-shell", "config.json"), "utf8"));
|
|
1237
|
+
const homeKey = realpathSync(f.gentleShellHome);
|
|
1238
|
+
assert.equal(config.provisioned[homeKey].gentleAi, "3.6.0");
|
|
1239
|
+
assert.equal(config.provisioned[homeKey].gentlePi, ownGentlePiVersion());
|
|
1240
|
+
assert.equal(typeof config.provisioned[homeKey].at, "string");
|
|
1241
|
+
assert.equal(existsSync(join(f.gentleShellHome, ".gentle-shell-setup.lock")), false);
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
test("a second launch against an already-provisioned home skips the flow entirely", (t) => {
|
|
1245
|
+
const f = fixture(t);
|
|
1246
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1247
|
+
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1248
|
+
writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
|
|
1249
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1250
|
+
|
|
1251
|
+
const first = run(env, []);
|
|
1252
|
+
assert.equal(first.status, 0, first.stderr);
|
|
1253
|
+
assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1);
|
|
1254
|
+
|
|
1255
|
+
const second = run(env, []);
|
|
1256
|
+
assert.equal(second.status, 0, second.stderr);
|
|
1257
|
+
assert.doesNotMatch(second.stderr, /gentle-shell: first run in/);
|
|
1258
|
+
assert.doesNotMatch(second.stderr, /gentle-ai pin changed/);
|
|
1259
|
+
assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1, "gentle-ai must not run a second time");
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
test("a changed gentle-ai pin re-runs the flow and updates the marker", (t) => {
|
|
1263
|
+
const f = fixture(t);
|
|
1264
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1265
|
+
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1266
|
+
writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
|
|
1267
|
+
const baseEnv = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript });
|
|
1268
|
+
|
|
1269
|
+
const first = run({ ...baseEnv, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" }, []);
|
|
1270
|
+
assert.equal(first.status, 0, first.stderr);
|
|
1271
|
+
|
|
1272
|
+
const second = run({ ...baseEnv, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.1" }, []);
|
|
1273
|
+
assert.equal(second.status, 0, second.stderr);
|
|
1274
|
+
assert.match(second.stderr, /gentle-shell: gentle-ai pin changed \(3\.6\.0 -> 3\.6\.1\): updating/);
|
|
1275
|
+
|
|
1276
|
+
assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 2);
|
|
1277
|
+
const config = JSON.parse(readFileSync(join(f.home, ".gentle-shell", "config.json"), "utf8"));
|
|
1278
|
+
const homeKey = realpathSync(f.gentleShellHome);
|
|
1279
|
+
assert.equal(config.provisioned[homeKey].gentleAi, "3.6.1");
|
|
1280
|
+
assert.equal(config.provisioned[homeKey].gentlePi, ownGentlePiVersion());
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
// A marker written before gentle-pi version tracking existed (S8) has no
|
|
1284
|
+
// `gentlePi` field: needsProvisioning must never trust that omission as a
|
|
1285
|
+
// match, so the next launch re-provisions and backfills the field.
|
|
1286
|
+
test("a marker without a gentlePi field re-runs the flow and backfills it", (t) => {
|
|
1287
|
+
const f = fixture(t);
|
|
1288
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1289
|
+
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1290
|
+
writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
|
|
1291
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1292
|
+
|
|
1293
|
+
const first = run(env, []);
|
|
1294
|
+
assert.equal(first.status, 0, first.stderr);
|
|
1295
|
+
assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1);
|
|
1296
|
+
|
|
1297
|
+
const configPath = join(f.home, ".gentle-shell", "config.json");
|
|
1298
|
+
const homeKey = realpathSync(f.gentleShellHome);
|
|
1299
|
+
const staleConfig = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1300
|
+
delete staleConfig.provisioned[homeKey].gentlePi;
|
|
1301
|
+
writeFileSync(configPath, JSON.stringify(staleConfig));
|
|
1302
|
+
|
|
1303
|
+
const second = run(env, []);
|
|
1304
|
+
assert.equal(second.status, 0, second.stderr);
|
|
1305
|
+
assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 2, "gentle-ai must re-run once the marker predates gentlePi tracking");
|
|
1306
|
+
|
|
1307
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1308
|
+
assert.equal(config.provisioned[homeKey].gentleAi, "3.6.0");
|
|
1309
|
+
assert.equal(config.provisioned[homeKey].gentlePi, ownGentlePiVersion());
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
test("a changed gentle-pi version re-runs the flow and prints the gentle-pi-changed line", (t) => {
|
|
1313
|
+
const f = fixture(t);
|
|
1314
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1315
|
+
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1316
|
+
writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
|
|
1317
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1318
|
+
|
|
1319
|
+
const first = run(env, []);
|
|
1320
|
+
assert.equal(first.status, 0, first.stderr);
|
|
1321
|
+
|
|
1322
|
+
const configPath = join(f.home, ".gentle-shell", "config.json");
|
|
1323
|
+
const homeKey = realpathSync(f.gentleShellHome);
|
|
1324
|
+
const staleConfig = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1325
|
+
staleConfig.provisioned[homeKey].gentlePi = "0.0.0-previous-launcher";
|
|
1326
|
+
writeFileSync(configPath, JSON.stringify(staleConfig));
|
|
1327
|
+
|
|
1328
|
+
const second = run(env, []);
|
|
1329
|
+
assert.equal(second.status, 0, second.stderr);
|
|
1330
|
+
assert.match(second.stderr, new RegExp(`gentle-shell: gentle-pi changed \\(0\\.0\\.0-previous-launcher -> ${ownGentlePiVersion().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\): updating`));
|
|
1331
|
+
assert.doesNotMatch(second.stderr, /gentle-ai pin changed/);
|
|
1332
|
+
|
|
1333
|
+
assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 2);
|
|
1334
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1335
|
+
assert.equal(config.provisioned[homeKey].gentlePi, ownGentlePiVersion());
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
test("a failing auto-provision flow warns, still launches pi, and writes no marker", (t) => {
|
|
1339
|
+
const f = fixture(t);
|
|
1340
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1341
|
+
writeGentleAiScript(gentleAiScript, 5);
|
|
1342
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1343
|
+
|
|
1344
|
+
const result = run(env, ["--mode", "rpc"]);
|
|
1345
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1346
|
+
assert.match(
|
|
1347
|
+
result.stderr,
|
|
1348
|
+
/gentle-shell: automatic setup failed \(exit 5\); starting anyway and retrying next run\. Run `gentle-shell setup` to see the full output\./,
|
|
1349
|
+
);
|
|
1350
|
+
// pi still launches, with today's plain injection (the home never got a
|
|
1351
|
+
// gentle-pi declaration since the flow failed).
|
|
1352
|
+
const payload = JSON.parse(result.stdout);
|
|
1353
|
+
assert.deepEqual(payload.args.slice(-2), ["--mode", "rpc"]);
|
|
1354
|
+
|
|
1355
|
+
const configPath = join(f.home, ".gentle-shell", "config.json");
|
|
1356
|
+
if (existsSync(configPath)) {
|
|
1357
|
+
const config = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1358
|
+
assert.equal(config.provisioned, undefined);
|
|
1359
|
+
}
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1362
|
+
test("a failing auto-provision flow names the --home selector in its retry remediation", (t) => {
|
|
1363
|
+
const f = fixture(t);
|
|
1364
|
+
const target = join(f.root, "custom-home");
|
|
1365
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1366
|
+
writeGentleAiScript(gentleAiScript, 5);
|
|
1367
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1368
|
+
|
|
1369
|
+
const result = run(env, ["--home", target, "--mode", "rpc"]);
|
|
1370
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1371
|
+
assert.match(
|
|
1372
|
+
result.stderr,
|
|
1373
|
+
new RegExp(`Run \`gentle-shell --home ${target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} setup\` to see the full output\\.`),
|
|
1374
|
+
);
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
test("GENTLE_SHELL_NO_AUTO_SETUP=1 skips auto-provisioning entirely", (t) => {
|
|
1378
|
+
const f = fixture(t);
|
|
1379
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1380
|
+
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1381
|
+
writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
|
|
1382
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_NO_AUTO_SETUP: "1" };
|
|
1383
|
+
|
|
1384
|
+
const result = run(env, []);
|
|
1385
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1386
|
+
assert.equal(existsSync(counterPath), false);
|
|
1387
|
+
const configPath = join(f.home, ".gentle-shell", "config.json");
|
|
1388
|
+
assert.equal(existsSync(configPath), false);
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
test("--link never auto-provisions", (t) => {
|
|
1392
|
+
const f = fixture(t);
|
|
1393
|
+
const piAgentDir = join(f.root, "pi-agent");
|
|
1394
|
+
mkdirSync(piAgentDir, { recursive: true });
|
|
1395
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1396
|
+
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1397
|
+
writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
|
|
1398
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, PI_CODING_AGENT_DIR: piAgentDir });
|
|
1399
|
+
|
|
1400
|
+
const result = run(env, ["--link", "--mode", "rpc"]);
|
|
1401
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1402
|
+
assert.equal(existsSync(counterPath), false);
|
|
1403
|
+
assert.equal(existsSync(join(piAgentDir, ".gentle-shell-setup.lock")), false);
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
test("a pi subcommand (list) never auto-provisions", (t) => {
|
|
1407
|
+
const f = fixture(t);
|
|
1408
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1409
|
+
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1410
|
+
writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
|
|
1411
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript });
|
|
1412
|
+
|
|
1413
|
+
const result = run(env, ["list"]);
|
|
1414
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1415
|
+
assert.equal(existsSync(counterPath), false);
|
|
1416
|
+
const payload = JSON.parse(result.stdout);
|
|
1417
|
+
assert.deepEqual(payload.args, ["list"]);
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
test("a fresh concurrent lock skips auto-provisioning for this run, without removing the lock", (t) => {
|
|
1421
|
+
const f = fixture(t);
|
|
1422
|
+
mkdirSync(f.gentleShellHome, { recursive: true });
|
|
1423
|
+
const lockPath = join(f.gentleShellHome, ".gentle-shell-setup.lock");
|
|
1424
|
+
writeFileSync(lockPath, "");
|
|
1425
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1426
|
+
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1427
|
+
writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
|
|
1428
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript });
|
|
1429
|
+
|
|
1430
|
+
const result = run(env, ["--mode", "rpc"]);
|
|
1431
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1432
|
+
assert.equal(existsSync(counterPath), false);
|
|
1433
|
+
assert.match(result.stderr, /already provisioning/);
|
|
1434
|
+
assert.equal(existsSync(lockPath), true);
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
test("a stale lock (older than 15 minutes) is removed and auto-provisioning proceeds", (t) => {
|
|
1438
|
+
const f = fixture(t);
|
|
1439
|
+
mkdirSync(f.gentleShellHome, { recursive: true });
|
|
1440
|
+
const lockPath = join(f.gentleShellHome, ".gentle-shell-setup.lock");
|
|
1441
|
+
writeFileSync(lockPath, "");
|
|
1442
|
+
const staleTime = new Date(Date.now() - 16 * 60 * 1000);
|
|
1443
|
+
utimesSync(lockPath, staleTime, staleTime);
|
|
1444
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1445
|
+
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1446
|
+
writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
|
|
1447
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1448
|
+
|
|
1449
|
+
const result = run(env, ["--mode", "rpc"]);
|
|
1450
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1451
|
+
assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1);
|
|
1452
|
+
assert.equal(existsSync(lockPath), false, "the lock must be released once the flow completes");
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
// --- auto-provisioning only touches homes gentle-shell owns (R1-001) -------
|
|
1456
|
+
|
|
1457
|
+
test("a --home pointing at an existing non-empty, unmarked directory is never auto-provisioned", (t) => {
|
|
1458
|
+
const f = fixture(t);
|
|
1459
|
+
const target = join(f.root, "existing-pi-home");
|
|
1460
|
+
mkdirSync(target, { recursive: true });
|
|
1461
|
+
writeFileSync(join(target, "settings.json"), JSON.stringify({}));
|
|
1462
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1463
|
+
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1464
|
+
writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
|
|
1465
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1466
|
+
|
|
1467
|
+
const result = run(env, ["--home", target, "--mode", "rpc"]);
|
|
1468
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1469
|
+
assert.equal(existsSync(counterPath), false, "gentle-ai must never run against a foreign, unmarked home");
|
|
1470
|
+
assert.match(
|
|
1471
|
+
result.stderr,
|
|
1472
|
+
new RegExp(`gentle-shell: ${target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} already has content and was not set up by gentle-shell`),
|
|
1473
|
+
);
|
|
1474
|
+
assert.match(result.stderr, new RegExp(`run \`gentle-shell --home ${target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} setup\` to provision it`));
|
|
1475
|
+
});
|
|
1476
|
+
|
|
1477
|
+
test("an empty or nonexistent --home is still auto-provisioned", (t) => {
|
|
1478
|
+
const f = fixture(t);
|
|
1479
|
+
const target = join(f.root, "brand-new-home");
|
|
1480
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1481
|
+
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1482
|
+
writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
|
|
1483
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1484
|
+
|
|
1485
|
+
const result = run(env, ["--home", target, "--mode", "rpc"]);
|
|
1486
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1487
|
+
assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1, "a fresh --home must still be auto-provisioned");
|
|
1488
|
+
});
|
|
1489
|
+
|
|
1490
|
+
test("a --home equal to pi's own default agent home is never auto-provisioned, even when empty", (t) => {
|
|
1491
|
+
const f = fixture(t);
|
|
1492
|
+
const defaultPiHome = join(f.home, ".pi", "agent");
|
|
1493
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1494
|
+
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1495
|
+
writeGentleAiScriptCountingRuns(gentleAiScript, counterPath);
|
|
1496
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1497
|
+
|
|
1498
|
+
const result = run(env, ["--home", defaultPiHome, "--mode", "rpc"]);
|
|
1499
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1500
|
+
assert.equal(existsSync(counterPath), false, "gentle-ai must never run against pi's own default agent home");
|
|
1501
|
+
assert.match(result.stderr, /never auto-provisions it/);
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
// --- --home ownership marker and retry after a failed first attempt (R3-001) -----
|
|
1505
|
+
|
|
1506
|
+
test("a freshly bootstrapped isolated or --home directory gets a gentle-shell ownership marker file", (t) => {
|
|
1507
|
+
const f = fixture(t);
|
|
1508
|
+
const target = join(f.root, "marked-home");
|
|
1509
|
+
const result = run(f.env, ["--home", target, "--mode", "rpc"]);
|
|
1510
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1511
|
+
const marker = JSON.parse(readFileSync(join(target, ".gentle-shell-home"), "utf8"));
|
|
1512
|
+
assert.equal(marker.createdBy, "gentle-shell");
|
|
1513
|
+
assert.equal(marker.version, ownGentlePiVersion());
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
test("a --home directory gentle-shell itself bootstrapped is retried after its first auto-provision attempt fails", (t) => {
|
|
1517
|
+
const f = fixture(t);
|
|
1518
|
+
const target = join(f.root, "retry-home");
|
|
1519
|
+
const failingGentleAiScript = join(f.root, "fake-gentle-ai-fails.mjs");
|
|
1520
|
+
writeGentleAiScript(failingGentleAiScript, 1);
|
|
1521
|
+
const firstEnv = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: failingGentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1522
|
+
|
|
1523
|
+
const first = run(firstEnv, ["--home", target, "--mode", "rpc"]);
|
|
1524
|
+
assert.equal(first.status, 0, first.stderr);
|
|
1525
|
+
assert.match(first.stderr, /gentle-shell: automatic setup failed/);
|
|
1526
|
+
assert.ok(existsSync(join(target, "settings.json")), "bootstrap must have seeded settings.json before the failed attempt");
|
|
1527
|
+
assert.ok(existsSync(join(target, ".gentle-shell-home")), "bootstrap must mark the home as gentle-shell-owned");
|
|
1528
|
+
|
|
1529
|
+
const counterPath = join(f.root, "gentle-ai-runs.log");
|
|
1530
|
+
const succeedingGentleAiScript = join(f.root, "fake-gentle-ai-succeeds.mjs");
|
|
1531
|
+
writeGentleAiScriptCountingRuns(succeedingGentleAiScript, counterPath);
|
|
1532
|
+
const secondEnv = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: succeedingGentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1533
|
+
|
|
1534
|
+
const second = run(secondEnv, ["--home", target, "--mode", "rpc"]);
|
|
1535
|
+
assert.equal(second.status, 0, second.stderr);
|
|
1536
|
+
assert.doesNotMatch(second.stderr, /already has content and was not set up by gentle-shell/);
|
|
1537
|
+
assert.equal(readFileSync(counterPath, "utf8").trim().split("\n").length, 1, "the retry must actually run gentle-ai against the previously-failed home");
|
|
1538
|
+
});
|
|
1539
|
+
|
|
1540
|
+
// --- auto-provisioning interrupts, resilience, timeouts, atomic writes -----
|
|
1541
|
+
|
|
1542
|
+
// Waits until `child`'s stderr has emitted a line matching `pattern`, so a
|
|
1543
|
+
// test can send a signal only once the flow has actually started (never
|
|
1544
|
+
// racing the signal against the child process not existing yet).
|
|
1545
|
+
function waitForStderrMatch(child, pattern) {
|
|
1546
|
+
return new Promise((resolve, reject) => {
|
|
1547
|
+
let buffer = "";
|
|
1548
|
+
const onData = (chunk) => {
|
|
1549
|
+
buffer += chunk.toString("utf8");
|
|
1550
|
+
if (pattern.test(buffer)) {
|
|
1551
|
+
child.stderr.off("data", onData);
|
|
1552
|
+
resolve(buffer);
|
|
1553
|
+
}
|
|
1554
|
+
};
|
|
1555
|
+
child.stderr.on("data", onData);
|
|
1556
|
+
child.once("error", reject);
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function waitForExit(child: ReturnType<typeof spawn>): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
|
|
1561
|
+
return new Promise((resolve) => {
|
|
1562
|
+
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function delay(ms) {
|
|
1567
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
// Unique token the stub gentle-ai below writes to its own stderr (relayed
|
|
1571
|
+
// through this launcher's inherited stdio, see buildSetupEnv's stdio wiring)
|
|
1572
|
+
// the instant it starts, before it sleeps. Waiting for this — instead of the
|
|
1573
|
+
// launcher's own "provisioning" stderr line, which is written before the
|
|
1574
|
+
// child is even spawned and proves nothing about the child or spawnAndWait's
|
|
1575
|
+
// signal-forwarding listeners being ready — is deterministic: it can only
|
|
1576
|
+
// appear once the child process actually exists and spawnAndWait has
|
|
1577
|
+
// resolved its Promise executor synchronously (spawn + signal handler
|
|
1578
|
+
// registration, no `await` in between), so no fixed settle delay is needed
|
|
1579
|
+
// before sending the real signal.
|
|
1580
|
+
const CHILD_READY_TOKEN = "GENTLE_SHELL_TEST_CHILD_READY";
|
|
1581
|
+
|
|
1582
|
+
test("SIGINT during automatic provisioning kills the child and exits 130 without launching pi", { timeout: 10000 }, async (t) => {
|
|
1583
|
+
const f = fixture(t);
|
|
1584
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai-sleep.mjs");
|
|
1585
|
+
writeFileSync(
|
|
1586
|
+
gentleAiScript,
|
|
1587
|
+
["#!/usr/bin/env node", `process.stderr.write(${JSON.stringify(CHILD_READY_TOKEN)} + "\\n");`, "await new Promise((resolve) => setTimeout(resolve, 20000));", "process.exit(0);", ""].join(
|
|
1588
|
+
"\n",
|
|
1589
|
+
),
|
|
1590
|
+
);
|
|
1591
|
+
chmodSync(gentleAiScript, 0o755);
|
|
1592
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1593
|
+
|
|
1594
|
+
const child = spawn(process.execPath, [binPath, "--mode", "rpc", "-p", "hi"], { env, stdio: ["ignore", "pipe", "pipe"] });
|
|
1595
|
+
let stdout = "";
|
|
1596
|
+
child.stdout.on("data", (chunk) => {
|
|
1597
|
+
stdout += chunk.toString("utf8");
|
|
1598
|
+
});
|
|
1599
|
+
t.after(() => {
|
|
1600
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
await waitForStderrMatch(child, new RegExp(CHILD_READY_TOKEN));
|
|
1604
|
+
child.kill("SIGINT");
|
|
1605
|
+
const { code, signal } = await waitForExit(child);
|
|
1606
|
+
|
|
1607
|
+
assert.equal(signal, null, "the launcher process itself must exit normally, not be killed by the signal");
|
|
1608
|
+
assert.equal(code, 130);
|
|
1609
|
+
assert.equal(stdout.trim(), "", "pi must never launch once an auto-provision spawn was interrupted");
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
test("a child that dies by a signal on its own during automatic provisioning is an ordinary failure, not a launcher interrupt: pi still launches", (t) => {
|
|
1613
|
+
const f = fixture(t);
|
|
1614
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai-self-kill.mjs");
|
|
1615
|
+
writeFileSync(gentleAiScript, ["#!/usr/bin/env node", "process.kill(process.pid, 'SIGKILL');", ""].join("\n"));
|
|
1616
|
+
chmodSync(gentleAiScript, 0o755);
|
|
1617
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1618
|
+
|
|
1619
|
+
const result = run(env, ["--mode", "rpc", "-p", "hi"]);
|
|
1620
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1621
|
+
assert.match(result.stderr, /gentle-shell: automatic setup failed/);
|
|
1622
|
+
const payload = JSON.parse(result.stdout);
|
|
1623
|
+
assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]);
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
test(
|
|
1627
|
+
"an unexpected error writing the provisioning marker (e.g. an unwritable config directory) still launches pi",
|
|
1628
|
+
{ skip: process.platform === "win32" ? "chmod-based write restriction is not portable to Windows" : false },
|
|
1629
|
+
(t) => {
|
|
1630
|
+
const f = fixture(t);
|
|
1631
|
+
// The config.json path itself does not exist yet (loadConfig's and
|
|
1632
|
+
// maybeAutoProvisionHome's own initial read both tolerate ENOENT, so
|
|
1633
|
+
// they succeed normally and reach the install); only its parent
|
|
1634
|
+
// directory is read+execute but not write, which lets writeRawConfig's
|
|
1635
|
+
// later mkdirSync — run only after a successful install, when it
|
|
1636
|
+
// writes the S7 marker — fail with EACCES instead of silently
|
|
1637
|
+
// succeeding. This isolates the failure to the write step inside
|
|
1638
|
+
// maybeAutoProvisionHome, the one this fix wraps in try/catch.
|
|
1639
|
+
const restrictedRoot = join(f.root, "locked-config-root");
|
|
1640
|
+
mkdirSync(restrictedRoot, { recursive: true });
|
|
1641
|
+
const configPath = join(restrictedRoot, ".gentle-shell", "config.json");
|
|
1642
|
+
chmodSync(restrictedRoot, 0o500);
|
|
1643
|
+
// No permission restore needed: restrictedRoot stays empty (its own
|
|
1644
|
+
// .gentle-shell subdirectory never gets created), and removing an
|
|
1645
|
+
// empty directory only requires write permission on its *parent*
|
|
1646
|
+
// (f.root, unaffected), not on the directory's own mode.
|
|
1647
|
+
|
|
1648
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1649
|
+
writeGentleAiScript(gentleAiScript);
|
|
1650
|
+
const env = enableAutoProvision({
|
|
1651
|
+
...f.env,
|
|
1652
|
+
GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript,
|
|
1653
|
+
GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0",
|
|
1654
|
+
GENTLE_SHELL_CONFIG: configPath,
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
const result = run(env, ["--mode", "rpc", "-p", "hi"]);
|
|
1658
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1659
|
+
assert.match(result.stderr, /gentle-shell: automatic setup failed unexpectedly/);
|
|
1660
|
+
const payload = JSON.parse(result.stdout);
|
|
1661
|
+
assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]);
|
|
1662
|
+
},
|
|
1663
|
+
);
|
|
1664
|
+
|
|
1665
|
+
test("a persona snapshot read failure is a non-fatal warning in both manual and automatic modes", (t) => {
|
|
1666
|
+
const f = fixture(t);
|
|
1667
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1668
|
+
writeGentleAiScript(gentleAiScript);
|
|
1669
|
+
// A directory where persona.json should be: readFileSync throws EISDIR
|
|
1670
|
+
// (not ENOENT), the exact non-fatal case snapshotFile must not propagate.
|
|
1671
|
+
const personaPath = personaPathFor(f);
|
|
1672
|
+
mkdirSync(personaPath, { recursive: true });
|
|
1673
|
+
|
|
1674
|
+
const manual = run({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript }, ["setup"]);
|
|
1675
|
+
assert.equal(manual.status, 0, manual.stderr);
|
|
1676
|
+
assert.match(manual.stderr, /gentle-shell: could not snapshot your Pi persona file/);
|
|
1677
|
+
|
|
1678
|
+
const auto = run(
|
|
1679
|
+
enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.1" }),
|
|
1680
|
+
["--mode", "rpc", "-p", "hi"],
|
|
1681
|
+
);
|
|
1682
|
+
assert.equal(auto.status, 0, auto.stderr);
|
|
1683
|
+
assert.match(auto.stderr, /gentle-shell: could not snapshot your Pi persona file/);
|
|
1684
|
+
const payload = JSON.parse(auto.stdout);
|
|
1685
|
+
assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]);
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
test("automatic setup failure includes the underlying message on the line after the generic failure line", (t) => {
|
|
1689
|
+
const f = fixture(t);
|
|
1690
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1691
|
+
writeGentleAiScript(gentleAiScript);
|
|
1692
|
+
// A pin below MIN_SETUP_GENTLE_AI_VERSION makes runSetupFlow fail early
|
|
1693
|
+
// with a concrete `message`, before ever spawning the stub.
|
|
1694
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.5.0" });
|
|
1695
|
+
|
|
1696
|
+
const result = run(env, ["--mode", "rpc"]);
|
|
1697
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1698
|
+
const lines = result.stderr.trim().split("\n");
|
|
1699
|
+
const failureIndex = lines.findIndex((line) => /gentle-shell: automatic setup failed/.test(line));
|
|
1700
|
+
assert.notEqual(failureIndex, -1, result.stderr);
|
|
1701
|
+
assert.match(lines[failureIndex + 1], /gentle-shell: setup needs the package-local gentle-ai v3\.6\.0 or newer \(pinned: 3\.5\.0\)/);
|
|
1702
|
+
});
|
|
1703
|
+
|
|
1704
|
+
test("a hung child during automatic provisioning is killed after the timeout ceiling, treated as a failure, and pi still launches", (t) => {
|
|
1705
|
+
const f = fixture(t);
|
|
1706
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai-sleep.mjs");
|
|
1707
|
+
writeFileSync(gentleAiScript, ["#!/usr/bin/env node", "await new Promise((resolve) => setTimeout(resolve, 60000));", "process.exit(0);", ""].join("\n"));
|
|
1708
|
+
chmodSync(gentleAiScript, 0o755);
|
|
1709
|
+
const env = enableAutoProvision({
|
|
1710
|
+
...f.env,
|
|
1711
|
+
GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript,
|
|
1712
|
+
GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0",
|
|
1713
|
+
GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS: "300",
|
|
1714
|
+
});
|
|
1715
|
+
|
|
1716
|
+
const started = Date.now();
|
|
1717
|
+
const result = run(env, ["--mode", "rpc", "-p", "hi"]);
|
|
1718
|
+
const elapsed = Date.now() - started;
|
|
1719
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1720
|
+
assert.ok(elapsed < 15000, `expected the hung child to be killed quickly, took ${elapsed}ms`);
|
|
1721
|
+
assert.match(result.stderr, /gentle-shell: automatic setup failed/);
|
|
1722
|
+
// The message reports the *effective* ceiling (GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS=300
|
|
1723
|
+
// above), not the hardcoded production default: 300ms is not a whole number
|
|
1724
|
+
// of minutes, so it renders in seconds.
|
|
1725
|
+
assert.match(result.stderr, /timed out after 0\.3 seconds/);
|
|
1726
|
+
const payload = JSON.parse(result.stdout);
|
|
1727
|
+
assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]);
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
test("a hung 'pi remove' during post-install cleanup is killed after the timeout ceiling and reports it in seconds too", (t) => {
|
|
1731
|
+
const f = fixture(t);
|
|
1732
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai-declares-conflict.mjs");
|
|
1733
|
+
writeGentleAiScriptDeclaringConflict(gentleAiScript);
|
|
1734
|
+
const hangingPiScript = join(f.root, "fake-pi-hangs-on-remove.mjs");
|
|
1735
|
+
writeFileSync(
|
|
1736
|
+
hangingPiScript,
|
|
1737
|
+
[
|
|
1738
|
+
"#!/usr/bin/env node",
|
|
1739
|
+
"const args = process.argv.slice(2);",
|
|
1740
|
+
'if (args.includes("--version")) { console.log("0.85.1"); process.exit(0); }',
|
|
1741
|
+
"if (args[0] === 'remove') { await new Promise((resolve) => setTimeout(resolve, 60000)); process.exit(0); }",
|
|
1742
|
+
"console.log(JSON.stringify({ args, PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR }));",
|
|
1743
|
+
"process.exit(0);",
|
|
1744
|
+
"",
|
|
1745
|
+
].join("\n"),
|
|
1746
|
+
);
|
|
1747
|
+
chmodSync(hangingPiScript, 0o755);
|
|
1748
|
+
const env = enableAutoProvision({
|
|
1749
|
+
...f.env,
|
|
1750
|
+
GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript,
|
|
1751
|
+
GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0",
|
|
1752
|
+
GENTLE_SHELL_PI: hangingPiScript,
|
|
1753
|
+
GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS: "2000",
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
const started = Date.now();
|
|
1757
|
+
const result = run(env, ["--mode", "rpc", "-p", "hi"]);
|
|
1758
|
+
const elapsed = Date.now() - started;
|
|
1759
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1760
|
+
assert.ok(elapsed < 15000, `expected the hung 'pi remove' to be killed quickly, took ${elapsed}ms`);
|
|
1761
|
+
assert.match(result.stderr, /gentle-shell: pi remove npm:@juicesharp\/rpiv-ask-user-question timed out after 2 seconds/);
|
|
1762
|
+
const payload = JSON.parse(result.stdout);
|
|
1763
|
+
assert.deepEqual(payload.args.slice(-4), ["--mode", "rpc", "-p", "hi"]);
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
test("manual setup never times out even past the auto-mode ceiling override", (t) => {
|
|
1767
|
+
const f = fixture(t);
|
|
1768
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai-sleep.mjs");
|
|
1769
|
+
writeFileSync(gentleAiScript, ["#!/usr/bin/env node", "await new Promise((resolve) => setTimeout(resolve, 300));", "process.exit(0);", ""].join("\n"));
|
|
1770
|
+
chmodSync(gentleAiScript, 0o755);
|
|
1771
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS: "50" };
|
|
1772
|
+
|
|
1773
|
+
const result = run(env, ["setup"]);
|
|
1774
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1775
|
+
assert.doesNotMatch(result.stderr, /timed out/);
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
test("the provisioning marker write leaves no stray temp file behind", (t) => {
|
|
1779
|
+
const f = fixture(t);
|
|
1780
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1781
|
+
writeGentleAiScriptCountingRuns(gentleAiScript, join(f.root, "gentle-ai-runs.log"));
|
|
1782
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1783
|
+
|
|
1784
|
+
const result = run(env, []);
|
|
1785
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1786
|
+
const configDir = join(f.home, ".gentle-shell");
|
|
1787
|
+
assert.deepEqual(readdirSync(configDir), ["config.json"]);
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
// --- default Gentleman-Cute theme -------------------------------------------
|
|
1791
|
+
|
|
1792
|
+
test("a freshly bootstrapped home defaults to the Gentleman-Cute theme", (t) => {
|
|
1793
|
+
const f = fixture(t);
|
|
1794
|
+
const result = run(f.env, ["--mode", "rpc"]);
|
|
1795
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1796
|
+
const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8"));
|
|
1797
|
+
assert.equal(settings.theme, "Gentleman-Cute");
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
test("automatic setup never overwrites a theme the home already declared", (t) => {
|
|
1801
|
+
const f = fixture(t);
|
|
1802
|
+
const target = join(f.root, "themed-home");
|
|
1803
|
+
mkdirSync(target, { recursive: true });
|
|
1804
|
+
writeFileSync(join(target, "settings.json"), JSON.stringify({ tuiMode: "fullscreen", theme: "rose" }));
|
|
1805
|
+
writeFileSync(join(target, ".gentle-shell-home"), JSON.stringify({ createdBy: "gentle-shell", version: ownGentlePiVersion() }));
|
|
1806
|
+
|
|
1807
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1808
|
+
writeGentleAiScript(gentleAiScript);
|
|
1809
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1810
|
+
|
|
1811
|
+
const result = run(env, ["--home", target, "--mode", "rpc"]);
|
|
1812
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1813
|
+
const settings = JSON.parse(readFileSync(join(target, "settings.json"), "utf8"));
|
|
1814
|
+
assert.equal(settings.theme, "rose");
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
test("gentle-shell --link setup never touches the home's theme", (t) => {
|
|
1818
|
+
const f = fixture(t);
|
|
1819
|
+
const piAgentDir = join(f.root, "pi-agent-link");
|
|
1820
|
+
mkdirSync(piAgentDir, { recursive: true });
|
|
1821
|
+
writeFileSync(join(piAgentDir, "settings.json"), JSON.stringify({ tuiMode: "fullscreen" }));
|
|
1822
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai.mjs");
|
|
1823
|
+
writeGentleAiScript(gentleAiScript);
|
|
1824
|
+
const env = { ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, PI_CODING_AGENT_DIR: piAgentDir };
|
|
1825
|
+
|
|
1826
|
+
const result = run(env, ["--link", "setup"]);
|
|
1827
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1828
|
+
const settings = JSON.parse(readFileSync(join(piAgentDir, "settings.json"), "utf8"));
|
|
1829
|
+
assert.equal(settings.theme, undefined, "link mode must never gain a theme from gentle-shell's own default-theme logic");
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
test("automatic setup replaces a theme gentle-ai wrote into a home that had none with the default Gentle Shell theme", (t) => {
|
|
1833
|
+
const f = fixture(t);
|
|
1834
|
+
const target = join(f.root, "theme-home");
|
|
1835
|
+
mkdirSync(target, { recursive: true });
|
|
1836
|
+
writeFileSync(join(target, "settings.json"), JSON.stringify({ tuiMode: "fullscreen" }));
|
|
1837
|
+
writeFileSync(join(target, ".gentle-shell-home"), JSON.stringify({ createdBy: "gentle-shell", version: ownGentlePiVersion() }));
|
|
1838
|
+
|
|
1839
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai-sets-theme.mjs");
|
|
1840
|
+
writeGentleAiScriptSettingTheme(gentleAiScript, "kanagawa");
|
|
1841
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1842
|
+
|
|
1843
|
+
const result = run(env, ["--home", target, "--mode", "rpc"]);
|
|
1844
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1845
|
+
const settings = JSON.parse(readFileSync(join(target, "settings.json"), "utf8"));
|
|
1846
|
+
assert.equal(settings.theme, "Gentleman-Cute");
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
// R3-theme-enforcement-dead-on-fresh-home: on a brand-new home, the isolated
|
|
1850
|
+
// bootstrap (installIsolatedTuiModeSetting) already writes "theme":
|
|
1851
|
+
// "Gentleman-Cute" into settings.json *before* auto-provisioning's gentle-ai
|
|
1852
|
+
// spawn ever runs — so the snapshot runSetupFlow takes right before that
|
|
1853
|
+
// spawn already has a theme, and a naive "only act when the home had no
|
|
1854
|
+
// theme before the spawn" check would never fire, letting gentle-ai's own
|
|
1855
|
+
// theme silently win on every freshly bootstrapped home.
|
|
1856
|
+
test("automatic setup restores the bootstrapped default theme when gentle-ai overwrites it on a fresh home", (t) => {
|
|
1857
|
+
const f = fixture(t);
|
|
1858
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai-sets-theme.mjs");
|
|
1859
|
+
writeGentleAiScriptSettingTheme(gentleAiScript, "kanagawa");
|
|
1860
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1861
|
+
|
|
1862
|
+
const result = run(env, ["--mode", "rpc"]);
|
|
1863
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1864
|
+
const settings = JSON.parse(readFileSync(join(f.gentleShellHome, "settings.json"), "utf8"));
|
|
1865
|
+
assert.equal(settings.theme, "Gentleman-Cute");
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
test("automatic setup restores the home's own theme when gentle-ai overwrites it", (t) => {
|
|
1869
|
+
const f = fixture(t);
|
|
1870
|
+
const target = join(f.root, "themed-home-to-restore");
|
|
1871
|
+
mkdirSync(target, { recursive: true });
|
|
1872
|
+
writeFileSync(join(target, "settings.json"), JSON.stringify({ tuiMode: "fullscreen", theme: "dracula" }));
|
|
1873
|
+
writeFileSync(join(target, ".gentle-shell-home"), JSON.stringify({ createdBy: "gentle-shell", version: ownGentlePiVersion() }));
|
|
1874
|
+
|
|
1875
|
+
const gentleAiScript = join(f.root, "fake-gentle-ai-sets-theme.mjs");
|
|
1876
|
+
writeGentleAiScriptSettingTheme(gentleAiScript, "kanagawa");
|
|
1877
|
+
const env = enableAutoProvision({ ...f.env, GENTLE_SHELL_GENTLE_AI_BIN: gentleAiScript, GENTLE_SHELL_GENTLE_AI_PIN: "3.6.0" });
|
|
1878
|
+
|
|
1879
|
+
const result = run(env, ["--home", target, "--mode", "rpc"]);
|
|
1880
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1881
|
+
const settings = JSON.parse(readFileSync(join(target, "settings.json"), "utf8"));
|
|
1882
|
+
assert.equal(settings.theme, "dracula");
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
// --- --package-root silently ignored in a declared non-link home ----------
|
|
1886
|
+
|
|
1887
|
+
test("--package-root warns and is ignored when an isolated home already declares gentle-pi", (t) => {
|
|
1888
|
+
const f = fixture(t);
|
|
1889
|
+
mkdirSync(f.gentleShellHome, { recursive: true });
|
|
1890
|
+
writeFileSync(join(f.gentleShellHome, "settings.json"), JSON.stringify({ packages: ["npm:gentle-pi@3.5.1"], tuiMode: "fullscreen" }));
|
|
1891
|
+
const forcedRoot = join(f.root, "forced-root");
|
|
1892
|
+
mkdirSync(forcedRoot, { recursive: true });
|
|
1893
|
+
|
|
1894
|
+
const result = run(f.env, ["--package-root", forcedRoot, "--mode", "rpc"]);
|
|
1895
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1896
|
+
assert.match(
|
|
1897
|
+
result.stderr,
|
|
1898
|
+
new RegExp(
|
|
1899
|
+
`--package-root only forces a take-over in --link mode; ${f.gentleShellHome.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} declares gentle-pi, so the installed package is used and ${forcedRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} is ignored`,
|
|
1900
|
+
),
|
|
1901
|
+
);
|
|
1902
|
+
assert.doesNotMatch(result.stderr, /taking over gentle-pi from/);
|
|
1903
|
+
|
|
1904
|
+
const payload = JSON.parse(result.stdout);
|
|
1905
|
+
assert.deepEqual(payload.args, ["--mode", "rpc"]);
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
test("--package-root prints no warning when the home does not declare gentle-pi", (t) => {
|
|
1909
|
+
const f = fixture(t);
|
|
1910
|
+
const forcedRoot = join(f.root, "forced-root");
|
|
1911
|
+
mkdirSync(forcedRoot, { recursive: true });
|
|
1912
|
+
|
|
1913
|
+
const result = run(f.env, ["--package-root", forcedRoot, "--mode", "rpc"]);
|
|
1914
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1915
|
+
assert.doesNotMatch(result.stderr, /--package-root only forces a take-over/);
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
test("--help mentions the setup subcommand", (t) => {
|
|
1919
|
+
const f = fixture(t);
|
|
1920
|
+
const result = run(f.env, ["--help"]);
|
|
1921
|
+
assert.equal(result.status, 0, result.stderr);
|
|
1922
|
+
assert.match(result.stdout, /\bsetup\b/);
|
|
1923
|
+
});
|
|
1924
|
+
|
|
190
1925
|
// --- --link takeover of a conflicting path package -----------------------
|
|
191
1926
|
//
|
|
192
1927
|
// Regression coverage for the settings.json shape that triggered the tool
|