gentle-pi 4.2.0 → 4.3.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 +336 -137
- package/assets/agents/gentle-ai-explore.md +1 -0
- package/assets/agents/gentle-ai-verify.md +6 -4
- package/assets/agents/gentle-ai-worker.md +7 -1
- package/assets/orchestrator-delegation-reference.md +163 -0
- package/assets/orchestrator-delegation.md +25 -176
- package/assets/orchestrator-tracking.md +1 -1
- package/assets/orchestrator-verification.md +14 -8
- package/assets/orchestrator-writer.md +5 -1
- package/assets/orchestrator.md +9 -9
- package/bin/gentle-shell.mjs +170 -22
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
- package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
- package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/assets/diagrams/agent-orchestration.svg +54 -15
- package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
- package/docs/assets/diagrams/measured-routing.svg +49 -0
- package/docs/assets/diagrams/native-review.svg +52 -12
- package/docs/assets/diagrams/odd-workflow.svg +128 -66
- package/docs/assets/diagrams/permission-modes.svg +51 -0
- package/docs/assets/features/audio-notifications.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/bundled-install.md +33 -3
- package/docs/delegated-verification.md +10 -8
- package/docs/gentle-agents-activity.md +11 -5
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +5 -4
- package/docs/install-wizard.md +8 -1
- package/docs/permission-modes.md +1 -1
- package/docs/readme-reference.md +107 -19
- package/docs/yolo-mode.md +21 -8
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +99 -12
- package/extensions/gentle-ai.ts +284 -52
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-shell.ts +27 -12
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +365 -0
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agents-protocol.ts +3 -0
- package/lib/agents-runner.ts +55 -6
- package/lib/agents-view.ts +3 -1
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/command-palette-catalog.ts +0 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +84 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-shell-bundled.ts +39 -3
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +145 -11
- package/lib/gentle-shell-uninstall.ts +18 -1
- package/lib/herdr-activity.ts +24 -0
- package/lib/native-review-cli.ts +9 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +9 -0
- package/lib/orchestrator-scope.ts +21 -3
- package/lib/provider-contract-bundle.ts +99 -53
- package/lib/review-candidate-view-owner.ts +29 -19
- package/lib/review-candidate-view.ts +36 -2
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-scratch.ts +474 -0
- package/lib/shell-bar.ts +25 -25
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-todo.ts +23 -4
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +29 -0
- package/lib/yolo-temp-scratch.ts +69 -0
- package/package.json +1 -2
- package/runtime/gentle-ai-binary.mjs +28 -7
- package/runtime/gentle-shell-bundled.mjs +38 -2
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +143 -9
- package/runtime/gentle-shell-uninstall.mjs +18 -1
- package/runtime/native-review-cli.mjs +8 -0
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +125 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-server.mjs +14 -4
- package/scripts/main-channel.mjs +3 -2
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +19 -13
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-runner.test.ts +260 -3
- package/tests/autonomous-guard.test.ts +122 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +14 -4
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +159 -0
- package/tests/delegate-for-reason-contract.test.ts +91 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +1 -1
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
- package/tests/gauge-theme.test.ts +120 -0
- package/tests/gentle-agents.test.ts +282 -3
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-child-guards.test.ts +61 -0
- package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +169 -6
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +79 -0
- package/tests/gentle-shell-uninstall.test.ts +30 -0
- package/tests/gentle-shell.test.ts +42 -9
- package/tests/gentle-todo.test.ts +179 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +33 -4
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-parity.test.ts +22 -1
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +19 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +66 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-manifest.test.ts +24 -20
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/primary-session-prompt.test.ts +101 -0
- package/tests/provider-contract-bundle.test.ts +118 -0
- package/tests/provider-contract-mirror.test.ts +24 -0
- package/tests/quiet-tool-rendering.test.ts +23 -0
- package/tests/rdd-aware-verification-contract.test.ts +29 -11
- package/tests/rdd-status-line.test.ts +79 -0
- package/tests/review-agent-end-preflight.test.ts +6 -0
- package/tests/review-candidate-view.test.ts +49 -0
- package/tests/review-contract-prompt.test.ts +5 -2
- package/tests/review-controller-native-routing.test.ts +14 -1
- package/tests/review-controller.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +59 -2
- package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-risk-assessment.test.ts +163 -76
- package/tests/review-session-standing-permission-controller.test.ts +14 -1
- package/tests/review-session-standing-permission.test.ts +27 -1
- package/tests/run-test-suite.test.ts +15 -0
- package/tests/runtime-harness.mjs +16 -6
- package/tests/session-changes-shell.test.ts +1 -1
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +945 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/shell-bar.test.ts +7 -0
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/small-task-inline-contract.test.ts +37 -0
- package/tests/standalone-config-home.test.ts +324 -0
- package/tests/startup-banner.test.ts +5 -4
- package/tests/startup-home-repo.test.ts +34 -0
- package/tests/support/orchestrator-modules.ts +1 -0
- package/tests/task-size-routing-contract.test.ts +69 -9
- package/tests/test-scratch-contract.test.ts +102 -0
- package/tests/test-strength-contract.test.ts +36 -1
- package/tests/tool-batching-contract.test.ts +64 -0
- package/tests/verification-proportionality.test.ts +46 -0
- package/tests/verifier-admission.test.ts +137 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/windows-hidden-processes.test.ts +5 -0
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-mode.test.ts +18 -0
- package/tests/yolo-temp-scratch.test.ts +71 -0
- package/scripts/mirror-odd-routing.mjs +0 -242
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -239,7 +239,7 @@ const FAKE_PI = [
|
|
|
239
239
|
"const args = process.argv.slice(2);",
|
|
240
240
|
"if (args[0] === 'install' && args[1] === 'npm:gentle-engram@0.4.2' && process.env.FIXTURE_INSTALL_CAPABILITY !== '0') {",
|
|
241
241
|
" const home = process.env.PI_CODING_AGENT_DIR; const metadata = join(home, 'npm/node_modules/gentle-engram/package.json'); mkdirSync(dirname(metadata), {recursive:true});",
|
|
242
|
-
" writeFileSync(metadata, JSON.stringify({name:'gentle-engram',version:'0.4.2',engram:{startupPolicy:1}}));",
|
|
242
|
+
" writeFileSync(metadata, JSON.stringify({name:'gentle-engram',version:'0.4.2',engram:{startupPolicy:1,...(process.env.FIXTURE_OWNED_COMPANION === '0' ? {} : {sessionOwned:1})}}));",
|
|
243
243
|
" const path = join(home,'settings.json'); let settings={}; try {settings=JSON.parse(readFileSync(path,'utf8'));} catch {}",
|
|
244
244
|
" if (process.env.FIXTURE_INSTALL_CAPABILITY !== 'metadata-only') { const packages = settings.packages ?? []; const index = packages.findIndex(p => /^npm:gentle-engram(?:@|$)/.test(typeof p === 'string' ? p : p.source));",
|
|
245
245
|
" if (index < 0) packages.push(args[1]); else packages[index] = typeof packages[index] === 'string' ? args[1] : {...packages[index], source:args[1]}; settings.packages = packages; writeFileSync(path, JSON.stringify(settings)); }",
|
|
@@ -255,7 +255,7 @@ const FAKE_PI = [
|
|
|
255
255
|
" }",
|
|
256
256
|
"}",
|
|
257
257
|
"const pick = ['PATH', 'GENTLE_SHELL_PI', 'GENTLE_PI_AGENTS_PI', 'PI_SKIP_VERSION_CHECK', 'npm_config_prefix', 'npm_config_registry', 'NODE_OPTIONS', 'PI_CODING_AGENT_DIR',",
|
|
258
|
-
"\t'GENTLE_SHELL_PREFIX', 'GENTLE_PI_CONFIG_HOME', 'ENGRAM_BIN', 'ENGRAM_URL', 'ENGRAM_STARTUP_POLICY', 'ENGRAM_STARTUP_NOTICE', 'GENTLE_AI_HOME', 'GENTLE_AI_PI_CONFIG_DIR', 'GENTLE_AI_ENGRAM_BIN', 'GENTLE_AI_NO_SELF_UPDATE', 'GH_TOKEN', 'ENGRAM_NO_UPDATE_CHECK'];",
|
|
258
|
+
"\t'GENTLE_SHELL_PREFIX', 'GENTLE_PI_CONFIG_HOME', 'ENGRAM_BIN', 'ENGRAM_URL', 'ENGRAM_SERVER_MODE', 'ENGRAM_SOCKET', 'ENGRAM_STARTUP_POLICY', 'ENGRAM_STARTUP_NOTICE', 'GENTLE_AI_HOME', 'GENTLE_AI_PI_CONFIG_DIR', 'GENTLE_AI_ENGRAM_BIN', 'GENTLE_AI_NO_SELF_UPDATE', 'GH_TOKEN', 'ENGRAM_NO_UPDATE_CHECK'];",
|
|
259
259
|
"const record = join(process.env.GENTLE_SHELL_PREFIX ?? '', 'config', 'engram-provenance.json');",
|
|
260
260
|
"const provenance = (() => { try { return JSON.parse(readFileSync(record, 'utf8')); } catch { return null; } })();",
|
|
261
261
|
"console.log(JSON.stringify({ cli: process.argv[1], args, provenance, env: Object.fromEntries(pick.map((k) => [k, process.env[k]])) }));",
|
|
@@ -264,6 +264,7 @@ const FAKE_PI = [
|
|
|
264
264
|
].join("\n");
|
|
265
265
|
const FIXTURE_CORE = [
|
|
266
266
|
"#!/bin/sh",
|
|
267
|
+
'if [ "$1" = --help ] && [ "$FIXTURE_OWNED_CORE" != 0 ]; then echo " serve --session-owned Private ephemeral HTTP server"; exit 0; fi',
|
|
267
268
|
'if [ "$1" != instance-id ]; then echo engram; exit 0; fi',
|
|
268
269
|
'[ -z "$FIXTURE_PROVISION_ENV" ] || env > "$FIXTURE_PROVISION_ENV"',
|
|
269
270
|
'[ -z "$FIXTURE_INSTANCE_ID_FAIL" ] || exit 3',
|
|
@@ -457,10 +458,170 @@ test("provenance mismatches disable memory behind the guard: instance, core dige
|
|
|
457
458
|
assert.equal(result.status, 0, `${name}: ${result.stderr}`);
|
|
458
459
|
const seen = lastLaunch(result);
|
|
459
460
|
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN], ["disabled", prefix.guard], name);
|
|
460
|
-
assert.match(result.stderr, /memory disabled.*could not verify.*compatibility.*Back up/s, name);
|
|
461
|
+
assert.match(result.stderr, name === "core digest" ? /memory disabled.*bundled core changed.*memory migrate --confirm-quiescent/s : /memory disabled.*could not verify.*compatibility.*Back up/s, name);
|
|
461
462
|
}
|
|
462
463
|
});
|
|
463
464
|
|
|
465
|
+
function migrationFixture(t: TestContext, outcome = "success") {
|
|
466
|
+
const prefix = bundledPrefix(t);
|
|
467
|
+
assert.equal(prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID }).status, 0);
|
|
468
|
+
const source = join(prefix.home, ".engram");
|
|
469
|
+
writeFileSync(join(source, "config.json"), '{"preserved":true}\n');
|
|
470
|
+
const originalRecord = readFileSync(provenancePath(prefix), "utf8");
|
|
471
|
+
const sourceBytes = readFileSync(join(source, "engram.db"));
|
|
472
|
+
const invocation = join(prefix.home, "migration-invoked");
|
|
473
|
+
writeFileSync(prefix.engram, `#!${process.execPath}\nimport {cpSync,writeFileSync} from 'node:fs';\nconst args=process.argv.slice(2);\nif(args[0]==='--help'){console.log(${JSON.stringify(outcome === "unsupported" ? "Commands: compatibility" : " migrate-copy --data-dir <absolute-source> --output-dir <new-absolute-destination> --confirm-quiescent --json\n serve --session-owned Private ephemeral HTTP server")});process.exit(0);}\nwriteFileSync(${JSON.stringify(invocation)},JSON.stringify({args,token:process.env.GH_TOKEN,cloud:process.env.ENGRAM_CLOUD_TOKEN}));\nif(args[0]!=='migrate-copy') process.exit(2);\nconst source=args[args.indexOf('--data-dir')+1],output=args[args.indexOf('--output-dir')+1];\nif(${JSON.stringify(outcome)}==='failure'){process.stderr.write('migration rejected\\n');process.exit(1);}\ncpSync(source,output,{recursive:true});\nconsole.log(JSON.stringify({source_dir:source,output_dir:output,instance_id:${JSON.stringify(outcome === "wrong-identity" ? OTHER_STORE_ID : STORE_ID)},migration_completed:true}));\n`, { mode: 0o700 });
|
|
474
|
+
return { ...prefix, source, originalRecord, sourceBytes, invocation };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
test("bundled local memory selects a verified session-owned provider instead of a detached shared server", { skip: posixHost }, (t) => {
|
|
478
|
+
const prefix = bundledPrefix(t);
|
|
479
|
+
const result = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID });
|
|
480
|
+
assert.equal(result.status, 0, result.stderr);
|
|
481
|
+
assert.deepEqual(memoryOf(result), ["allow", prefix.engram]);
|
|
482
|
+
const seen = lastLaunch(result);
|
|
483
|
+
assert.equal(seen.env.ENGRAM_SERVER_MODE, "session-owned");
|
|
484
|
+
assert.equal(seen.env.ENGRAM_URL, undefined);
|
|
485
|
+
assert.equal(seen.env.ENGRAM_SOCKET, undefined);
|
|
486
|
+
assert.equal(seen.provenance.instanceId, STORE_ID);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("missing private capabilities never silently fall back or provision a store", { skip: posixHost }, (t) => {
|
|
490
|
+
for (const env of [{ FIXTURE_OWNED_CORE: "0" }, { FIXTURE_OWNED_COMPANION: "0" }]) {
|
|
491
|
+
const prefix = bundledPrefix(t);
|
|
492
|
+
const result = prefix.run([], { ...env, FIXTURE_CREATE_STORE: STORE_ID });
|
|
493
|
+
assert.equal(result.status, 0, result.stderr);
|
|
494
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
|
|
495
|
+
assert.match(result.stderr, /session-owned.*supporting published/s);
|
|
496
|
+
assert.equal(existsSync(join(prefix.home, ".engram")), false);
|
|
497
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("every selected companion must advertise private support, including mixed local and npm selections", { skip: posixHost }, (t) => {
|
|
502
|
+
const prefix = bundledPrefix(t);
|
|
503
|
+
const local = join(prefix.home, "legacy-companion");
|
|
504
|
+
mkdirSync(local);
|
|
505
|
+
writeFileSync(join(local, "package.json"), JSON.stringify({ name: "gentle-engram", engram: { startupPolicy: 1 } }));
|
|
506
|
+
writeFileSync(prefix.metadata, JSON.stringify({ name: "gentle-engram", engram: { startupPolicy: 1, sessionOwned: 1 } }));
|
|
507
|
+
const settings = join(prefix.home, ".gentle-shell", "agent", "settings.json");
|
|
508
|
+
const selected = JSON.stringify({ packages: [local, "npm:gentle-engram@0.4.2"] });
|
|
509
|
+
writeFileSync(settings, selected);
|
|
510
|
+
const result = prefix.run([], { FIXTURE_CREATE_STORE: STORE_ID });
|
|
511
|
+
assert.equal(result.status, 0, result.stderr);
|
|
512
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
|
|
513
|
+
assert.match(result.stderr, /session-owned.*supporting published/s);
|
|
514
|
+
assert.deepEqual(JSON.parse(readFileSync(settings, "utf8")).packages, JSON.parse(selected).packages);
|
|
515
|
+
assert.equal(existsSync(join(prefix.home, ".engram")), false);
|
|
516
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("explicit empty or conflicting private modes remain invalid without provisioning", { skip: posixHost }, (t) => {
|
|
520
|
+
for (const mode of ["", "shared", " "]) {
|
|
521
|
+
const prefix = bundledPrefix(t);
|
|
522
|
+
const result = prefix.run([], { ENGRAM_SERVER_MODE: mode, FIXTURE_CREATE_STORE: STORE_ID });
|
|
523
|
+
assert.equal(result.status, 0, result.stderr);
|
|
524
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
|
|
525
|
+
assert.equal(lastLaunch(result).env.ENGRAM_SERVER_MODE, mode);
|
|
526
|
+
assert.match(result.stderr, /explicit ENGRAM_SERVER_MODE.*unset.*session-owned/s);
|
|
527
|
+
assert.equal(existsSync(join(prefix.home, ".engram")), false);
|
|
528
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("an explicit Engram socket remains untouched and is never probed", { skip: posixHost }, (t) => {
|
|
533
|
+
const prefix = bundledPrefix(t);
|
|
534
|
+
const result = prefix.run([], { ENGRAM_SOCKET: "/fixture/selected.sock", FIXTURE_NO_PROBE: "1" });
|
|
535
|
+
assert.equal(result.status, 0, result.stderr);
|
|
536
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
|
|
537
|
+
assert.equal(lastLaunch(result).env.ENGRAM_SOCKET, "/fixture/selected.sock");
|
|
538
|
+
assert.equal(existsSync(join(prefix.home, ".engram")), false);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("a stopped Shell store with a changed core gets migration guidance, not a server upgrade guess", { skip: posixHost }, (t) => {
|
|
542
|
+
const prefix = migrationFixture(t);
|
|
543
|
+
const result = prefix.run([]);
|
|
544
|
+
assert.equal(result.status, 0, result.stderr);
|
|
545
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
|
|
546
|
+
assert.match(result.stderr, /bundled core changed/);
|
|
547
|
+
assert.match(result.stderr, /gentle-shell memory migrate --confirm-quiescent/);
|
|
548
|
+
assert.equal(existsSync(prefix.invocation), false);
|
|
549
|
+
assert.equal(readFileSync(provenancePath(prefix), "utf8"), prefix.originalRecord);
|
|
550
|
+
assert.deepEqual(readFileSync(join(prefix.source, "engram.db")), prefix.sourceBytes);
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("memory migrate explicitly promotes a verified copy and retains the original store", { skip: posixHost }, (t) => {
|
|
554
|
+
const prefix = migrationFixture(t);
|
|
555
|
+
const result = prefix.run(["memory", "migrate", "--confirm-quiescent"], { FIXTURE_NO_PROBE: "1", GH_TOKEN: "fixture-secret", ENGRAM_CLOUD_TOKEN: "fixture-cloud" });
|
|
556
|
+
assert.equal(result.status, 0, result.stderr);
|
|
557
|
+
assert.equal(result.stderr, "");
|
|
558
|
+
const receipt = JSON.parse(result.stdout);
|
|
559
|
+
assert.deepEqual(Object.keys(receipt).sort(), ["backup_dir", "instance_id", "status"]);
|
|
560
|
+
assert.equal(receipt.status, "migrated");
|
|
561
|
+
assert.equal(receipt.instance_id, STORE_ID);
|
|
562
|
+
assert.deepEqual(readFileSync(join(receipt.backup_dir, "engram.db")), prefix.sourceBytes);
|
|
563
|
+
assert.equal(readFileSync(join(receipt.backup_dir, "config.json"), "utf8"), '{"preserved":true}\n');
|
|
564
|
+
assert.equal(readFileSync(join(prefix.source, "config.json"), "utf8"), '{"preserved":true}\n');
|
|
565
|
+
assert.deepEqual(JSON.parse(readFileSync(provenancePath(prefix), "utf8")), { schema: 1, dataDir: prefix.source, instanceId: STORE_ID, coreSha256: sha256File(prefix.engram) });
|
|
566
|
+
const invoked = JSON.parse(readFileSync(prefix.invocation, "utf8"));
|
|
567
|
+
assert.equal(invoked.args[0], "migrate-copy");
|
|
568
|
+
assert.equal(invoked.token, undefined);
|
|
569
|
+
assert.equal(invoked.cloud, undefined);
|
|
570
|
+
assert.equal(existsSync(join(prefix.root, "config", "engram-migration.json")), false);
|
|
571
|
+
assert.deepEqual(memoryOf(prefix.run([])), ["allow", prefix.engram]);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("memory migrate rejects missing confirmation and malformed flags without invoking Engram", { skip: posixHost }, (t) => {
|
|
575
|
+
const prefix = migrationFixture(t);
|
|
576
|
+
for (const args of [["memory", "migrate"], ["memory", "migrate", "--confirm-quiescent", "--unknown"], ["memory", "migrate", "--confirm-quiescent", "--confirm-quiescent"]]) {
|
|
577
|
+
const result = prefix.run(args, { FIXTURE_NO_PROBE: "1" });
|
|
578
|
+
assert.equal(result.status, 2, result.stderr);
|
|
579
|
+
assert.equal(result.stdout, "");
|
|
580
|
+
assert.equal(result.stderr, "Usage: gentle-shell memory migrate --confirm-quiescent\nStop every Engram client before running this command.\n");
|
|
581
|
+
assert.equal(existsSync(prefix.invocation), false);
|
|
582
|
+
assert.equal(readFileSync(provenancePath(prefix), "utf8"), prefix.originalRecord);
|
|
583
|
+
assert.deepEqual(readFileSync(join(prefix.source, "engram.db")), prefix.sourceBytes);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("memory migrate never sends an unknown command to an older bundled core", { skip: posixHost }, (t) => {
|
|
588
|
+
const prefix = migrationFixture(t, "unsupported");
|
|
589
|
+
const result = prefix.run(["memory", "migrate", "--confirm-quiescent"], { FIXTURE_NO_PROBE: "1" });
|
|
590
|
+
assert.equal(result.status, 1, result.stderr);
|
|
591
|
+
assert.equal(result.stdout, "");
|
|
592
|
+
assert.match(result.stderr, /does not support migrate-copy/);
|
|
593
|
+
assert.equal(existsSync(prefix.invocation), false);
|
|
594
|
+
assert.equal(readFileSync(provenancePath(prefix), "utf8"), prefix.originalRecord);
|
|
595
|
+
assert.deepEqual(readFileSync(join(prefix.source, "engram.db")), prefix.sourceBytes);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("memory migrate refuses provider failure and mismatched evidence without changing provenance or source", { skip: posixHost }, (t) => {
|
|
599
|
+
for (const outcome of ["failure", "wrong-identity"]) {
|
|
600
|
+
const prefix = migrationFixture(t, outcome);
|
|
601
|
+
const result = prefix.run(["memory", "migrate", "--confirm-quiescent"], { FIXTURE_NO_PROBE: "1" });
|
|
602
|
+
assert.equal(result.status, 1, result.stderr);
|
|
603
|
+
assert.equal(result.stdout, "");
|
|
604
|
+
assert.match(result.stderr, /^gentle-shell: memory migration failed:/);
|
|
605
|
+
assert.equal(readFileSync(provenancePath(prefix), "utf8"), prefix.originalRecord);
|
|
606
|
+
assert.deepEqual(readFileSync(join(prefix.source, "engram.db")), prefix.sourceBytes);
|
|
607
|
+
assert.equal(existsSync(join(prefix.root, "config", "engram-migration.json")), false);
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("an interrupted memory promotion blocks fresh provisioning until recovery", { skip: posixHost }, (t) => {
|
|
612
|
+
const prefix = bundledPrefix(t);
|
|
613
|
+
mkdirSync(join(prefix.root, "config"), { recursive: true });
|
|
614
|
+
const marker = join(prefix.root, "config", "engram-migration.json");
|
|
615
|
+
writeFileSync(marker, '{"interrupted":true}\n');
|
|
616
|
+
const result = prefix.run([], { FIXTURE_NO_PROBE: "1" });
|
|
617
|
+
assert.equal(result.status, 0, result.stderr);
|
|
618
|
+
assert.deepEqual(memoryOf(result), ["disabled", prefix.guard]);
|
|
619
|
+
assert.match(result.stderr, /incomplete memory migration/);
|
|
620
|
+
assert.equal(existsSync(join(prefix.home, ".engram")), false);
|
|
621
|
+
assert.equal(readFileSync(marker, "utf8"), '{"interrupted":true}\n');
|
|
622
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
623
|
+
});
|
|
624
|
+
|
|
464
625
|
test("public help explains compatibility evidence without promising update recovery", { skip: posixHost }, (t) => {
|
|
465
626
|
const prefix = bundledPrefix(t);
|
|
466
627
|
const result = prefix.run(["--help"]);
|
|
@@ -497,7 +658,8 @@ test("a listening server is reused for Shell's store only with the recorded /hea
|
|
|
497
658
|
assert.equal(result.status, 0, `${name}: ${result.stderr}`);
|
|
498
659
|
const seen = lastLaunch(result);
|
|
499
660
|
assert.deepEqual([seen.env.ENGRAM_STARTUP_POLICY, seen.env.ENGRAM_BIN, seen.env.ENGRAM_URL],
|
|
500
|
-
[policy, policy === "allow" ?
|
|
661
|
+
[policy, prefix.guard, policy === "allow" ? "http://127.0.0.1:7437" : undefined], name);
|
|
662
|
+
assert.equal(seen.env.ENGRAM_SERVER_MODE, undefined, name);
|
|
501
663
|
}
|
|
502
664
|
});
|
|
503
665
|
|
|
@@ -720,8 +882,9 @@ test("capability readback also requires Pi to declare the selected pinned compan
|
|
|
720
882
|
const result = prefix.run([], { FIXTURE_INSTALL_CAPABILITY: "metadata-only" });
|
|
721
883
|
assert.equal(result.status, 0, result.stderr);
|
|
722
884
|
assert.match(result.stderr, /startupPolicy/);
|
|
723
|
-
//
|
|
724
|
-
assert.
|
|
885
|
+
// Unverified companion capability cannot authorize even fresh provisioning.
|
|
886
|
+
assert.equal(existsSync(join(prefix.home, ".engram")), false);
|
|
887
|
+
assert.equal(existsSync(provenancePath(prefix)), false);
|
|
725
888
|
});
|
|
726
889
|
|
|
727
890
|
test("pinning preserves resource filters and unrelated settings and does not reinstall next launch", { skip: posixHost }, (t) => {
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { formatImportPiReport, importPi, nodeImportPiFs } from "../lib/gentle-shell-import-pi.ts";
|
|
8
|
+
|
|
9
|
+
// `gentle-shell home import-pi` core: real temp directories, never the real
|
|
10
|
+
// ~/.pi or ~/.gentle-shell. Credential contents never appear in assertions.
|
|
11
|
+
|
|
12
|
+
const AUTH = JSON.stringify({ anthropic: { type: "oauth", refresh: "dummy-refresh", access: "dummy-access" } });
|
|
13
|
+
const MCP = JSON.stringify({ mcpServers: { demo: { command: "demo" } } }, null, 2);
|
|
14
|
+
|
|
15
|
+
function fixture(t: test.TestContext) {
|
|
16
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-shell-import-pi-"));
|
|
17
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
18
|
+
const source = join(root, "pi-agent");
|
|
19
|
+
const target = join(root, "gentle-agent");
|
|
20
|
+
mkdirSync(source, { recursive: true });
|
|
21
|
+
mkdirSync(target, { recursive: true });
|
|
22
|
+
return { root, source, target };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function seedSource(source: string) {
|
|
26
|
+
writeFileSync(join(source, "auth.json"), AUTH, { mode: 0o644 });
|
|
27
|
+
chmodSync(join(source, "auth.json"), 0o644);
|
|
28
|
+
writeFileSync(join(source, "mcp.json"), MCP);
|
|
29
|
+
chmodSync(join(source, "mcp.json"), 0o640);
|
|
30
|
+
writeFileSync(join(source, "settings.json"), `${JSON.stringify({
|
|
31
|
+
defaultProvider: "anthropic",
|
|
32
|
+
defaultModel: "claude-opus-5-5",
|
|
33
|
+
defaultThinkingLevel: "high",
|
|
34
|
+
packages: ["npm:foreign"],
|
|
35
|
+
extensions: ["./x.ts"],
|
|
36
|
+
theme: "dark",
|
|
37
|
+
npmCommand: ["npm"],
|
|
38
|
+
}, null, 2)}\n`);
|
|
39
|
+
writeFileSync(join(source, "mcp-cache.json"), "{}");
|
|
40
|
+
writeFileSync(join(source, "mcp-npx-cache.json"), "{}");
|
|
41
|
+
writeFileSync(join(source, "AGENTS.md"), "# agents\n");
|
|
42
|
+
mkdirSync(join(source, "sessions"));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function snapshot(dir: string) {
|
|
46
|
+
return Object.fromEntries(readdirSync(dir).sort().map((name) => {
|
|
47
|
+
const stat = statSync(join(dir, name));
|
|
48
|
+
const hash = stat.isFile() ? createHash("sha256").update(readFileSync(join(dir, name))).digest("hex") : "dir";
|
|
49
|
+
return [name, { hash, mode: stat.mode, mtimeMs: stat.mtimeMs }];
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function statusOf(result: ReturnType<typeof importPi>, item: string) {
|
|
54
|
+
return result.items.find((entry) => entry.item === item)?.status;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const modeOf = (path: string) => statSync(path).mode & 0o777;
|
|
58
|
+
|
|
59
|
+
test("copies auth.json (0600) and mcp.json (source mode) only when absent in the target", (t) => {
|
|
60
|
+
const f = fixture(t);
|
|
61
|
+
seedSource(f.source);
|
|
62
|
+
const result = importPi({ source: f.source, target: f.target, dryRun: false });
|
|
63
|
+
assert.equal(result.failed, false);
|
|
64
|
+
assert.equal(statusOf(result, "auth.json"), "copied");
|
|
65
|
+
assert.equal(statusOf(result, "mcp.json"), "copied");
|
|
66
|
+
assert.ok(readFileSync(join(f.target, "auth.json"), "utf8") === AUTH, "auth.json bytes copied verbatim");
|
|
67
|
+
assert.ok(readFileSync(join(f.target, "mcp.json"), "utf8") === MCP);
|
|
68
|
+
if (process.platform !== "win32") {
|
|
69
|
+
assert.equal(modeOf(join(f.target, "auth.json")), 0o600);
|
|
70
|
+
assert.equal(modeOf(join(f.target, "mcp.json")), 0o640);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("never overwrites a target file that already exists and is idempotent", (t) => {
|
|
75
|
+
const f = fixture(t);
|
|
76
|
+
seedSource(f.source);
|
|
77
|
+
writeFileSync(join(f.target, "auth.json"), "{\"mine\":true}");
|
|
78
|
+
const first = importPi({ source: f.source, target: f.target, dryRun: false });
|
|
79
|
+
assert.equal(statusOf(first, "auth.json"), "present");
|
|
80
|
+
assert.ok(readFileSync(join(f.target, "auth.json"), "utf8") === "{\"mine\":true}", "existing target auth.json kept");
|
|
81
|
+
const before = snapshot(f.target);
|
|
82
|
+
const second = importPi({ source: f.source, target: f.target, dryRun: false });
|
|
83
|
+
assert.equal(second.failed, false);
|
|
84
|
+
assert.ok(second.items.every((entry) => entry.status !== "copied"), "a second run copies nothing");
|
|
85
|
+
assert.deepEqual(snapshot(f.target), before);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("never modifies the source: content, mode and mtime stay the same", (t) => {
|
|
89
|
+
const f = fixture(t);
|
|
90
|
+
seedSource(f.source);
|
|
91
|
+
const before = snapshot(f.source);
|
|
92
|
+
importPi({ source: f.source, target: f.target, dryRun: false });
|
|
93
|
+
assert.deepEqual(snapshot(f.source), before);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("never copies caches, AGENTS.md, sessions or any other file", (t) => {
|
|
97
|
+
const f = fixture(t);
|
|
98
|
+
seedSource(f.source);
|
|
99
|
+
importPi({ source: f.source, target: f.target, dryRun: false });
|
|
100
|
+
assert.deepEqual(readdirSync(f.target).sort(), ["auth.json", "mcp.json", "settings.json"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("missing source files are reported as not found, not errors", (t) => {
|
|
104
|
+
const f = fixture(t);
|
|
105
|
+
const result = importPi({ source: f.source, target: f.target, dryRun: false });
|
|
106
|
+
assert.equal(result.failed, false);
|
|
107
|
+
assert.equal(statusOf(result, "auth.json"), "not found");
|
|
108
|
+
assert.equal(statusOf(result, "mcp.json"), "not found");
|
|
109
|
+
assert.equal(statusOf(result, "settings.json"), "not found");
|
|
110
|
+
assert.deepEqual(readdirSync(f.target), []);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("skips a symlinked or non-regular source file", { skip: process.platform === "win32" }, (t) => {
|
|
114
|
+
const f = fixture(t);
|
|
115
|
+
writeFileSync(join(f.root, "elsewhere.json"), AUTH);
|
|
116
|
+
symlinkSync(join(f.root, "elsewhere.json"), join(f.source, "auth.json"));
|
|
117
|
+
mkdirSync(join(f.source, "mcp.json"));
|
|
118
|
+
const result = importPi({ source: f.source, target: f.target, dryRun: false });
|
|
119
|
+
assert.equal(statusOf(result, "auth.json"), "skipped");
|
|
120
|
+
assert.equal(statusOf(result, "mcp.json"), "skipped");
|
|
121
|
+
assert.equal(existsSync(join(f.target, "auth.json")), false);
|
|
122
|
+
assert.equal(existsSync(join(f.target, "mcp.json")), false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("settings: copies only the default provider/model/thinking keys absent in the target, preserving every target key and order", (t) => {
|
|
126
|
+
const f = fixture(t);
|
|
127
|
+
seedSource(f.source);
|
|
128
|
+
writeFileSync(join(f.target, "settings.json"), `${JSON.stringify({ tuiMode: "fullscreen", theme: "Gentleman-Cute", defaultModel: "kept-model", packages: ["npm:own"] }, null, 2)}\n`);
|
|
129
|
+
const result = importPi({ source: f.source, target: f.target, dryRun: false });
|
|
130
|
+
assert.equal(result.failed, false);
|
|
131
|
+
assert.equal(statusOf(result, "settings.json defaultProvider"), "copied");
|
|
132
|
+
assert.equal(statusOf(result, "settings.json defaultModel"), "present");
|
|
133
|
+
assert.equal(statusOf(result, "settings.json defaultThinkingLevel"), "copied");
|
|
134
|
+
const text = readFileSync(join(f.target, "settings.json"), "utf8");
|
|
135
|
+
assert.equal(text, `${JSON.stringify({
|
|
136
|
+
tuiMode: "fullscreen", theme: "Gentleman-Cute", defaultModel: "kept-model", packages: ["npm:own"],
|
|
137
|
+
defaultProvider: "anthropic", defaultThinkingLevel: "high",
|
|
138
|
+
}, null, 2)}\n`);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("settings: the target keeps its own indentation and trailing-newline style", (t) => {
|
|
142
|
+
const f = fixture(t);
|
|
143
|
+
seedSource(f.source);
|
|
144
|
+
writeFileSync(join(f.target, "settings.json"), `{\n\t"tuiMode": "fullscreen",\n\t"zeta": 1\n}`);
|
|
145
|
+
importPi({ source: f.source, target: f.target, dryRun: false });
|
|
146
|
+
assert.equal(readFileSync(join(f.target, "settings.json"), "utf8"), JSON.stringify({
|
|
147
|
+
tuiMode: "fullscreen", zeta: 1, defaultProvider: "anthropic", defaultModel: "claude-opus-5-5", defaultThinkingLevel: "high",
|
|
148
|
+
}, null, "\t"));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("settings: an unreadable settings.json is reported per item while the other items still run", (t) => {
|
|
152
|
+
if (process.platform === "win32" || process.getuid?.() === 0) return t.skip("needs POSIX permissions as a non-root user");
|
|
153
|
+
const f = fixture(t);
|
|
154
|
+
seedSource(f.source);
|
|
155
|
+
const settings = join(f.target, "settings.json");
|
|
156
|
+
writeFileSync(settings, "{}\n");
|
|
157
|
+
chmodSync(settings, 0o000);
|
|
158
|
+
try {
|
|
159
|
+
const result = importPi({ source: f.source, target: f.target, dryRun: false });
|
|
160
|
+
assert.equal(statusOf(result, "auth.json"), "copied");
|
|
161
|
+
assert.equal(statusOf(result, "settings.json"), "failed");
|
|
162
|
+
assert.equal(result.failed, true);
|
|
163
|
+
assert.match(formatImportPiReport(result, { source: f.source, target: f.target, dryRun: false }).join("\n"), /settings\.json: failed/);
|
|
164
|
+
} finally {
|
|
165
|
+
chmodSync(settings, 0o600);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("a destination that cannot be classified is one failed item while the other items still run", (t) => {
|
|
170
|
+
const f = fixture(t);
|
|
171
|
+
seedSource(f.source);
|
|
172
|
+
const blocked = join(f.target, "auth.json");
|
|
173
|
+
const fs = { ...nodeImportPiFs, kind: (path: string) => {
|
|
174
|
+
if (path === blocked) throw Object.assign(new Error(`ENOTDIR: not a directory, lstat '${path}'`), { code: "ENOTDIR" });
|
|
175
|
+
return nodeImportPiFs.kind(path);
|
|
176
|
+
} };
|
|
177
|
+
for (const dryRun of [true, false]) {
|
|
178
|
+
const result = importPi({ source: f.source, target: f.target, dryRun, fs });
|
|
179
|
+
assert.equal(statusOf(result, "auth.json"), "failed", `dryRun=${dryRun}`);
|
|
180
|
+
assert.equal(statusOf(result, "mcp.json"), dryRun ? "would copy" : "copied");
|
|
181
|
+
assert.equal(result.failed, true);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("settings: a missing target settings.json gets only the allowlisted keys", (t) => {
|
|
186
|
+
const f = fixture(t);
|
|
187
|
+
seedSource(f.source);
|
|
188
|
+
importPi({ source: f.source, target: f.target, dryRun: false });
|
|
189
|
+
assert.deepEqual(JSON.parse(readFileSync(join(f.target, "settings.json"), "utf8")), {
|
|
190
|
+
defaultProvider: "anthropic", defaultModel: "claude-opus-5-5", defaultThinkingLevel: "high",
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("settings: nothing to add leaves the target settings.json untouched", (t) => {
|
|
195
|
+
const f = fixture(t);
|
|
196
|
+
writeFileSync(join(f.source, "settings.json"), JSON.stringify({ packages: ["npm:x"] }));
|
|
197
|
+
writeFileSync(join(f.target, "settings.json"), "{\"tuiMode\":\"fullscreen\"}");
|
|
198
|
+
const before = snapshot(f.target);
|
|
199
|
+
importPi({ source: f.source, target: f.target, dryRun: false });
|
|
200
|
+
assert.deepEqual(snapshot(f.target), before);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("settings: malformed source or target JSON is reported and nothing is written", (t) => {
|
|
204
|
+
const f = fixture(t);
|
|
205
|
+
writeFileSync(join(f.source, "settings.json"), "{ not json");
|
|
206
|
+
const badSource = importPi({ source: f.source, target: f.target, dryRun: false });
|
|
207
|
+
assert.equal(statusOf(badSource, "settings.json"), "skipped");
|
|
208
|
+
assert.equal(badSource.failed, false);
|
|
209
|
+
assert.equal(existsSync(join(f.target, "settings.json")), false);
|
|
210
|
+
|
|
211
|
+
writeFileSync(join(f.source, "settings.json"), JSON.stringify({ defaultProvider: "anthropic" }));
|
|
212
|
+
writeFileSync(join(f.target, "settings.json"), "[1, 2]");
|
|
213
|
+
const badTarget = importPi({ source: f.source, target: f.target, dryRun: false });
|
|
214
|
+
assert.equal(statusOf(badTarget, "settings.json"), "skipped");
|
|
215
|
+
assert.equal(readFileSync(join(f.target, "settings.json"), "utf8"), "[1, 2]");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("dry run reports the same plan and writes nothing, not even the target directory", (t) => {
|
|
219
|
+
const f = fixture(t);
|
|
220
|
+
seedSource(f.source);
|
|
221
|
+
const target = join(f.root, "not-created", "agent");
|
|
222
|
+
const result = importPi({ source: f.source, target, dryRun: true });
|
|
223
|
+
assert.equal(result.failed, false);
|
|
224
|
+
assert.equal(statusOf(result, "auth.json"), "would copy");
|
|
225
|
+
assert.equal(statusOf(result, "settings.json defaultProvider"), "would copy");
|
|
226
|
+
assert.equal(existsSync(join(f.root, "not-created")), false);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("a write failure is reported per item and keeps what was already copied", { skip: process.platform === "win32" }, (t) => {
|
|
230
|
+
const f = fixture(t);
|
|
231
|
+
seedSource(f.source);
|
|
232
|
+
// A read-only target makes every write fail; nothing is half-written.
|
|
233
|
+
writeFileSync(join(f.target, "settings.json"), "{}");
|
|
234
|
+
chmodSync(f.target, 0o500);
|
|
235
|
+
let result;
|
|
236
|
+
try {
|
|
237
|
+
result = importPi({ source: f.source, target: f.target, dryRun: false });
|
|
238
|
+
} finally {
|
|
239
|
+
chmodSync(f.target, 0o700); // before the fixture's own cleanup
|
|
240
|
+
}
|
|
241
|
+
assert.equal(result.failed, true);
|
|
242
|
+
assert.equal(statusOf(result, "auth.json"), "failed");
|
|
243
|
+
assert.equal(statusOf(result, "settings.json defaultProvider"), "failed");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("the report has one line per item and a summary, and never prints credential contents", (t) => {
|
|
247
|
+
const f = fixture(t);
|
|
248
|
+
seedSource(f.source);
|
|
249
|
+
const result = importPi({ source: f.source, target: f.target, dryRun: false });
|
|
250
|
+
const lines = formatImportPiReport(result, { source: f.source, target: f.target, dryRun: false });
|
|
251
|
+
const text = lines.join("\n");
|
|
252
|
+
assert.match(text, /auth\.json: copied \(0600\)/);
|
|
253
|
+
assert.match(text, /mcp\.json: copied/);
|
|
254
|
+
assert.match(text, /settings\.json defaultModel: copied/);
|
|
255
|
+
assert.match(text, /Imported 5 item\(s\)/);
|
|
256
|
+
assert.doesNotMatch(text, /dummy-refresh|dummy-access/);
|
|
257
|
+
|
|
258
|
+
const again = formatImportPiReport(importPi({ source: f.source, target: f.target, dryRun: true }), { source: f.source, target: f.target, dryRun: true });
|
|
259
|
+
assert.match(again.join("\n"), /auth\.json: already present, kept/);
|
|
260
|
+
assert.match(again.join("\n"), /Dry run/);
|
|
261
|
+
assert.match(again.join("\n"), /Nothing to import/);
|
|
262
|
+
});
|
|
@@ -5,7 +5,9 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import { CHILD_PACKAGE_INJECTION_ENV, encodeChildPackageInjection, parseChildPackageInjection } from "../lib/child-package-injection.ts";
|
|
7
7
|
import {
|
|
8
|
+
GENTLE_AI_PI_EXCLUDE_PACKAGES_ENV,
|
|
8
9
|
MIN_PI_VERSION,
|
|
10
|
+
withLauncherPackagesExcluded,
|
|
9
11
|
MIN_SETUP_GENTLE_AI_VERSION,
|
|
10
12
|
PI_SUBCOMMANDS,
|
|
11
13
|
buildPiInvocation,
|
|
@@ -37,6 +39,7 @@ import {
|
|
|
37
39
|
userPiHome,
|
|
38
40
|
resolvePiRuntime,
|
|
39
41
|
restoreJsonField,
|
|
42
|
+
separateHomeHint,
|
|
40
43
|
settingsDeclareGentlePi,
|
|
41
44
|
shellQuote,
|
|
42
45
|
type PackageJsonPeerShape,
|
|
@@ -66,6 +69,20 @@ test("parseLauncherArgs returns all-false defaults for an empty argv", () => {
|
|
|
66
69
|
});
|
|
67
70
|
});
|
|
68
71
|
|
|
72
|
+
test("parseLauncherArgs reserves explicit memory recovery without consuming prior commands or escaped prompts", () => {
|
|
73
|
+
const memory = parseLauncherArgs(["memory", "migrate", "--confirm-quiescent"]);
|
|
74
|
+
assert.equal(memory.command, "memory");
|
|
75
|
+
assert.deepEqual(memory.commandArgs, ["migrate", "--confirm-quiescent"]);
|
|
76
|
+
assert.deepEqual(memory.passthrough, []);
|
|
77
|
+
for (const command of ["home", "upgrade", "self-uninstall"] as const) {
|
|
78
|
+
const parsed = parseLauncherArgs([command, "--help"]);
|
|
79
|
+
assert.equal(parsed.command, command);
|
|
80
|
+
assert.deepEqual(parsed.commandArgs, ["--help"]);
|
|
81
|
+
}
|
|
82
|
+
assert.deepEqual(parseLauncherArgs(["--", "memory"]).passthrough, ["memory"]);
|
|
83
|
+
assert.match(helpText(), /gentle-shell memory migrate --confirm-quiescent/);
|
|
84
|
+
});
|
|
85
|
+
|
|
69
86
|
test("parseLauncherArgs captures a --package-root value from the next argument", () => {
|
|
70
87
|
assert.equal(parseLauncherArgs(["--package-root", "/custom/root"]).packageRoot, "/custom/root");
|
|
71
88
|
});
|
|
@@ -205,6 +222,40 @@ test("parseLauncherArgs treats home as a plain passthrough token when it is not
|
|
|
205
222
|
assert.deepEqual(parsed.passthrough, ["home"]);
|
|
206
223
|
});
|
|
207
224
|
|
|
225
|
+
test("parseLauncherArgs accepts home selectors ahead of home import-pi only", () => {
|
|
226
|
+
const link = parseLauncherArgs(["--link", "home", "import-pi"]);
|
|
227
|
+
assert.equal(link.link, true);
|
|
228
|
+
assert.equal(link.command, "home");
|
|
229
|
+
assert.deepEqual(link.commandArgs, ["import-pi"]);
|
|
230
|
+
assert.deepEqual(link.passthrough, []);
|
|
231
|
+
const path = parseLauncherArgs(["--home", "/x", "home", "import-pi", "--dry-run"]);
|
|
232
|
+
assert.equal(path.home, "/x");
|
|
233
|
+
assert.equal(path.command, "home");
|
|
234
|
+
assert.deepEqual(path.commandArgs, ["import-pi", "--dry-run"]);
|
|
235
|
+
const first = parseLauncherArgs(["home", "import-pi", "--dry-run"]);
|
|
236
|
+
assert.equal(first.command, "home");
|
|
237
|
+
assert.deepEqual(first.commandArgs, ["import-pi", "--dry-run"]);
|
|
238
|
+
// Anything else after a selector stays pi's, as before.
|
|
239
|
+
assert.deepEqual(parseLauncherArgs(["--link", "home", "link"]).passthrough, ["home", "link"]);
|
|
240
|
+
assert.deepEqual(parseLauncherArgs(["-p", "home", "import-pi"]).passthrough, ["-p", "home", "import-pi"]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("help text documents gentle-shell home import-pi", () => {
|
|
244
|
+
const text = helpText();
|
|
245
|
+
assert.ok(text.includes(" gentle-shell [home selectors] home import-pi [--dry-run]"));
|
|
246
|
+
assert.match(text, /import-pi\s+Copy your pi sign-ins \(auth\.json\) and MCP servers \(mcp\.json\)/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("separateHomeHint mentions home import-pi only when the user's pi home has sign-ins", () => {
|
|
250
|
+
const isolated: ResolvedHome = { mode: "isolated", dir: "/h/.gentle-shell/agent", source: "default" };
|
|
251
|
+
const without = separateHomeHint(isolated, { piSignIns: false });
|
|
252
|
+
assert.equal(without, "gentle-shell: using a separate home at /h/.gentle-shell/agent. Run 'gentle-shell --link' to reuse your pi sign-ins and chats.");
|
|
253
|
+
const withSignIns = separateHomeHint(isolated, { piSignIns: true });
|
|
254
|
+
assert.match(withSignIns, /Run 'gentle-shell --link' to reuse your pi sign-ins and chats, or 'gentle-shell home import-pi' to copy your sign-ins and MCP servers into this home\.$/);
|
|
255
|
+
const path = separateHomeHint({ mode: "path", dir: "/custom", source: "flag" }, { piSignIns: true });
|
|
256
|
+
assert.match(path, /'gentle-shell --home \/custom home import-pi'/);
|
|
257
|
+
});
|
|
258
|
+
|
|
208
259
|
// --- setup subcommand ----------------------------------------------------
|
|
209
260
|
//
|
|
210
261
|
// Unlike `home`, `setup` is not restricted to argv[0]: it accepts the home
|
|
@@ -293,6 +344,19 @@ test("parseLauncherArgs recognises a pi subcommand as the first token after --",
|
|
|
293
344
|
assert.deepEqual(parsed.passthrough, ["install", "npm:x"]);
|
|
294
345
|
});
|
|
295
346
|
|
|
347
|
+
test("parseLauncherArgs keeps a separator that follows a pi subcommand (#2140)", () => {
|
|
348
|
+
const parsed = parseLauncherArgs(["mcp", "add", "demo", "--", "npx", "demo-server"]);
|
|
349
|
+
assert.equal(parsed.piSubcommand, "mcp");
|
|
350
|
+
assert.deepEqual(parsed.passthrough, ["mcp", "add", "demo", "--", "npx", "demo-server"]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("parseLauncherArgs still consumes a later separator when no pi subcommand started", () => {
|
|
354
|
+
const parsed = parseLauncherArgs(["hello", "--", "--link"]);
|
|
355
|
+
assert.equal(parsed.piSubcommand, undefined);
|
|
356
|
+
assert.equal(parsed.link, false);
|
|
357
|
+
assert.deepEqual(parsed.passthrough, ["hello", "--link"]);
|
|
358
|
+
});
|
|
359
|
+
|
|
296
360
|
test("parseLauncherArgs errors when --link is combined with --isolated", () => {
|
|
297
361
|
const parsed = parseLauncherArgs(["--link", "--isolated"]);
|
|
298
362
|
assert.match(parsed.error ?? "", /--link/);
|
|
@@ -2032,3 +2096,18 @@ test("the isolated-home bootstrap creates ~/.gentle-shell as 0700 under umask 00
|
|
|
2032
2096
|
rmSync(root, { recursive: true, force: true });
|
|
2033
2097
|
}
|
|
2034
2098
|
});
|
|
2099
|
+
|
|
2100
|
+
// #2143: Gentle AI installs npm:gentle-pi into the home only for the launcher
|
|
2101
|
+
// to remove it again; a Gentle AI with package exclusion (gentle-ai#5503)
|
|
2102
|
+
// skips it, and an older one ignores the variable.
|
|
2103
|
+
test("withLauncherPackagesExcluded asks Gentle AI to skip npm:gentle-pi, keeping a caller's own exclusions", () => {
|
|
2104
|
+
assert.equal(GENTLE_AI_PI_EXCLUDE_PACKAGES_ENV, "GENTLE_AI_PI_EXCLUDE_PACKAGES");
|
|
2105
|
+
assert.equal(withLauncherPackagesExcluded({ PATH: "/bin" }).GENTLE_AI_PI_EXCLUDE_PACKAGES, "npm:gentle-pi");
|
|
2106
|
+
assert.equal(withLauncherPackagesExcluded({ GENTLE_AI_PI_EXCLUDE_PACKAGES: "" }).GENTLE_AI_PI_EXCLUDE_PACKAGES, "npm:gentle-pi");
|
|
2107
|
+
assert.equal(withLauncherPackagesExcluded({ GENTLE_AI_PI_EXCLUDE_PACKAGES: "npm:pi-btw" }).GENTLE_AI_PI_EXCLUDE_PACKAGES, "npm:pi-btw,npm:gentle-pi");
|
|
2108
|
+
assert.equal(withLauncherPackagesExcluded({ GENTLE_AI_PI_EXCLUDE_PACKAGES: "npm:gentle-pi, npm:pi-btw" }).GENTLE_AI_PI_EXCLUDE_PACKAGES, "npm:gentle-pi,npm:pi-btw");
|
|
2109
|
+
const input = { PATH: "/bin" };
|
|
2110
|
+
assert.equal(withLauncherPackagesExcluded(input).PATH, "/bin");
|
|
2111
|
+
assert.deepEqual(input, { PATH: "/bin" }, "the caller's env is not mutated");
|
|
2112
|
+
});
|
|
2113
|
+
|
|
@@ -976,3 +976,33 @@ test("bundled on Windows: the user Path entry goes first, the running node.exe,
|
|
|
976
976
|
assert.equal(existsSync(join(w.shared.agent, "auth.json")), true);
|
|
977
977
|
} finally { w.cleanup(); }
|
|
978
978
|
});
|
|
979
|
+
|
|
980
|
+
// #2201: the standalone isolated home keeps its config in ~/.gentle-shell/gentle-ai.
|
|
981
|
+
test("Gentle Shell's own config folder next to the isolated home is removed; the shared config home keeps its rule", async () => {
|
|
982
|
+
const w = world();
|
|
983
|
+
try {
|
|
984
|
+
const own = join(w.shell, "gentle-ai");
|
|
985
|
+
write(join(own, "profiles.json"));
|
|
986
|
+
write(join(own, "persona.json"));
|
|
987
|
+
assert.equal(await w.uninstall(["--dry-run"]), 0);
|
|
988
|
+
assert.ok(section(w.out, "Remove Gentle Shell data:").some((line) => line.includes(own)), w.out.join("\n"));
|
|
989
|
+
assert.equal(await w.uninstall(["--yes"]), 0);
|
|
990
|
+
assert.equal(existsSync(own), false);
|
|
991
|
+
assert.equal(existsSync(w.shell), false);
|
|
992
|
+
for (const name of ["profiles.json", "persona.json"]) assert.equal(existsSync(join(w.config, name)), true, name);
|
|
993
|
+
} finally { w.cleanup(); }
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
test("the gentle-ai folder next to a custom GENTLE_SHELL_HOME is kept: it may not be Gentle Shell's", async () => {
|
|
997
|
+
const w = world();
|
|
998
|
+
try {
|
|
999
|
+
const custom = join(w.root, "elsewhere", "agent");
|
|
1000
|
+
const sibling = join(w.root, "elsewhere", "gentle-ai");
|
|
1001
|
+
write(join(custom, ".gentle-shell-home"));
|
|
1002
|
+
write(join(sibling, "notes.json"));
|
|
1003
|
+
assert.equal(await w.uninstall(["--yes"], { env: { GENTLE_SHELL_HOME: custom } }), 0);
|
|
1004
|
+
assert.equal(existsSync(custom), false);
|
|
1005
|
+
assert.equal(existsSync(join(sibling, "notes.json")), true);
|
|
1006
|
+
assert.ok(section(w.out, "Kept:").some((line) => line.includes(sibling)), w.out.join("\n"));
|
|
1007
|
+
} finally { w.cleanup(); }
|
|
1008
|
+
});
|