gentle-pi 4.2.0 → 4.4.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 +340 -138
- 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/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- 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 +11 -11
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +237 -139
- 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 +65 -30
- package/docs/delegated-verification.md +16 -8
- package/docs/gentle-agents-activity.md +16 -6
- package/docs/gentle-music.md +61 -0
- package/docs/gentle-shell-uninstall.md +8 -1
- package/docs/gentle-shell.md +80 -9
- package/docs/install-wizard.md +37 -6
- package/docs/permission-modes.md +8 -3
- package/docs/readme-reference.md +120 -28
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +38 -10
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/codegraph-tools.ts +68 -6
- package/extensions/gentle-agents.ts +270 -48
- package/extensions/gentle-ai.ts +490 -113
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-herdr-activity.ts +2 -3
- package/extensions/gentle-music.ts +289 -0
- package/extensions/gentle-shell.ts +359 -85
- package/extensions/gentle-todo.ts +43 -9
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/quiet-tools.ts +1 -1
- package/extensions/session-scratch.ts +384 -0
- package/extensions/skill-registry.ts +63 -8
- package/extensions/startup-banner.ts +2 -3
- package/lib/agent-home.ts +8 -3
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +60 -1
- package/lib/agents-runner.ts +107 -9
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-view.ts +3 -1
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/bounded-writer-admission.ts +51 -0
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +2 -4
- package/lib/commit-slice-signal.ts +100 -0
- package/lib/context-backstop.ts +120 -0
- package/lib/delivered-test-lint.ts +80 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/destructive-command-guard.ts +120 -9
- package/lib/external-editor.ts +31 -0
- package/lib/gauge-theme.ts +146 -0
- package/lib/gentle-ai-binary.ts +28 -7
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +146 -36
- package/lib/gentle-shell-import-pi.ts +233 -0
- package/lib/gentle-shell-launcher.ts +154 -15
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +51 -2
- package/lib/herdr-activity.ts +24 -0
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +20 -5
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-discovery.ts +5 -1
- package/lib/orchestrator-presence.ts +87 -8
- package/lib/orchestrator-scope.ts +31 -3
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- 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-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/review-risk-assessment.ts +43 -38
- package/lib/review-session-standing-permission.ts +3 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +30 -1
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +523 -0
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +29 -28
- package/lib/shell-changes-view.ts +80 -13
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-gauge.ts +49 -6
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +26 -6
- package/lib/shell-todo.ts +24 -5
- package/lib/shell-usage-view.ts +5 -1
- package/lib/shell-usage.ts +7 -7
- package/lib/verifier-admission.ts +30 -0
- package/lib/vim-editor-adapter.ts +11 -8
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- 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 +145 -35
- package/runtime/gentle-shell-import-pi.mjs +234 -0
- package/runtime/gentle-shell-launcher.mjs +152 -13
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +51 -2
- package/runtime/native-review-cli.mjs +19 -4
- package/runtime/review-risk-assessment.mjs +43 -38
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/bundled-engram-migration.mjs +119 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +7 -7
- package/scripts/installer-preflight.mjs +5 -3
- package/scripts/installer-server.mjs +14 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +94 -19
- package/scripts/pi-update.mjs +34 -6
- package/scripts/run-test-suite.mjs +5 -0
- package/scripts/verify-package-files.mjs +43 -13
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/skills/gentle-ai/SKILL.md +3 -3
- package/skills/work-unit-commits/SKILL.md +5 -3
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agent-risk-escalation.test.ts +37 -10
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +485 -7
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +142 -0
- package/tests/bounded-writer-admission.test.ts +11 -3
- package/tests/bundled-install.test.ts +3 -3
- package/tests/card-content-policy.test.ts +19 -9
- package/tests/child-safety.test.ts +39 -0
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/codegraph-tools.test.ts +123 -2
- package/tests/command-palette.test.ts +15 -5
- package/tests/commit-slice-signal.test.ts +162 -0
- package/tests/context-backstop.test.ts +225 -0
- package/tests/delegate-for-reason-contract.test.ts +92 -24
- package/tests/delivered-test-lint.test.ts +105 -0
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/destructive-command-guard.test.ts +85 -1
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- 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 +645 -21
- 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-ai.test.ts +292 -13
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +335 -0
- package/tests/gentle-shell-bin.test.ts +163 -0
- package/tests/gentle-shell-bundled.test.ts +392 -176
- package/tests/gentle-shell-import-pi.test.ts +262 -0
- package/tests/gentle-shell-launcher.test.ts +107 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +89 -0
- package/tests/gentle-shell.test.ts +225 -17
- package/tests/gentle-todo.test.ts +190 -0
- package/tests/headless-delivery-question.test.ts +58 -0
- package/tests/herdr-activity.test.ts +34 -1
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +10 -2
- package/tests/installer-windows-bootstrap.test.ts +94 -7
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +17 -4
- package/tests/native-review-parity.test.ts +23 -2
- package/tests/no-self-pause-contract.test.ts +5 -2
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-integration.test.ts +5 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +128 -162
- package/tests/odd-routing-contract.test.ts +115 -30
- package/tests/orchestrator-budget.test.ts +22 -8
- package/tests/orchestrator-discovery.test.ts +29 -0
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +3 -1
- package/tests/orchestrator-scope.test.ts +80 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/orchestrator-status-publication-contract.test.ts +12 -8
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +24 -20
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update-launcher.test.ts +21 -2
- package/tests/pi-update.test.ts +99 -4
- 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 +147 -3
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +258 -0
- package/tests/review-owner-identity.test.ts +132 -0
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- 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-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +23 -0
- package/tests/session-scratch.test.ts +1011 -0
- package/tests/session-subject-routing-contract.test.ts +2 -1
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +21 -2
- package/tests/shell-changes-view.test.ts +137 -1
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-gauge.test.ts +42 -0
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +50 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- 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 +70 -10
- 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 +154 -0
- package/tests/verify-agent-contract.test.ts +8 -8
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +30 -2
- package/tests/writer-edit-surface-scope.test.ts +3 -0
- package/tests/writer-feature-document-admission.test.ts +156 -0
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +19 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { lstatSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
|
|
2
2
|
import { request as httpRequest } from "node:http";
|
|
3
3
|
import { createConnection } from "node:net";
|
|
4
|
-
import { dirname, posix, win32 } from "node:path";
|
|
4
|
+
import { dirname, isAbsolute, posix, win32 } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
7
|
// The launcher in a bundled install (S5, S8): pure decisions over an injected
|
|
@@ -27,6 +27,46 @@ export interface BundledInstall {
|
|
|
27
27
|
|
|
28
28
|
type Env = Record<string, string | undefined>;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Builds the detached shared server's environment without retaining session scratch.
|
|
32
|
+
* Windows Node can reintroduce an omitted TEMP from its parent, so use the
|
|
33
|
+
* original stable ancestor there; POSIX omits scratch temp settings instead.
|
|
34
|
+
*/
|
|
35
|
+
export function sharedEngramEnv(base: Env, platform: string = process.platform): Env {
|
|
36
|
+
const path = pathFor(platform);
|
|
37
|
+
const env: Env = {};
|
|
38
|
+
const scratch = (value: string) => /^(gp|ga)[A-Za-z0-9]{6}$/.test(path.basename(path.normalize(value)));
|
|
39
|
+
let stableTemp: string | undefined;
|
|
40
|
+
let tempKey: string | undefined;
|
|
41
|
+
for (const [key, value] of Object.entries(base)) {
|
|
42
|
+
const name = platform === "win32" ? key.toUpperCase() : key;
|
|
43
|
+
if (name.startsWith("ENGRAM_") || name === "GH_TOKEN" || name === "GITHUB_TOKEN") continue;
|
|
44
|
+
if (["TMPDIR", "TMP", "TEMP"].includes(name) && value !== undefined && scratch(value)) {
|
|
45
|
+
if (platform !== "win32") continue;
|
|
46
|
+
// A drive-relative or root-relative path depends on ambient state.
|
|
47
|
+
const root = win32.parse(value).root;
|
|
48
|
+
if (!/^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+[\\/]?)$/.test(root) || value.split(/[\\/]/).includes("..")) {
|
|
49
|
+
throw new Error("An absolute Windows scratch temp path is required for shared Engram.");
|
|
50
|
+
}
|
|
51
|
+
let stable = win32.normalize(value);
|
|
52
|
+
while (scratch(stable)) {
|
|
53
|
+
const parent = win32.dirname(stable);
|
|
54
|
+
if (parent === stable) throw new Error("An absolute Windows scratch temp path is required for shared Engram.");
|
|
55
|
+
stable = parent;
|
|
56
|
+
}
|
|
57
|
+
env[key] = stable;
|
|
58
|
+
stableTemp ??= stable;
|
|
59
|
+
} else {
|
|
60
|
+
env[key] = value;
|
|
61
|
+
}
|
|
62
|
+
if (name === "TEMP") tempKey = key;
|
|
63
|
+
}
|
|
64
|
+
// Keep an existing custom TEMP, even if TMP/TMPDIR chose another root.
|
|
65
|
+
if (stableTemp !== undefined && (tempKey === undefined || env[tempKey] === undefined)) env[tempKey ?? "TEMP"] = stableTemp;
|
|
66
|
+
env.ENGRAM_NO_UPDATE_CHECK = "1";
|
|
67
|
+
return env;
|
|
68
|
+
}
|
|
69
|
+
|
|
30
70
|
// The same rules scripts/bundled-install.mjs writes versions and markers with.
|
|
31
71
|
const VERSION_ID = /^[0-9A-Za-z][0-9A-Za-z.-]*$/;
|
|
32
72
|
const VERSION_MARKER = ".gentle-shell-version";
|
|
@@ -175,6 +215,29 @@ export function bundledGentlePiConfigHome(env: Env): string | undefined {
|
|
|
175
215
|
return prefix === undefined ? undefined : bundledPiConfigDir(prefix);
|
|
176
216
|
}
|
|
177
217
|
|
|
218
|
+
// #2201: the standalone launcher gives Pi this variable in its isolated home
|
|
219
|
+
// only, naming the config folder next to the agent dir (standaloneConfigHome:
|
|
220
|
+
// ~/.gentle-shell/gentle-ai by default). It is launcher-owned, never
|
|
221
|
+
// GENTLE_PI_CONFIG_HOME: Gentle AI 4.0.0 reads that one as the parent of its
|
|
222
|
+
// own gentle-ai folder, and the agent's shell would pass it to any gentle-ai.
|
|
223
|
+
export const GENTLE_SHELL_CONFIG_HOME_ENV = "GENTLE_SHELL_CONFIG_HOME";
|
|
224
|
+
|
|
225
|
+
// gentle-pi's config home, the one rule every reader shares: a bundled
|
|
226
|
+
// prefix's folder, then an explicit GENTLE_PI_CONFIG_HOME, then the launcher's
|
|
227
|
+
// marker (absolute only), then ~/.pi/gentle-ai. Empty values count as unset.
|
|
228
|
+
// `launcherMarker: false` is for install-level state written outside a session
|
|
229
|
+
// (the main channel's channel.json, builds and dev-binary.json): the installer
|
|
230
|
+
// and `gentle-shell upgrade` never see the marker, so their readers ignore it.
|
|
231
|
+
export function resolveGentlePiConfigHome(env: Env, home: string, options: { launcherMarker?: boolean; platform?: string } = {}): string {
|
|
232
|
+
const path = pathFor(options.platform ?? process.platform);
|
|
233
|
+
const bundled = bundledGentlePiConfigHome(env);
|
|
234
|
+
if (bundled !== undefined) return bundled;
|
|
235
|
+
if (env.GENTLE_PI_CONFIG_HOME) return env.GENTLE_PI_CONFIG_HOME;
|
|
236
|
+
const marker = env[GENTLE_SHELL_CONFIG_HOME_ENV];
|
|
237
|
+
if (options.launcherMarker !== false && marker && path.isAbsolute(marker)) return marker;
|
|
238
|
+
return path.join(home, ".pi", "gentle-ai");
|
|
239
|
+
}
|
|
240
|
+
|
|
178
241
|
// The exact Pi of the version (S8): the Pi the version's package.json pins,
|
|
179
242
|
// installed inside the version folder with Pi's own bin entry. Never another Pi.
|
|
180
243
|
export function bundledPi(install: BundledInstall, input: { platform: string; fs: BundledFs }): BundledPi {
|
|
@@ -209,13 +272,14 @@ export function bundledPi(install: BundledInstall, input: { platform: string; fs
|
|
|
209
272
|
// Gentle AI's variables, never set here because the agent's shell would pass
|
|
210
273
|
// them to a gentle-ai the user runs), and the published Engram as
|
|
211
274
|
// gentle-engram's ENGRAM_BIN unless the user chose one. With a memory decision
|
|
212
|
-
// (S2, S6): the startup policy, and ENGRAM_BIN is the
|
|
213
|
-
//
|
|
214
|
-
//
|
|
275
|
+
// (S2, S6): the startup policy, and ENGRAM_BIN is the Shell-owned guard, so no
|
|
276
|
+
// provider, whatever loaded it, runs an Engram binary against a store; allowed
|
|
277
|
+
// memory reaches the shared server through ENGRAM_URL only.
|
|
215
278
|
export const ENGRAM_COMPANION_SOURCE = "npm:gentle-engram@0.4.2";
|
|
216
|
-
export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: Shell could not verify safe
|
|
279
|
+
export const ENGRAM_RECOVERY_NOTICE = "Engram memory disabled: Shell could not verify safe server reuse. Matching versions or updating alone do not prove compatibility. Reuse requires a server on 127.0.0.1:7437 with verified /compatibility evidence; Shell never stops or replaces a server already listening there. Back up existing Engram data before manual changes; do not delete it or bypass the safety check. Shell rechecks on every launch and remains usable without memory.";
|
|
280
|
+
export const ENGRAM_START_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not start the shared Engram server, or it did not report ready in time. Restart Shell to retry; if another program holds 127.0.0.1:7437, stop it first. Shell remains usable without memory.";
|
|
281
|
+
export const ENGRAM_NEWER_SCHEMA_NOTICE = "Engram memory disabled: ~/.engram was migrated by a newer Engram than Shell's bundled core, so Shell will not start a server on it. Start that newer Engram's server yourself (engram serve); Shell reuses a running server with verified /compatibility evidence. Back up the store; do not delete or downgrade it. Shell remains usable without memory.";
|
|
217
282
|
// `gentle-shell setup` publishes the verified core (ensureEngram) on every run.
|
|
218
|
-
export const ENGRAM_PROVISION_FAILED_NOTICE = "Engram memory disabled for this launch: Shell could not provision a new Engram store identity, so it cannot vouch for that store. Restart Shell to retry. If Shell keeps reporting unknown compatibility and ~/.engram holds only .instance-id files, back it up and remove it, then restart Shell. Shell remains usable without memory.";
|
|
219
283
|
export const ENGRAM_CORE_MISSING_NOTICE = "Engram memory disabled: the bundled Engram core is not installed yet. Run 'gentle-shell setup' to install the verified core, then restart Shell. Shell remains usable without memory.";
|
|
220
284
|
// The first Engram release whose server answers GET /compatibility (PR #1792).
|
|
221
285
|
export const ENGRAM_COMPATIBILITY_MIN_VERSION = "3.4.0";
|
|
@@ -236,13 +300,14 @@ function outdatedServerVersion(version: string | undefined): string | undefined
|
|
|
236
300
|
// The default endpoint gentle-engram uses without ENGRAM_URL or ENGRAM_PORT.
|
|
237
301
|
export const ENGRAM_DEFAULT_PORT = 7437;
|
|
238
302
|
export const ENGRAM_DEFAULT_URL = `http://127.0.0.1:${ENGRAM_DEFAULT_PORT}`;
|
|
239
|
-
const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_DATABASE_URL"];
|
|
303
|
+
const ENGRAM_SELECTIONS = ["ENGRAM_BIN", "ENGRAM_URL", "ENGRAM_DATA_DIR", "ENGRAM_PORT", "ENGRAM_SOCKET", "ENGRAM_DATABASE_URL", "ENGRAM_SERVER_MODE"];
|
|
240
304
|
const HEX32 = /^[a-f0-9]{32}$/;
|
|
241
305
|
|
|
242
306
|
export type EngramProbe = { kind: "refused" } | { kind: "unknown" } | { kind: "listening"; compatible: boolean; healthInstanceId?: string; healthVersion?: string };
|
|
243
|
-
// `
|
|
244
|
-
// `
|
|
245
|
-
|
|
307
|
+
// `url`: the shared server, reached explicitly once /compatibility verified it.
|
|
308
|
+
// `spawn`: nothing listens yet; Shell starts the bundled core's server and must
|
|
309
|
+
// see it verified before Pi starts, or memory is disabled for that launch.
|
|
310
|
+
export type EngramStartup = { policy: "allow" | "disabled"; notice?: string; url?: string; spawn?: true };
|
|
246
311
|
|
|
247
312
|
// Without an invalid policy or an explicit Engram selection, the default
|
|
248
313
|
// endpoint may be probed; an explicit selection is never contacted.
|
|
@@ -252,26 +317,24 @@ export function engramProbeAllowed(env: Env): boolean {
|
|
|
252
317
|
}
|
|
253
318
|
|
|
254
319
|
// Evidence only: never run an executable, open SQLite or infer compatibility
|
|
255
|
-
// from versions.
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
320
|
+
// from versions. Like other agents' hooks, Shell shares the global server: a
|
|
321
|
+
// verified listener is reused, and with nothing listening the verified bundled
|
|
322
|
+
// core starts one on Engram's defaults, unless the default store's header
|
|
323
|
+
// (`userVersion`) shows a newer schema than that core's. A listener that does
|
|
324
|
+
// not verify is never replaced. A server version only selects the wording.
|
|
325
|
+
export function classifyEngramStartup(input: { env: Env; endpoint: EngramProbe; core: boolean; coreVersion: string; userVersion?: number }): EngramStartup {
|
|
260
326
|
if (!engramProbeAllowed(input.env)) return { policy: "disabled", notice: ENGRAM_RECOVERY_NOTICE };
|
|
261
327
|
const endpoint = input.endpoint;
|
|
262
|
-
const outdated = endpoint.kind === "listening" && !endpoint.compatible ? outdatedServerVersion(endpoint.healthVersion) : undefined;
|
|
263
|
-
const unknown: EngramStartup = { policy: "disabled", notice: outdated === undefined ? ENGRAM_RECOVERY_NOTICE : engramOutdatedServerNotice(outdated) };
|
|
264
328
|
if (endpoint.kind === "listening" && endpoint.compatible) return { policy: "allow", url: ENGRAM_DEFAULT_URL };
|
|
265
|
-
if (endpoint.kind === "refused"
|
|
266
|
-
|
|
329
|
+
if (endpoint.kind === "refused") {
|
|
330
|
+
if (!input.core) return { policy: "disabled", notice: ENGRAM_CORE_MISSING_NOTICE };
|
|
331
|
+
if (input.userVersion !== undefined && !engramHeaderAllows(input.userVersion, bundledEngramSchema(input.coreVersion))) {
|
|
332
|
+
return { policy: "disabled", notice: ENGRAM_NEWER_SCHEMA_NOTICE };
|
|
333
|
+
}
|
|
334
|
+
return { policy: "allow", url: ENGRAM_DEFAULT_URL, spawn: true };
|
|
267
335
|
}
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
// Shell's own store, created by this exact core: offline, the provider starts
|
|
271
|
-
// that core; a running server must be that store and that exact core.
|
|
272
|
-
if (endpoint.kind === "refused") return { policy: "allow" };
|
|
273
|
-
if (endpoint.kind === "listening" && endpoint.healthInstanceId === own.instanceId && endpoint.healthVersion === input.coreVersion) return { policy: "allow" };
|
|
274
|
-
return unknown;
|
|
336
|
+
const outdated = endpoint.kind === "listening" ? outdatedServerVersion(endpoint.healthVersion) : undefined;
|
|
337
|
+
return { policy: "disabled", notice: outdated === undefined ? ENGRAM_RECOVERY_NOTICE : engramOutdatedServerNotice(outdated) };
|
|
275
338
|
}
|
|
276
339
|
|
|
277
340
|
// The exact contract a startup-verified store reports (Engram GET /compatibility).
|
|
@@ -331,8 +394,8 @@ export async function probeEngramEndpoint(input: { port?: number; timeoutMs?: nu
|
|
|
331
394
|
return { kind: "listening", compatible: false, healthInstanceId: identified ? body.instance_id : undefined, healthVersion: identified ? body.version : undefined };
|
|
332
395
|
}
|
|
333
396
|
|
|
334
|
-
// Shell's
|
|
335
|
-
//
|
|
397
|
+
// Shell's record of a store an earlier Shell provisioned with its bundled core,
|
|
398
|
+
// read only by the explicit `gentle-shell memory migrate` recovery.
|
|
336
399
|
export type EngramProvenance = { schema: 1; dataDir: string; instanceId: string; coreSha256: string };
|
|
337
400
|
|
|
338
401
|
export function engramProvenancePath(prefix: string, platform: string = process.platform): string {
|
|
@@ -346,14 +409,10 @@ export function parseEngramProvenance(text: string | undefined): EngramProvenanc
|
|
|
346
409
|
return { schema: 1, dataDir: record.dataDir, instanceId: record.instanceId, coreSha256: record.coreSha256 };
|
|
347
410
|
}
|
|
348
411
|
|
|
349
|
-
export function engramProvenanceMatches(record: EngramProvenance | undefined, observed: { dataDir: string; instanceId?: string; coreSha256?: string }): boolean {
|
|
350
|
-
return record !== undefined && record.dataDir === observed.dataDir && record.instanceId === observed.instanceId && record.coreSha256 === observed.coreSha256;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
412
|
// The bundled core's own schema (SQLite user_version), verified from the
|
|
354
|
-
// tagged sources v3.3.2 and v3.
|
|
413
|
+
// tagged sources v3.3.2, v3.4.0 and v3.5.2: internal/store/store.go `const schemaVersion = 1`.
|
|
355
414
|
// A pin without a verified entry leaves the header check unavailable.
|
|
356
|
-
const BUNDLED_ENGRAM_SCHEMA: Record<string, number> = { "3.3.2": 1, "3.4.0": 1 };
|
|
415
|
+
const BUNDLED_ENGRAM_SCHEMA: Record<string, number> = { "3.3.2": 1, "3.4.0": 1, "3.5.2": 1 };
|
|
357
416
|
|
|
358
417
|
export function bundledEngramSchema(coreVersion: string): number | undefined {
|
|
359
418
|
return Object.hasOwn(BUNDLED_ENGRAM_SCHEMA, coreVersion) ? BUNDLED_ENGRAM_SCHEMA[coreVersion] : undefined;
|
|
@@ -423,8 +482,7 @@ export function bundledChildEnv(base: Env, input: { node: string; cli: string; p
|
|
|
423
482
|
return env;
|
|
424
483
|
}
|
|
425
484
|
if (memory.url !== undefined) env.ENGRAM_URL = memory.url;
|
|
426
|
-
|
|
427
|
-
env.ENGRAM_BIN = local ?? input.guard;
|
|
485
|
+
env.ENGRAM_BIN = input.guard;
|
|
428
486
|
return env;
|
|
429
487
|
}
|
|
430
488
|
|
|
@@ -455,6 +513,58 @@ export function withBundledNpmCommand(settingsText: string | undefined, command:
|
|
|
455
513
|
return { status: "write", text: settingsText === undefined || settingsText.endsWith("\n") ? `${serialized}\n` : serialized };
|
|
456
514
|
}
|
|
457
515
|
|
|
516
|
+
export type BundledNpmCommandRemoval = { status: "unchanged" } | { status: "malformed" } | { status: "removed"; text: string };
|
|
517
|
+
|
|
518
|
+
// settings.json without `npmCommand`: every other key kept in order and the
|
|
519
|
+
// file's indentation and final newline kept.
|
|
520
|
+
function settingsWithoutNpmCommand(settingsText: string, settings: Record<string, unknown>): string {
|
|
521
|
+
const indent = settingsText.match(/\{\r?\n([ \t]+)/)?.[1] ?? " ";
|
|
522
|
+
const { npmCommand: _removed, ...rest } = settings;
|
|
523
|
+
const serialized = JSON.stringify(rest, null, indent);
|
|
524
|
+
return settingsText.endsWith("\n") ? `${serialized}\n` : serialized;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// The agent home's settings.json without a `npmCommand` naming the prefix a
|
|
528
|
+
// bundled self-uninstall removed (gentle-shell-uninstall.ts): one absolute
|
|
529
|
+
// entry inside `withinRemoved` drops the whole key, every other key kept in
|
|
530
|
+
// order and the file's indentation and final newline kept. A file that is not
|
|
531
|
+
// a JSON object is never rewritten; a command naming nothing inside it stays.
|
|
532
|
+
export function withoutBundledNpmCommand(settingsText: string | undefined, withinRemoved: (path: string) => boolean): BundledNpmCommandRemoval {
|
|
533
|
+
if (settingsText === undefined) return { status: "unchanged" };
|
|
534
|
+
const settings = parseJson(settingsText);
|
|
535
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return { status: "malformed" };
|
|
536
|
+
const current = settings.npmCommand;
|
|
537
|
+
if (!Array.isArray(current) || !current.some((part) => typeof part === "string" && isAbsolute(part) && withinRemoved(part))) return { status: "unchanged" };
|
|
538
|
+
return { status: "removed", text: settingsWithoutNpmCommand(settingsText, settings) };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// An `npmCommand` a bundled install wrote (scripts/bundled-install.mjs
|
|
542
|
+
// bundledNpmCommand, with npm-cli.js and --prefix in earlier releases): our
|
|
543
|
+
// node in runtime/node-<v>, then npm's settings and cache flags, and
|
|
544
|
+
// --update-notifier=false last. A command the user chose never matches, even
|
|
545
|
+
// when its program is missing.
|
|
546
|
+
export function isBundledNpmCommand(command: readonly unknown[]): boolean {
|
|
547
|
+
const [node] = command;
|
|
548
|
+
return typeof node === "string" && /[\\/]runtime[\\/]node-[^\\/]+[\\/](node\.exe|bin[\\/]node)$/.test(node)
|
|
549
|
+
&& ["--userconfig=", "--globalconfig=", "--cache="].every((flag) => command.some((part) => typeof part === "string" && part.startsWith(flag)))
|
|
550
|
+
&& command[command.length - 1] === "--update-notifier=false";
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// The agent home's settings.json without an `npmCommand` a bundled install
|
|
554
|
+
// wrote whose executable (its first entry) no longer exists, as after a
|
|
555
|
+
// bundled self-uninstall that kept the home (#2469): Pi runs that command for
|
|
556
|
+
// every package install, so a key naming a deleted node.exe fails them all
|
|
557
|
+
// with ENOENT. A command whose executable exists, that names no absolute
|
|
558
|
+
// path, or that the user set themselves is never touched.
|
|
559
|
+
export function withoutMissingBundledNpmCommand(settingsText: string | undefined, exists: (path: string) => boolean): BundledNpmCommandRemoval {
|
|
560
|
+
if (settingsText === undefined) return { status: "unchanged" };
|
|
561
|
+
const settings = parseJson(settingsText);
|
|
562
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) return { status: "malformed" };
|
|
563
|
+
const executable = Array.isArray(settings.npmCommand) ? settings.npmCommand[0] : undefined;
|
|
564
|
+
if (typeof executable !== "string" || !isAbsolute(executable) || exists(executable) || !isBundledNpmCommand(settings.npmCommand as unknown[])) return { status: "unchanged" };
|
|
565
|
+
return { status: "removed", text: settingsWithoutNpmCommand(settingsText, settings) };
|
|
566
|
+
}
|
|
567
|
+
|
|
458
568
|
export interface BundledUpdatePlan {
|
|
459
569
|
// Pi's own update was asked for: redirect it to `gentle-shell upgrade`.
|
|
460
570
|
self: boolean;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { chmodSync, closeSync, linkSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { detectJsonFormatting } from "./gentle-shell-launcher.ts";
|
|
4
|
+
|
|
5
|
+
// `gentle-shell home import-pi`: a one-time, opt-in copy of the user's pi
|
|
6
|
+
// sign-ins and MCP servers into a separate Gentle Shell home, as an
|
|
7
|
+
// alternative to --link. Only the files and settings keys below are read;
|
|
8
|
+
// packages, extensions, caches, AGENTS.md and sessions are never copied, so
|
|
9
|
+
// the target home keeps its own provisioning. Nothing is ever overwritten and
|
|
10
|
+
// the source is only read.
|
|
11
|
+
|
|
12
|
+
// auth.json holds OAuth tokens and API keys: always private in the copy.
|
|
13
|
+
const IMPORTED_FILES: readonly { name: string; mode?: number }[] = [
|
|
14
|
+
{ name: "auth.json", mode: 0o600 },
|
|
15
|
+
{ name: "mcp.json" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// Pi 1.2.0's own settings for the default model (SettingsManager
|
|
19
|
+
// get/setDefaultProvider, DefaultModel, DefaultThinkingLevel).
|
|
20
|
+
export const IMPORTED_SETTINGS_KEYS: readonly string[] = ["defaultProvider", "defaultModel", "defaultThinkingLevel"];
|
|
21
|
+
|
|
22
|
+
export interface ImportPiFs {
|
|
23
|
+
// lstat: a symbolic link is "other", never followed.
|
|
24
|
+
kind: (path: string) => "missing" | "file" | "dir" | "other";
|
|
25
|
+
mode: (path: string) => number;
|
|
26
|
+
readText: (path: string) => string;
|
|
27
|
+
// Creates `to` with exactly `mode`; throws when it already exists.
|
|
28
|
+
copyNew: (from: string, to: string, mode: number) => void;
|
|
29
|
+
// Temp file next to `path`, then a rename onto it.
|
|
30
|
+
writeAtomic: (path: string, text: string) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ImportPiStatus = "copied" | "would copy" | "present" | "not found" | "skipped" | "failed";
|
|
34
|
+
|
|
35
|
+
export interface ImportPiItem {
|
|
36
|
+
item: string;
|
|
37
|
+
status: ImportPiStatus;
|
|
38
|
+
// The copy's mode, or why the item was skipped or failed. Never file content.
|
|
39
|
+
detail?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ImportPiResult {
|
|
43
|
+
items: ImportPiItem[];
|
|
44
|
+
failed: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tempPathFor(path: string): string {
|
|
48
|
+
return join(dirname(path), `.${basename(path)}.gentle-shell-${process.pid}.tmp`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function removeQuietly(path: string): void {
|
|
52
|
+
try {
|
|
53
|
+
unlinkSync(path);
|
|
54
|
+
} catch {
|
|
55
|
+
// Nothing left to clean up.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeTemp(path: string, data: string | Buffer, mode: number): string {
|
|
60
|
+
const temp = tempPathFor(path);
|
|
61
|
+
const fd = openSync(temp, "wx", mode);
|
|
62
|
+
try {
|
|
63
|
+
try {
|
|
64
|
+
writeFileSync(fd, data);
|
|
65
|
+
} finally {
|
|
66
|
+
closeSync(fd);
|
|
67
|
+
}
|
|
68
|
+
chmodSync(temp, mode); // the umask must not change the mode
|
|
69
|
+
} catch (error) {
|
|
70
|
+
// A partial copy may hold credentials: never leave it behind.
|
|
71
|
+
removeQuietly(temp);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
return temp;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const nodeImportPiFs: ImportPiFs = {
|
|
78
|
+
kind: (path) => {
|
|
79
|
+
try {
|
|
80
|
+
const stat = lstatSync(path);
|
|
81
|
+
if (stat.isSymbolicLink()) return "other";
|
|
82
|
+
return stat.isFile() ? "file" : stat.isDirectory() ? "dir" : "other";
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing";
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
mode: (path) => lstatSync(path).mode & 0o777,
|
|
89
|
+
readText: (path) => readFileSync(path, "utf8"),
|
|
90
|
+
copyNew: (from, to, mode) => {
|
|
91
|
+
const temp = writeTemp(to, readFileSync(from), mode);
|
|
92
|
+
try {
|
|
93
|
+
linkSync(temp, to); // EEXIST instead of replacing a file that appeared meanwhile
|
|
94
|
+
} finally {
|
|
95
|
+
removeQuietly(temp);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
writeAtomic: (path, text) => {
|
|
99
|
+
const mode = lstatSync(path, { throwIfNoEntry: false })?.mode;
|
|
100
|
+
const temp = writeTemp(path, text, mode === undefined ? 0o600 : mode & 0o777);
|
|
101
|
+
try {
|
|
102
|
+
renameSync(temp, path);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
removeQuietly(temp);
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function reason(error: unknown): string {
|
|
111
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
112
|
+
return typeof code === "string" ? code : error instanceof Error ? error.message : String(error);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseObject(text: string): Record<string, unknown> | undefined {
|
|
116
|
+
try {
|
|
117
|
+
const value: unknown = JSON.parse(text.replace(/^\uFEFF/, ""));
|
|
118
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function importFile(name: string, mode: number | undefined, input: { source: string; target: string; dryRun: boolean; fs: ImportPiFs }): ImportPiItem {
|
|
125
|
+
const { fs } = input;
|
|
126
|
+
const from = join(input.source, name);
|
|
127
|
+
const to = join(input.target, name);
|
|
128
|
+
// Classification can throw too (ENOTDIR under a file): one failed item, never an aborted report.
|
|
129
|
+
try {
|
|
130
|
+
const kind = fs.kind(from);
|
|
131
|
+
if (kind === "missing") return { item: name, status: "not found" };
|
|
132
|
+
if (kind !== "file") return { item: name, status: "skipped", detail: "not a regular file" };
|
|
133
|
+
if (fs.kind(to) !== "missing") return { item: name, status: "present" };
|
|
134
|
+
const copyMode = mode ?? fs.mode(from);
|
|
135
|
+
const detail = `${copyMode.toString(8).padStart(4, "0")}`;
|
|
136
|
+
if (input.dryRun) return { item: name, status: "would copy", detail };
|
|
137
|
+
fs.copyNew(from, to, copyMode);
|
|
138
|
+
return { item: name, status: "copied", detail };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return { item: name, status: "failed", detail: reason(error) };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function importSettings(input: { source: string; target: string; dryRun: boolean; fs: ImportPiFs }): ImportPiItem[] {
|
|
145
|
+
try {
|
|
146
|
+
return planAndWriteSettings(input);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// An unreadable settings.json stays one failed item, never an aborted report.
|
|
149
|
+
return [{ item: "settings.json", status: "failed", detail: reason(error) }];
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function planAndWriteSettings(input: { source: string; target: string; dryRun: boolean; fs: ImportPiFs }): ImportPiItem[] {
|
|
154
|
+
const { fs } = input;
|
|
155
|
+
const from = join(input.source, "settings.json");
|
|
156
|
+
const to = join(input.target, "settings.json");
|
|
157
|
+
const sourceKind = fs.kind(from);
|
|
158
|
+
if (sourceKind === "missing") return [{ item: "settings.json", status: "not found" }];
|
|
159
|
+
if (sourceKind !== "file") return [{ item: "settings.json", status: "skipped", detail: "not a regular file" }];
|
|
160
|
+
const source = parseObject(fs.readText(from));
|
|
161
|
+
if (source === undefined) return [{ item: "settings.json", status: "skipped", detail: `${from} is not a JSON object` }];
|
|
162
|
+
const targetKind = fs.kind(to);
|
|
163
|
+
if (targetKind !== "missing" && targetKind !== "file") return [{ item: "settings.json", status: "skipped", detail: `${to} is not a regular file` }];
|
|
164
|
+
const targetText = targetKind === "missing" ? undefined : fs.readText(to);
|
|
165
|
+
const target = targetText === undefined ? {} : parseObject(targetText);
|
|
166
|
+
if (target === undefined) return [{ item: "settings.json", status: "skipped", detail: `${to} is not a JSON object` }];
|
|
167
|
+
|
|
168
|
+
const items: ImportPiItem[] = [];
|
|
169
|
+
const added: Record<string, unknown> = {};
|
|
170
|
+
for (const key of IMPORTED_SETTINGS_KEYS) {
|
|
171
|
+
const item = `settings.json ${key}`;
|
|
172
|
+
if (!Object.hasOwn(source, key)) items.push({ item, status: "not found" });
|
|
173
|
+
else if (Object.hasOwn(target, key)) items.push({ item, status: "present" });
|
|
174
|
+
else if (typeof source[key] !== "string") items.push({ item, status: "skipped", detail: "not a string" });
|
|
175
|
+
else {
|
|
176
|
+
added[key] = source[key];
|
|
177
|
+
items.push({ item, status: input.dryRun ? "would copy" : "copied" });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (input.dryRun || Object.keys(added).length === 0) return items;
|
|
181
|
+
try {
|
|
182
|
+
// Keep the target's own indentation and trailing-newline style.
|
|
183
|
+
const style = targetText === undefined ? { indent: undefined, trailingNewline: true } : detectJsonFormatting(targetText);
|
|
184
|
+
const body = JSON.stringify({ ...target, ...added }, null, style.indent ?? 2);
|
|
185
|
+
fs.writeAtomic(to, style.trailingNewline ? `${body}\n` : body);
|
|
186
|
+
return items;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const detail = reason(error);
|
|
189
|
+
return items.map((entry) => (entry.status === "copied" ? { ...entry, status: "failed", detail } : entry));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Copies what the target lacks. A dry run reads the same files and writes
|
|
194
|
+
// nothing. The caller has already made sure `target` is a home this launcher
|
|
195
|
+
// owns (and exists, unless this is a dry run).
|
|
196
|
+
export function importPi(input: { source: string; target: string; dryRun: boolean; fs?: ImportPiFs }): ImportPiResult {
|
|
197
|
+
const options = { ...input, fs: input.fs ?? nodeImportPiFs };
|
|
198
|
+
const items = IMPORTED_FILES.map((file) => importFile(file.name, file.mode, options));
|
|
199
|
+
items.push(...importSettings(options));
|
|
200
|
+
return { items, failed: items.some((item) => item.status === "failed") };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function describe(item: ImportPiItem): string {
|
|
204
|
+
switch (item.status) {
|
|
205
|
+
case "copied":
|
|
206
|
+
case "would copy":
|
|
207
|
+
return item.detail !== undefined && item.item === "auth.json" ? `${item.status} (${item.detail})` : item.status;
|
|
208
|
+
case "present":
|
|
209
|
+
return "already present, kept";
|
|
210
|
+
case "not found":
|
|
211
|
+
return "not found";
|
|
212
|
+
case "skipped":
|
|
213
|
+
return `skipped: ${item.detail}`;
|
|
214
|
+
case "failed":
|
|
215
|
+
return `failed: ${item.detail}`;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function formatImportPiReport(result: ImportPiResult, context: { source: string; target: string; dryRun: boolean }): string[] {
|
|
220
|
+
const lines = [
|
|
221
|
+
context.dryRun
|
|
222
|
+
? `Dry run: would import from ${context.source} into ${context.target} (nothing is written)`
|
|
223
|
+
: `Importing from ${context.source} into ${context.target}`,
|
|
224
|
+
];
|
|
225
|
+
for (const item of result.items) lines.push(` ${item.item}: ${describe(item)}`);
|
|
226
|
+
const count = (...statuses: ImportPiStatus[]) => result.items.filter((item) => statuses.includes(item.status)).length;
|
|
227
|
+
const imported = count("copied", "would copy");
|
|
228
|
+
const rest = `${count("present")} already present, ${count("not found")} not found, ${count("skipped")} skipped`;
|
|
229
|
+
if (result.failed) lines.push(`Import failed for ${count("failed")} item(s); ${count("copied")} copied and kept. ${rest}.`);
|
|
230
|
+
else if (imported === 0) lines.push(`Nothing to import: ${rest}.`);
|
|
231
|
+
else lines.push(`${context.dryRun ? "Would import" : "Imported"} ${imported} item(s); ${rest}.`);
|
|
232
|
+
return lines;
|
|
233
|
+
}
|