@pellux/goodvibes-tui 1.25.0 → 1.26.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/CHANGELOG.md +15 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +6616 -2776
- package/package.json +8 -7
- package/src/cli/help.ts +10 -0
- package/src/cli/redaction.ts +68 -3
- package/src/config/goodvibes-home.ts +10 -0
- package/src/config/secret-config.ts +64 -8
- package/src/core/turn-event-wiring.ts +7 -16
- package/src/core/workstream-notification.ts +69 -0
- package/src/daemon/cli.ts +48 -4
- package/src/daemon/handlers/credentials.ts +15 -5
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +332 -0
- package/src/daemon/send/composition.ts +99 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/input/commands/config.ts +68 -2
- package/src/input/commands/local-provider-runtime.ts +9 -1
- package/src/input/commands/operator-runtime.ts +6 -1
- package/src/input/commands/payment-card-intake.ts +200 -0
- package/src/input/commands/profile-render.ts +250 -0
- package/src/input/commands/profile-runtime.ts +362 -0
- package/src/input/commands/profile-types.ts +381 -0
- package/src/input/commands.ts +7 -0
- package/src/input/composer-editor.ts +4 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +13 -1
- package/src/input/payments-config.ts +167 -0
- package/src/input/provider-key-intake.ts +10 -2
- package/src/input/settings-modal-commit.ts +146 -0
- package/src/input/settings-modal-data.ts +30 -0
- package/src/input/settings-modal-reset.ts +6 -2
- package/src/input/settings-modal-secrets.ts +9 -2
- package/src/input/settings-modal-types.ts +17 -2
- package/src/input/settings-modal.ts +27 -107
- package/src/panels/modals/marketplace-modal.ts +5 -0
- package/src/panels/modals/skills-modal.ts +5 -0
- package/src/renderer/settings-modal-helpers.ts +4 -0
- package/src/renderer/settings-modal.ts +16 -1
- package/src/runtime/bootstrap.ts +2 -2
- package/src/runtime/install-self-check-startup.ts +23 -0
- package/src/runtime/onboarding/types.ts +6 -1
- package/src/runtime/path-shadow-startup.ts +243 -0
- package/src/runtime/path-shadow.ts +418 -0
- package/src/runtime/reachability-notice.ts +100 -0
- package/src/runtime/runtime-services-types.ts +243 -0
- package/src/runtime/services.ts +14 -205
- package/src/verification/verification-ledger.ts +104 -4
- package/src/version.ts +1 -1
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* path-shadow-startup.ts — the boot-time surfacing of "is this the build you
|
|
3
|
+
* are actually reaching, and is it the current one".
|
|
4
|
+
*
|
|
5
|
+
* It wires the real host inputs (this process's executable, the real PATH,
|
|
6
|
+
* existsSync/realpathSync, and a bounded `<path> --version` spawn) into the
|
|
7
|
+
* pure scan in path-shadow.ts and the pure wording in reachability-notice.ts,
|
|
8
|
+
* then prints the result through the same SystemMessageRouter the rest of
|
|
9
|
+
* startup uses, at high priority, before the session gets going.
|
|
10
|
+
*
|
|
11
|
+
* Cost discipline, because this runs on every start:
|
|
12
|
+
* - the first scan is existence-only: no process is spawned while there is
|
|
13
|
+
* nothing to report, which is the overwhelmingly common case;
|
|
14
|
+
* - versions are probed only after a shadow has already been found, and
|
|
15
|
+
* only with `--version`, bounded by a short timeout;
|
|
16
|
+
* - the latest-release lookup only happens when it can actually change what
|
|
17
|
+
* the user should do: a package-managed or source install (which will
|
|
18
|
+
* never swap itself), or an install that has just been found unreachable.
|
|
19
|
+
* A healthy binary install has already been brought to the latest release
|
|
20
|
+
* by the launch auto-updater, so asking again would be a network round
|
|
21
|
+
* trip that can only confirm what just happened.
|
|
22
|
+
*
|
|
23
|
+
* Every failure is swallowed. A reachability check must never block or crash
|
|
24
|
+
* boot.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { spawnSync } from 'node:child_process';
|
|
28
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
29
|
+
import { basename, dirname, join } from 'node:path';
|
|
30
|
+
import { detectInstallKind, fallbackUpdateCommand, type UpdateFetchLike } from './update-check.ts';
|
|
31
|
+
import { checkForUpdate } from '../input/commands/update-runtime.ts';
|
|
32
|
+
import {
|
|
33
|
+
scanCommandShadows,
|
|
34
|
+
splitPathEntries,
|
|
35
|
+
type ShadowScanResult,
|
|
36
|
+
} from './path-shadow.ts';
|
|
37
|
+
import { buildReachabilityNotices, type ReachabilityNotice } from './reachability-notice.ts';
|
|
38
|
+
import { VERSION } from '../version.ts';
|
|
39
|
+
import type { SystemMessageRouter } from '../core/system-message-router.ts';
|
|
40
|
+
|
|
41
|
+
/** The commands a goodvibes install places side by side in one directory. */
|
|
42
|
+
export const INSTALLED_COMMANDS = ['goodvibes', 'goodvibes-daemon', 'goodvibes-agent'] as const;
|
|
43
|
+
|
|
44
|
+
/** How long a `<path> --version` probe may take before it is abandoned. */
|
|
45
|
+
const VERSION_PROBE_TIMEOUT_MS = 3000;
|
|
46
|
+
/** How long the latest-release lookup may take before startup moves on without it. */
|
|
47
|
+
const LATEST_LOOKUP_TIMEOUT_MS = 2500;
|
|
48
|
+
|
|
49
|
+
function safeRealPath(path: string): string {
|
|
50
|
+
try {
|
|
51
|
+
return realpathSync(path);
|
|
52
|
+
} catch {
|
|
53
|
+
return path;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isExecutableFile(path: string): boolean {
|
|
58
|
+
try {
|
|
59
|
+
const stats = statSync(path);
|
|
60
|
+
if (!stats.isFile()) return false;
|
|
61
|
+
// Any execute bit is enough: the shell searches for an executable file,
|
|
62
|
+
// and which bit applies depends on ownership we are not going to compute.
|
|
63
|
+
return (stats.mode & 0o111) !== 0;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Runs `<path> --version` and returns its first line. Never inherits stdio, so
|
|
71
|
+
* a binary that tries to draw a terminal cannot disturb this one.
|
|
72
|
+
*/
|
|
73
|
+
export function probeVersionLine(path: string): string | undefined {
|
|
74
|
+
try {
|
|
75
|
+
const result = spawnSync(path, ['--version'], {
|
|
76
|
+
timeout: VERSION_PROBE_TIMEOUT_MS,
|
|
77
|
+
encoding: 'utf8',
|
|
78
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
79
|
+
windowsHide: true,
|
|
80
|
+
});
|
|
81
|
+
if (result.error || typeof result.stdout !== 'string') return undefined;
|
|
82
|
+
return result.stdout.split('\n')[0];
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The PATH directory that provides THIS running executable, which is the
|
|
90
|
+
* position everything else is measured against.
|
|
91
|
+
*
|
|
92
|
+
* Preferred answer: the first PATH entry whose `<dir>/<command>` resolves,
|
|
93
|
+
* through symlinks, to this same file. That is exactly "where the shell would
|
|
94
|
+
* find me", and it is right for a linked package install as well as a
|
|
95
|
+
* standalone binary.
|
|
96
|
+
*
|
|
97
|
+
* Fallback, for a standalone binary only: the directory the executable sits
|
|
98
|
+
* in. That directory being absent from PATH is itself worth reporting — an
|
|
99
|
+
* installed binary nobody can reach by name. For a package-managed install
|
|
100
|
+
* the executable lives inside node_modules, which is never on PATH and never
|
|
101
|
+
* meant to be, so there is nothing honest to say and the check stays silent.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveSelfDirectory(input: {
|
|
104
|
+
readonly execPath: string;
|
|
105
|
+
readonly command: string;
|
|
106
|
+
readonly pathEntries: readonly string[];
|
|
107
|
+
readonly realPath: (path: string) => string;
|
|
108
|
+
readonly isExecutableFile: (path: string) => boolean;
|
|
109
|
+
}): string | undefined {
|
|
110
|
+
const self = input.realPath(input.execPath);
|
|
111
|
+
for (const directory of input.pathEntries) {
|
|
112
|
+
const candidate = join(directory, input.command);
|
|
113
|
+
if (!input.isExecutableFile(candidate)) continue;
|
|
114
|
+
if (input.realPath(candidate) === self) return directory;
|
|
115
|
+
}
|
|
116
|
+
if (detectInstallKind(input.execPath) === 'binary') return dirname(self);
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Which of the installed commands actually sit in this directory. */
|
|
121
|
+
function commandsPresentIn(directory: string, exists: (path: string) => boolean): string[] {
|
|
122
|
+
return INSTALLED_COMMANDS.filter((command) => exists(join(directory, command)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ReachabilityCheckResult {
|
|
126
|
+
readonly notices: readonly ReachabilityNotice[];
|
|
127
|
+
readonly scan?: ShadowScanResult | undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The whole check, with the network lookup injected so tests never reach it.
|
|
132
|
+
* Returns the notices to print; an empty list is the healthy case.
|
|
133
|
+
*/
|
|
134
|
+
export async function runReachabilityCheck(input: {
|
|
135
|
+
readonly execPath: string;
|
|
136
|
+
readonly pathValue: string | undefined;
|
|
137
|
+
readonly homeDir: string;
|
|
138
|
+
readonly runningVersion: string;
|
|
139
|
+
readonly commandName?: string | undefined;
|
|
140
|
+
/** Resolves the newest released version, or undefined when it cannot be determined. */
|
|
141
|
+
readonly resolveLatest: () => Promise<string | undefined>;
|
|
142
|
+
// The host touches, injectable so a test drives a whole scenario with fake
|
|
143
|
+
// paths and never spawns anything. Production passes none of these.
|
|
144
|
+
readonly isExecutableFile?: ((path: string) => boolean) | undefined;
|
|
145
|
+
readonly realPath?: ((path: string) => string) | undefined;
|
|
146
|
+
readonly probeVersion?: ((path: string) => string | undefined) | undefined;
|
|
147
|
+
}): Promise<ReachabilityCheckResult> {
|
|
148
|
+
const command = input.commandName ?? basename(input.execPath);
|
|
149
|
+
const pathEntries = splitPathEntries(input.pathValue);
|
|
150
|
+
const installKind = detectInstallKind(input.execPath);
|
|
151
|
+
const fileIsExecutable = input.isExecutableFile ?? isExecutableFile;
|
|
152
|
+
const resolvePath = input.realPath ?? safeRealPath;
|
|
153
|
+
const versionProbe = input.probeVersion ?? probeVersionLine;
|
|
154
|
+
|
|
155
|
+
// A source checkout is not an install: there is no maintained copy to be
|
|
156
|
+
// shadowed and no release to be behind.
|
|
157
|
+
if (installKind === 'source') return { notices: [] };
|
|
158
|
+
|
|
159
|
+
const selfDirectory = resolveSelfDirectory({
|
|
160
|
+
execPath: input.execPath,
|
|
161
|
+
command,
|
|
162
|
+
pathEntries,
|
|
163
|
+
realPath: resolvePath,
|
|
164
|
+
isExecutableFile: fileIsExecutable,
|
|
165
|
+
});
|
|
166
|
+
if (!selfDirectory) return { notices: [] };
|
|
167
|
+
|
|
168
|
+
const commands = commandsPresentIn(selfDirectory, fileIsExecutable);
|
|
169
|
+
const base = {
|
|
170
|
+
commands: commands.length > 0 ? commands : [command],
|
|
171
|
+
installDir: selfDirectory,
|
|
172
|
+
pathEntries,
|
|
173
|
+
homeDir: input.homeDir,
|
|
174
|
+
isExecutableFile: fileIsExecutable,
|
|
175
|
+
realPath: resolvePath,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Existence-only first: nothing is spawned while there is nothing to report.
|
|
179
|
+
const cheapScan = scanCommandShadows(base);
|
|
180
|
+
const scan = cheapScan.hasProblem
|
|
181
|
+
? scanCommandShadows({ ...base, probeVersion: versionProbe })
|
|
182
|
+
: cheapScan;
|
|
183
|
+
|
|
184
|
+
const latestVersion = scan.hasProblem || installKind !== 'binary'
|
|
185
|
+
? await input.resolveLatest()
|
|
186
|
+
: undefined;
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
scan,
|
|
190
|
+
notices: buildReachabilityNotices({
|
|
191
|
+
scan,
|
|
192
|
+
runningVersion: input.runningVersion,
|
|
193
|
+
latestVersion,
|
|
194
|
+
updateCommand: installKind === 'binary'
|
|
195
|
+
? 'curl -fsSL https://goodvibes.sh/install.sh | sh'
|
|
196
|
+
: fallbackUpdateCommand(installKind),
|
|
197
|
+
}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The real latest-release lookup, bounded so a slow network cannot hold up boot. */
|
|
202
|
+
async function resolveLatestReleaseBounded(): Promise<string | undefined> {
|
|
203
|
+
try {
|
|
204
|
+
// The same release lookup /update uses — there is deliberately no second
|
|
205
|
+
// source of truth for what the latest version is.
|
|
206
|
+
const lookup = checkForUpdate(fetch as UpdateFetchLike, VERSION).then((result) => result.latestTag);
|
|
207
|
+
const timed = await Promise.race([
|
|
208
|
+
lookup,
|
|
209
|
+
new Promise<undefined>((resolve) => {
|
|
210
|
+
const timer = setTimeout(() => resolve(undefined), LATEST_LOOKUP_TIMEOUT_MS);
|
|
211
|
+
timer.unref?.();
|
|
212
|
+
}),
|
|
213
|
+
]);
|
|
214
|
+
return timed ?? undefined;
|
|
215
|
+
} catch {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Boot-time wiring. Prints one high-priority system message per line so the
|
|
222
|
+
* report reads as prose rather than a single wrapped paragraph, and swallows
|
|
223
|
+
* everything.
|
|
224
|
+
*/
|
|
225
|
+
export async function announceReachability(router: SystemMessageRouter): Promise<void> {
|
|
226
|
+
try {
|
|
227
|
+
const result = await runReachabilityCheck({
|
|
228
|
+
execPath: process.execPath,
|
|
229
|
+
pathValue: process.env['PATH'],
|
|
230
|
+
homeDir: process.env['HOME'] ?? '',
|
|
231
|
+
runningVersion: VERSION,
|
|
232
|
+
commandName: 'goodvibes',
|
|
233
|
+
resolveLatest: resolveLatestReleaseBounded,
|
|
234
|
+
});
|
|
235
|
+
for (const notice of result.notices) {
|
|
236
|
+
for (const line of notice.lines) {
|
|
237
|
+
router.high(`[Install] ${line}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} catch {
|
|
241
|
+
// Best-effort — a reachability check must never block or crash boot.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* path-shadow.ts — decide whether the copy of a command the updater maintains
|
|
3
|
+
* is actually the copy the user's shell runs.
|
|
4
|
+
*
|
|
5
|
+
* OWNERSHIP: this is a verbatim copy of the SDK's canonical module,
|
|
6
|
+
* `@pellux/goodvibes-sdk/platform/runtime/path-shadow`, which is where the
|
|
7
|
+
* policy lives because more than one surface needs it (this TUI, the agent,
|
|
8
|
+
* and the installer's POSIX sh statement of the same rules). The copy exists
|
|
9
|
+
* only because the SDK release carrying that module has not been published
|
|
10
|
+
* yet and this package pins an exact published version. When it publishes,
|
|
11
|
+
* replace this file's body with a re-export of the SDK subpath — exactly the
|
|
12
|
+
* shape update-check.ts already uses for the update policy — and delete
|
|
13
|
+
* nothing else; the wiring in path-shadow-startup.ts and the tests in
|
|
14
|
+
* src/test/runtime/path-shadow.test.ts keep working unchanged.
|
|
15
|
+
*
|
|
16
|
+
* The platform's update guarantee is "one verified swap mechanism, nothing
|
|
17
|
+
* ever drifts". That guarantee holds only while there is exactly one copy of
|
|
18
|
+
* each command reachable on PATH. When a second, older copy sits in a
|
|
19
|
+
* directory that comes EARLIER on PATH than the install directory, the
|
|
20
|
+
* updater keeps upgrading a file the user never reaches: `goodvibes-agent`
|
|
21
|
+
* reports itself current, an older build answers the typing, and the product
|
|
22
|
+
* looks like it is lying about its own capabilities. That is exactly what
|
|
23
|
+
* happened with a leftover `~/.bun/bin/goodvibes-agent` link (1.18.1) sitting
|
|
24
|
+
* at PATH position 2 while `~/.local/bin/goodvibes-agent` (1.21.0) sat at
|
|
25
|
+
* position 21.
|
|
26
|
+
*
|
|
27
|
+
* This module is the one place that decides those facts. It is pure: every
|
|
28
|
+
* filesystem and subprocess touch (does this file exist and is it runnable,
|
|
29
|
+
* where does this symlink actually point, what does `<path> --version` say)
|
|
30
|
+
* is injected, so the policy is provable under test with fake paths and no
|
|
31
|
+
* real install. The surfaces wire the real I/O:
|
|
32
|
+
* - a client's startup check reports a shadow before anything else renders;
|
|
33
|
+
* - the installer's shell implementation (goodvibes-tui scripts/install.sh)
|
|
34
|
+
* mirrors these same rules in POSIX sh, because an installer cannot
|
|
35
|
+
* import TypeScript.
|
|
36
|
+
*
|
|
37
|
+
* Removal is deliberately conservative. A copy is only ever offered for
|
|
38
|
+
* removal when it is recognisably one of OUR programs — a link into an
|
|
39
|
+
* installed `@pellux/goodvibes-*` package, or a file that answers
|
|
40
|
+
* `--version` with `<command> <semver>` — and only when it lives inside the
|
|
41
|
+
* user's own home directory. Everything else is reported and left alone.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Types
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* How confident we are that a copy found on PATH is one of our own programs,
|
|
50
|
+
* and therefore whether it is safe to offer to remove it.
|
|
51
|
+
*
|
|
52
|
+
* - `install-target`: the copy living in the directory the installer and
|
|
53
|
+
* the auto-updater maintain. Never a removal candidate.
|
|
54
|
+
* - `package-link`: an entry that resolves into an installed
|
|
55
|
+
* `@pellux/goodvibes-*` package (a `bun add -g` / `npm i -g` link, or a
|
|
56
|
+
* project-local dependency link). Removed by uninstalling that package.
|
|
57
|
+
* - `our-binary`: a standalone file that answers `--version` with
|
|
58
|
+
* `<command> <semver>`, i.e. a previous standalone install of the same
|
|
59
|
+
* program. Removed by deleting the file.
|
|
60
|
+
* - `unknown`: anything we cannot positively identify, including anything
|
|
61
|
+
* outside the user's home directory. Reported, never removed.
|
|
62
|
+
*/
|
|
63
|
+
export type ShadowOwnership = 'install-target' | 'package-link' | 'our-binary' | 'unknown';
|
|
64
|
+
|
|
65
|
+
/** How a recognised copy is removed, and by what command. */
|
|
66
|
+
export interface ShadowRemoval {
|
|
67
|
+
/** `package` = uninstall the owning package; `file` = delete the PATH entry itself. */
|
|
68
|
+
readonly kind: 'package' | 'file';
|
|
69
|
+
/** The `@pellux/...` package name, when `kind` is `package`. */
|
|
70
|
+
readonly packageName?: string | undefined;
|
|
71
|
+
/** The exact command a user (or the installer) runs to remove this copy. */
|
|
72
|
+
readonly command: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One copy of a command found on PATH. */
|
|
76
|
+
export interface CommandCopy {
|
|
77
|
+
/** The command name, e.g. `goodvibes-agent`. */
|
|
78
|
+
readonly command: string;
|
|
79
|
+
/** The PATH directory the copy was found in. */
|
|
80
|
+
readonly directory: string;
|
|
81
|
+
/** `<directory>/<command>` — the path the shell would execute. */
|
|
82
|
+
readonly path: string;
|
|
83
|
+
/** Zero-based position of `directory` in PATH. Lower wins. */
|
|
84
|
+
readonly pathIndex: number;
|
|
85
|
+
/** The symlink-resolved path, or `path` when it is not a link. */
|
|
86
|
+
readonly resolvedPath: string;
|
|
87
|
+
/** What `<path> --version` reported, when it could be probed. */
|
|
88
|
+
readonly version?: string | undefined;
|
|
89
|
+
readonly ownership: ShadowOwnership;
|
|
90
|
+
/** Present only when `ownership` is `package-link` or `our-binary`. */
|
|
91
|
+
readonly removal?: ShadowRemoval | undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The reachability verdict for one command. */
|
|
95
|
+
export interface CommandShadowReport {
|
|
96
|
+
readonly command: string;
|
|
97
|
+
/** Every copy found, in PATH order. */
|
|
98
|
+
readonly copies: readonly CommandCopy[];
|
|
99
|
+
/** The copy the shell actually runs — the first on PATH. Absent when the command is not on PATH at all. */
|
|
100
|
+
readonly winner?: CommandCopy | undefined;
|
|
101
|
+
/** The copy in the install directory. Absent when the install directory is not on PATH. */
|
|
102
|
+
readonly installed?: CommandCopy | undefined;
|
|
103
|
+
/**
|
|
104
|
+
* Copies that come earlier on PATH than the install directory's copy. A
|
|
105
|
+
* non-empty list means the maintained install is unreachable by name.
|
|
106
|
+
*/
|
|
107
|
+
readonly shadowing: readonly CommandCopy[];
|
|
108
|
+
/** True when the install directory holds this command but is not on PATH at all. */
|
|
109
|
+
readonly installDirNotOnPath: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The whole-install verdict across every command the installer maintains. */
|
|
113
|
+
export interface ShadowScanResult {
|
|
114
|
+
readonly reports: readonly CommandShadowReport[];
|
|
115
|
+
/** Commands whose maintained copy loses to an earlier PATH entry. */
|
|
116
|
+
readonly shadowed: readonly CommandShadowReport[];
|
|
117
|
+
/** True when any command is shadowed or the install directory is missing from PATH. */
|
|
118
|
+
readonly hasProblem: boolean;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface ShadowScanInput {
|
|
122
|
+
/** Command names the installer/updater maintains, e.g. `['goodvibes', 'goodvibes-daemon', 'goodvibes-agent']`. */
|
|
123
|
+
readonly commands: readonly string[];
|
|
124
|
+
/** The directory the installer writes to and the auto-updater swaps in place. */
|
|
125
|
+
readonly installDir: string;
|
|
126
|
+
/** PATH split into directories, in order, exactly as the shell would search them. */
|
|
127
|
+
readonly pathEntries: readonly string[];
|
|
128
|
+
/** The user's home directory. Nothing outside it is ever a removal candidate. */
|
|
129
|
+
readonly homeDir: string;
|
|
130
|
+
/** True when `path` exists and is a runnable file. */
|
|
131
|
+
readonly isExecutableFile: (path: string) => boolean;
|
|
132
|
+
/** Resolves a symlink chain; returns `path` unchanged when it is not a link or cannot be resolved. */
|
|
133
|
+
readonly realPath: (path: string) => string;
|
|
134
|
+
/**
|
|
135
|
+
* Runs `<path> --version` and returns its first line, or undefined when it
|
|
136
|
+
* cannot be run. Optional: without it, `our-binary` can never be
|
|
137
|
+
* established and such copies stay `unknown` (reported, never removed).
|
|
138
|
+
*/
|
|
139
|
+
readonly probeVersion?: ((path: string) => string | undefined) | undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// PATH handling
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Splits a raw PATH string into directories in search order.
|
|
148
|
+
*
|
|
149
|
+
* Empty entries (from a leading, trailing, or doubled separator) mean "the
|
|
150
|
+
* current directory" to a POSIX shell. They are dropped rather than resolved:
|
|
151
|
+
* a cwd-relative hit is not a stable install anyone can reason about, and
|
|
152
|
+
* treating it as one would produce a different verdict per directory.
|
|
153
|
+
* Duplicated directories collapse to their first occurrence, which is the
|
|
154
|
+
* only position that can ever win.
|
|
155
|
+
*/
|
|
156
|
+
export function splitPathEntries(rawPath: string | undefined, separator = ':'): string[] {
|
|
157
|
+
if (!rawPath) return [];
|
|
158
|
+
const seen = new Set<string>();
|
|
159
|
+
const entries: string[] = [];
|
|
160
|
+
for (const raw of rawPath.split(separator)) {
|
|
161
|
+
const entry = raw.trim();
|
|
162
|
+
if (entry.length === 0) continue;
|
|
163
|
+
const normalized = stripTrailingSeparators(entry);
|
|
164
|
+
if (normalized.length === 0) continue;
|
|
165
|
+
if (seen.has(normalized)) continue;
|
|
166
|
+
seen.add(normalized);
|
|
167
|
+
entries.push(normalized);
|
|
168
|
+
}
|
|
169
|
+
return entries;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Drops trailing `/` (or `\`) so `~/.local/bin` and `~/.local/bin/` are one directory. */
|
|
173
|
+
function stripTrailingSeparators(value: string): string {
|
|
174
|
+
let end = value.length;
|
|
175
|
+
while (end > 1 && (value[end - 1] === '/' || value[end - 1] === '\\')) end -= 1;
|
|
176
|
+
return value.slice(0, end);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function joinPath(directory: string, name: string): string {
|
|
180
|
+
return `${stripTrailingSeparators(directory)}/${name}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function segmentsOf(path: string): string[] {
|
|
184
|
+
return path.split(/[\\/]/).filter((segment) => segment.length > 0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** True when `path` is `root` itself or lives underneath it. */
|
|
188
|
+
export function isWithinDirectory(path: string, root: string): boolean {
|
|
189
|
+
const normalizedRoot = stripTrailingSeparators(root);
|
|
190
|
+
if (normalizedRoot.length === 0) return false;
|
|
191
|
+
const normalizedPath = stripTrailingSeparators(path);
|
|
192
|
+
if (normalizedPath === normalizedRoot) return true;
|
|
193
|
+
return normalizedPath.startsWith(`${normalizedRoot}/`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Ownership classification
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
/** The npm scope every package this platform publishes lives under. */
|
|
201
|
+
const PACKAGE_SCOPE = '@pellux';
|
|
202
|
+
/** Only `@pellux/goodvibes-*` packages are ours; a same-scope stranger is not assumed. */
|
|
203
|
+
const PACKAGE_PREFIX = 'goodvibes-';
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Extracts the owning `@pellux/goodvibes-*` package name from a resolved
|
|
207
|
+
* path, by finding the LAST `node_modules` segment and reading the scoped
|
|
208
|
+
* package directory that follows it. The last occurrence is the one that
|
|
209
|
+
* owns the file: a nested `node_modules/a/node_modules/b/bin/x` belongs to
|
|
210
|
+
* `b`, not `a`.
|
|
211
|
+
*/
|
|
212
|
+
export function owningPackageName(resolvedPath: string): string | undefined {
|
|
213
|
+
const segments = segmentsOf(resolvedPath);
|
|
214
|
+
for (let i = segments.length - 1; i >= 0; i -= 1) {
|
|
215
|
+
if (segments[i] !== 'node_modules') continue;
|
|
216
|
+
const scope = segments[i + 1];
|
|
217
|
+
const name = segments[i + 2];
|
|
218
|
+
if (scope !== PACKAGE_SCOPE || name === undefined) return undefined;
|
|
219
|
+
if (!name.startsWith(PACKAGE_PREFIX)) return undefined;
|
|
220
|
+
return `${scope}/${name}`;
|
|
221
|
+
}
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Picks the uninstall command for a package link, from where the package
|
|
227
|
+
* actually lives. A bun global install lives under `.bun/install/global`;
|
|
228
|
+
* everything else is treated as npm-managed, which is also the correct
|
|
229
|
+
* advice for a `bun add -g` that used an npm-style prefix.
|
|
230
|
+
*/
|
|
231
|
+
function packageRemoval(packageName: string, resolvedPath: string): ShadowRemoval {
|
|
232
|
+
const isBunGlobal = /[\\/]\.bun[\\/]install[\\/]global[\\/]/.test(resolvedPath)
|
|
233
|
+
|| segmentsOf(resolvedPath).includes('.bun');
|
|
234
|
+
return {
|
|
235
|
+
kind: 'package',
|
|
236
|
+
packageName,
|
|
237
|
+
command: isBunGlobal ? `bun remove -g ${packageName}` : `npm rm -g ${packageName}`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* True when a `--version` line is one of ours: `<command> <dotted numbers>`,
|
|
243
|
+
* which is the exact shape every goodvibes command prints. Anything else —
|
|
244
|
+
* an unrelated program that happens to share the name, a wrapper script, a
|
|
245
|
+
* `--version` that errors — fails, and the copy stays `unknown`.
|
|
246
|
+
*/
|
|
247
|
+
export function versionLineIdentifiesCommand(line: string | undefined, command: string): string | undefined {
|
|
248
|
+
if (!line) return undefined;
|
|
249
|
+
const match = /^([A-Za-z0-9._-]+)[ \t]+v?(\d+(?:\.\d+)*(?:[-+][0-9A-Za-z.-]+)?)\s*$/.exec(line.trim());
|
|
250
|
+
if (!match) return undefined;
|
|
251
|
+
if (match[1] !== command) return undefined;
|
|
252
|
+
return match[2];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// The scan
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
function classify(input: ShadowScanInput, copy: {
|
|
260
|
+
readonly command: string;
|
|
261
|
+
readonly directory: string;
|
|
262
|
+
readonly path: string;
|
|
263
|
+
readonly pathIndex: number;
|
|
264
|
+
readonly resolvedPath: string;
|
|
265
|
+
}): { ownership: ShadowOwnership; removal?: ShadowRemoval | undefined; version?: string | undefined } {
|
|
266
|
+
if (isWithinDirectory(copy.path, input.installDir)) {
|
|
267
|
+
return { ownership: 'install-target', version: probe(input, copy.path, copy.command) };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const version = probe(input, copy.path, copy.command);
|
|
271
|
+
|
|
272
|
+
// Nothing outside the user's own home is ever a removal candidate, however
|
|
273
|
+
// confidently we recognise it: a system-wide copy is somebody else's to
|
|
274
|
+
// manage, and this code must never reach outside the user's directories.
|
|
275
|
+
const insideHome = isWithinDirectory(copy.path, input.homeDir)
|
|
276
|
+
&& isWithinDirectory(copy.resolvedPath, input.homeDir);
|
|
277
|
+
|
|
278
|
+
const packageName = owningPackageName(copy.resolvedPath);
|
|
279
|
+
if (packageName && insideHome) {
|
|
280
|
+
return { ownership: 'package-link', removal: packageRemoval(packageName, copy.resolvedPath), version };
|
|
281
|
+
}
|
|
282
|
+
if (packageName) {
|
|
283
|
+
return { ownership: 'unknown', version };
|
|
284
|
+
}
|
|
285
|
+
if (version !== undefined && insideHome) {
|
|
286
|
+
return { ownership: 'our-binary', removal: { kind: 'file', command: `rm ${copy.path}` }, version };
|
|
287
|
+
}
|
|
288
|
+
return { ownership: 'unknown', version };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function probe(input: ShadowScanInput, path: string, command: string): string | undefined {
|
|
292
|
+
if (!input.probeVersion) return undefined;
|
|
293
|
+
try {
|
|
294
|
+
return versionLineIdentifiesCommand(input.probeVersion(path), command);
|
|
295
|
+
} catch {
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function resolve(input: ShadowScanInput, path: string): string {
|
|
301
|
+
try {
|
|
302
|
+
return input.realPath(path);
|
|
303
|
+
} catch {
|
|
304
|
+
return path;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function exists(input: ShadowScanInput, path: string): boolean {
|
|
309
|
+
try {
|
|
310
|
+
return input.isExecutableFile(path);
|
|
311
|
+
} catch {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Scans PATH for every copy of every maintained command and decides which one wins. */
|
|
317
|
+
export function scanCommandShadows(input: ShadowScanInput): ShadowScanResult {
|
|
318
|
+
const entries = input.pathEntries.map(stripTrailingSeparators).filter((entry) => entry.length > 0);
|
|
319
|
+
const installDir = stripTrailingSeparators(input.installDir);
|
|
320
|
+
const installDirOnPath = entries.some((entry) => entry === installDir);
|
|
321
|
+
|
|
322
|
+
const reports: CommandShadowReport[] = [];
|
|
323
|
+
for (const command of input.commands) {
|
|
324
|
+
const copies: CommandCopy[] = [];
|
|
325
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
326
|
+
const directory = entries[index] as string;
|
|
327
|
+
const path = joinPath(directory, command);
|
|
328
|
+
if (!exists(input, path)) continue;
|
|
329
|
+
const resolvedPath = resolve(input, path);
|
|
330
|
+
const partial = { command, directory, path, pathIndex: index, resolvedPath };
|
|
331
|
+
copies.push({ ...partial, ...classify(input, partial) });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const installedOnPath = copies.find((copy) => copy.ownership === 'install-target');
|
|
335
|
+
// The install directory may hold the command while being absent from
|
|
336
|
+
// PATH; that install is just as unreachable as a shadowed one.
|
|
337
|
+
const installedOffPath = !installDirOnPath && exists(input, joinPath(installDir, command));
|
|
338
|
+
|
|
339
|
+
const shadowing = installedOnPath
|
|
340
|
+
? copies.filter((copy) => copy.pathIndex < installedOnPath.pathIndex)
|
|
341
|
+
: [];
|
|
342
|
+
|
|
343
|
+
reports.push({
|
|
344
|
+
command,
|
|
345
|
+
copies,
|
|
346
|
+
winner: copies[0],
|
|
347
|
+
installed: installedOnPath,
|
|
348
|
+
shadowing,
|
|
349
|
+
installDirNotOnPath: installedOffPath,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const shadowed = reports.filter((report) => report.shadowing.length > 0 || report.installDirNotOnPath);
|
|
354
|
+
return { reports, shadowed, hasProblem: shadowed.length > 0 };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Plain-words reporting
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
function describeVersion(copy: CommandCopy): string {
|
|
362
|
+
return copy.version ? `version ${copy.version}` : 'version unknown';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Renders one shadowed command as plain lines: which path wins, what version
|
|
367
|
+
* each copy is, and the exact command that fixes it. No jargon, no severity
|
|
368
|
+
* words — the user needs to know which file answers when they type the name.
|
|
369
|
+
*/
|
|
370
|
+
export function describeShadowReport(report: CommandShadowReport): string[] {
|
|
371
|
+
const lines: string[] = [];
|
|
372
|
+
if (report.installDirNotOnPath) {
|
|
373
|
+
lines.push(
|
|
374
|
+
`${report.command} is installed but its directory is not on your PATH, so typing "${report.command}" does not reach it.`,
|
|
375
|
+
);
|
|
376
|
+
return lines;
|
|
377
|
+
}
|
|
378
|
+
const winner = report.winner;
|
|
379
|
+
const installed = report.installed;
|
|
380
|
+
if (!winner || !installed) return lines;
|
|
381
|
+
|
|
382
|
+
lines.push(
|
|
383
|
+
`Typing "${report.command}" runs ${winner.path} (${describeVersion(winner)}), `
|
|
384
|
+
+ `not the copy this installer maintains at ${installed.path} (${describeVersion(installed)}).`,
|
|
385
|
+
);
|
|
386
|
+
for (const copy of report.shadowing) {
|
|
387
|
+
if (copy.removal) {
|
|
388
|
+
lines.push(` ${copy.path} is a copy of our own program. Remove it with: ${copy.removal.command}`);
|
|
389
|
+
} else {
|
|
390
|
+
lines.push(
|
|
391
|
+
` ${copy.path} is not something we can identify as ours, so it will not be touched. `
|
|
392
|
+
+ `Remove or reorder it yourself, or put ${installed.directory} earlier on your PATH.`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return lines;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Renders every problem in a scan, in command order. Empty when the scan is clean. */
|
|
400
|
+
export function describeShadowScan(result: ShadowScanResult): string[] {
|
|
401
|
+
return result.shadowed.flatMap((report) => describeShadowReport(report));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The copies a caller may offer to remove: recognised copies of our own
|
|
406
|
+
* program that are shadowing the maintained install. Deduplicated by path,
|
|
407
|
+
* since one leftover package install typically provides several commands.
|
|
408
|
+
*/
|
|
409
|
+
export function removableShadows(result: ShadowScanResult): CommandCopy[] {
|
|
410
|
+
const byPath = new Map<string, CommandCopy>();
|
|
411
|
+
for (const report of result.shadowed) {
|
|
412
|
+
for (const copy of report.shadowing) {
|
|
413
|
+
if (!copy.removal) continue;
|
|
414
|
+
if (!byPath.has(copy.path)) byPath.set(copy.path, copy);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return [...byPath.values()];
|
|
418
|
+
}
|