@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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const { loadCatalog } = require('../setup/catalog');
|
|
2
|
+
const { detectEnvironment } = require('../platform');
|
|
3
|
+
const { buildToolPlan } = require('../pkg/builders');
|
|
4
|
+
const { closestMatches } = require('../utils/closest-match');
|
|
5
|
+
const { executeInstall } = require('./executor');
|
|
6
|
+
const { renderResults } = require('./report');
|
|
7
|
+
const { appendInstalled } = require('../setup/registry');
|
|
8
|
+
const { parseToolIds } = require('./plan');
|
|
9
|
+
const { saveReport } = require('../reports');
|
|
10
|
+
const { getVersion } = require('../commands/version');
|
|
11
|
+
|
|
12
|
+
// Builds plan entries for a validated tool-id list (shared with plan.js).
|
|
13
|
+
function planEntriesFor(requested, { environment } = {}) {
|
|
14
|
+
const catalog = loadCatalog();
|
|
15
|
+
const byId = new Map(catalog.flatMap(c => c.tools.map(t => [t.id, { ...t, category: c.id }])));
|
|
16
|
+
const unknown = requested.filter(id => !byId.has(id));
|
|
17
|
+
if (unknown.length > 0) {
|
|
18
|
+
const allIds = [...byId.keys()];
|
|
19
|
+
const suggestions = [...new Set(unknown.flatMap(id => closestMatches(id, allIds)))].slice(0, 5);
|
|
20
|
+
const lines = [`Unknown tool id in ADM_TOOLS: ${unknown.join(', ')}`];
|
|
21
|
+
if (suggestions.length > 0) lines.push(`Did you mean: ${suggestions.join(', ')}?`);
|
|
22
|
+
lines.push('');
|
|
23
|
+
lines.push('Valid tool ids:');
|
|
24
|
+
lines.push(allIds.join(', '));
|
|
25
|
+
return { unknown: true, message: lines.join('\n') };
|
|
26
|
+
}
|
|
27
|
+
const env = environment || detectEnvironment();
|
|
28
|
+
const entries = requested.map(id => {
|
|
29
|
+
const tool = byId.get(id);
|
|
30
|
+
return { id, name: tool.name, category: tool.category, method: tool.installMethod, ...buildToolPlan(tool, env) };
|
|
31
|
+
});
|
|
32
|
+
return { unknown: false, entries, environment: env };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Real install run: ADM_TOOLS=<ids> adm (no ADM_DRY_RUN).
|
|
36
|
+
// Exit codes: 0 = all installed/skipped · 1 = unknown tool id (usage) ·
|
|
37
|
+
// 2 = at least one install failed (execution failure).
|
|
38
|
+
async function runInstall(toolsSpec, opts = {}) {
|
|
39
|
+
const log = opts.log || (s => process.stdout.write(s + '\n'));
|
|
40
|
+
const err = opts.err || (s => process.stderr.write(s + '\n'));
|
|
41
|
+
|
|
42
|
+
let requested;
|
|
43
|
+
try {
|
|
44
|
+
requested = parseToolIds(toolsSpec);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
err(`ADM failed: ${e.message}`);
|
|
47
|
+
return 2;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let plan;
|
|
51
|
+
try {
|
|
52
|
+
plan = planEntriesFor(requested);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
err(`ADM failed: cannot load the tool catalog: ${e.message}`);
|
|
55
|
+
return 2;
|
|
56
|
+
}
|
|
57
|
+
if (plan.unknown) {
|
|
58
|
+
err(plan.message);
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const outcome = await executeInstall(plan.entries, {
|
|
63
|
+
...opts,
|
|
64
|
+
rcFile: plan.environment.rcFile,
|
|
65
|
+
onInstalled: opts.onInstalled || (entry => appendInstalled(entry, { configDir: opts.configDir })),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const reportText = renderResults(outcome.results, { summary: false });
|
|
69
|
+
log(reportText);
|
|
70
|
+
log('');
|
|
71
|
+
log(`Done: ${outcome.installed} installed, ${outcome.skipped} skipped, ${outcome.failed} failed.`);
|
|
72
|
+
|
|
73
|
+
// Persist the report to the global cache (owner decision) — a missing
|
|
74
|
+
// config dir yields a loud ⚠ on stderr, never a silent skip.
|
|
75
|
+
const body = [
|
|
76
|
+
`ADM install report — ${new Date().toISOString()}`,
|
|
77
|
+
`ADM ${getVersion()} · ADM_TOOLS=${toolsSpec}`,
|
|
78
|
+
'',
|
|
79
|
+
reportText,
|
|
80
|
+
`Done: ${outcome.installed} installed, ${outcome.skipped} skipped, ${outcome.failed} failed.`,
|
|
81
|
+
].join('\n') + '\n';
|
|
82
|
+
const saved = saveReport('install', body, { configDir: opts.configDir });
|
|
83
|
+
if (saved.error) err(`⚠ ${saved.error}`);
|
|
84
|
+
|
|
85
|
+
return outcome.failed > 0 ? 2 : 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { runInstall, planEntriesFor };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
// Idempotent writer of PATH entries into shell rc files (#29, #31).
|
|
6
|
+
//
|
|
7
|
+
// Contract:
|
|
8
|
+
// - writes to the rc of the CURRENT shell (bash → .bashrc, zsh → .zshrc …)
|
|
9
|
+
// - double run = zero duplicates (marker is replaced, not appended)
|
|
10
|
+
// - cleans old ADM markers from any previous version (v0.2 included)
|
|
11
|
+
|
|
12
|
+
// Every marker string ADM has ever written into rc files. Cleaning removes
|
|
13
|
+
// every line containing any of them; `upsertRcEntry` re-adds only the current
|
|
14
|
+
// marker + entry pair.
|
|
15
|
+
const ADM_RC_MARKERS = ['# adm-cli', 'adm completion', 'adm-cli npm path'];
|
|
16
|
+
|
|
17
|
+
// Canonical managed entries: marker line + the PATH line appended after it.
|
|
18
|
+
// Keys are purposes; both entries are cleaned from any rc we touch.
|
|
19
|
+
const RC_ENTRIES = {
|
|
20
|
+
'npm-global': {
|
|
21
|
+
marker: '# adm-cli npm path',
|
|
22
|
+
line: 'export PATH="$HOME/.npm-global/bin:$PATH"',
|
|
23
|
+
},
|
|
24
|
+
launcher: {
|
|
25
|
+
marker: '# adm-cli',
|
|
26
|
+
line: 'export PATH="$HOME/.local/bin:$PATH"',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function entryFor(entry) {
|
|
31
|
+
if (typeof entry === 'string') {
|
|
32
|
+
const def = RC_ENTRIES[entry];
|
|
33
|
+
if (!def) throw new Error(`unknown rc entry "${entry}" — valid: ${Object.keys(RC_ENTRIES).join(', ')}`);
|
|
34
|
+
return def;
|
|
35
|
+
}
|
|
36
|
+
return entry; // { marker, line } custom pair
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Builds the managed block ("marker\nline") for an entry.
|
|
40
|
+
function entryBlock(entry) {
|
|
41
|
+
const def = entryFor(entry);
|
|
42
|
+
return `${def.marker}\n${def.line}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Lines this module considers "managed by ADM" for a given entry: the marker
|
|
46
|
+
// lines of ANY ADM version plus the exact canonical PATH lines. Removing them
|
|
47
|
+
// before appending is what makes old markers disappear on the way.
|
|
48
|
+
function managedLines(entry) {
|
|
49
|
+
const def = entryFor(entry);
|
|
50
|
+
const lines = [...ADM_RC_MARKERS];
|
|
51
|
+
for (const key of Object.keys(RC_ENTRIES)) {
|
|
52
|
+
if (key !== 'launcher' && key !== 'npm-global') continue;
|
|
53
|
+
lines.push(RC_ENTRIES[key].line);
|
|
54
|
+
}
|
|
55
|
+
// Custom entries carry their own line.
|
|
56
|
+
if (!def.marker) lines.push(def.line);
|
|
57
|
+
else lines.push(def.line);
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Reads an rc file; returns '' when missing (fresh machine).
|
|
62
|
+
function readRc(rcPath, readFile) {
|
|
63
|
+
try {
|
|
64
|
+
return readFile(rcPath, 'utf8');
|
|
65
|
+
} catch {
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Upserts the entry block into the rc file.
|
|
71
|
+
// rcFile — basename in home (".bashrc") or an absolute path; null → no rc
|
|
72
|
+
// known for this shell → { changed: false, reason: 'no-rc-file' }.
|
|
73
|
+
// Returns { changed, rcFile, removed } (removed = ADM-managed lines cleaned).
|
|
74
|
+
function upsertRcEntry(opts = {}) {
|
|
75
|
+
const {
|
|
76
|
+
rcFile,
|
|
77
|
+
entry,
|
|
78
|
+
home = os.homedir(),
|
|
79
|
+
readFile = fs.readFileSync,
|
|
80
|
+
writeFile = fs.writeFileSync,
|
|
81
|
+
existsSync = fs.existsSync,
|
|
82
|
+
} = opts;
|
|
83
|
+
|
|
84
|
+
if (!rcFile) return { changed: false, rcFile: null, removed: 0, reason: 'no-rc-file' };
|
|
85
|
+
|
|
86
|
+
const rcPath = path.isAbsolute(rcFile) ? rcFile : path.join(home, rcFile);
|
|
87
|
+
const def = entryFor(entry);
|
|
88
|
+
const original = readRc(rcPath, readFile);
|
|
89
|
+
const lines = original.split('\n');
|
|
90
|
+
|
|
91
|
+
const strip = managedLines(entry);
|
|
92
|
+
const kept = lines.filter(line => !strip.some(s => line.trim() === s.trim() || line.includes(s)));
|
|
93
|
+
const removed = lines.length - kept.length;
|
|
94
|
+
|
|
95
|
+
// Drop leading/trailing blank lines left over by removals, keep body intact.
|
|
96
|
+
const body = kept.join('\n').replace(/\n{3,}$/, '\n\n').replace(/^\n+/, '');
|
|
97
|
+
const block = entryBlock(entry);
|
|
98
|
+
const content = body ? `${body.replace(/\n+$/, '')}\n\n${block}\n` : `${block}\n`;
|
|
99
|
+
|
|
100
|
+
const unchanged = original === content;
|
|
101
|
+
if (!unchanged) {
|
|
102
|
+
if (!existsSync(path.dirname(rcPath))) {
|
|
103
|
+
return { changed: false, rcFile: rcPath, removed, reason: 'no-home-dir' };
|
|
104
|
+
}
|
|
105
|
+
writeFile(rcPath, content, 'utf8');
|
|
106
|
+
}
|
|
107
|
+
return { changed: !unchanged, rcFile: rcPath, removed };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Only removes ADM-managed lines (no re-add). Used by uninstall flows that
|
|
111
|
+
// want a surgical cleanup for a single rc.
|
|
112
|
+
function cleanRcEntry(opts = {}) {
|
|
113
|
+
const {
|
|
114
|
+
rcFile,
|
|
115
|
+
home = os.homedir(),
|
|
116
|
+
readFile = fs.readFileSync,
|
|
117
|
+
writeFile = fs.writeFileSync,
|
|
118
|
+
} = opts;
|
|
119
|
+
if (!rcFile) return { changed: false, removed: 0 };
|
|
120
|
+
const rcPath = path.isAbsolute(rcFile) ? rcFile : path.join(home, rcFile);
|
|
121
|
+
const original = readRc(rcPath, readFile);
|
|
122
|
+
const lines = original.split('\n');
|
|
123
|
+
const kept = lines.filter(line => !ADM_RC_MARKERS.some(m => line.includes(m)));
|
|
124
|
+
const removed = lines.length - kept.length;
|
|
125
|
+
if (removed > 0) writeFile(rcPath, kept.join('\n'), 'utf8');
|
|
126
|
+
return { changed: removed > 0, removed };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { upsertRcEntry, cleanRcEntry, RC_ENTRIES, ADM_RC_MARKERS, entryBlock };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { configDir, readConfig, writeConfig } = require('./config');
|
|
4
|
+
|
|
5
|
+
// v0.3 removed the AI/GitHub/GitLab features. On the first start of v0.3 we
|
|
6
|
+
// delete everything those features left on disk — tokens must not sit around
|
|
7
|
+
// unread — and remember it with a `cleanedAt` marker in the config.
|
|
8
|
+
//
|
|
9
|
+
// Legacy artifacts:
|
|
10
|
+
// ~/.adm/tokens.enc — encrypted GitHub/GitLab tokens
|
|
11
|
+
// ~/.adm/ai-knowledge.json — AI knowledge cache
|
|
12
|
+
// macOS keychain entries — service "adm-cli" (github-token, gitlab-token…)
|
|
13
|
+
//
|
|
14
|
+
// Runs at most once: skipped when config.cleanedAt is set.
|
|
15
|
+
const LEGACY_FILES = ['tokens.enc', 'ai-knowledge.json'];
|
|
16
|
+
const KEYCHAIN_SERVICE = 'adm-cli';
|
|
17
|
+
|
|
18
|
+
function listKeychainAccounts() {
|
|
19
|
+
const { execFileSync } = require('child_process');
|
|
20
|
+
try {
|
|
21
|
+
const out = execFileSync('security', ['dump-keychain'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
22
|
+
const accounts = [];
|
|
23
|
+
// dump-keychain prints one block per entry; match the service and account
|
|
24
|
+
// attributes within each block.
|
|
25
|
+
const svceRe = /"svce"<blob>="([^"]*)"/g;
|
|
26
|
+
const acctRe = /"acct"<blob>="([^"]+)"/g;
|
|
27
|
+
for (const entry of out.split(/(?=class:)/g)) {
|
|
28
|
+
svceRe.lastIndex = 0;
|
|
29
|
+
acctRe.lastIndex = 0;
|
|
30
|
+
const svce = svceRe.exec(entry);
|
|
31
|
+
const acct = acctRe.exec(entry);
|
|
32
|
+
if (svce && acct && svce[1] === KEYCHAIN_SERVICE) accounts.push(acct[1]);
|
|
33
|
+
}
|
|
34
|
+
return accounts;
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function deleteKeychainAccount(account) {
|
|
41
|
+
const { execFileSync } = require('child_process');
|
|
42
|
+
try {
|
|
43
|
+
execFileSync('security', ['delete-generic-password', '-s', KEYCHAIN_SERVICE, '-a', account],
|
|
44
|
+
{ stdio: ['ignore', 'pipe', 'ignore'] });
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function legacyCleanup({ log, platform, env } = {}) {
|
|
52
|
+
log = log || (s => process.stdout.write(s + '\n'));
|
|
53
|
+
platform = platform || process.platform;
|
|
54
|
+
env = env || process.env;
|
|
55
|
+
|
|
56
|
+
const dir = process.env.ADM_CONFIG_DIR || configDir();
|
|
57
|
+
const config = await readConfig();
|
|
58
|
+
if (config.cleanedAt) return { skipped: true, removed: [] };
|
|
59
|
+
|
|
60
|
+
const removed = [];
|
|
61
|
+
|
|
62
|
+
// Files from the old AI/token features
|
|
63
|
+
for (const name of LEGACY_FILES) {
|
|
64
|
+
const file = path.join(dir, name);
|
|
65
|
+
try {
|
|
66
|
+
fs.unlinkSync(file);
|
|
67
|
+
removed.push(file);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (e.code !== 'ENOENT') throw e;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// macOS keychain tokens
|
|
74
|
+
if (platform === 'darwin') {
|
|
75
|
+
for (const account of listKeychainAccounts()) {
|
|
76
|
+
if (deleteKeychainAccount(account)) removed.push(`keychain:${KEYCHAIN_SERVICE}/${account}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const marker = { ...config, cleanedAt: new Date().toISOString() };
|
|
81
|
+
await writeConfig(marker);
|
|
82
|
+
|
|
83
|
+
if (removed.length > 0) {
|
|
84
|
+
const lines = [
|
|
85
|
+
'ADM cleanup: removed data from features that were dropped in v0.3 (AI, GitHub/GitLab integrations):',
|
|
86
|
+
...removed.map(r => ` - ${r}`),
|
|
87
|
+
'',
|
|
88
|
+
'Your secrets were deleted so they do not sit unread on disk.',
|
|
89
|
+
];
|
|
90
|
+
log(lines.join('\n'));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { skipped: false, removed };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { legacyCleanup, LEGACY_FILES, KEYCHAIN_SERVICE };
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
const { resolvePackageName } = require('./package-names');
|
|
2
|
+
const { resolveScript } = require('./script-urls');
|
|
3
|
+
const { installCommand } = require('./managers');
|
|
4
|
+
|
|
5
|
+
// ── ecosystem package-name overrides ──────────────────────────
|
|
6
|
+
// Only where the install identifier differs from the catalog tool id.
|
|
7
|
+
const GO_PACKAGES = {
|
|
8
|
+
'golangci-lint': 'github.com/golangci/golangci-lint/v2/cmd/golangci-lint',
|
|
9
|
+
goreleaser: 'github.com/goreleaser/goreleaser/v2',
|
|
10
|
+
air: 'github.com/air-verse/air',
|
|
11
|
+
ginkgo: 'github.com/onsi/ginkgo/v2/ginkgo',
|
|
12
|
+
delve: 'github.com/go-delve/delve/cmd/dlv',
|
|
13
|
+
gofumpt: 'mvdan.cc/gofumpt',
|
|
14
|
+
goimports: 'golang.org/x/tools/cmd/goimports',
|
|
15
|
+
gomock: 'go.uber.org/mock/mockgen',
|
|
16
|
+
staticcheck: 'honnef.co/go/tools/cmd/staticcheck',
|
|
17
|
+
gosec: 'github.com/securego/gosec/v2/cmd/gosec',
|
|
18
|
+
gotestsum: 'gotest.tools/gotestsum',
|
|
19
|
+
swag: 'github.com/swaggo/swag/cmd/swag',
|
|
20
|
+
'protoc-gen-go': 'google.golang.org/protobuf/cmd/protoc-gen-go',
|
|
21
|
+
grpcurl: 'github.com/fullstorydev/grpcurl/cmd/grpcurl',
|
|
22
|
+
ghq: 'github.com/x-motemen/ghq',
|
|
23
|
+
fzf: 'github.com/junegunn/fzf',
|
|
24
|
+
lazygit: 'github.com/jesseduffield/lazygit',
|
|
25
|
+
hugo: 'github.com/gohugoio/hugo',
|
|
26
|
+
temporal: 'github.com/temporalio/cli/cmd/temporal',
|
|
27
|
+
ko: 'github.com/google/ko',
|
|
28
|
+
buf: 'github.com/bufbuild/buf/cmd/buf',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const CARGO_PACKAGES = { delta: 'git-delta' };
|
|
32
|
+
const GEM_PACKAGES = { 'factory-bot': 'factory_bot' };
|
|
33
|
+
const COMPOSER_PACKAGES = {
|
|
34
|
+
'php-cs-fixer': 'friendsofphp/php-cs-fixer',
|
|
35
|
+
phpunit: 'phpunit/phpunit',
|
|
36
|
+
pest: 'pestphp/pest',
|
|
37
|
+
laravel: 'laravel/installer',
|
|
38
|
+
drush: 'drush/drush',
|
|
39
|
+
deployer: 'deployer/deployer',
|
|
40
|
+
phan: 'phan/phan',
|
|
41
|
+
rector: 'rector/rector',
|
|
42
|
+
infection: 'infection/infection',
|
|
43
|
+
};
|
|
44
|
+
const DOTNET_PACKAGES = {
|
|
45
|
+
sonarscanner: 'dotnet-sonarscanner',
|
|
46
|
+
coverlet: 'coverlet.console',
|
|
47
|
+
reportgenerator: 'dotnet-reportgenerator-globaltool',
|
|
48
|
+
roslynator: 'roslynator.commandline',
|
|
49
|
+
nuke: 'nuke.globaltool',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Binary produced after install (for `command -v <bin>` status checks) where
|
|
53
|
+
// it differs from the catalog id.
|
|
54
|
+
const NPM_BINS = {
|
|
55
|
+
'@anthropic-ai/claude-code': 'claude',
|
|
56
|
+
'@openai/codex': 'codex',
|
|
57
|
+
'@anthropic-ai/gemini-cli': 'gemini',
|
|
58
|
+
'@githubnext/github-copilot-cli': 'github-copilot-cli',
|
|
59
|
+
'@plandex/cli': 'plandex',
|
|
60
|
+
// npm package name differs from the produced binary — the status probe
|
|
61
|
+
// must check the REAL command or installed tools look "not installed"
|
|
62
|
+
// (HITL finding, 0.3.0: picking installed TypeScript kept re-"installing").
|
|
63
|
+
typescript: 'tsc',
|
|
64
|
+
};
|
|
65
|
+
const GO_BINS = { delve: 'dlv', gomock: 'mockgen' };
|
|
66
|
+
const CARGO_BINS = { delta: 'delta', 'fd-find': 'fd', ripgrep: 'rg', 'du-dust': 'dust', bottom: 'btm' };
|
|
67
|
+
|
|
68
|
+
const NVM_INSTALL = 'curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | sh';
|
|
69
|
+
|
|
70
|
+
// Pick the python installer command:
|
|
71
|
+
// pip available → pip / pip3 available → pip3 / otherwise → python3 -m pip
|
|
72
|
+
// On Windows the fallback MUST be `python -m pip`: python3.exe is commonly a
|
|
73
|
+
// Microsoft Store alias stub that silently opens the Store (installs hang).
|
|
74
|
+
function pipCommand(pkgName, availability = {}, os) {
|
|
75
|
+
if (availability.pip) return `pip install --upgrade ${pkgName}`;
|
|
76
|
+
if (availability.pip3) return `pip3 install --upgrade ${pkgName}`;
|
|
77
|
+
const python = os === 'win32' ? 'python -m pip install --upgrade' : 'python3 -m pip install --upgrade';
|
|
78
|
+
return `${python} ${pkgName}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Builds the install plan entry for one tool in one environment.
|
|
82
|
+
// Returns:
|
|
83
|
+
// { status: 'planned', command, manager?, package?, statusSource }
|
|
84
|
+
// { status: 'manual', reason }
|
|
85
|
+
// { status: 'link', url }
|
|
86
|
+
function buildToolPlan(tool, env, opts = {}) {
|
|
87
|
+
const method = tool.installMethod;
|
|
88
|
+
const id = tool.id;
|
|
89
|
+
|
|
90
|
+
const planned = (fields) => ({
|
|
91
|
+
status: 'planned',
|
|
92
|
+
...fields,
|
|
93
|
+
statusSource: fields.statusSource || `command -v ${statusBin(id, method)}`,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
switch (method) {
|
|
97
|
+
case 'nvm':
|
|
98
|
+
// POSIX nvm installs via the official script. Windows has no `sh` (and
|
|
99
|
+
// nvm-windows is a different tool entirely) — install Node.js LTS
|
|
100
|
+
// through the detected manager instead (HITL finding, 0.3.0).
|
|
101
|
+
if (env.os === 'win32') {
|
|
102
|
+
const NODE_WIN = { winget: 'OpenJS.NodeJS.LTS', choco: 'nodejs-lts', scoop: 'nodejs-lts' };
|
|
103
|
+
const nodePkg = NODE_WIN[env.manager];
|
|
104
|
+
if (!nodePkg) {
|
|
105
|
+
return { status: 'manual', reason: 'no supported package manager for Node.js — install the LTS from https://nodejs.org' };
|
|
106
|
+
}
|
|
107
|
+
return planned({ command: installCommand(env.manager, nodePkg), manager: env.manager, package: nodePkg, statusSource: 'command -v node' });
|
|
108
|
+
}
|
|
109
|
+
return planned({ command: NVM_INSTALL, statusSource: 'command -v node' });
|
|
110
|
+
|
|
111
|
+
case 'npm':
|
|
112
|
+
case 'node': {
|
|
113
|
+
const name = NPM_BINS[id] || id;
|
|
114
|
+
return planned({ command: `npm install -g ${id}@latest`, statusSource: `command -v ${name}` });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case 'pip':
|
|
118
|
+
case 'python': {
|
|
119
|
+
const pkgName = id;
|
|
120
|
+
return planned({ command: pipCommand(pkgName, opts.pipAvailability, env.os) });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
case 'cargo': {
|
|
124
|
+
const crate = CARGO_PACKAGES[id] || id;
|
|
125
|
+
return planned({ command: `cargo install ${crate}`, package: crate, statusSource: `command -v ${CARGO_BINS[id] || id}` });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case 'go': {
|
|
129
|
+
const modulePath = GO_PACKAGES[id] || id;
|
|
130
|
+
return planned({ command: `go install ${modulePath}@latest`, package: modulePath, statusSource: `command -v ${GO_BINS[id] || id}` });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
case 'gem': {
|
|
134
|
+
const gem = GEM_PACKAGES[id];
|
|
135
|
+
if (!gem) {
|
|
136
|
+
// dry-rb is a family of gems, not a single installable gem
|
|
137
|
+
if (id === 'dry-rb') return { status: 'manual', reason: 'dry-rb is a family of gems (dry-validation, dry-rails…) — install the gem you need: https://dry-rb.org' };
|
|
138
|
+
}
|
|
139
|
+
return planned({ command: `gem install ${gem || id}`, package: gem || id });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
case 'composer': {
|
|
143
|
+
const pkgName = COMPOSER_PACKAGES[id] || id;
|
|
144
|
+
return planned({ command: `composer global require ${pkgName}`, package: pkgName });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case 'dotnet': {
|
|
148
|
+
const pkgName = DOTNET_PACKAGES[id] || id;
|
|
149
|
+
return planned({ command: `dotnet tool install -g ${pkgName}`, package: pkgName });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case 'rustup': {
|
|
153
|
+
// rustup itself (the "cargo" catalog entry) and rustup components
|
|
154
|
+
if (id === 'cargo') {
|
|
155
|
+
const s = resolveScript('rustup', env.os, env.arch);
|
|
156
|
+
if (s.manual) return { status: 'manual', reason: s.manual };
|
|
157
|
+
return planned({ command: s.run, shell: s.shell, statusSource: 'command -v cargo' });
|
|
158
|
+
}
|
|
159
|
+
return planned({ command: `rustup component add ${id}` });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
case 'script': {
|
|
163
|
+
const resolved = resolveScript(id, env.os, env.arch);
|
|
164
|
+
if (resolved.manual) return { status: 'manual', reason: resolved.manual };
|
|
165
|
+
// `shell: 'powershell'` propagates to the executor (Windows-only scripts).
|
|
166
|
+
return planned({ command: resolved.run, package: resolved.url, shell: resolved.shell });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
case 'link':
|
|
170
|
+
return { status: 'link', url: tool.url || `https://marketplace.visualstudio.com/search?term=${id}` };
|
|
171
|
+
|
|
172
|
+
default: {
|
|
173
|
+
// Manager-backed install: brew / apt / dnf / pacman / zypper / apk /
|
|
174
|
+
// winget / choco / scoop. Manager methods in the catalog all mean
|
|
175
|
+
// "install via the system manager" — resolved per environment.
|
|
176
|
+
const resolved = resolvePackageName(id, env.manager);
|
|
177
|
+
if (resolved.manual) return { status: 'manual', reason: resolved.manual };
|
|
178
|
+
const command = installCommand(env.manager, resolved.name);
|
|
179
|
+
if (!command) return { status: 'manual', reason: `no package manager detected on ${env.os} — install ${id} manually` };
|
|
180
|
+
return planned({ command, manager: env.manager, package: resolved.name });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Best-effort retry route for a tool whose primary plan came back manual
|
|
186
|
+
// (#34: the wizard must try, not silently refuse). The manager route already
|
|
187
|
+
// failed — before giving up, try the script-installer table for the same
|
|
188
|
+
// platform. Returns the original entry when no script route exists either
|
|
189
|
+
// (the executor then reports `failed` with the reason as the hint).
|
|
190
|
+
function withFallbackRoute(entry, env) {
|
|
191
|
+
if (entry.status !== 'manual') return entry;
|
|
192
|
+
const resolved = resolveScript(entry.id, env.os, env.arch);
|
|
193
|
+
if (resolved.manual) return entry;
|
|
194
|
+
return {
|
|
195
|
+
...entry,
|
|
196
|
+
status: 'planned',
|
|
197
|
+
command: resolved.run,
|
|
198
|
+
package: resolved.url,
|
|
199
|
+
shell: resolved.shell,
|
|
200
|
+
method: 'script',
|
|
201
|
+
retryFallback: true,
|
|
202
|
+
statusSource: entry.statusSource || `command -v ${statusBin(entry.id, 'script')}`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function statusBin(id, method) {
|
|
207
|
+
if (NPM_BINS[id]) return NPM_BINS[id];
|
|
208
|
+
if (GO_BINS[id]) return GO_BINS[id];
|
|
209
|
+
if (CARGO_BINS[id]) return CARGO_BINS[id];
|
|
210
|
+
return id;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Every install method the builders handle (builder matrix reference).
|
|
214
|
+
const ALL_METHODS = [
|
|
215
|
+
'nvm', 'npm', 'node', 'pip', 'python', 'cargo', 'go', 'gem', 'composer',
|
|
216
|
+
'dotnet', 'rustup', 'script', 'link',
|
|
217
|
+
'brew', 'apt', 'dnf', 'pacman', 'zypper', 'apk', 'winget', 'choco', 'scoop',
|
|
218
|
+
];
|
|
219
|
+
|
|
220
|
+
module.exports = { buildToolPlan, withFallbackRoute, pipCommand, statusBin, ALL_METHODS, NVM_INSTALL };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
// Package-manager abstraction: which binary to look up and how to build the
|
|
5
|
+
// install command for a package name. All commands are POSIX `sh` compatible —
|
|
6
|
+
// no /bin/bash hardcoding (Alpine, NixOS…).
|
|
7
|
+
const MANAGERS = {
|
|
8
|
+
brew: { os: 'darwin', bin: 'brew', install: pkgName => `brew install ${pkgName}` },
|
|
9
|
+
apt: { os: 'linux', bin: 'apt-get', install: pkgName => `sudo apt-get install -y ${pkgName}` },
|
|
10
|
+
dnf: { os: 'linux', bin: 'dnf', install: pkgName => `sudo dnf install -y ${pkgName}` },
|
|
11
|
+
pacman: { os: 'linux', bin: 'pacman', install: pkgName => `sudo pacman -S --noconfirm ${pkgName}` },
|
|
12
|
+
zypper: { os: 'linux', bin: 'zypper', install: pkgName => `sudo zypper install -y ${pkgName}` },
|
|
13
|
+
apk: { os: 'linux', bin: 'apk', install: pkgName => `sudo apk add ${pkgName}` },
|
|
14
|
+
winget: { os: 'win32', bin: 'winget', install: pkgName => `winget install --id ${pkgName} --exact --accept-source-agreements --accept-package-agreements` },
|
|
15
|
+
choco: { os: 'win32', bin: 'choco', install: pkgName => `choco install ${pkgName} --yes` },
|
|
16
|
+
scoop: { os: 'win32', bin: 'scoop', install: pkgName => `scoop install ${pkgName}` },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const SUPPORTED_MANAGERS = Object.keys(MANAGERS);
|
|
20
|
+
|
|
21
|
+
// Checks whether the manager's binary exists in PATH. Pure fs walk — no exec,
|
|
22
|
+
// fast enough for dry-run planning, deterministic and testable via env override.
|
|
23
|
+
function isManagerAvailable(name, opts = {}) {
|
|
24
|
+
const def = MANAGERS[name];
|
|
25
|
+
if (!def) return false;
|
|
26
|
+
const env = opts.env || process.env;
|
|
27
|
+
const platform = opts.platform || process.platform;
|
|
28
|
+
const statSync = opts.statSync || fs.statSync;
|
|
29
|
+
const accessSync = opts.accessSync || fs.accessSync;
|
|
30
|
+
|
|
31
|
+
const sep = platform === 'win32' ? ';' : ':';
|
|
32
|
+
const dirs = (env.PATH || env.Path || '').split(sep).filter(Boolean);
|
|
33
|
+
const exts = platform === 'win32'
|
|
34
|
+
? ['', '.exe', '.cmd', '.bat', '.ps1']
|
|
35
|
+
: [''];
|
|
36
|
+
|
|
37
|
+
for (const dir of dirs) {
|
|
38
|
+
for (const ext of exts) {
|
|
39
|
+
const candidate = `${dir}${path.sep}${def.bin}${ext}`;
|
|
40
|
+
try {
|
|
41
|
+
statSync(candidate);
|
|
42
|
+
accessSync(candidate);
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
// next candidate
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Install command for a manager + package name, or null for unknown manager.
|
|
53
|
+
function installCommand(manager, pkgName) {
|
|
54
|
+
const def = MANAGERS[manager];
|
|
55
|
+
if (!def) return null;
|
|
56
|
+
return def.install(pkgName);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { MANAGERS, SUPPORTED_MANAGERS, isManagerAvailable, installCommand };
|