@crystalplatforms/adm 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +154 -161
- package/bin/adm +18 -21
- package/package.json +9 -22
- package/scripts/completion/adm.bash +12 -55
- package/scripts/completion/adm.zsh +21 -58
- package/scripts/install.js +8 -8
- package/src/commands/config.js +66 -0
- package/src/commands/dotfiles.js +49 -32
- package/src/commands/feedback.js +12 -0
- package/src/commands/help.js +33 -0
- package/src/commands/uninstall.js +73 -54
- package/src/commands/version.js +7 -0
- package/src/commands.js +26 -0
- package/src/config/index.js +32 -32
- package/src/data/extensions.json +358 -358
- package/src/engines.js +38 -0
- package/src/install/executor.js +263 -0
- package/src/install/hints.js +94 -0
- package/src/install/npm-prefix.js +110 -0
- package/src/install/plan.js +75 -0
- package/src/install/report.js +46 -0
- package/src/install/run.js +88 -0
- package/src/install/shell-paths.js +129 -0
- package/src/legacy-cleanup.js +96 -0
- package/src/pkg/builders.js +220 -0
- package/src/pkg/managers.js +59 -0
- package/src/pkg/package-names.js +229 -0
- package/src/pkg/script-urls.js +100 -0
- package/src/platform/index.js +129 -0
- package/src/platform/os-release.js +27 -0
- package/src/platform/shell.js +111 -0
- package/src/reports.js +37 -0
- package/src/router.js +157 -0
- package/src/setup/catalog.js +13 -13
- package/src/setup/git-step.js +128 -0
- package/src/setup/loader.js +108 -0
- package/src/setup/prompts.js +259 -0
- package/src/setup/registry.js +48 -0
- package/src/setup/spinner.js +67 -0
- package/src/setup/wizard.js +319 -195
- package/src/upgrade/index.js +499 -0
- package/src/utils/closest-match.js +36 -0
- package/src/utils/dotfiles-sync.js +89 -89
- package/src/utils/file-conflict-handler.js +30 -30
- package/bin/adm-tui +0 -13
- package/src/ai/knowledge.js +0 -61
- package/src/commands/connect.js +0 -76
- package/src/commands/issue.js +0 -80
- package/src/commands/pr.js +0 -55
- package/src/commands/theme.js +0 -103
- package/src/commands/upgrade.js +0 -114
- package/src/installer/git-config.js +0 -34
- package/src/installer/node-installer.js +0 -50
- package/src/installer/package-manager-installer.js +0 -31
- package/src/installer/ssh-setup.js +0 -31
- package/src/installer/system-packages.js +0 -49
- package/src/installers/index.js +0 -66
- package/src/integrations/ai-backend.js +0 -60
- package/src/integrations/ai-providers/registry.js +0 -261
- package/src/integrations/github.js +0 -107
- package/src/integrations/gitlab.js +0 -121
- package/src/plugins/context.js +0 -64
- package/src/plugins/loader.js +0 -50
- package/src/plugins/runner.js +0 -25
- package/src/setup/installer.js +0 -192
- package/src/shell/detector.js +0 -10
- package/src/tui/app-state.js +0 -374
- package/src/tui/app.js +0 -442
- package/src/tui/autocomplete.js +0 -57
- package/src/tui/commands/registry.js +0 -823
- package/src/tui/components/SetupScreen.js +0 -292
- package/src/ui/animations.js +0 -24
- package/src/ui/spinner.js +0 -34
- package/src/ui/theme/cyberpunk.js +0 -14
- package/src/ui/theme/dark.js +0 -14
- package/src/ui/theme/forest.js +0 -14
- package/src/ui/theme/light.js +0 -14
- package/src/ui/theme/monokai.js +0 -14
- package/src/ui/theme/nord.js +0 -14
- package/src/ui/theme.js +0 -49
- package/src/utils/command-history.js +0 -43
- package/src/utils/error-handler.js +0 -69
- package/src/utils/fuzzy-search.js +0 -20
- package/src/utils/github-error-handler.js +0 -26
- package/src/utils/keychain.js +0 -97
- package/src/utils/open-url.js +0 -15
- package/src/utils/terminal-detection.js +0 -26
- package/src/utils/token-encryption.js +0 -96
package/src/engines.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Node-version gate (#33): the only module bin/adm may require on OLD Node.
|
|
2
|
+
// bin/adm itself and this file stay old-Node-parseable (no optional chaining,
|
|
3
|
+
// no ??) — src/ elsewhere is allowed modern syntax because this check runs
|
|
4
|
+
// BEFORE any other require, turning an opaque SyntaxError into a friendly,
|
|
5
|
+
// enumerated fix list (story 4), exit 1.
|
|
6
|
+
|
|
7
|
+
var NODE_MAJOR_MIN = 20;
|
|
8
|
+
var NPM_PACKAGE = '@crystalplatforms/adm';
|
|
9
|
+
var REPO = 'CrystalPlatforms/ADM-CLI';
|
|
10
|
+
|
|
11
|
+
function nodeCheck(versions, majorMin) {
|
|
12
|
+
var min = majorMin || NODE_MAJOR_MIN;
|
|
13
|
+
var v = (versions && versions.node) || '0';
|
|
14
|
+
var major = Number(String(v).split('.')[0]);
|
|
15
|
+
var ok = major >= min;
|
|
16
|
+
var message = '';
|
|
17
|
+
if (!ok) {
|
|
18
|
+
var lines = [
|
|
19
|
+
'',
|
|
20
|
+
'ADM needs Node.js ' + min + ' or newer (you have v' + v + ').',
|
|
21
|
+
'',
|
|
22
|
+
'Fix it any of these ways:',
|
|
23
|
+
' 1. Install the Node.js LTS from https://nodejs.org',
|
|
24
|
+
' 2. nvm: nvm install ' + min + ' && nvm use ' + min,
|
|
25
|
+
' 3. fnm: fnm install ' + min + ' && fnm use ' + min,
|
|
26
|
+
' 4. Windows: winget install OpenJS.NodeJS.LTS',
|
|
27
|
+
'',
|
|
28
|
+
'Or skip Node entirely — install the portable bundle (recommended):',
|
|
29
|
+
' macOS / Linux: curl -fsSL https://raw.githubusercontent.com/' + REPO + '/main/scripts/installer.sh | sh',
|
|
30
|
+
' Windows: irm https://raw.githubusercontent.com/' + REPO + '/main/scripts/install.ps1 | iex',
|
|
31
|
+
'',
|
|
32
|
+
];
|
|
33
|
+
message = lines.join('\n') + '\n';
|
|
34
|
+
}
|
|
35
|
+
return { ok: ok, version: v, major: major, min: min, message: message };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { nodeCheck, NODE_MAJOR_MIN, NPM_PACKAGE };
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
const { hintFor } = require('./hints');
|
|
2
|
+
const { repairNpmPrefix, npmBinDir } = require('./npm-prefix');
|
|
3
|
+
|
|
4
|
+
// Executes install-plan entries produced by buildToolPlan (#28) for real
|
|
5
|
+
// (#29). Per tool it returns one of:
|
|
6
|
+
// { status: 'installed', command, binDirs? }
|
|
7
|
+
// { status: 'skipped', reason: 'already installed' }
|
|
8
|
+
// { status: 'failed', command, error, hint } ← hint = copyable fix
|
|
9
|
+
//
|
|
10
|
+
// Contract details the acceptance criteria pin down:
|
|
11
|
+
// - every command runs through POSIX `sh` (the shell option literally says
|
|
12
|
+
// "sh") — no /bin/bash hardcode, Alpine/NixOS keep working (story 13)
|
|
13
|
+
// - after a successful install the tool's bin dir is prepended to THIS
|
|
14
|
+
// process' PATH, so later installs in the same run see the new binary
|
|
15
|
+
// - an npm EACCES triggers the no-sudo prefix repair (~/.npm-global) and
|
|
16
|
+
// exactly one retry; a second failure returns `failed` + hint
|
|
17
|
+
|
|
18
|
+
const EXEC_TIMEOUT_MS = 20 * 60 * 1000; // generous: heavy deps (aider-chat, torch…) build for a long time
|
|
19
|
+
|
|
20
|
+
// Default executor shell per platform: POSIX `sh` everywhere (story 13 — no
|
|
21
|
+
// /bin/bash hardcode), cmd.exe on Windows (sh.exe is not part of the OS;
|
|
22
|
+
// winget/choco/scoop commands are cmd-compatible).
|
|
23
|
+
function defaultShell(platform) {
|
|
24
|
+
return platform === 'win32' ? true : 'sh';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The status probe text (`command -v <bin>`) is POSIX; cmd.exe needs `where`.
|
|
28
|
+
function probeCommand(statusSource, platform) {
|
|
29
|
+
if (platform === 'win32') return String(statusSource).replace(/^command -v\s+/, 'where ');
|
|
30
|
+
return statusSource;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Runs a command. execFunc injectable for tests (they assert the shell option
|
|
34
|
+
// is 'sh' on POSIX). Resolves { code, stdout, stderr }.
|
|
35
|
+
function shExec(command, opts = {}) {
|
|
36
|
+
const execFunc = opts.execFunc || require('child_process').exec;
|
|
37
|
+
const shell = opts.shell === undefined ? defaultShell(opts.platform || process.platform) : opts.shell;
|
|
38
|
+
return new Promise(resolve => {
|
|
39
|
+
execFunc(command, {
|
|
40
|
+
shell,
|
|
41
|
+
timeout: opts.timeoutMs || EXEC_TIMEOUT_MS,
|
|
42
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
43
|
+
env: opts.env,
|
|
44
|
+
}, (err, stdout, stderr) => {
|
|
45
|
+
resolve({
|
|
46
|
+
code: err ? (err.code === undefined ? 1 : err.code) : 0,
|
|
47
|
+
stdout: String(stdout || ''),
|
|
48
|
+
stderr: String(stderr || ''),
|
|
49
|
+
err,
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Status probe: runs the entry's statusSource (`command -v <bin>` / `where`
|
|
56
|
+
// on Windows). Unix probes always run in sh.
|
|
57
|
+
async function isInstalled(entry, opts = {}) {
|
|
58
|
+
const platform = opts.platform || process.platform;
|
|
59
|
+
const source = entry.statusSource || `command -v ${entry.id}`;
|
|
60
|
+
const res = await shExec(probeCommand(source, platform), {
|
|
61
|
+
execFunc: opts.execFunc,
|
|
62
|
+
shell: platform === 'win32' ? true : 'sh',
|
|
63
|
+
platform,
|
|
64
|
+
});
|
|
65
|
+
return res.code === 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Well-known bin dirs per install method — used for the in-process PATH
|
|
69
|
+
// refresh. Candidates that don't exist on disk are skipped by refreshPath.
|
|
70
|
+
const METHOD_BIN_CANDIDATES = {
|
|
71
|
+
npm: (entry, ctx) => (ctx.npmBin ? [ctx.npmBin] : []),
|
|
72
|
+
node: (entry, ctx) => (ctx.npmBin ? [ctx.npmBin] : []),
|
|
73
|
+
nvm: () => [], // resolved dynamically via `nvm which current`
|
|
74
|
+
cargo: () => ['~/.cargo/bin'],
|
|
75
|
+
go: () => ['~/go/bin'],
|
|
76
|
+
gem: () => ['~/.gem/ruby/bin'],
|
|
77
|
+
composer: () => ['~/.composer/vendor/bin', '~/.config/composer/vendor/bin'],
|
|
78
|
+
dotnet: () => ['~/.dotnet/tools'],
|
|
79
|
+
pip: () => ['~/.local/bin'],
|
|
80
|
+
python: () => ['~/.local/bin'],
|
|
81
|
+
script: () => ['~/.local/bin'],
|
|
82
|
+
rustup: () => ['~/.cargo/bin'],
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Resolves bin dirs for a freshly installed entry (exec for dynamic ones).
|
|
86
|
+
async function resolveBinDirs(entry, opts = {}) {
|
|
87
|
+
const home = opts.home || require('os').homedir();
|
|
88
|
+
const platform = opts.platform || process.platform;
|
|
89
|
+
const expand = p => p.replace(/^~(?=\/|$)/, home);
|
|
90
|
+
const dirs = [];
|
|
91
|
+
const fs = opts.fs || require('fs');
|
|
92
|
+
|
|
93
|
+
const push = d => { if (!dirs.includes(d)) dirs.push(d); };
|
|
94
|
+
|
|
95
|
+
const candidates = (METHOD_BIN_CANDIDATES[entry.method] || (() => []))(entry, opts) || [];
|
|
96
|
+
for (const c of candidates) {
|
|
97
|
+
const dir = expand(c);
|
|
98
|
+
try { fs.accessSync(dir); push(dir); } catch { /* not created — skip */ }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (entry.method === 'nvm') {
|
|
102
|
+
const res = await shExec('. "$HOME/.nvm/nvm.sh" && nvm which current', {
|
|
103
|
+
execFunc: opts.execFunc, shell: 'sh',
|
|
104
|
+
});
|
|
105
|
+
if (res.code === 0 && res.stdout.trim()) {
|
|
106
|
+
const nodeBin = require('path').dirname(res.stdout.trim().split('\n').pop().trim());
|
|
107
|
+
try { fs.accessSync(nodeBin); push(nodeBin); } catch { /* skip */ }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (entry.method === 'npm' || entry.method === 'node') {
|
|
112
|
+
// A repaired prefix means the bin dir is ~/.npm-global/bin, not the
|
|
113
|
+
// system prefix the process started with.
|
|
114
|
+
const { getNpmPrefix } = require('./npm-prefix');
|
|
115
|
+
const prefix = opts.npmPrefix || await getNpmPrefix({ execFunc: opts.execFunc, platform });
|
|
116
|
+
const bin = npmBinDir(prefix, platform);
|
|
117
|
+
if (bin) {
|
|
118
|
+
try { fs.accessSync(expand(bin)); push(expand(bin)); } catch { /* skip */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return dirs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Prepends dirs to THIS process' PATH so later installs see new binaries.
|
|
126
|
+
// Returns the dirs actually added (dedup, existence-checked upstream).
|
|
127
|
+
function refreshProcessPath(dirs, { env, platform } = {}) {
|
|
128
|
+
env = env || process.env;
|
|
129
|
+
platform = platform || process.platform;
|
|
130
|
+
const key = platform === 'win32' ? 'Path' in env ? 'Path' : 'PATH' : 'PATH';
|
|
131
|
+
const sep = platform === 'win32' ? ';' : ':';
|
|
132
|
+
const current = (env[key] || '').split(sep).filter(Boolean);
|
|
133
|
+
const added = dirs.filter(d => !current.includes(d));
|
|
134
|
+
if (added.length > 0) {
|
|
135
|
+
env[key] = [...added, ...current].join(sep);
|
|
136
|
+
}
|
|
137
|
+
return added;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isEaccsFailure(res) {
|
|
141
|
+
const text = `${res.stderr}\n${res.stdout}\n${res.err && res.err.message || ''}`;
|
|
142
|
+
return /EACCES|EPERM|permission denied/i.test(text);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Installs ONE entry. Returns the per-tool result (see module doc).
|
|
146
|
+
// opts.force (or entry.force — set per-entry by the wizard's reinstall, #34)
|
|
147
|
+
// skips the "already installed" probe: the install command runs regardless.
|
|
148
|
+
async function installEntry(entry, opts = {}) {
|
|
149
|
+
const platform = opts.platform || process.platform;
|
|
150
|
+
const base = { id: entry.id, name: entry.name, category: entry.category, method: entry.method };
|
|
151
|
+
|
|
152
|
+
if (entry.status === 'manual') {
|
|
153
|
+
return { ...base, status: 'failed', error: entry.reason, hint: entry.reason };
|
|
154
|
+
}
|
|
155
|
+
if (entry.status === 'link') {
|
|
156
|
+
return { ...base, status: 'skipped', reason: 'manual extension — install from the link', url: entry.url };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Already present → skipped (AC: rerun → all skipped) — unless the caller
|
|
160
|
+
// explicitly forces a reinstall (#34: wizard picks of installed tools).
|
|
161
|
+
if (!opts.force && !entry.force && await isInstalled(entry, opts)) {
|
|
162
|
+
return { ...base, status: 'skipped', reason: 'already installed', statusSource: entry.statusSource };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Reinstall semantics (HITL finding, 0.3.0): winget refuses a plain
|
|
166
|
+
// `install` when the package already exists ("No applicable upgrade
|
|
167
|
+
// found", exit ≠ 0) — a forced reinstall needs `--force`. choco likewise
|
|
168
|
+
// needs `--force` to actually reinstall instead of no-op-ing. npm/brew/
|
|
169
|
+
// apt & friends reinstall (or no-op with exit 0) out of the box.
|
|
170
|
+
const command = entry.force && (entry.manager === 'winget' || entry.manager === 'choco')
|
|
171
|
+
? `${entry.command} --force`
|
|
172
|
+
: entry.command;
|
|
173
|
+
// shell: 'powershell' only for Windows script-tools that need it; otherwise
|
|
174
|
+
// the per-platform default (sh on unix, cmd on Windows).
|
|
175
|
+
const shell = platform === 'win32' && entry.shell ? entry.shell : undefined;
|
|
176
|
+
|
|
177
|
+
const run = () => shExec(command, { execFunc: opts.execFunc, shell, timeoutMs: opts.timeoutMs, platform });
|
|
178
|
+
|
|
179
|
+
let res = await run();
|
|
180
|
+
|
|
181
|
+
// npm global EACCES → no-sudo prefix repair + one retry.
|
|
182
|
+
if (res.code !== 0 && isEaccsFailure(res) &&
|
|
183
|
+
(entry.method === 'npm' || entry.method === 'node') && platform !== 'win32' &&
|
|
184
|
+
opts.npmPrefixRepair !== false) {
|
|
185
|
+
const repairFn = opts.repairNpmPrefix || repairNpmPrefix;
|
|
186
|
+
const repair = await repairFn({
|
|
187
|
+
home: opts.home, platform, execFunc: opts.execFunc, rcFile: opts.rcFile,
|
|
188
|
+
});
|
|
189
|
+
if (repair.repaired) {
|
|
190
|
+
res = await run();
|
|
191
|
+
if (res.code === 0) {
|
|
192
|
+
const binDirs = await resolveBinDirs(entry, { ...opts, npmPrefix: repair.prefix });
|
|
193
|
+
return {
|
|
194
|
+
...base, status: 'installed', command, binDirs,
|
|
195
|
+
note: 'npm prefix repaired (~/.npm-global); future installs need no sudo',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (res.code !== 0) {
|
|
202
|
+
const error = (res.stderr || res.stdout || '').trim().split('\n').slice(-6).join('\n')
|
|
203
|
+
|| (res.err && res.err.message) || `exit code ${res.code}`;
|
|
204
|
+
return {
|
|
205
|
+
...base,
|
|
206
|
+
status: 'failed',
|
|
207
|
+
command,
|
|
208
|
+
error,
|
|
209
|
+
hint: hintFor(`${res.stderr}\n${res.stdout}\n${res.err && res.err.message || ''}`, {
|
|
210
|
+
method: entry.method, command, manager: entry.manager, rcFile: opts.rcFile, platform,
|
|
211
|
+
}),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const binDirs = await resolveBinDirs(entry, opts);
|
|
216
|
+
return { ...base, status: 'installed', command, binDirs };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Installs a list of entries sequentially, refreshing the in-process PATH
|
|
220
|
+
// after each success. onProgress({ tool, result }) fires per finished tool
|
|
221
|
+
// (the wizard drives its spinner from it); onInstalled fires per successful
|
|
222
|
+
// install (the wizard/router hook the registry up to it).
|
|
223
|
+
async function executeInstall(entries, opts = {}) {
|
|
224
|
+
const onProgress = opts.onProgress || (() => {});
|
|
225
|
+
const onStart = opts.onStart || (() => {});
|
|
226
|
+
const onInstalled = opts.onInstalled || null;
|
|
227
|
+
const results = [];
|
|
228
|
+
|
|
229
|
+
for (const entry of entries) {
|
|
230
|
+
onStart({ tool: entry });
|
|
231
|
+
const result = await installEntry(entry, opts);
|
|
232
|
+
if (result.status === 'installed' && result.binDirs && result.binDirs.length > 0) {
|
|
233
|
+
result.pathAdded = refreshProcessPath(result.binDirs, { env: opts.env, platform: opts.platform });
|
|
234
|
+
}
|
|
235
|
+
if (result.status === 'installed' && onInstalled) {
|
|
236
|
+
try {
|
|
237
|
+
await onInstalled({ id: entry.id, name: entry.name, method: entry.method });
|
|
238
|
+
} catch { /* registry must never break an install */ }
|
|
239
|
+
}
|
|
240
|
+
onProgress({ tool: entry, result });
|
|
241
|
+
results.push(result);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
results,
|
|
246
|
+
installed: results.filter(r => r.status === 'installed').length,
|
|
247
|
+
skipped: results.filter(r => r.status === 'skipped').length,
|
|
248
|
+
failed: results.filter(r => r.status === 'failed').length,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
executeInstall,
|
|
254
|
+
installEntry,
|
|
255
|
+
isInstalled,
|
|
256
|
+
refreshProcessPath,
|
|
257
|
+
resolveBinDirs,
|
|
258
|
+
shExec,
|
|
259
|
+
probeCommand,
|
|
260
|
+
defaultShell,
|
|
261
|
+
METHOD_BIN_CANDIDATES,
|
|
262
|
+
EXEC_TIMEOUT_MS,
|
|
263
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Hint catalog (#29): a failed install must always end with a hint holding a
|
|
2
|
+
// ready-to-copy command. `hintFor` is pure — no I/O — so tests assert on the
|
|
3
|
+
// exact strings.
|
|
4
|
+
|
|
5
|
+
const NPM_PREFIX_FIX = 'mkdir -p ~/.npm-global && npm config set prefix ~/.npm-global';
|
|
6
|
+
|
|
7
|
+
const HOMEBREW_INSTALL = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"';
|
|
8
|
+
|
|
9
|
+
// errorText: combined stdout+stderr of the failed command (or its message).
|
|
10
|
+
// ctx: { tool, method, command, manager, rcFile, platform }
|
|
11
|
+
// Returns a multi-line hint string. Always non-empty (generic fallback).
|
|
12
|
+
function hintFor(errorText, ctx = {}) {
|
|
13
|
+
const text = String(errorText || '');
|
|
14
|
+
const command = ctx.command || '';
|
|
15
|
+
const lines = [];
|
|
16
|
+
|
|
17
|
+
const isNpm = ctx.method === 'npm' || ctx.method === 'node' ||
|
|
18
|
+
(typeof command === 'string' && command.startsWith('npm install'));
|
|
19
|
+
|
|
20
|
+
if (/EACCES|EPERM|permission denied/i.test(text)) {
|
|
21
|
+
if (isNpm && ctx.platform !== 'win32') {
|
|
22
|
+
lines.push('npm cannot write to its global prefix (permission denied).');
|
|
23
|
+
lines.push('Fix it without sudo — copy & run:');
|
|
24
|
+
lines.push(` ${NPM_PREFIX_FIX}`);
|
|
25
|
+
if (ctx.rcFile) {
|
|
26
|
+
lines.push('Then make sure the prefix is on PATH (ADM adds this automatically on the next run):');
|
|
27
|
+
lines.push(` echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/${ctx.rcFile}`);
|
|
28
|
+
}
|
|
29
|
+
return lines.join('\n');
|
|
30
|
+
}
|
|
31
|
+
if (command.includes('sudo')) {
|
|
32
|
+
lines.push('This install needs your password — run it manually in a terminal where you can type it:');
|
|
33
|
+
lines.push(` ${command}`);
|
|
34
|
+
return lines.join('\n');
|
|
35
|
+
}
|
|
36
|
+
lines.push('Permission denied. Run the command with elevated permissions or fix ownership, e.g.:');
|
|
37
|
+
lines.push(` sudo ${command}`);
|
|
38
|
+
return lines.join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (/command not found|ENOENT|not recognized/i.test(text)) {
|
|
42
|
+
// Missing prerequisite binaries get targeted hints.
|
|
43
|
+
if (/^brew\b|\/brew\b/.test(command)) {
|
|
44
|
+
lines.push('Homebrew is not installed. Install it first:');
|
|
45
|
+
lines.push(` ${HOMEBREW_INSTALL}`);
|
|
46
|
+
return lines.join('\n');
|
|
47
|
+
}
|
|
48
|
+
if (/\bpip\b/.test(command)) {
|
|
49
|
+
lines.push('pip is not available — use python3 -m pip instead (copy & run):');
|
|
50
|
+
lines.push(` ${command.replace(/^pip\b/, 'python3 -m pip')}`);
|
|
51
|
+
return lines.join('\n');
|
|
52
|
+
}
|
|
53
|
+
if (isNpm || /npm\b/.test(command)) {
|
|
54
|
+
lines.push('npm was not found — install Node.js first: ADM_TOOLS=node adm');
|
|
55
|
+
return lines.join('\n');
|
|
56
|
+
}
|
|
57
|
+
lines.push('A required command was not found in PATH. Install it first, then retry.');
|
|
58
|
+
if (command) lines.push(`Command that failed: ${command}`);
|
|
59
|
+
return lines.join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Package genuinely missing from the manager's repositories
|
|
63
|
+
// (winget: "No package found matching input criteria", apt: "Unable to
|
|
64
|
+
// locate package", dnf/pacman/apk/zypper variants).
|
|
65
|
+
if (/no package found matching input criteria|unable to locate package|no matching (packages|package)|target not found|unable to select packages|not found in (the )?repositories/i.test(text)) {
|
|
66
|
+
const SEARCH = {
|
|
67
|
+
winget: 'winget search', choco: 'choco search', scoop: 'scoop search',
|
|
68
|
+
apt: 'apt-cache search', dnf: 'dnf search', pacman: 'pacman -Ss',
|
|
69
|
+
zypper: 'zypper search', apk: 'apk search', brew: 'brew search',
|
|
70
|
+
};
|
|
71
|
+
lines.push(`This package is not in the ${ctx.manager || 'package manager'} repositories (or the id is different there).`);
|
|
72
|
+
if (ctx.manager && SEARCH[ctx.manager]) {
|
|
73
|
+
lines.push('Find the exact id, then retry — copy & run:');
|
|
74
|
+
lines.push(` ${SEARCH[ctx.manager]} <term>`);
|
|
75
|
+
}
|
|
76
|
+
if (command) lines.push(`Command that failed: ${command}`);
|
|
77
|
+
return lines.join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// winget manifests that drifted from the actual installer file
|
|
81
|
+
if (/installer hash does not match/i.test(text)) {
|
|
82
|
+
lines.push("This winget package's manifest is outdated (the installer hash changed upstream) — it is a winget repository issue, not a broken setup.");
|
|
83
|
+
lines.push('Retry later, search a different version, or install it manually.');
|
|
84
|
+
if (command) lines.push(`Command that failed: ${command}`);
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Generic fallback: always points at the exact command to inspect.
|
|
89
|
+
lines.push('Install failed. Run the command manually to see the full error:');
|
|
90
|
+
if (command) lines.push(` ${command}`);
|
|
91
|
+
return lines.join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { hintFor, NPM_PREFIX_FIX, HOMEBREW_INSTALL };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { upsertRcEntry } = require('./shell-paths');
|
|
3
|
+
|
|
4
|
+
// npm global-prefix repair without sudo (#29, story 16).
|
|
5
|
+
//
|
|
6
|
+
// When npm's global prefix is a system directory (/usr, /usr/local) that the
|
|
7
|
+
// user cannot write to, `npm install -g` fails with EACCES. The fix:
|
|
8
|
+
// 1. `npm config set prefix ~/.npm-global`
|
|
9
|
+
// 2. PATH entry for ~/.npm-global/bin in the current shell's rc (marker)
|
|
10
|
+
// Both steps are idempotent; step 2 goes through shell-paths.
|
|
11
|
+
|
|
12
|
+
// System prefixes where a plain `npm install -g` needs root.
|
|
13
|
+
const SYSTEM_PREFIXES = ['/usr', '/usr/local', '/usr/lib'];
|
|
14
|
+
|
|
15
|
+
function defaultTarget(home) {
|
|
16
|
+
return `${home}${path.sep}.npm-global`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Checks whether a prefix directory is writable by the current user.
|
|
20
|
+
// Injectable fs for tests; missing dir counts as "not writable".
|
|
21
|
+
function isWritable(dir, { accessSync, constants } = {}) {
|
|
22
|
+
accessSync = accessSync || require('fs').accessSync;
|
|
23
|
+
constants = constants || require('fs').constants;
|
|
24
|
+
try {
|
|
25
|
+
accessSync(dir, constants.W_OK);
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Runs `npm config get prefix` (exec injectable for tests).
|
|
33
|
+
async function getNpmPrefix({ execFunc, platform } = {}) {
|
|
34
|
+
execFunc = execFunc || require('child_process').exec;
|
|
35
|
+
const res = await new Promise((resolve) => {
|
|
36
|
+
execFunc('npm config get prefix', { shell: platform === 'win32' ? undefined : 'sh' }, (err, stdout) => {
|
|
37
|
+
resolve({ err, out: String(stdout || '').trim() });
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
return res.out || '';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// True when the prefix needs repair: it is a known system prefix AND not
|
|
44
|
+
// writable by the current user. Windows prefixes (%APPDATA%\npm) are
|
|
45
|
+
// user-writable — never needs repair there.
|
|
46
|
+
function prefixNeedsRepair(prefix, { home, platform, accessSync, constants } = {}) {
|
|
47
|
+
if (platform === 'win32') return false;
|
|
48
|
+
if (!prefix) return false;
|
|
49
|
+
const normalized = String(prefix).replace(/\/+$/, '');
|
|
50
|
+
const system = SYSTEM_PREFIXES.some(p => normalized === p || normalized.startsWith(`${p}/`));
|
|
51
|
+
if (!system) return false;
|
|
52
|
+
return !isWritable(normalized, { accessSync, constants });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Applies the repair. Returns:
|
|
56
|
+
// { repaired, prefix, target, rcFile, hint }
|
|
57
|
+
// { repaired: false, reason } when nothing needed doing or a step failed.
|
|
58
|
+
async function repairNpmPrefix(opts = {}) {
|
|
59
|
+
const {
|
|
60
|
+
home = require('os').homedir(),
|
|
61
|
+
platform = process.platform,
|
|
62
|
+
execFunc,
|
|
63
|
+
rcFile,
|
|
64
|
+
shellPaths = upsertRcEntry,
|
|
65
|
+
} = opts;
|
|
66
|
+
|
|
67
|
+
if (platform === 'win32') {
|
|
68
|
+
return { repaired: false, reason: 'windows — npm prefix is user-writable by default' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const target = defaultTarget(home);
|
|
72
|
+
const exec = execFunc || require('child_process').exec;
|
|
73
|
+
|
|
74
|
+
// 1. point npm's global prefix at ~/.npm-global
|
|
75
|
+
const set = await new Promise((resolve) => {
|
|
76
|
+
exec(`npm config set prefix "${target}"`, { shell: 'sh' }, (err) => resolve({ err }));
|
|
77
|
+
});
|
|
78
|
+
if (set.err) {
|
|
79
|
+
return { repaired: false, reason: `npm config set prefix failed: ${set.err.message}`, hint: ` ${'mkdir -p ~/.npm-global && npm config set prefix ~/.npm-global'}` };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 2. PATH entry (idempotent, marker-managed)
|
|
83
|
+
let rc = null;
|
|
84
|
+
try {
|
|
85
|
+
const res = shellPaths({ rcFile, entry: 'npm-global', home });
|
|
86
|
+
rc = res.rcFile;
|
|
87
|
+
} catch {
|
|
88
|
+
rc = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
repaired: true,
|
|
93
|
+
prefix: target,
|
|
94
|
+
target,
|
|
95
|
+
rcFile: rc,
|
|
96
|
+
hint: [
|
|
97
|
+
'npm prefix repaired — future global installs go to ~/.npm-global (no sudo).',
|
|
98
|
+
'Open a new shell or copy & run to use it right now:',
|
|
99
|
+
' export PATH="$HOME/.npm-global/bin:$PATH"',
|
|
100
|
+
].join('\n'),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Binary dir for a given npm prefix (unix appends /bin; windows is flat).
|
|
105
|
+
function npmBinDir(prefix, platform = process.platform) {
|
|
106
|
+
if (!prefix) return null;
|
|
107
|
+
return platform === 'win32' ? prefix : `${prefix.replace(/\/+$/, '')}/bin`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { repairNpmPrefix, prefixNeedsRepair, getNpmPrefix, npmBinDir, SYSTEM_PREFIXES, isWritable, defaultTarget };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const { loadCatalog } = require('../setup/catalog');
|
|
2
|
+
const { detectEnvironment, formatEnvironment } = require('../platform');
|
|
3
|
+
const { buildToolPlan } = require('../pkg/builders');
|
|
4
|
+
const { closestMatches } = require('../utils/closest-match');
|
|
5
|
+
|
|
6
|
+
// Parses the ADM_TOOLS spec ("id,id id") into tool ids.
|
|
7
|
+
function parseToolIds(spec) {
|
|
8
|
+
return String(spec)
|
|
9
|
+
.split(/[\s,]+/)
|
|
10
|
+
.map(s => s.trim())
|
|
11
|
+
.filter(Boolean);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Non-interactive install planning: ADM_TOOLS=<ids> adm
|
|
15
|
+
// Prints the plan for the current environment, changes nothing, exits 0.
|
|
16
|
+
// Unknown ids → usage error (1) enumerating the valid ids with closest
|
|
17
|
+
// matches. Execution errors → 2.
|
|
18
|
+
function runPlan(toolsSpec, { log, err } = {}) {
|
|
19
|
+
log = log || (s => process.stdout.write(s + '\n'));
|
|
20
|
+
err = err || (s => process.stderr.write(s + '\n'));
|
|
21
|
+
|
|
22
|
+
let catalog;
|
|
23
|
+
try {
|
|
24
|
+
catalog = loadCatalog();
|
|
25
|
+
} catch (e) {
|
|
26
|
+
err(`ADM failed: cannot load the tool catalog: ${e.message}`);
|
|
27
|
+
return 2;
|
|
28
|
+
}
|
|
29
|
+
const byId = new Map(catalog.flatMap(c => c.tools.map(t => [t.id, { ...t, category: c.id }])));
|
|
30
|
+
|
|
31
|
+
const requested = parseToolIds(toolsSpec);
|
|
32
|
+
const unknown = requested.filter(id => !byId.has(id));
|
|
33
|
+
if (unknown.length > 0) {
|
|
34
|
+
const allIds = [...byId.keys()];
|
|
35
|
+
const suggestions = [...new Set(unknown.flatMap(id => closestMatches(id, allIds)))].slice(0, 5);
|
|
36
|
+
const lines = [
|
|
37
|
+
`Unknown tool id in ADM_TOOLS: ${unknown.join(', ')}`,
|
|
38
|
+
];
|
|
39
|
+
if (suggestions.length > 0) lines.push(`Did you mean: ${suggestions.join(', ')}?`);
|
|
40
|
+
lines.push('');
|
|
41
|
+
lines.push('Valid tool ids:');
|
|
42
|
+
lines.push(allIds.join(', '));
|
|
43
|
+
err(lines.join('\n'));
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const env = detectEnvironment();
|
|
48
|
+
const entries = requested.map(id => {
|
|
49
|
+
const tool = byId.get(id);
|
|
50
|
+
return { id, name: tool.name, category: tool.category, ...buildToolPlan(tool, env) };
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
log(`ADM install plan — ${entries.length} tool${entries.length === 1 ? '' : 's'} (dry run, nothing was installed)`);
|
|
54
|
+
log('');
|
|
55
|
+
log(`Environment:\n${formatEnvironment(env)}`);
|
|
56
|
+
log('');
|
|
57
|
+
for (const e of entries) {
|
|
58
|
+
const head = `${e.id} (${e.category}) — ${e.status}`;
|
|
59
|
+
log(head);
|
|
60
|
+
if (e.status === 'planned') {
|
|
61
|
+
log(` command: ${e.command}`);
|
|
62
|
+
if (e.manager) log(` manager: ${e.manager} · package: ${e.package}`);
|
|
63
|
+
log(` check: ${e.statusSource}`);
|
|
64
|
+
} else if (e.status === 'manual') {
|
|
65
|
+
log(` why: ${e.reason}`);
|
|
66
|
+
} else if (e.status === 'link') {
|
|
67
|
+
log(` url: ${e.url}`);
|
|
68
|
+
}
|
|
69
|
+
log('');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { runPlan, parseToolIds };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Per-tool result rendering shared by the ADM_TOOLS runner (#29) and the
|
|
2
|
+
// wizard's final report (#30). Plain text, no colors — readable in logs/CI.
|
|
3
|
+
|
|
4
|
+
const STATUS_MARKS = { installed: '✓', skipped: '✓', failed: '✗', reinstalled: '✓', manual: 'i' };
|
|
5
|
+
|
|
6
|
+
const STATUS_LABELS = {
|
|
7
|
+
installed: 'installed',
|
|
8
|
+
reinstalled: 'reinstalled',
|
|
9
|
+
skipped: 'skipped',
|
|
10
|
+
failed: 'failed',
|
|
11
|
+
manual: 'manual',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Renders the final report lines for a list of results
|
|
15
|
+
// (as produced by executeInstall).
|
|
16
|
+
function renderResults(results, { summary = true } = {}) {
|
|
17
|
+
const lines = [];
|
|
18
|
+
for (const r of results) {
|
|
19
|
+
const mark = STATUS_MARKS[r.status] || ' ';
|
|
20
|
+
const label = STATUS_LABELS[r.status] || r.status;
|
|
21
|
+
lines.push(`${mark} ${r.name || r.id} — ${label}`);
|
|
22
|
+
if (r.status === 'skipped' && r.reason) lines.push(` ${r.reason}${r.url ? `: ${r.url}` : ''}`);
|
|
23
|
+
if (r.status === 'manual' && (r.hint || r.reason)) lines.push(` ${r.hint || r.reason}`);
|
|
24
|
+
if (r.note) lines.push(` ${r.note}`);
|
|
25
|
+
if (r.status === 'failed') {
|
|
26
|
+
lines.push(` command: ${r.command || '(none)'}`);
|
|
27
|
+
if (r.error) lines.push(` error: ${String(r.error).trim()}`);
|
|
28
|
+
if (r.hint) {
|
|
29
|
+
lines.push(' hint:');
|
|
30
|
+
for (const hl of String(r.hint).split('\n')) lines.push(` ${hl}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (summary) {
|
|
35
|
+
const installed = results.filter(r => r.status === 'installed').length;
|
|
36
|
+
const reinstalled = results.filter(r => r.status === 'reinstalled').length;
|
|
37
|
+
const skipped = results.filter(r => r.status === 'skipped').length;
|
|
38
|
+
const manual = results.filter(r => r.status === 'manual').length;
|
|
39
|
+
const failed = results.filter(r => r.status === 'failed').length;
|
|
40
|
+
lines.push('');
|
|
41
|
+
lines.push(`Done: ${installed} installed${reinstalled ? `, ${reinstalled} reinstalled` : ''}, ${skipped} skipped${manual ? `, ${manual} manual` : ''}, ${failed} failed.`);
|
|
42
|
+
}
|
|
43
|
+
return lines.join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { renderResults, STATUS_MARKS };
|