gentle-pi 3.5.0 → 3.6.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 +50 -41
- package/assets/orchestrator-delegation.md +2 -0
- package/bin/gentle-shell.mjs +1068 -16
- package/docs/gentle-shell.md +1 -1
- package/docs/readme-reference.md +108 -19
- package/extensions/gentle-ai.ts +27 -48
- package/lib/gentle-shell-launcher.ts +734 -37
- package/lib/inprocess-reviewer.ts +54 -6
- package/lib/native-review-cli.ts +12 -0
- package/package.json +1 -1
- package/runtime/gentle-shell-launcher.mjs +732 -35
- package/runtime/native-review-cli.mjs +12 -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-rpc-publisher.test.ts +66 -0
- package/tests/gentle-agents.test.ts +46 -0
- 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 +2389 -6
- package/tests/gentle-shell-launcher.test.ts +1053 -10
- 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 +28 -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/lib/odd-runtime-delegation-gate.ts +0 -88
package/bin/gentle-shell.mjs
CHANGED
|
@@ -4,7 +4,21 @@
|
|
|
4
4
|
// resolution, pi resolution order, the version gate, and the pi invocation —
|
|
5
5
|
// lives in that pure, unit-tested module; this file only wires it to the real
|
|
6
6
|
// process, filesystem, and child process.
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
accessSync,
|
|
9
|
+
closeSync,
|
|
10
|
+
constants as fsConstants,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
openSync,
|
|
14
|
+
readdirSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
realpathSync,
|
|
17
|
+
renameSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
statSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from "node:fs";
|
|
8
22
|
import { createRequire } from "node:module";
|
|
9
23
|
import { constants as osConstants, homedir } from "node:os";
|
|
10
24
|
import { delimiter, dirname, join, resolve as resolvePath } from "node:path";
|
|
@@ -13,18 +27,34 @@ import { fileURLToPath } from "node:url";
|
|
|
13
27
|
import {
|
|
14
28
|
buildPiInvocation,
|
|
15
29
|
checkPiVersion,
|
|
30
|
+
decideTakeOver,
|
|
16
31
|
describeVersion,
|
|
32
|
+
discoverLooseExtensionEntries,
|
|
33
|
+
findGentlePiDeclaration,
|
|
34
|
+
forceJsonFieldIfAbsentInOriginal,
|
|
17
35
|
helpText,
|
|
36
|
+
homeSelectorFlags,
|
|
37
|
+
isSetupCapablePin,
|
|
18
38
|
launcherConfigPath,
|
|
39
|
+
MIN_SETUP_GENTLE_AI_VERSION,
|
|
19
40
|
missingPiMessage,
|
|
41
|
+
needsProvisioning,
|
|
42
|
+
otherPackageInjections,
|
|
20
43
|
parseLauncherArgs,
|
|
21
44
|
parseLauncherConfig,
|
|
45
|
+
parseRawLauncherConfig,
|
|
22
46
|
planSpawn,
|
|
47
|
+
POST_INSTALL_REMOVAL_SOURCES,
|
|
48
|
+
postInstallRemovals,
|
|
49
|
+
provisionedEntry,
|
|
50
|
+
recordProvisioned,
|
|
23
51
|
resolveHome,
|
|
24
52
|
resolvePiRuntime,
|
|
25
|
-
|
|
53
|
+
restoreJsonField,
|
|
54
|
+
shellQuote,
|
|
26
55
|
} from "../runtime/gentle-shell-launcher.mjs";
|
|
27
|
-
import {
|
|
56
|
+
import { GENTLE_AI_VERSION, gentleAiBinaryPath, PackageLocalGentleAiBinaryMissingError } from "../runtime/gentle-ai-binary.mjs";
|
|
57
|
+
import { DEFAULT_THEME_NAME, installIsolatedTuiModeSetting } from "../scripts/install-tui-mode-setting.mjs";
|
|
28
58
|
|
|
29
59
|
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
30
60
|
|
|
@@ -83,12 +113,154 @@ function ownPackageVersion() {
|
|
|
83
113
|
}
|
|
84
114
|
|
|
85
115
|
function emptyArgs() {
|
|
86
|
-
return {
|
|
116
|
+
return {
|
|
117
|
+
link: false,
|
|
118
|
+
isolated: false,
|
|
119
|
+
home: undefined,
|
|
120
|
+
packageRoot: undefined,
|
|
121
|
+
help: false,
|
|
122
|
+
version: false,
|
|
123
|
+
command: undefined,
|
|
124
|
+
commandArgs: [],
|
|
125
|
+
passthrough: [],
|
|
126
|
+
piSubcommand: undefined,
|
|
127
|
+
error: undefined,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// package.json "name" reader injected into findGentlePiDeclaration: a
|
|
132
|
+
// missing or unreadable package.json, or a non-string "name", is never an
|
|
133
|
+
// error here — it just means that path package is not gentle-pi.
|
|
134
|
+
function readPackageName(dir) {
|
|
135
|
+
try {
|
|
136
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
137
|
+
return typeof pkg.name === "string" ? pkg.name : undefined;
|
|
138
|
+
} catch {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Best-effort realpath: a directory that does not exist (yet, or ever)
|
|
144
|
+
// cannot be realpath'd, so the take-over decision falls back to comparing
|
|
145
|
+
// the raw path instead of failing.
|
|
146
|
+
function safeRealpath(path) {
|
|
147
|
+
try {
|
|
148
|
+
return realpathSync(path);
|
|
149
|
+
} catch {
|
|
150
|
+
return path;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Used to filter the loose extension dirs a take-over re-injects: a missing
|
|
155
|
+
// path, or one that is not a directory (for example a stray file named
|
|
156
|
+
// "extensions"), is silently excluded rather than passed to pi as -e.
|
|
157
|
+
function isDirectory(path) {
|
|
158
|
+
try {
|
|
159
|
+
return statSync(path).isDirectory();
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Real-fs adapter for discoverLooseExtensionEntries (lib/gentle-shell-launcher.ts):
|
|
166
|
+
// statSync-based isFile/isDirectory (not readdirSync's Dirent, which uses
|
|
167
|
+
// lstat and so would treat a symlinked file or directory as neither) so a
|
|
168
|
+
// symlinked loose extension resolves the same way pi's own fs.existsSync-based
|
|
169
|
+
// checks would.
|
|
170
|
+
const looseExtensionFs = {
|
|
171
|
+
readdir(dir) {
|
|
172
|
+
let names;
|
|
173
|
+
try {
|
|
174
|
+
names = readdirSync(dir);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
// resolveLooseExtensionEntries only calls this once isDirectory(dir)
|
|
177
|
+
// has already confirmed the directory exists, so a failure here (for
|
|
178
|
+
// example EACCES) is a real read failure, not a missing directory.
|
|
179
|
+
// Warn instead of silently dropping every loose extension it would
|
|
180
|
+
// have contributed (R4-loose-extension-enumeration-fails-silently).
|
|
181
|
+
process.stderr.write(`gentle-shell: could not read loose extension directory ${dir}: ${error.message} (skipping)\n`);
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
return names.map((name) => {
|
|
185
|
+
const entryPath = join(dir, name);
|
|
186
|
+
try {
|
|
187
|
+
const entryStat = statSync(entryPath);
|
|
188
|
+
return { name, isFile: entryStat.isFile(), isDirectory: entryStat.isDirectory() };
|
|
189
|
+
} catch {
|
|
190
|
+
return { name, isFile: false, isDirectory: false };
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
exists: existsSync,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
// A loose extensions directory that is itself a self-contained extension —
|
|
198
|
+
// a package.json declaring a non-empty "pi.extensions" manifest — is passed
|
|
199
|
+
// through as a single -e <dir> instead of being broken into per-file
|
|
200
|
+
// entries: pi's own module loader (jiti) resolves that case directly,
|
|
201
|
+
// exactly as it would for any other explicitly configured package path. A
|
|
202
|
+
// root-level index.ts/index.js is deliberately NOT treated as that same
|
|
203
|
+
// marker: pi's own discovery loads it as just another loose file, so
|
|
204
|
+
// collapsing the whole directory on its presence silently dropped sibling
|
|
205
|
+
// loose files like extra.ts (R4-loose-index-collapses-sibling-extensions).
|
|
206
|
+
function readPiManifestExtensions(dir) {
|
|
207
|
+
try {
|
|
208
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
209
|
+
return Array.isArray(pkg?.pi?.extensions) ? pkg.pi.extensions : undefined;
|
|
210
|
+
} catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function looseDirHasOwnEntryPoint(dir) {
|
|
216
|
+
const manifestExtensions = readPiManifestExtensions(dir);
|
|
217
|
+
return manifestExtensions !== undefined && manifestExtensions.length > 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Resolves one candidate loose-extensions directory (<agentDir>/extensions or
|
|
221
|
+
// <cwd>/.pi/extensions) into the -e entries a take-over must re-inject: the
|
|
222
|
+
// directory itself when it is a self-contained extension, otherwise every
|
|
223
|
+
// loose file discoverLooseExtensionEntries finds inside it. A missing or
|
|
224
|
+
// non-directory candidate resolves to no entries.
|
|
225
|
+
function resolveLooseExtensionEntries(dir) {
|
|
226
|
+
if (!isDirectory(dir)) return [];
|
|
227
|
+
if (looseDirHasOwnEntryPoint(dir)) return [dir];
|
|
228
|
+
return discoverLooseExtensionEntries(dir, looseExtensionFs);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Test/development-only override for the launcher config.json path
|
|
232
|
+
// (normally launcherConfigPath(homedir())). Lets a test — or the
|
|
233
|
+
// packed-artifact E2E script, which also needs `--link` probes against the
|
|
234
|
+
// real pi home and so cannot just redirect HOME wholesale — read and write
|
|
235
|
+
// the `home` subcommand's and the auto-provisioning marker's config file
|
|
236
|
+
// without ever touching the real ~/.gentle-shell/config.json. Never
|
|
237
|
+
// consulted outside these two call sites; see docs/readme-reference.md.
|
|
238
|
+
function resolveConfigPath() {
|
|
239
|
+
const override = process.env.GENTLE_SHELL_CONFIG;
|
|
240
|
+
return override !== undefined && override.length > 0 ? override : launcherConfigPath(homedir());
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Raw config.json as a plain object (see RawLauncherConfig in
|
|
244
|
+
// lib/gentle-shell-launcher.ts): unlike parseLauncherConfig, this preserves
|
|
245
|
+
// every key, so a write (home persistence, or the provisioning marker below)
|
|
246
|
+
// never drops a key it does not itself understand.
|
|
247
|
+
function readRawConfig(configPath) {
|
|
248
|
+
return parseRawLauncherConfig(readJsonIfExists(configPath));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Atomic (temp file in the same directory, then rename): a crash or kill
|
|
252
|
+
// mid-write must never leave config.json truncated or partially written,
|
|
253
|
+
// since it also carries the S7 provisioning marker every plain launch reads.
|
|
254
|
+
function writeRawConfig(configPath, config) {
|
|
255
|
+
const configDir = dirname(configPath);
|
|
256
|
+
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
257
|
+
const tempPath = join(configDir, `.${basenameOf(configPath)}.gentle-shell-${process.pid}.tmp`);
|
|
258
|
+
writeFileSync(tempPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
259
|
+
renameSync(tempPath, configPath);
|
|
87
260
|
}
|
|
88
261
|
|
|
89
262
|
function loadConfig() {
|
|
90
|
-
const
|
|
91
|
-
const text = readJsonIfExists(configPath);
|
|
263
|
+
const text = readJsonIfExists(resolveConfigPath());
|
|
92
264
|
return text === undefined ? undefined : parseLauncherConfig(text);
|
|
93
265
|
}
|
|
94
266
|
|
|
@@ -102,21 +274,775 @@ function handleHomeCommand(commandArgs) {
|
|
|
102
274
|
const [value] = commandArgs;
|
|
103
275
|
if (value.length === 0) fail("gentle-shell home requires a non-empty argument. Run 'gentle-shell --help'.", 2);
|
|
104
276
|
|
|
105
|
-
const configPath =
|
|
106
|
-
const
|
|
107
|
-
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
277
|
+
const configPath = resolveConfigPath();
|
|
278
|
+
const existing = readRawConfig(configPath);
|
|
108
279
|
|
|
109
280
|
if (value === "link" || value === "isolated") {
|
|
110
|
-
|
|
281
|
+
writeRawConfig(configPath, { ...existing, home: value });
|
|
111
282
|
process.stdout.write(`Saved home: ${value}\n`);
|
|
112
283
|
process.exit(0);
|
|
113
284
|
}
|
|
114
285
|
const dir = resolvePath(value);
|
|
115
|
-
|
|
286
|
+
writeRawConfig(configPath, { ...existing, home: dir });
|
|
116
287
|
process.stdout.write(`Saved home: path ${dir}\n`);
|
|
117
288
|
process.exit(0);
|
|
118
289
|
}
|
|
119
290
|
|
|
291
|
+
// Test/development-only override for the setup subcommand's gentle-ai
|
|
292
|
+
// executable path. Lets a test point at a stub script (or a deliberately
|
|
293
|
+
// missing path) without touching the real pinned .gentle-ai/v<version>/gentle-ai
|
|
294
|
+
// install this package ships, and without needing to fake its release-asset
|
|
295
|
+
// integrity manifest. Never consulted outside `setup`; see docs/readme-reference.md.
|
|
296
|
+
function resolveSetupGentleAiBinary() {
|
|
297
|
+
const override = process.env.GENTLE_SHELL_GENTLE_AI_BIN;
|
|
298
|
+
return override !== undefined && override.length > 0 ? override : gentleAiBinaryPath();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Test/development-only override for the setup subcommand's reported
|
|
302
|
+
// package-local gentle-ai pin. Lets a test simulate an older or newer pin
|
|
303
|
+
// without changing the real installed .gentle-ai/v<version> bundle. Never
|
|
304
|
+
// consulted outside `setup`; see docs/readme-reference.md.
|
|
305
|
+
function resolveSetupGentleAiPin() {
|
|
306
|
+
const override = process.env.GENTLE_SHELL_GENTLE_AI_PIN;
|
|
307
|
+
return override !== undefined && override.length > 0 ? override : GENTLE_AI_VERSION;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const SKIP_GENTLE_AI_INSTALL_ENV = "GENTLE_PI_SKIP_GENTLE_AI_INSTALL";
|
|
311
|
+
|
|
312
|
+
// Test/development-only override for the setup subcommand's self-heal
|
|
313
|
+
// installer script path. Lets a test point at a stub installer (one that
|
|
314
|
+
// creates the stub binary, or deliberately doesn't) instead of running the
|
|
315
|
+
// real node scripts/install-gentle-ai.mjs, whose supply-chain integrity
|
|
316
|
+
// checks (and real network download) a test cannot cheaply satisfy. Never
|
|
317
|
+
// consulted outside `setup`; see docs/readme-reference.md.
|
|
318
|
+
function resolveSetupGentleAiInstaller() {
|
|
319
|
+
const override = process.env.GENTLE_SHELL_GENTLE_AI_INSTALLER;
|
|
320
|
+
return override !== undefined && override.length > 0 ? override : join(packageRoot, "scripts", "install-gentle-ai.mjs");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Spawns `command` and resolves once it exits, instead of exiting the
|
|
324
|
+
// process directly: the shared core the manual `setup` subcommand and the
|
|
325
|
+
// automatic first-run provisioning flow (S7) both drive, deciding for
|
|
326
|
+
// themselves whether to `process.exit` (setup) or warn and continue (auto
|
|
327
|
+
// mode). `stdio` lets a silent caller route the child's stdout/stderr to the
|
|
328
|
+
// launcher's own stderr (see runSetupFlow) while a manual `setup` keeps the
|
|
329
|
+
// child's stdio inherited. A `signal` on the result records that the child
|
|
330
|
+
// exited via signal for any reason; `interrupted: true` additionally marks
|
|
331
|
+
// that it happened because *this launcher itself* received
|
|
332
|
+
// SIGINT/SIGTERM/SIGHUP and forwarded it — as opposed to the child dying by a
|
|
333
|
+
// signal entirely on its own (a crash, an OOM kill, an external `kill`),
|
|
334
|
+
// which is an ordinary failure, not a request to stop (R3-002). Only
|
|
335
|
+
// `interrupted` lets a caller skip printing remediation advice and abort the
|
|
336
|
+
// whole launch; a plain `signal` with no `interrupted` is treated like any
|
|
337
|
+
// other failure. `timeoutMs`, when given, kills the child and resolves with
|
|
338
|
+
// `timedOut: true` instead of waiting forever on a hung gentle-ai/pi
|
|
339
|
+
// invocation; only the automatic first-run flow passes it (see
|
|
340
|
+
// AUTO_SETUP_CHILD_TIMEOUT_MS below) — manual `setup` never times out.
|
|
341
|
+
function spawnAndWait(command, args, env, stdio, timeoutMs) {
|
|
342
|
+
return new Promise((resolve) => {
|
|
343
|
+
const launchPlan = planSpawn({ command, args, platform: process.platform });
|
|
344
|
+
const child = spawn(launchPlan.command, launchPlan.args, { stdio, env, shell: launchPlan.shell });
|
|
345
|
+
let timedOut = false;
|
|
346
|
+
let interrupted = false;
|
|
347
|
+
const timer = timeoutMs !== undefined ? setTimeout(() => {
|
|
348
|
+
timedOut = true;
|
|
349
|
+
child.kill("SIGTERM");
|
|
350
|
+
}, timeoutMs) : undefined;
|
|
351
|
+
const signalHandlers = ["SIGINT", "SIGTERM", "SIGHUP"].map((signal) => {
|
|
352
|
+
const handler = () => {
|
|
353
|
+
interrupted = true;
|
|
354
|
+
child.kill(signal);
|
|
355
|
+
};
|
|
356
|
+
process.on(signal, handler);
|
|
357
|
+
return [signal, handler];
|
|
358
|
+
});
|
|
359
|
+
const cleanup = () => {
|
|
360
|
+
for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
|
|
361
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
362
|
+
};
|
|
363
|
+
child.on("error", (error) => {
|
|
364
|
+
cleanup();
|
|
365
|
+
resolve({ ok: false, exitCode: 1, error });
|
|
366
|
+
});
|
|
367
|
+
child.on("exit", (code, signal) => {
|
|
368
|
+
cleanup();
|
|
369
|
+
if (timedOut) {
|
|
370
|
+
resolve({ ok: false, exitCode: 1, timedOut: true });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (signal) {
|
|
374
|
+
resolve(
|
|
375
|
+
interrupted
|
|
376
|
+
? { ok: false, exitCode: signalExitCode(signal), signal, interrupted: true }
|
|
377
|
+
: { ok: false, exitCode: signalExitCode(signal), signal },
|
|
378
|
+
);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const exitCode = code ?? 1;
|
|
382
|
+
resolve({ ok: exitCode === 0, exitCode });
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Renders `ms` as a human ceiling for a "timed out after ..." message: whole
|
|
388
|
+
// minutes when `ms` is an exact multiple of 60000 (matching the production
|
|
389
|
+
// 15-minute default and any operator-chosen whole-minute override), seconds
|
|
390
|
+
// otherwise — including the sub-second overrides
|
|
391
|
+
// GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS sets in tests. Used at every "timed out
|
|
392
|
+
// after ..." call site instead of a hardcoded "15 minutes"
|
|
393
|
+
// (R2-timeout-message-hardcoded), so the message always reflects the ceiling
|
|
394
|
+
// that actually fired.
|
|
395
|
+
function formatTimeoutCeiling(ms) {
|
|
396
|
+
if (ms % 60000 === 0) {
|
|
397
|
+
const minutes = ms / 60000;
|
|
398
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
399
|
+
}
|
|
400
|
+
const seconds = ms / 1000;
|
|
401
|
+
return `${seconds} second${seconds === 1 ? "" : "s"}`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Self-heals a missing package-local gentle-ai binary before the setup flow
|
|
405
|
+
// gives up on it. `npm install -g <tarball>` on a machine whose npm config
|
|
406
|
+
// disables lifecycle scripts (`ignore-scripts=true`, this maintainer's own
|
|
407
|
+
// machine included) never runs the package's own postinstall
|
|
408
|
+
// (scripts/install-gentle-ai.mjs), so .gentle-ai/v<pin>/gentle-ai is missing
|
|
409
|
+
// even though the package itself installed fine. Running that same
|
|
410
|
+
// installer here recovers it: it downloads the pinned, sha256-verified
|
|
411
|
+
// release asset, exactly as postinstall would have. Skipped when
|
|
412
|
+
// GENTLE_PI_SKIP_GENTLE_AI_INSTALL is "1" — the same variable that already
|
|
413
|
+
// controls whether real postinstall provisioning runs (see
|
|
414
|
+
// docs/readme-reference.md) — in which case today's plain missing-binary
|
|
415
|
+
// failure is kept, with the variable named in the message. Returns
|
|
416
|
+
// {ok, exitCode, message} instead of exiting the process, so the caller
|
|
417
|
+
// decides whether to exit (manual setup) or warn and continue (auto mode).
|
|
418
|
+
//
|
|
419
|
+
// Signal-contract note (R2-signal-contract-cleanup-gap): unlike the two
|
|
420
|
+
// children spawnAndWait drives during this flow (the package-local gentle-ai
|
|
421
|
+
// binary in runSetupFlow, and each `pi remove` in removePostInstallSources),
|
|
422
|
+
// this installer runs via a *synchronous* spawnSync with no timeout and no
|
|
423
|
+
// launcher-interrupt tracking. A SIGINT/SIGTERM/SIGHUP reaching the launcher
|
|
424
|
+
// while this specific call is blocking falls back to Node's default signal
|
|
425
|
+
// disposition (the launcher exits immediately) instead of the
|
|
426
|
+
// interrupted-vs-ordinary-failure distinction spawnAndWait's callers get. The
|
|
427
|
+
// installer script itself is small, fast, and non-interactive in practice, so
|
|
428
|
+
// this gap is accepted rather than converting it to the async, timeout-bound
|
|
429
|
+
// spawnAndWait path.
|
|
430
|
+
function ensurePackageLocalGentleAi(binaryPath, pinnedVersion, stdio) {
|
|
431
|
+
if (existsSync(binaryPath)) return { ok: true };
|
|
432
|
+
if (process.env[SKIP_GENTLE_AI_INSTALL_ENV] === "1") {
|
|
433
|
+
return {
|
|
434
|
+
ok: false,
|
|
435
|
+
exitCode: 1,
|
|
436
|
+
message: `${new PackageLocalGentleAiBinaryMissingError(binaryPath).message} (${SKIP_GENTLE_AI_INSTALL_ENV} is set; not installing it automatically)`,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
process.stderr.write(
|
|
440
|
+
`gentle-shell: the package-local gentle-ai v${pinnedVersion} is missing (npm lifecycle scripts may be disabled); installing it now\n`,
|
|
441
|
+
);
|
|
442
|
+
const installerPath = resolveSetupGentleAiInstaller();
|
|
443
|
+
const result = spawnSync(process.execPath, [installerPath], { stdio });
|
|
444
|
+
if (result.error) {
|
|
445
|
+
return { ok: false, exitCode: 1, message: `Could not run the gentle-ai installer at ${installerPath}: ${result.error.message}` };
|
|
446
|
+
}
|
|
447
|
+
if (!existsSync(binaryPath)) return { ok: false, exitCode: 1, message: new PackageLocalGentleAiBinaryMissingError(binaryPath).message };
|
|
448
|
+
return { ok: true };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Shared env for the gentle-ai install spawn and the pi remove cleanup spawn
|
|
452
|
+
// below: PI_CODING_AGENT_DIR/GENTLE_PI_AGENT_HOME point both at the resolved
|
|
453
|
+
// home, and the resolved pi runtime's directory is prepended to PATH so
|
|
454
|
+
// gentle-ai's (or pi's own) preflight finds `pi` even when it is bundled or
|
|
455
|
+
// given through GENTLE_SHELL_PI.
|
|
456
|
+
function buildSetupEnv(home, runtime) {
|
|
457
|
+
return {
|
|
458
|
+
...process.env,
|
|
459
|
+
PI_CODING_AGENT_DIR: home.dir,
|
|
460
|
+
GENTLE_PI_AGENT_HOME: home.dir,
|
|
461
|
+
PATH: `${dirname(runtime.command)}${delimiter}${process.env.PATH ?? ""}`,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// The shared Pi persona file gentle-ai writes on every install, regardless
|
|
466
|
+
// of the target home: its own PiPersonaConfigPath always resolves against
|
|
467
|
+
// the OS home, never PI_CODING_AGENT_DIR (gentle-ai internal/components/persona/inject.go),
|
|
468
|
+
// so a `setup` run for any home silently resets whatever persona mode the
|
|
469
|
+
// user already chose back to gentle-ai's default preset unless something
|
|
470
|
+
// snapshots and restores it. See snapshotFile/restoreFile below and
|
|
471
|
+
// docs/readme-reference.md's setup "Known limitation".
|
|
472
|
+
function sharedPersonaPath() {
|
|
473
|
+
return join(homedir(), ".pi", "gentle-ai", "persona.json");
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Records `path`'s current state before a child process that might rewrite
|
|
477
|
+
// it runs: whether it exists, and if so its exact bytes and mode. Returns
|
|
478
|
+
// `{ path, existed: false }` for a missing file so restoreFile below knows
|
|
479
|
+
// to delete rather than rewrite it. Any error other than "does not exist"
|
|
480
|
+
// propagates — a snapshot that silently treats a permissions error as
|
|
481
|
+
// "missing" would then delete a file it never actually read.
|
|
482
|
+
function snapshotFile(path) {
|
|
483
|
+
try {
|
|
484
|
+
const bytes = readFileSync(path);
|
|
485
|
+
const mode = statSync(path).mode & 0o777;
|
|
486
|
+
return { path, existed: true, bytes, mode };
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (error.code === "ENOENT") return { path, existed: false };
|
|
489
|
+
throw error;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Restores `snapshot` after the child that might have rewritten it exits,
|
|
494
|
+
// but only when its current state actually differs from what was recorded:
|
|
495
|
+
// a changed existing file is rewritten atomically (temp file in the same
|
|
496
|
+
// directory, then renamed, so a crash mid-restore never leaves a partial
|
|
497
|
+
// file) preserving the original mode; a file that did not exist before is
|
|
498
|
+
// removed if the child created one. Returns true when a restore/removal
|
|
499
|
+
// actually happened, so the caller prints exactly one notice.
|
|
500
|
+
function restoreFile(snapshot) {
|
|
501
|
+
const { path, existed } = snapshot;
|
|
502
|
+
if (!existed) {
|
|
503
|
+
if (!existsSync(path)) return false;
|
|
504
|
+
rmSync(path, { force: true });
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
let currentBytes;
|
|
508
|
+
try {
|
|
509
|
+
currentBytes = readFileSync(path);
|
|
510
|
+
} catch (error) {
|
|
511
|
+
if (error.code !== "ENOENT") throw error;
|
|
512
|
+
}
|
|
513
|
+
if (currentBytes !== undefined && currentBytes.equals(snapshot.bytes)) return false;
|
|
514
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
515
|
+
const tempPath = join(dirname(path), `.${basenameOf(path)}.gentle-shell-restore-${process.pid}.tmp`);
|
|
516
|
+
writeFileSync(tempPath, snapshot.bytes, { mode: snapshot.mode });
|
|
517
|
+
renameSync(tempPath, path);
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function basenameOf(path) {
|
|
522
|
+
const parts = path.split(/[\\/]/);
|
|
523
|
+
return parts[parts.length - 1];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// gentle-ai records the running binary's managed-asset bundle digest in the
|
|
527
|
+
// shared `~/.gentle-ai/state.json`, field managed_asset_digest (gentle-ai
|
|
528
|
+
// internal/cli/run.go, internal/state/state.go), regardless of which home it
|
|
529
|
+
// was installing into — same shared-file-outside-PI_CODING_AGENT_DIR problem
|
|
530
|
+
// as sharedPersonaPath above. The pinned package-local gentle-ai this setup
|
|
531
|
+
// flow spawns writes its own digest there, so afterward the user's own
|
|
532
|
+
// (unrelated, on-PATH) gentle-ai reports its managed assets as outdated and
|
|
533
|
+
// demands `gentle-ai sync`, even though nothing about the user's install
|
|
534
|
+
// changed. Unlike persona.json, state.json also carries fields the pinned
|
|
535
|
+
// gentle-ai is supposed to update (for example installed_agents), so this
|
|
536
|
+
// restores only the managed_asset_digest field via restoreJsonField
|
|
537
|
+
// (lib/gentle-shell-launcher.ts) instead of snapshotting the whole file. See
|
|
538
|
+
// docs/readme-reference.md's setup "Known limitation".
|
|
539
|
+
function sharedGentleAiStatePath() {
|
|
540
|
+
return join(homedir(), ".gentle-ai", "state.json");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const MANAGED_ASSET_DIGEST_FIELD = "managed_asset_digest";
|
|
544
|
+
|
|
545
|
+
// Reads state.json's raw text before the gentle-ai spawn that might rewrite
|
|
546
|
+
// it, tolerating a missing or unparsable file by returning undefined: unlike
|
|
547
|
+
// snapshotFile (persona.json above), there is nothing worth restoring later
|
|
548
|
+
// in that case, so the caller skips the restore step entirely rather than
|
|
549
|
+
// treating "missing" as its own snapshot state.
|
|
550
|
+
function readParsableJsonText(path) {
|
|
551
|
+
const text = readJsonIfExists(path);
|
|
552
|
+
if (text === undefined) return undefined;
|
|
553
|
+
try {
|
|
554
|
+
JSON.parse(text);
|
|
555
|
+
} catch {
|
|
556
|
+
return undefined;
|
|
557
|
+
}
|
|
558
|
+
return text;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Restores managed_asset_digest in state.json after the gentle-ai spawn.
|
|
562
|
+
// Deliberately does NOT delete or otherwise touch a state.json the child
|
|
563
|
+
// created where none existed before (unlike restoreFile's whole-file
|
|
564
|
+
// persona.json handling): state.json is the user's own gentle-ai global
|
|
565
|
+
// state file, not something gentle-shell owns end-to-end, so removing one
|
|
566
|
+
// the tool just created would destroy state fields unrelated to this fix —
|
|
567
|
+
// `originalText` is undefined for that case (see readParsableJsonText
|
|
568
|
+
// above), and this returns early without reading or writing anything.
|
|
569
|
+
// Returns true when it actually wrote a restored file, so the caller prints
|
|
570
|
+
// exactly one notice.
|
|
571
|
+
function restoreManagedAssetDigestField(path, originalText) {
|
|
572
|
+
if (originalText === undefined) return false;
|
|
573
|
+
const currentText = readJsonIfExists(path);
|
|
574
|
+
if (currentText === undefined) return false;
|
|
575
|
+
const restoredText = restoreJsonField(originalText, currentText, MANAGED_ASSET_DIGEST_FIELD);
|
|
576
|
+
if (restoredText === undefined) return false;
|
|
577
|
+
const mode = statSync(path).mode & 0o777;
|
|
578
|
+
const tempPath = join(dirname(path), `.${basenameOf(path)}.gentle-shell-restore-${process.pid}.tmp`);
|
|
579
|
+
writeFileSync(tempPath, restoredText, { mode });
|
|
580
|
+
renameSync(tempPath, path);
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Restores the home's own settings.json "theme" field to whatever it was
|
|
585
|
+
// right before the gentle-ai spawn (`originalSettingsText`) — gentle-ai's
|
|
586
|
+
// managed install may write its own theme into settings.json, which would
|
|
587
|
+
// otherwise silently replace the theme Gentle Shell had going in. This is a
|
|
588
|
+
// field-level snapshot/restore around the spawn, not a "only act if there
|
|
589
|
+
// was no theme before" check: on a brand-new home, the isolated-home
|
|
590
|
+
// bootstrap (installIsolatedTuiModeSetting, withIsolatedHomeDefaults in
|
|
591
|
+
// scripts/install-tui-mode-setting.mjs) already wrote DEFAULT_THEME_NAME
|
|
592
|
+
// into settings.json before this snapshot is taken, so `originalSettingsText`
|
|
593
|
+
// already declares a theme there too — a check that skipped restoring
|
|
594
|
+
// whenever the original had a theme would never fire on a fresh home and let
|
|
595
|
+
// gentle-ai's own theme win. When the original truly declared a theme
|
|
596
|
+
// (either that bootstrap default, or the user's own earlier choice),
|
|
597
|
+
// whatever gentle-ai changed it to afterward is restored via the pure
|
|
598
|
+
// restoreJsonField (lib/gentle-shell-launcher.ts). When the original had no
|
|
599
|
+
// theme at all, there is nothing to restore, so DEFAULT_THEME_NAME is forced
|
|
600
|
+
// instead via forceJsonFieldIfAbsentInOriginal, so a home still ends up
|
|
601
|
+
// themed. Unlike the persona/state restores above, this is not a shared file
|
|
602
|
+
// outside the home — it is the home's own settings.json, so no whole-file
|
|
603
|
+
// snapshot/restore pairing is needed, only a before/after comparison.
|
|
604
|
+
// Returns "restored" or "forced" when it actually wrote the file (so the
|
|
605
|
+
// caller can print the matching notice), or false when nothing changed.
|
|
606
|
+
function enforceDefaultThemeField(settingsPath, originalSettingsText) {
|
|
607
|
+
if (originalSettingsText === undefined) return false;
|
|
608
|
+
const currentText = readJsonIfExists(settingsPath);
|
|
609
|
+
if (currentText === undefined) return false;
|
|
610
|
+
let originalHadTheme;
|
|
611
|
+
try {
|
|
612
|
+
const originalValue = JSON.parse(originalSettingsText);
|
|
613
|
+
originalHadTheme = typeof originalValue === "object" && originalValue !== null && !Array.isArray(originalValue) && Object.prototype.hasOwnProperty.call(originalValue, "theme");
|
|
614
|
+
} catch {
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
const newText = originalHadTheme
|
|
618
|
+
? restoreJsonField(originalSettingsText, currentText, "theme")
|
|
619
|
+
: forceJsonFieldIfAbsentInOriginal(originalSettingsText, currentText, "theme", DEFAULT_THEME_NAME);
|
|
620
|
+
if (newText === undefined) return false;
|
|
621
|
+
const mode = statSync(settingsPath).mode & 0o777;
|
|
622
|
+
const tempPath = join(dirname(settingsPath), `.${basenameOf(settingsPath)}.gentle-shell-restore-${process.pid}.tmp`);
|
|
623
|
+
writeFileSync(tempPath, newText, { mode });
|
|
624
|
+
renameSync(tempPath, settingsPath);
|
|
625
|
+
return originalHadTheme ? "restored" : "forced";
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Never let a snapshot/restore step itself abort setup: a persona.json or
|
|
629
|
+
// state.json this launcher cannot read or write for an unexpected reason
|
|
630
|
+
// (EACCES, ENOSPC, a path that turned into a directory, ...) must not crash
|
|
631
|
+
// `gentle-shell setup` or block the automatic first-run flow from still
|
|
632
|
+
// launching pi — it only means that one file's shared-state protection did
|
|
633
|
+
// not apply this run. `label` and `path` identify what failed to the user;
|
|
634
|
+
// the caller decides what "safe" default to fall back to.
|
|
635
|
+
function safely(label, path, fallback, fn) {
|
|
636
|
+
try {
|
|
637
|
+
return fn();
|
|
638
|
+
} catch (error) {
|
|
639
|
+
process.stderr.write(`gentle-shell: could not ${label} at ${path} (${error.message}); continuing\n`);
|
|
640
|
+
return fallback;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Provisions `home` with everything `gentle-ai install --agent pi` installs
|
|
645
|
+
// into a regular Pi, by spawning the package-local pinned gentle-ai binary
|
|
646
|
+
// (never a PATH `gentle-ai`) with PI_CODING_AGENT_DIR/GENTLE_PI_AGENT_HOME set
|
|
647
|
+
// to `home.dir` and the resolved pi runtime's directory prepended to PATH, so
|
|
648
|
+
// gentle-ai's own preflight finds `pi` even when it is bundled or given
|
|
649
|
+
// through GENTLE_SHELL_PI, then removes any conflicting package it declared
|
|
650
|
+
// (see runPostInstallCleanup below). `home` and `runtime` are resolved by
|
|
651
|
+
// the caller exactly as a normal run resolves them (including the
|
|
652
|
+
// isolated/--home bootstrap and the pi version gate). Precondition: the
|
|
653
|
+
// package-local gentle-ai pin must be at least MIN_SETUP_GENTLE_AI_VERSION —
|
|
654
|
+
// the first release that honors PI_CODING_AGENT_DIR here — or this refuses
|
|
655
|
+
// to spawn it, since an older pin would silently provision the caller's real
|
|
656
|
+
// ~/.pi/agent.
|
|
657
|
+
//
|
|
658
|
+
// Returns {ok, exitCode, message?} instead of exiting the process: the
|
|
659
|
+
// manual `setup` subcommand (handleSetupCommand) exits on the result, and
|
|
660
|
+
// the automatic first-run flow (maybeAutoProvisionHome, S7) warns and
|
|
661
|
+
// continues the launch on failure instead. `stdio` is threaded through to
|
|
662
|
+
// both child spawns unchanged (see spawnAndWait and
|
|
663
|
+
// ensurePackageLocalGentleAi above) — "inherit" for a manual `setup`, or
|
|
664
|
+
// `["ignore", 2, 2]` in auto mode so every child's stdout/stderr lands on
|
|
665
|
+
// this launcher's own stderr and its real stdout stays clean for `--mode
|
|
666
|
+
// rpc`/`-p` consumers. `timeoutMs` is threaded into every child this flow
|
|
667
|
+
// spawns (see spawnAndWait's own doc comment) — manual `setup` never passes
|
|
668
|
+
// it, so it never times out; the automatic flow does (S9).
|
|
669
|
+
async function runSetupFlow(home, runtime, { dryRun, stdio, timeoutMs }) {
|
|
670
|
+
const pinnedVersion = resolveSetupGentleAiPin();
|
|
671
|
+
if (!isSetupCapablePin(pinnedVersion)) {
|
|
672
|
+
return {
|
|
673
|
+
ok: false,
|
|
674
|
+
exitCode: 1,
|
|
675
|
+
message: `gentle-shell: setup needs the package-local gentle-ai v${MIN_SETUP_GENTLE_AI_VERSION} or newer (pinned: ${pinnedVersion}); this build cannot provision a home without touching ~/.pi/agent`,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const binaryPath = resolveSetupGentleAiBinary();
|
|
680
|
+
const ensured = ensurePackageLocalGentleAi(binaryPath, pinnedVersion, stdio);
|
|
681
|
+
if (!ensured.ok) return ensured;
|
|
682
|
+
|
|
683
|
+
process.stderr.write(`gentle-shell: provisioning ${home.dir} with the gentle-ai companion packages\n`);
|
|
684
|
+
|
|
685
|
+
const setupArgs = ["install", "--agent", "pi", "--scope", "global", ...(dryRun ? ["--dry-run"] : [])];
|
|
686
|
+
const env = buildSetupEnv(home, runtime);
|
|
687
|
+
const personaPath = sharedPersonaPath();
|
|
688
|
+
const personaSnapshot = safely("snapshot your Pi persona file", personaPath, undefined, () => snapshotFile(personaPath));
|
|
689
|
+
const statePath = sharedGentleAiStatePath();
|
|
690
|
+
const originalStateText = safely("read your Gentle AI state file", statePath, undefined, () => readParsableJsonText(statePath));
|
|
691
|
+
// Theme restore (never for --link — that home is the user's own
|
|
692
|
+
// pre-existing pi agent home — and never for a --dry-run, which must
|
|
693
|
+
// write nothing): snapshot the home's own settings.json theme right
|
|
694
|
+
// before the spawn, so enforceDefaultThemeField can restore it
|
|
695
|
+
// afterward if gentle-ai's managed install changed it — whether that
|
|
696
|
+
// theme was the isolated-home bootstrap's own default or the user's own
|
|
697
|
+
// earlier choice.
|
|
698
|
+
const trackTheme = !dryRun && home.mode !== "link";
|
|
699
|
+
const settingsPath = join(home.dir, "settings.json");
|
|
700
|
+
const originalSettingsText = trackTheme ? safely("read your Pi settings file", settingsPath, undefined, () => readParsableJsonText(settingsPath)) : undefined;
|
|
701
|
+
let installResult;
|
|
702
|
+
try {
|
|
703
|
+
installResult = await spawnAndWait(binaryPath, setupArgs, env, stdio, timeoutMs);
|
|
704
|
+
} finally {
|
|
705
|
+
if (personaSnapshot !== undefined && safely("restore your Pi persona file", personaPath, false, () => restoreFile(personaSnapshot))) {
|
|
706
|
+
process.stderr.write(`gentle-shell: kept your Pi persona unchanged (gentle-ai rewrote ${personaPath}; tracked upstream)\n`);
|
|
707
|
+
}
|
|
708
|
+
if (safely("restore your Gentle AI managed-asset record", statePath, false, () => restoreManagedAssetDigestField(statePath, originalStateText))) {
|
|
709
|
+
process.stderr.write(
|
|
710
|
+
`gentle-shell: kept your Gentle AI managed-asset record unchanged (the pinned gentle-ai rewrote ${statePath}; tracked upstream)\n`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
const themeOutcome = trackTheme ? safely("apply the default Gentle Shell theme", settingsPath, false, () => enforceDefaultThemeField(settingsPath, originalSettingsText)) : false;
|
|
714
|
+
if (themeOutcome === "forced") {
|
|
715
|
+
process.stderr.write(`gentle-shell: set the default ${DEFAULT_THEME_NAME} theme for ${home.dir} (no theme was set before this run)\n`);
|
|
716
|
+
} else if (themeOutcome === "restored") {
|
|
717
|
+
process.stderr.write(`gentle-shell: kept your Pi theme unchanged (gentle-ai rewrote ${settingsPath}; tracked upstream)\n`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
if (installResult.timedOut) {
|
|
721
|
+
return { ok: false, exitCode: 1, message: `gentle-shell: gentle-ai install timed out after ${formatTimeoutCeiling(timeoutMs)}` };
|
|
722
|
+
}
|
|
723
|
+
if (installResult.error) {
|
|
724
|
+
return { ok: false, exitCode: 1, message: `Could not start the gentle-ai binary: ${installResult.error.message}` };
|
|
725
|
+
}
|
|
726
|
+
if (!installResult.ok) return installResult;
|
|
727
|
+
|
|
728
|
+
return runPostInstallCleanup(home, runtime, dryRun, stdio, timeoutMs);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// The stderr line printed once `source` is actually removed from `home`.
|
|
732
|
+
// npm:gentle-pi names the running launcher's own version, so it is
|
|
733
|
+
// self-evident which copy stays authoritative; every other source keeps its
|
|
734
|
+
// original gentle-ai #4820 wording unchanged.
|
|
735
|
+
function postInstallRemovingMessage(source, home) {
|
|
736
|
+
if (source === "npm:gentle-pi") {
|
|
737
|
+
return `gentle-shell: removing ${source} from ${home.dir}: this launcher loads its own gentle-pi ${ownPackageVersion()}, so the home always matches it`;
|
|
738
|
+
}
|
|
739
|
+
return `gentle-shell: removing ${source} from ${home.dir}: gentle-pi ships ask_user_question and Pi refuses two providers (gentle-ai #4820)`;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// The --dry-run stderr line for `source`, printed unconditionally (see
|
|
743
|
+
// runPostInstallCleanup below). Kept byte-identical to the pre-existing
|
|
744
|
+
// rpiv wording; npm:gentle-pi gets its own analogous "would remove" line.
|
|
745
|
+
function postInstallWouldRemoveMessage(source) {
|
|
746
|
+
if (source === "npm:gentle-pi") {
|
|
747
|
+
return `gentle-shell: setup would then remove ${source} if the install declares it: this launcher loads its own gentle-pi ${ownPackageVersion()}, so the home always matches it`;
|
|
748
|
+
}
|
|
749
|
+
return `gentle-shell: setup would then remove ${source} if the install declares it (gentle-ai #4820)`;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Runs once the gentle-ai install spawned by runSetupFlow above has exited
|
|
753
|
+
// 0. gentle-ai's managed Pi stack always declares two packages this launcher
|
|
754
|
+
// must remove from the just-provisioned home itself, unless this is a
|
|
755
|
+
// --dry-run: npm:@juicesharp/rpiv-ask-user-question, which conflicts with
|
|
756
|
+
// gentle-pi's own first-party ask_user_question tool (Pi refuses two
|
|
757
|
+
// providers for the same tool name; gentle-ai #4820, gentle-shell #1277,
|
|
758
|
+
// fix pending upstream), and npm:gentle-pi itself, which must never survive
|
|
759
|
+
// setup — this launcher always loads its own gentle-pi, never the one
|
|
760
|
+
// gentle-ai's stack installs. A --dry-run gentle-ai install writes nothing,
|
|
761
|
+
// so settings.json read afterwards would only report whatever pre-existed
|
|
762
|
+
// the run (e.g. the isolated-home bootstrap), never what the skipped
|
|
763
|
+
// install would have declared; report every known removal source
|
|
764
|
+
// unconditionally instead of reading settings.json at all.
|
|
765
|
+
async function runPostInstallCleanup(home, runtime, dryRun, stdio, timeoutMs) {
|
|
766
|
+
if (dryRun) {
|
|
767
|
+
for (const source of POST_INSTALL_REMOVAL_SOURCES) {
|
|
768
|
+
process.stderr.write(`${postInstallWouldRemoveMessage(source)}\n`);
|
|
769
|
+
}
|
|
770
|
+
return { ok: true, exitCode: 0 };
|
|
771
|
+
}
|
|
772
|
+
const settingsText = readJsonIfExists(join(home.dir, "settings.json"));
|
|
773
|
+
const removals = postInstallRemovals(settingsText);
|
|
774
|
+
if (removals.length === 0) return { ok: true, exitCode: 0 };
|
|
775
|
+
return removePostInstallSources(removals, 0, home, runtime, stdio, timeoutMs);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Removes each declared post-install source in turn via the resolved pi
|
|
779
|
+
// runtime itself (never gentle-ai), stopping at the first failure so its
|
|
780
|
+
// exit code and actionable message are not masked by a later removal.
|
|
781
|
+
async function removePostInstallSources(sources, index, home, runtime, stdio, timeoutMs) {
|
|
782
|
+
if (index >= sources.length) return { ok: true, exitCode: 0 };
|
|
783
|
+
const source = sources[index];
|
|
784
|
+
process.stderr.write(`${postInstallRemovingMessage(source, home)}\n`);
|
|
785
|
+
const env = buildSetupEnv(home, runtime);
|
|
786
|
+
const result = await spawnAndWait(runtime.command, [...runtime.args, "remove", source], env, stdio, timeoutMs);
|
|
787
|
+
if (result.timedOut) {
|
|
788
|
+
return { ok: false, exitCode: 1, message: `gentle-shell: pi remove ${source} timed out after ${formatTimeoutCeiling(timeoutMs)}` };
|
|
789
|
+
}
|
|
790
|
+
if (result.error) {
|
|
791
|
+
return { ok: false, exitCode: 1, message: `Could not run the pi runtime to remove ${source}: ${result.error.message}` };
|
|
792
|
+
}
|
|
793
|
+
if (!result.ok) {
|
|
794
|
+
// Only a launcher-forwarded interrupt (R3-002) skips remediation and
|
|
795
|
+
// bubbles straight up; a signal death the child caused on its own is an
|
|
796
|
+
// ordinary failure and gets the same remediation message as any other.
|
|
797
|
+
if (result.interrupted) return result;
|
|
798
|
+
const remediation = [...homeSelectorFlags(home).map(shellQuote), "remove", source].join(" ");
|
|
799
|
+
return { ok: false, exitCode: result.exitCode, message: `gentle-shell: could not remove ${source}; run \`gentle-shell ${remediation}\` before starting` };
|
|
800
|
+
}
|
|
801
|
+
return removePostInstallSources(sources, index + 1, home, runtime, stdio, timeoutMs);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// CLI entry for `gentle-shell [home selectors] setup [--dry-run]`: parses
|
|
805
|
+
// --dry-run, runs the shared flow with the child's stdio inherited (today's
|
|
806
|
+
// behavior, unchanged), then exits with its result — this is the one place
|
|
807
|
+
// that keeps the pre-S7 exit semantics `handleSetupCommand` always had.
|
|
808
|
+
async function handleSetupCommand(commandArgs, home, runtime) {
|
|
809
|
+
let dryRun = false;
|
|
810
|
+
for (const arg of commandArgs) {
|
|
811
|
+
if (arg === "--dry-run") {
|
|
812
|
+
dryRun = true;
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
fail(`Unrecognized argument for 'gentle-shell setup': ${arg}\nRun 'gentle-shell --help' for usage.`, 2);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const result = await runSetupFlow(home, runtime, { dryRun, stdio: "inherit" });
|
|
819
|
+
if (!result.ok && result.message !== undefined) process.stderr.write(`${result.message}\n`);
|
|
820
|
+
process.exit(result.exitCode);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const AUTO_SETUP_OPT_OUT_ENV = "GENTLE_SHELL_NO_AUTO_SETUP";
|
|
824
|
+
const SETUP_LOCK_STALE_MS = 15 * 60 * 1000;
|
|
825
|
+
|
|
826
|
+
// gentle-shell never auto-provisions a home it does not itself own: the
|
|
827
|
+
// dedicated isolated home is always owned outright, but a `--home <path>` (or
|
|
828
|
+
// a persisted `home <path>` config) can just as easily name the user's real
|
|
829
|
+
// pi agent directory, or any other pre-existing, unrelated directory. Only a
|
|
830
|
+
// path home that is new (does not exist yet) or empty — or one this launcher
|
|
831
|
+
// has already provisioned before, per the config marker — is fair game;
|
|
832
|
+
// everything else (R1-001) is left alone with a one-time hint instead.
|
|
833
|
+
function defaultPiAgentDir() {
|
|
834
|
+
return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function printForeignHomeHint(home, reason) {
|
|
838
|
+
const remediation = [...homeSelectorFlags(home).map(shellQuote), "setup"].join(" ");
|
|
839
|
+
process.stderr.write(`gentle-shell: ${home.dir} ${reason}; run \`gentle-shell ${remediation}\` to provision it\n`);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Ownership marker (R3-001): written into a home's own directory by the
|
|
843
|
+
// isolated/--home bootstrap (main, below) the moment gentle-shell creates
|
|
844
|
+
// that home — the same place it seeds settings.json with tuiMode. Lets
|
|
845
|
+
// homeIsForeign recognize a home gentle-shell itself created even when the
|
|
846
|
+
// config.json provisioning marker was never written because the *first*
|
|
847
|
+
// auto-provision attempt against it failed (a --home directory whose
|
|
848
|
+
// bootstrap already seeded settings.json otherwise looks identical to an
|
|
849
|
+
// unrelated non-empty directory on the next launch, and would be treated as
|
|
850
|
+
// foreign and never retried). Content is a one-line JSON object naming the
|
|
851
|
+
// launcher version that created it, purely informational — homeIsForeign
|
|
852
|
+
// only checks the file's existence.
|
|
853
|
+
const HOME_OWNERSHIP_MARKER_FILENAME = ".gentle-shell-home";
|
|
854
|
+
|
|
855
|
+
function homeOwnershipMarkerPath(home) {
|
|
856
|
+
return join(home.dir, HOME_OWNERSHIP_MARKER_FILENAME);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function writeHomeOwnershipMarker(home) {
|
|
860
|
+
const content = JSON.stringify({ createdBy: "gentle-shell", version: ownPackageVersion() });
|
|
861
|
+
writeFileSync(homeOwnershipMarkerPath(home), `${content}\n`, "utf8");
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// `homeHadContentBeforeBootstrap` must be read by the caller (main, below)
|
|
865
|
+
// before the isolated/--home bootstrap runs: that bootstrap itself creates
|
|
866
|
+
// and seeds a brand-new directory (settings.json with tuiMode, and now the
|
|
867
|
+
// ownership marker above), so checking directory contents from inside this
|
|
868
|
+
// function would always see that seeded content and wrongly call a genuinely
|
|
869
|
+
// fresh home "foreign".
|
|
870
|
+
function homeIsForeign(home, previousEntry, homeHadContentBeforeBootstrap) {
|
|
871
|
+
if (home.mode !== "path") return false; // the isolated home is always owned
|
|
872
|
+
if (previousEntry !== undefined) return false; // already provisioned by gentle-shell before; trust the marker
|
|
873
|
+
if (safeRealpath(home.dir) === safeRealpath(defaultPiAgentDir())) return true; // never touch pi's own default home, even if empty
|
|
874
|
+
if (existsSync(homeOwnershipMarkerPath(home))) return false; // gentle-shell's own bootstrap created this home (R3-001); retry it even after a failed first attempt
|
|
875
|
+
return homeHadContentBeforeBootstrap;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Guards concurrent first-run auto-provisioning of the same home: an
|
|
879
|
+
// exclusive create (`wx`) fails when the lock already exists. A lock file
|
|
880
|
+
// younger than SETUP_LOCK_STALE_MS means another gentle-shell process is (or
|
|
881
|
+
// very recently was) provisioning this home, so this run skips
|
|
882
|
+
// auto-provisioning entirely rather than racing gentle-ai's own installer;
|
|
883
|
+
// the existing lock is left untouched since this run never owned it. An
|
|
884
|
+
// older lock is stale — a previous run crashed or was killed before its
|
|
885
|
+
// `finally` released it — so it is removed here, but only after re-stating
|
|
886
|
+
// it immediately before the removal to confirm it is *still* stale at that
|
|
887
|
+
// exact moment: a concurrent process may have refreshed it (or removed and
|
|
888
|
+
// recreated it) between the first check and now, and a lock a racing process
|
|
889
|
+
// just legitimately acquired must never be deleted out from under it. If the
|
|
890
|
+
// post-removal retry `wx` create itself then fails (another process won the
|
|
891
|
+
// race to recreate it first), this run simply skips provisioning rather than
|
|
892
|
+
// looping. Any unexpected fs error (permissions, a vanished lock between the
|
|
893
|
+
// EEXIST and a stat, …) must never block the launch, so it resolves to
|
|
894
|
+
// "proceed" rather than failing closed.
|
|
895
|
+
function lockAgeMs(lockPath) {
|
|
896
|
+
return Date.now() - statSync(lockPath).mtimeMs;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function acquireSetupLock(lockPath) {
|
|
900
|
+
try {
|
|
901
|
+
closeSync(openSync(lockPath, "wx"));
|
|
902
|
+
return true;
|
|
903
|
+
} catch (error) {
|
|
904
|
+
if (error.code !== "EEXIST") return true;
|
|
905
|
+
let age;
|
|
906
|
+
try {
|
|
907
|
+
age = lockAgeMs(lockPath);
|
|
908
|
+
} catch {
|
|
909
|
+
return true;
|
|
910
|
+
}
|
|
911
|
+
if (age < SETUP_LOCK_STALE_MS) {
|
|
912
|
+
process.stderr.write(
|
|
913
|
+
`gentle-shell: another gentle-shell process is already provisioning ${dirname(lockPath)}; skipping automatic setup for this run\n`,
|
|
914
|
+
);
|
|
915
|
+
return false;
|
|
916
|
+
}
|
|
917
|
+
let ageNow;
|
|
918
|
+
try {
|
|
919
|
+
ageNow = lockAgeMs(lockPath);
|
|
920
|
+
} catch {
|
|
921
|
+
return false;
|
|
922
|
+
}
|
|
923
|
+
if (ageNow < SETUP_LOCK_STALE_MS) return false;
|
|
924
|
+
try {
|
|
925
|
+
rmSync(lockPath, { force: true });
|
|
926
|
+
} catch {
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
929
|
+
try {
|
|
930
|
+
closeSync(openSync(lockPath, "wx"));
|
|
931
|
+
return true;
|
|
932
|
+
} catch {
|
|
933
|
+
return false;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function releaseSetupLock(lockPath) {
|
|
939
|
+
try {
|
|
940
|
+
rmSync(lockPath, { force: true });
|
|
941
|
+
} catch {
|
|
942
|
+
// Best-effort cleanup only: a missing or unremovable lock file must
|
|
943
|
+
// never fail an otherwise-successful (or already-failed) run.
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Ceiling for every child this flow spawns (S9): a hung pinned gentle-ai or
|
|
948
|
+
// pi invocation must never hang a plain `gentle-shell` launch forever.
|
|
949
|
+
// Test/development only: GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS overrides the
|
|
950
|
+
// 15-minute ceiling so a test can exercise it without actually waiting;
|
|
951
|
+
// documented as test/development-only in docs/readme-reference.md. Manual
|
|
952
|
+
// `setup` never passes a timeout at all (see handleSetupCommand).
|
|
953
|
+
const AUTO_SETUP_CHILD_TIMEOUT_MS = 15 * 60 * 1000;
|
|
954
|
+
|
|
955
|
+
function resolveAutoSetupTimeoutMs() {
|
|
956
|
+
const override = process.env.GENTLE_SHELL_AUTO_SETUP_TIMEOUT_MS;
|
|
957
|
+
const parsed = override !== undefined && override.length > 0 ? Number(override) : undefined;
|
|
958
|
+
return parsed !== undefined && Number.isFinite(parsed) ? parsed : AUTO_SETUP_CHILD_TIMEOUT_MS;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Runs the same flow as `gentle-shell setup` automatically before a plain
|
|
962
|
+
// launch, for an isolated or `--home <path>` home that was never provisioned
|
|
963
|
+
// or was provisioned with a different gentle-ai pin (S7). Never runs for
|
|
964
|
+
// `--link` (the caller only calls this for home.mode "isolated"/"path") or a
|
|
965
|
+
// pi subcommand (the caller only calls this when args.piSubcommand is
|
|
966
|
+
// undefined) — see main() below. Never blocks the launch: a failure (an
|
|
967
|
+
// older pin, a missing binary the self-heal could not recover, a non-zero
|
|
968
|
+
// gentle-ai or pi exit, a timeout, or a spawned child dying by a signal on
|
|
969
|
+
// its own — a crash, an OOM kill, an external `kill`, never something this
|
|
970
|
+
// launcher asked for) only warns and lets the plain launch continue with
|
|
971
|
+
// today's injection behavior, to retry automatically on a later run —
|
|
972
|
+
// except an interrupt (SIGINT/SIGTERM/SIGHUP) actually reaching *this
|
|
973
|
+
// launcher*, which forwards it to the spawned child and returns
|
|
974
|
+
// `{ exitCode }` instead (R3-002) so the caller (main, below) exits the
|
|
975
|
+
// whole launcher immediately without starting pi (S9): the user asked this
|
|
976
|
+
// process to stop, not to fall back to a plain launch. Returns undefined to
|
|
977
|
+
// mean "continue the launch normally".
|
|
978
|
+
async function maybeAutoProvisionHome(home, runtime, { homeHadContentBeforeBootstrap }) {
|
|
979
|
+
if (process.env[AUTO_SETUP_OPT_OUT_ENV] === "1") return undefined;
|
|
980
|
+
|
|
981
|
+
const configPath = resolveConfigPath();
|
|
982
|
+
const homeKey = safeRealpath(home.dir);
|
|
983
|
+
const pin = resolveSetupGentleAiPin();
|
|
984
|
+
const gentlePiVersion = ownPackageVersion();
|
|
985
|
+
const beforeConfig = readRawConfig(configPath);
|
|
986
|
+
if (!needsProvisioning(beforeConfig, homeKey, pin, gentlePiVersion)) return undefined;
|
|
987
|
+
|
|
988
|
+
const previous = provisionedEntry(beforeConfig, homeKey);
|
|
989
|
+
if (homeIsForeign(home, previous, homeHadContentBeforeBootstrap)) {
|
|
990
|
+
const reason =
|
|
991
|
+
safeRealpath(home.dir) === safeRealpath(defaultPiAgentDir())
|
|
992
|
+
? "is pi's own default agent home; gentle-shell never auto-provisions it"
|
|
993
|
+
: "already has content and was not set up by gentle-shell";
|
|
994
|
+
printForeignHomeHint(home, reason);
|
|
995
|
+
return undefined;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const lockPath = join(home.dir, ".gentle-shell-setup.lock");
|
|
999
|
+
if (!acquireSetupLock(lockPath)) return undefined;
|
|
1000
|
+
|
|
1001
|
+
try {
|
|
1002
|
+
if (previous === undefined) {
|
|
1003
|
+
process.stderr.write(
|
|
1004
|
+
`gentle-shell: first run in ${home.dir}: installing the Gentle AI companion packages (one time; set ${AUTO_SETUP_OPT_OUT_ENV}=1 to skip)\n`,
|
|
1005
|
+
);
|
|
1006
|
+
} else {
|
|
1007
|
+
// A marker written before gentle-pi version tracking existed (S8)
|
|
1008
|
+
// has no `gentlePi` field: needsProvisioning above already treats
|
|
1009
|
+
// that as changed, so this reports "unknown" as its prior value
|
|
1010
|
+
// instead of "undefined".
|
|
1011
|
+
const gentleAiChanged = previous.gentleAi !== pin;
|
|
1012
|
+
const gentlePiChanged = previous.gentlePi !== gentlePiVersion;
|
|
1013
|
+
if (gentleAiChanged && gentlePiChanged) {
|
|
1014
|
+
process.stderr.write(
|
|
1015
|
+
`gentle-shell: gentle-ai pin changed (${previous.gentleAi} -> ${pin}) and gentle-pi changed (${previous.gentlePi ?? "unknown"} -> ${gentlePiVersion}): updating ${home.dir}\n`,
|
|
1016
|
+
);
|
|
1017
|
+
} else if (gentlePiChanged) {
|
|
1018
|
+
process.stderr.write(`gentle-shell: gentle-pi changed (${previous.gentlePi ?? "unknown"} -> ${gentlePiVersion}): updating ${home.dir}\n`);
|
|
1019
|
+
} else {
|
|
1020
|
+
process.stderr.write(`gentle-shell: gentle-ai pin changed (${previous.gentleAi} -> ${pin}): updating ${home.dir}\n`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const result = await runSetupFlow(home, runtime, { dryRun: false, stdio: ["ignore", 2, 2], timeoutMs: resolveAutoSetupTimeoutMs() });
|
|
1025
|
+
// Abort the whole launch only for a launcher-forwarded interrupt
|
|
1026
|
+
// (R3-002); a child that exited via signal on its own (crash, OOM kill,
|
|
1027
|
+
// external kill) falls through to the ordinary-failure branch below,
|
|
1028
|
+
// which warns and still starts pi.
|
|
1029
|
+
if (result.interrupted) return { exitCode: result.exitCode };
|
|
1030
|
+
if (!result.ok) {
|
|
1031
|
+
const remediation = [...homeSelectorFlags(home).map(shellQuote), "setup"].join(" ");
|
|
1032
|
+
process.stderr.write(
|
|
1033
|
+
`gentle-shell: automatic setup failed (exit ${result.exitCode}); starting anyway and retrying next run. Run \`gentle-shell ${remediation}\` to see the full output.\n`,
|
|
1034
|
+
);
|
|
1035
|
+
if (result.message !== undefined) process.stderr.write(`${result.message}\n`);
|
|
1036
|
+
return undefined;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
writeRawConfig(configPath, recordProvisioned(readRawConfig(configPath), homeKey, pin, gentlePiVersion, new Date().toISOString()));
|
|
1040
|
+
return undefined;
|
|
1041
|
+
} finally {
|
|
1042
|
+
releaseSetupLock(lockPath);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
120
1046
|
async function main() {
|
|
121
1047
|
const args = parseLauncherArgs(process.argv.slice(2));
|
|
122
1048
|
if (args.error !== undefined) fail(`${args.error}\nRun 'gentle-shell --help' for usage.`, 2);
|
|
@@ -157,25 +1083,151 @@ async function main() {
|
|
|
157
1083
|
process.exit(0);
|
|
158
1084
|
}
|
|
159
1085
|
|
|
1086
|
+
// Home-ownership signal for auto-provisioning (S9, homeIsForeign): must be
|
|
1087
|
+
// read before the isolated-home bootstrap below creates and seeds a
|
|
1088
|
+
// brand-new --home directory with its own settings.json — after that
|
|
1089
|
+
// bootstrap runs, "did this home already have content" can no longer be
|
|
1090
|
+
// answered by looking at the directory.
|
|
1091
|
+
let homeHadContentBeforeBootstrap = false;
|
|
1092
|
+
if (existsSync(home.dir)) {
|
|
1093
|
+
try {
|
|
1094
|
+
homeHadContentBeforeBootstrap = readdirSync(home.dir).length > 0;
|
|
1095
|
+
} catch {
|
|
1096
|
+
homeHadContentBeforeBootstrap = false;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
160
1100
|
// Isolated-home bootstrap: only on a home gentle-shell has not seen before
|
|
161
1101
|
// (link never bootstraps — it reuses the user's own pi agent home as-is).
|
|
162
1102
|
if ((home.mode === "isolated" || home.mode === "path") && !existsSync(home.dir)) {
|
|
163
1103
|
mkdirSync(home.dir, { recursive: true });
|
|
164
1104
|
await installIsolatedTuiModeSetting(home.dir);
|
|
1105
|
+
writeHomeOwnershipMarker(home); // R3-001: lets a failed first auto-provision attempt still be retried later
|
|
165
1106
|
process.stderr.write(`gentle-shell: using a separate home at ${home.dir}. Run 'gentle-shell --link' to reuse your pi sign-ins and chats.\n`);
|
|
166
1107
|
}
|
|
167
1108
|
|
|
168
|
-
|
|
169
|
-
|
|
1109
|
+
if (args.command === "setup") {
|
|
1110
|
+
await handleSetupCommand(args.commandArgs, home, runtime);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Auto-provision (S7): a plain launch against an isolated or --home home
|
|
1115
|
+
// (never --link) runs the same flow as `gentle-shell setup` automatically
|
|
1116
|
+
// before pi starts, so the maintainer's own packages install without ever
|
|
1117
|
+
// needing to know `setup` exists. Skipped for a pi subcommand
|
|
1118
|
+
// (`gentle-shell install/remove/list/...`) — argv[0] must stay the bare
|
|
1119
|
+
// subcommand for pi to dispatch it, same reason the declaration/take-over
|
|
1120
|
+
// block below skips it. Must run before that block reads settings.json,
|
|
1121
|
+
// so that block sees settings.json exactly as this same auto-provision run
|
|
1122
|
+
// (if any) left it — notably with any npm:gentle-pi declaration already
|
|
1123
|
+
// removed again by runPostInstallCleanup, since the home never actually
|
|
1124
|
+
// keeps that declaration — instead of reading stale pre-setup content
|
|
1125
|
+
// within the same launch. Never lets an unexpected failure here (fs
|
|
1126
|
+
// errors, a lock, a malformed config) block the launch itself (R4): any
|
|
1127
|
+
// throw is caught and only warned about, exactly like an ordinary
|
|
1128
|
+
// setup-flow failure.
|
|
1129
|
+
if ((home.mode === "isolated" || home.mode === "path") && args.piSubcommand === undefined) {
|
|
1130
|
+
let autoProvisionResult;
|
|
1131
|
+
try {
|
|
1132
|
+
autoProvisionResult = await maybeAutoProvisionHome(home, runtime, { homeHadContentBeforeBootstrap });
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
process.stderr.write(`gentle-shell: automatic setup failed unexpectedly (${error.message}); starting anyway and retrying next run.\n`);
|
|
1135
|
+
autoProvisionResult = undefined;
|
|
1136
|
+
}
|
|
1137
|
+
// Only an interrupt reaching the spawned child (SIGINT/SIGTERM/SIGHUP)
|
|
1138
|
+
// returns a result here: the user asked this process to stop, so it
|
|
1139
|
+
// exits with the same signal-derived code instead of falling through
|
|
1140
|
+
// to launch pi.
|
|
1141
|
+
if (autoProvisionResult !== undefined) process.exit(autoProvisionResult.exitCode);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const packageRootExplicit = args.packageRoot !== undefined;
|
|
1145
|
+
const effectivePackageRoot = packageRootExplicit ? resolvePath(args.packageRoot) : packageRoot;
|
|
1146
|
+
// R4-forced-package-root-unvalidated / R3-005: an unvalidated --package-root
|
|
1147
|
+
// forces a take-over (dropping normal extension discovery via
|
|
1148
|
+
// --no-extensions) and then hands pi -e/--theme/--skill/--prompt-template
|
|
1149
|
+
// flags pointing at directories that do not exist, turning an operator typo
|
|
1150
|
+
// into an obscure pi loader failure instead of a clear launcher error.
|
|
1151
|
+
if (packageRootExplicit && !isDirectory(effectivePackageRoot)) {
|
|
1152
|
+
fail(`--package-root ${args.packageRoot} does not exist or is not a directory.`, 2);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
let declaration;
|
|
1156
|
+
let takeOver = false;
|
|
1157
|
+
let otherPackagePaths = [];
|
|
1158
|
+
let looseExtensionEntries = [];
|
|
1159
|
+
|
|
1160
|
+
// Every mode consults the home's own settings.json for a gentle-pi
|
|
1161
|
+
// declaration, not just --link: `gentle-shell setup` installs
|
|
1162
|
+
// npm:gentle-pi into an isolated or --home home's settings.json, and once
|
|
1163
|
+
// that declaration exists the launcher must stop injecting its own copy
|
|
1164
|
+
// on top of it (buildPiInvocation skips injection whenever a declaration
|
|
1165
|
+
// is present and there is no take-over). A path declaration in a
|
|
1166
|
+
// non-link home follows the same take-over rules as --link. A home
|
|
1167
|
+
// without any declaration keeps the plain injection, unchanged.
|
|
1168
|
+
//
|
|
1169
|
+
// --package-root only forces a take-over in --link mode: an isolated or
|
|
1170
|
+
// --home target has no pre-existing pi installation to defer to, so
|
|
1171
|
+
// forcing --no-extensions there would just strip its own settings-driven
|
|
1172
|
+
// discovery for no benefit (see the "gated on link mode" bin test). A pi
|
|
1173
|
+
// subcommand skips this whole block: buildPiInvocation ignores
|
|
1174
|
+
// takeOver/declaration once piSubcommand is set, and running the
|
|
1175
|
+
// take-over/loose-dir discovery anyway would still print a misleading
|
|
1176
|
+
// "taking over gentle-pi..." message (and otherPackageInjections
|
|
1177
|
+
// warnings) for a plain `gentle-shell install npm:x` that never actually
|
|
1178
|
+
// takes anything over.
|
|
1179
|
+
if (args.piSubcommand === undefined) {
|
|
170
1180
|
const settingsText = readJsonIfExists(join(home.dir, "settings.json"));
|
|
171
|
-
|
|
1181
|
+
declaration = findGentlePiDeclaration(settingsText, { agentDir: home.dir, readPackageName });
|
|
1182
|
+
// --package-root only forces a take-over in --link mode (see below);
|
|
1183
|
+
// in every other mode a declared home silently keeps using its
|
|
1184
|
+
// declared gentle-pi and --package-root has no effect at all. Warn
|
|
1185
|
+
// once so an operator does not assume --package-root took effect.
|
|
1186
|
+
if (packageRootExplicit && home.mode !== "link" && declaration !== undefined) {
|
|
1187
|
+
process.stderr.write(
|
|
1188
|
+
`gentle-shell: --package-root only forces a take-over in --link mode; ${home.dir} declares gentle-pi, so the installed package is used and ${args.packageRoot} is ignored\n`,
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
const realEffectivePackageRoot = safeRealpath(effectivePackageRoot);
|
|
1192
|
+
const realDeclaredDir = declaration?.kind === "path" ? safeRealpath(declaration.dir) : undefined;
|
|
1193
|
+
takeOver = decideTakeOver({
|
|
1194
|
+
declaration,
|
|
1195
|
+
realPackageRoot: realEffectivePackageRoot,
|
|
1196
|
+
realDeclaredDir,
|
|
1197
|
+
packageRootExplicit: home.mode === "link" && packageRootExplicit,
|
|
1198
|
+
});
|
|
1199
|
+
if (takeOver) {
|
|
1200
|
+
const skip = declaration ?? { kind: "path", dir: realEffectivePackageRoot };
|
|
1201
|
+
const injections = otherPackageInjections({ settingsText, agentDir: home.dir, skip, isDirectory, realpath: safeRealpath });
|
|
1202
|
+
otherPackagePaths = injections.paths;
|
|
1203
|
+
for (const warning of injections.warnings) process.stderr.write(`${warning}\n`);
|
|
1204
|
+
// --no-extensions drops pi's normal settings-driven extension
|
|
1205
|
+
// discovery, which also covers loose (non-package) extensions
|
|
1206
|
+
// under <agentDir>/extensions and the project-local
|
|
1207
|
+
// <cwd>/.pi/extensions. Re-injecting either directory wholesale
|
|
1208
|
+
// as `-e <dir>` does not work for a directory of loose files: pi's
|
|
1209
|
+
// -e flag hands the path straight to its module loader with no
|
|
1210
|
+
// directory-discovery pass, so a bare directory of loose files
|
|
1211
|
+
// fails with "Cannot find module ...". Resolve each candidate
|
|
1212
|
+
// into its actual loose file entries (or pass it through
|
|
1213
|
+
// unchanged when it is itself a self-contained extension) so a
|
|
1214
|
+
// take-over does not silently stop loading them.
|
|
1215
|
+
looseExtensionEntries = [join(home.dir, "extensions"), join(process.cwd(), ".pi", "extensions")].flatMap(resolveLooseExtensionEntries);
|
|
1216
|
+
const declaredFrom = declaration === undefined ? "the requested package root" : declaration.kind === "npm" ? "npm:gentle-pi" : declaration.dir;
|
|
1217
|
+
process.stderr.write(
|
|
1218
|
+
`gentle-shell: taking over gentle-pi from ${declaredFrom} for this run (settings unchanged; its skills, prompts, and themes still load alongside this launcher's).\n`,
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
172
1221
|
}
|
|
173
1222
|
|
|
174
1223
|
const invocation = buildPiInvocation({
|
|
175
1224
|
runtime,
|
|
176
1225
|
home,
|
|
177
|
-
packageRoot,
|
|
178
|
-
|
|
1226
|
+
packageRoot: effectivePackageRoot,
|
|
1227
|
+
declaration,
|
|
1228
|
+
takeOver,
|
|
1229
|
+
otherPackagePaths,
|
|
1230
|
+
looseExtensionEntries,
|
|
179
1231
|
passthrough: args.passthrough,
|
|
180
1232
|
piSubcommand: args.piSubcommand,
|
|
181
1233
|
baseEnv: process.env,
|