getculpa 0.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,144 @@
1
+ // T-CF28-5 (postinstall output) + T-CF28-7 (the install-time half of the
2
+ // Docker ask) — the lines postinstall prints when it finishes.
3
+ //
4
+ // Extracted from scripts/install.js's main() as a PURE function. The words
5
+ // are the product here: the shipped 1.0.1 printed "Culpa installed
6
+ // successfully." on a fresh Mac with no Docker, where nothing could actually
7
+ // start. That line lived inline in a CommonJS script with no export, so no
8
+ // test could assert it. It is a line-builder now so the claims it makes are
9
+ // checked by tests/npm-core-install-summary.test.ts.
10
+ //
11
+ // TWO RULES THIS FILE EXISTS TO ENFORCE:
12
+ //
13
+ // 1. Never claim "ready" unless it IS ready. The founder's ruled headline is
14
+ // "Culpa is installed and ready." with `ready` in green — printed when
15
+ // Docker is genuinely usable. When Docker is missing or stopped, the
16
+ // headline drops to "Culpa is installed." and the Docker requirement is
17
+ // stated plainly. Printing the ruled line unconditionally would repeat the
18
+ // exact defect that created T-CF28-7.
19
+ //
20
+ // 2. Say what each platform will really do. postinstall CANNOT prompt
21
+ // (scripts/install.js:13-15 — npm installs run headless and a stdin read
22
+ // would hang), so this file only DETECTS and TELLS; the asking happens at
23
+ // first `getculpa`. win32 already ships that flow
24
+ // (installers/windows/launch-culpa.ps1:179-234); darwin gains it in
25
+ // lib/start.mjs; linux stays guidance-only, because installing a system
26
+ // package without explicit consent is out of bounds (the same rule
27
+ // lib/docker.mjs:73-79 already follows for starting the engine).
28
+
29
+ import { green } from "./tty.mjs";
30
+
31
+ const DOCKER_DESKTOP_URL = "https://www.docker.com/products/docker-desktop/";
32
+ const DOCKER_ENGINE_DOCS_URL = "https://docs.docker.com/engine/install/";
33
+
34
+ function headline(classification, version, previousVersion, dockerReady, colour) {
35
+ switch (classification) {
36
+ case "upgrade":
37
+ return `Culpa updated: ${previousVersion} -> ${version} (staged; applies at next \`getculpa\`).`;
38
+ case "downgrade-package":
39
+ return `Culpa ${previousVersion} is already installed; this package (${version}) is older and was not applied.`;
40
+ case "same-version":
41
+ case "repair":
42
+ return "Culpa installation repaired.";
43
+ default:
44
+ // "fresh" — the only case the founder's ruled wording describes.
45
+ return dockerReady ? `Culpa is installed and ${green("ready", colour)}.` : "Culpa is installed.";
46
+ }
47
+ }
48
+
49
+ function inventory(appDir, imagesStaged, collectorStaged, payloadStaged) {
50
+ return [
51
+ ` Location: ${appDir}`,
52
+ // T-CF28-4b: the CLI payload is the program `getculpa` actually runs. When
53
+ // it is missing the install is not usable at all, so this row names the
54
+ // one command that finishes the job rather than leaving a dead first run.
55
+ ` Culpa CLI: ${payloadStaged ? "installed" : "deferred - run `getculpa update` to finish"}`,
56
+ ` Images: ${imagesStaged ? "staged" : "deferred to first run"}`,
57
+ ` Capture: ${collectorStaged ? "collector installed" : "off (collector not installed)"}`,
58
+ ];
59
+ }
60
+
61
+ // Docker is MISSING. Each platform gets only what is true for it.
62
+ function dockerMissingLines(platform) {
63
+ const lines = [
64
+ "Culpa needs Docker to run: its database and services run in containers.",
65
+ "Docker was not found on this machine.",
66
+ ];
67
+ if (platform === "darwin") {
68
+ lines.push(
69
+ " Install it with: brew install --cask docker",
70
+ " Homebrew asks for your password, and you must open Docker once from",
71
+ " Applications before Culpa can start - it is not a one-click install.",
72
+ ` Or download it: ${DOCKER_DESKTOP_URL}`,
73
+ "Culpa will offer to do this for you the first time you run `getculpa`.",
74
+ );
75
+ } else if (platform === "win32") {
76
+ lines.push(
77
+ "Culpa will offer to install Docker for you the first time you run `getculpa`.",
78
+ ` Or download it: ${DOCKER_DESKTOP_URL}`,
79
+ );
80
+ } else {
81
+ lines.push(
82
+ ` Install Docker Engine: ${DOCKER_ENGINE_DOCS_URL}`,
83
+ "Culpa will not install system packages for you - install it yourself, then",
84
+ "run `getculpa`.",
85
+ );
86
+ }
87
+ return lines;
88
+ }
89
+
90
+ // Docker is PRESENT but the engine is stopped. Starting it is something Culpa
91
+ // really does do on darwin (lib/docker.mjs:69-72, `open -a Docker`) and win32
92
+ // (installers/windows/launch-culpa.ps1:236-244) — but never on linux.
93
+ function dockerStoppedLines(platform) {
94
+ const lines = ["Docker is installed but its engine is not running."];
95
+ if (platform === "linux") {
96
+ lines.push(" Start it yourself, for example: sudo systemctl start docker");
97
+ } else {
98
+ lines.push(" Culpa will start it for you when you run `getculpa`.");
99
+ }
100
+ return lines;
101
+ }
102
+
103
+ export function buildInstallSummary(opts) {
104
+ const {
105
+ classification,
106
+ version,
107
+ previousVersion = null,
108
+ appDir,
109
+ dockerState,
110
+ imagesStaged = false,
111
+ collectorStaged = false,
112
+ // T-CF28-4b. Fail-CLOSED, matching imagesStaged/collectorStaged above: a
113
+ // caller that cannot say whether the payload is there has no evidence for
114
+ // "ready", and this function exists precisely to stop readiness being
115
+ // claimed without evidence. scripts/install.js always passes the real value.
116
+ payloadStaged = false,
117
+ platform = process.platform,
118
+ isTTY = false,
119
+ env = {},
120
+ } = opts;
121
+
122
+ const dockerReady = dockerState === "ready";
123
+ // "ready" means the next command will work. A missing CLI payload breaks
124
+ // that just as completely as a missing Docker, so it gates the word too.
125
+ const ready = dockerReady && payloadStaged;
126
+ const colour = { isTTY, env };
127
+ const lines = [headline(classification, version, previousVersion, ready, colour)];
128
+
129
+ // A refused downgrade staged nothing — an inventory there would describe a
130
+ // state this run did not produce (lib/provision.mjs:175-183).
131
+ if (classification !== "downgrade-package") {
132
+ lines.push(...inventory(appDir, imagesStaged, collectorStaged, payloadStaged));
133
+ }
134
+
135
+ if (!dockerReady) {
136
+ lines.push("");
137
+ lines.push(...(dockerState === "missing" ? dockerMissingLines(platform) : dockerStoppedLines(platform)));
138
+ }
139
+
140
+ lines.push("");
141
+ lines.push(ready ? "Run:" : "Then run:");
142
+ lines.push(" getculpa");
143
+ return lines;
144
+ }
@@ -0,0 +1,9 @@
1
+ export interface GetAppDirOptions {
2
+ env?: Record<string, string | undefined>;
3
+ platform?: string;
4
+ homedir?: string;
5
+ }
6
+
7
+ export function getAppDir(opts?: GetAppDirOptions): string;
8
+
9
+ export function canonicalAppDir(opts?: GetAppDirOptions): string;
package/lib/paths.mjs ADDED
@@ -0,0 +1,61 @@
1
+ // CF20-T3 — per-OS app dir resolution (Windows-installer parity).
2
+ //
3
+ // Real conventions, verified against the installers themselves:
4
+ // win32: %LOCALAPPDATA%\Culpa (installers/windows/install-culpa.ps1:19)
5
+ // darwin: ~/Library/Application Support/Culpa (installers/macos/install-culpa.command:7)
6
+ // linux: ~/.local/share/culpa, honoring XDG_DATA_HOME (no shipped Linux
7
+ // installer yet — this is the XDG-spec default for that base path)
8
+ //
9
+ // path.win32 / path.posix are used explicitly (never the ambient `path`)
10
+ // so the SAME function returns the correct separator for a simulated
11
+ // platform on any host OS — this is what makes it testable on a Windows
12
+ // dev machine without lying about macOS/Linux output.
13
+
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+
17
+ // CULPA_APP_DIR is a test-only escape hatch (task spec: "for tests only").
18
+ // It is still readable in production because there is no safe way to tell
19
+ // "a test set this" from "an operator set this" from inside the module, and
20
+ // refusing a real operator's override would be a worse failure mode than
21
+ // honoring it.
22
+ //
23
+ // D-108 (cli-v1.0.2): the vendored Rust launcher honors CULPA_APP_DIR too —
24
+ // as a fallback when its own CULPA_HOME is unset (native/culpa-launcher/
25
+ // src/home.rs). So relocating with this variable moves BOTH halves: the
26
+ // stack/licence state this module resolves AND the launcher's
27
+ // versions/updates state. Keep the two sides in step if either changes.
28
+ export function getAppDir(opts = {}) {
29
+ const env = opts.env ?? process.env;
30
+ const platform = opts.platform ?? process.platform;
31
+ const homedir = opts.homedir ?? os.homedir();
32
+
33
+ if (env.CULPA_APP_DIR) return env.CULPA_APP_DIR;
34
+
35
+ if (platform === "win32") {
36
+ const base = env.LOCALAPPDATA || path.win32.join(homedir, "AppData", "Local");
37
+ return path.win32.join(base, "Culpa");
38
+ }
39
+ if (platform === "darwin") {
40
+ return path.posix.join(homedir, "Library", "Application Support", "Culpa");
41
+ }
42
+ // linux and other POSIX platforms
43
+ const xdgDataHome = env.XDG_DATA_HOME || path.posix.join(homedir, ".local", "share");
44
+ return path.posix.join(xdgDataHome, "culpa");
45
+ }
46
+
47
+ // CF20-R — the ONE fixed HKCU uninstall key (installers/windows/
48
+ // install-culpa.ps1's Add/Remove Programs registration, culpa-setup.iss's
49
+ // AppId) is per-MACHINE, not per-app-dir. A second app dir on the same
50
+ // Windows machine (CULPA_APP_DIR pointed somewhere non-default) must never
51
+ // register/overwrite that shared key — the gate's historical incident class.
52
+ // canonicalAppDir answers "what would getAppDir() return if CULPA_APP_DIR
53
+ // were NOT set" — the override is stripped, everything else (platform,
54
+ // homedir, LOCALAPPDATA/XDG_DATA_HOME) still applies — so a caller can tell
55
+ // whether the app dir it was actually given is the canonical, registry-owning
56
+ // one.
57
+ export function canonicalAppDir(opts = {}) {
58
+ const env = { ...(opts.env ?? process.env) };
59
+ delete env.CULPA_APP_DIR;
60
+ return getAppDir({ ...opts, env });
61
+ }
@@ -0,0 +1,39 @@
1
+ export interface PlatformSupport {
2
+ supported: boolean;
3
+ message?: string;
4
+ }
5
+
6
+ export function checkPlatformSupport(platform?: string, arch?: string): PlatformSupport;
7
+
8
+ export interface DiskSpaceResult {
9
+ known: boolean;
10
+ availableBytes: number | null;
11
+ }
12
+
13
+ export function checkDiskSpaceBestEffort(targetDir: string): DiskSpaceResult;
14
+
15
+ export type SpawnSyncLike = (cmd: string, args?: string[], opts?: unknown) => { status: number | null };
16
+
17
+ export function checkDockerPresent(spawnSync?: SpawnSyncLike): boolean;
18
+ export function checkDockerEngineReachable(spawnSync?: SpawnSyncLike): boolean;
19
+
20
+ export function parsePinnedServerVersion(composeText: string | null | undefined): string | null;
21
+ export function compareVersions(a: string | null | undefined, b: string | null | undefined): -1 | 0 | 1;
22
+
23
+ export type InstallKind = "fresh" | "upgrade" | "same-version" | "repair";
24
+
25
+ export function classifyInstall(opts: { appDir: string; currentVersion: string }): InstallKind;
26
+
27
+ export type UpgradeClassification = InstallKind | "downgrade-package";
28
+
29
+ export interface UpgradeClassificationResult {
30
+ classification: UpgradeClassification;
31
+ previousVersion: string | null;
32
+ }
33
+
34
+ export function classifyUpgrade(opts: { appDir: string; currentVersion: string }): UpgradeClassificationResult;
35
+
36
+ export function isIncomingComposeOlder(
37
+ incomingComposeText: string | null | undefined,
38
+ stagedComposeText: string | null | undefined,
39
+ ): boolean;
@@ -0,0 +1,169 @@
1
+ // CF20-T3 — preflight checks for the shared install core. Detection only:
2
+ // nothing here starts Docker, pulls an image, or touches the app dir.
3
+ //
4
+ // Docker checks take an injectable spawnSync (default: the real one), the
5
+ // same pattern install-culpa.ps1 uses for Test-DockerInstalled's -Probe —
6
+ // so a test can stub docker's presence/engine state without a real Docker
7
+ // install or a fragile PATH shim.
8
+
9
+ import { spawnSync as realSpawnSync } from "node:child_process";
10
+ import { existsSync, readFileSync, statfsSync } from "node:fs";
11
+ import path from "node:path";
12
+ import { PROBE_TIMEOUT_MS } from "./docker.mjs";
13
+
14
+ const SUPPORTED_TRIPLES = new Set(["win32-x64", "linux-x64", "darwin-x64", "darwin-arm64"]);
15
+
16
+ // Mirrors the launcher triples already load-bearing in scripts/install.js's
17
+ // targetTriple() (win32/x64, linux/x64, darwin/x64, darwin/arm64).
18
+ export function checkPlatformSupport(platform = process.platform, arch = process.arch) {
19
+ if (SUPPORTED_TRIPLES.has(`${platform}-${arch}`)) return { supported: true };
20
+ const message =
21
+ "Culpa currently supports Windows x64, Linux x64, and macOS (Intel or Apple Silicon). " +
22
+ `Detected: ${platform}/${arch}.`;
23
+ return { supported: false, message };
24
+ }
25
+
26
+ // Best-effort: `statfs` is unavailable or throws on some hosts/paths (e.g. a
27
+ // directory that does not exist yet). Unknown is a valid, honestly-reported
28
+ // outcome — this never blocks or fails the install.
29
+ export function checkDiskSpaceBestEffort(targetDir) {
30
+ try {
31
+ const probeDir = nearestExistingAncestor(targetDir);
32
+ const stats = statfsSync(probeDir);
33
+ return { known: true, availableBytes: stats.bavail * stats.bsize };
34
+ } catch {
35
+ return { known: false, availableBytes: null };
36
+ }
37
+ }
38
+
39
+ function nearestExistingAncestor(startDir) {
40
+ let dir = startDir;
41
+ for (let i = 0; i < 64; i++) {
42
+ if (existsSync(dir)) return dir;
43
+ const parent = path.dirname(dir);
44
+ if (parent === dir) return dir; // reached the filesystem root
45
+ dir = parent;
46
+ }
47
+ return dir;
48
+ }
49
+
50
+ export function checkDockerPresent(spawnSync = realSpawnSync) {
51
+ // CF20-R: PROBE_TIMEOUT_MS (docker.mjs) — a hung docker CLI must not stall
52
+ // npm install forever.
53
+ const result = spawnSync("docker", ["--version"], { timeout: PROBE_TIMEOUT_MS });
54
+ return result.status === 0;
55
+ }
56
+
57
+ // Reachability only — never starts the engine (that is launch-time's job).
58
+ export function checkDockerEngineReachable(spawnSync = realSpawnSync) {
59
+ const result = spawnSync("docker", ["info", "--format", "ok"], { timeout: PROBE_TIMEOUT_MS });
60
+ return result.status === 0;
61
+ }
62
+
63
+ // Same parsing target as launch-culpa.ps1's Get-PinnedServerVersion.
64
+ export function parsePinnedServerVersion(composeText) {
65
+ const m = /culpa-server:([^"\s]+)/.exec(composeText ?? "");
66
+ return m ? m[1] : null;
67
+ }
68
+
69
+ // Same fail-open-to-"equal" semantics as launch-culpa.ps1's
70
+ // Compare-CulpaVersions: an unparsable tag never brick the comparison, it
71
+ // just reads as 0 (equal). -1 = a older, 1 = a newer.
72
+ export function compareVersions(a, b) {
73
+ const rx = /^v?(\d+)\.(\d+)\.(\d+)/;
74
+ const ma = rx.exec(a ?? "");
75
+ const mb = rx.exec(b ?? "");
76
+ if (!ma || !mb) return 0;
77
+ for (let i = 1; i <= 3; i++) {
78
+ const na = Number(ma[i]);
79
+ const nb = Number(mb[i]);
80
+ if (na < nb) return -1;
81
+ if (na > nb) return 1;
82
+ }
83
+ return 0;
84
+ }
85
+
86
+ // Shared by classifyInstall and classifyUpgrade so the two can never read
87
+ // "what version is already here" two different ways.
88
+ function resolveInstalledVersionInfo(appDir) {
89
+ const liveComposePath = path.join(appDir, "docker-compose.yml");
90
+ if (!existsSync(appDir) || !existsSync(liveComposePath)) {
91
+ return { exists: false, installedVersion: null, state: null };
92
+ }
93
+ const statePath = path.join(appDir, "install-state.json");
94
+ const state = readJsonSafe(statePath);
95
+ const pinned = parsePinnedServerVersion(readTextSafe(liveComposePath));
96
+ const installedVersion = state?.packageVersion ?? pinned ?? null;
97
+ return { exists: true, installedVersion, state };
98
+ }
99
+
100
+ // fresh: nothing installed here yet.
101
+ // repair: something is here (a live compose exists) but there is no state
102
+ // file we trust to say what version it is (e.g. a Windows-GUI-only
103
+ // install, or a corrupt state file) — never guess, ask for a repair.
104
+ // upgrade / same-version: read from install-state.json's packageVersion
105
+ // (falling back to the live compose's pinned tag) compared against the
106
+ // version of the package currently running.
107
+ export function classifyInstall({ appDir, currentVersion }) {
108
+ const info = resolveInstalledVersionInfo(appDir);
109
+ if (!info.exists) return "fresh";
110
+ if (!info.installedVersion || !info.state) return "repair";
111
+
112
+ const cmp = compareVersions(info.installedVersion, currentVersion);
113
+ if (cmp === 0) return "same-version";
114
+ if (cmp === -1) return "upgrade";
115
+ return "repair"; // installed reads NEWER than this package — never silently downgrade
116
+ }
117
+
118
+ // CF20-T6 — wires classifyInstall (T3 review Minor #4: implemented, tested,
119
+ // ORPHANED — nothing called it) into the postinstall flow, and splits
120
+ // classifyInstall's "repair" bucket into the two shapes postinstall must
121
+ // message and record differently:
122
+ // - a genuinely unknown install (no trusted version info at all), vs.
123
+ // - "downgrade-package": npm delivered an OLDER getculpa than the
124
+ // version already staged here (a stale mirror, a pinned older version,
125
+ // an explicit rollback) — never silently downgrade.
126
+ // previousVersion is best-effort (state's packageVersion first, the live
127
+ // compose's pinned tag as fallback) and is null only for a genuinely fresh
128
+ // install.
129
+ export function classifyUpgrade({ appDir, currentVersion }) {
130
+ const classification = classifyInstall({ appDir, currentVersion });
131
+ const { installedVersion: previousVersion } = resolveInstalledVersionInfo(appDir);
132
+ if (classification === "repair" && previousVersion && compareVersions(previousVersion, currentVersion) === 1) {
133
+ return { classification: "downgrade-package", previousVersion };
134
+ }
135
+ return { classification, previousVersion };
136
+ }
137
+
138
+ // CF20-T6 — mirrors launch-culpa.ps1's Test-BackwardPin one-direction rule
139
+ // (QW-1/QW-3 semantics) so the SHIPPED culpa-compose.yml can get the same
140
+ // protection the live compose already has via provision.mjs's
141
+ // stageLiveComposeIfAbsent: an older shipped file must never silently win
142
+ // over a newer one already staged — the downgrade-package case. A malformed
143
+ // incoming pin against a well-formed staged baseline fails CLOSED (treated
144
+ // as older, the same asymmetry Test-BackwardPin uses); an absent incoming
145
+ // or staged pin is permissive — there is no provable baseline to violate.
146
+ export function isIncomingComposeOlder(incomingComposeText, stagedComposeText) {
147
+ const incoming = parsePinnedServerVersion(incomingComposeText);
148
+ const staged = parsePinnedServerVersion(stagedComposeText);
149
+ if (!incoming || !staged) return false;
150
+ const rx = /^v?(\d+)\.(\d+)\.(\d+)/;
151
+ if (!rx.test(incoming) && rx.test(staged)) return true;
152
+ return compareVersions(incoming, staged) === -1;
153
+ }
154
+
155
+ function readJsonSafe(filePath) {
156
+ try {
157
+ return JSON.parse(readFileSync(filePath, "utf8"));
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ function readTextSafe(filePath) {
164
+ try {
165
+ return readFileSync(filePath, "utf8");
166
+ } catch {
167
+ return "";
168
+ }
169
+ }
@@ -0,0 +1,54 @@
1
+ import type { UpgradeClassification } from "./preflight.d.mts";
2
+
3
+ export const PARITY_ASSETS: string[];
4
+
5
+ // T-CF28-6 — the Windows-only subset, and the platform-scoped accessor that
6
+ // provision(), repair() and doctor() all read.
7
+ export const WINDOWS_ONLY_ASSETS: readonly string[];
8
+ export function parityAssetsFor(platform?: string): string[];
9
+
10
+ // T-CF29-8 — the app-dir name culpa-collector.ps1 reads (Node platform-arch),
11
+ // not the Rust target triple the release asset uses.
12
+ export function collectorBinaryName(platform?: string, arch?: string): string;
13
+
14
+ // Exported so lib/repair.mjs stages the collector to the SAME place with the
15
+ // SAME name — the two must never disagree about where capture lives.
16
+ export function stageCollectorBinary(opts: {
17
+ appDir: string;
18
+ vendorDir: string;
19
+ platform?: string;
20
+ arch?: string;
21
+ }): boolean;
22
+
23
+ // CF20-T5: exported for lib/repair.mjs's reuse — see provision.mjs's comment
24
+ // on the export.
25
+ export function stageParityFiles(
26
+ appDir: string,
27
+ env?: Record<string, string | undefined>,
28
+ platform?: string,
29
+ ): void;
30
+ export function stageLiveComposeIfAbsent(appDir: string): boolean;
31
+
32
+ export interface ProvisionOptions {
33
+ appDir: string;
34
+ currentVersion: string;
35
+ collectorFetched?: boolean;
36
+ platform?: string;
37
+ arch?: string;
38
+ vendorDir?: string;
39
+ dockerSpawnSync?: (cmd: string, args?: string[], opts?: unknown) => { status: number | null };
40
+ delegateWindowsInstaller?: (appDir: string) => boolean;
41
+ env?: Record<string, string | undefined>;
42
+ }
43
+
44
+ export type DockerState = "missing" | "installed-not-running" | "ready";
45
+
46
+ export interface ProvisionResult {
47
+ classification: UpgradeClassification;
48
+ previousVersion: string | null;
49
+ dockerState: DockerState;
50
+ imagesStaged: boolean;
51
+ collectorStaged: boolean;
52
+ }
53
+
54
+ export function provision(opts: ProvisionOptions): Promise<ProvisionResult>;