@crystalplatforms/adm 0.2.3

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +210 -0
  3. package/bin/adm +26 -0
  4. package/bin/adm-tui +13 -0
  5. package/package.json +62 -0
  6. package/scripts/completion/adm.bash +81 -0
  7. package/scripts/completion/adm.zsh +89 -0
  8. package/scripts/install.js +8 -0
  9. package/src/ai/knowledge.js +61 -0
  10. package/src/commands/connect.js +76 -0
  11. package/src/commands/dotfiles.js +72 -0
  12. package/src/commands/issue.js +80 -0
  13. package/src/commands/pr.js +55 -0
  14. package/src/commands/theme.js +103 -0
  15. package/src/commands/uninstall.js +72 -0
  16. package/src/commands/upgrade.js +114 -0
  17. package/src/config/index.js +32 -0
  18. package/src/data/extensions.json +358 -0
  19. package/src/installer/git-config.js +34 -0
  20. package/src/installer/node-installer.js +50 -0
  21. package/src/installer/package-manager-installer.js +31 -0
  22. package/src/installer/ssh-setup.js +31 -0
  23. package/src/installer/system-packages.js +49 -0
  24. package/src/installers/index.js +66 -0
  25. package/src/integrations/ai-backend.js +60 -0
  26. package/src/integrations/ai-providers/registry.js +261 -0
  27. package/src/integrations/github.js +107 -0
  28. package/src/integrations/gitlab.js +121 -0
  29. package/src/plugins/context.js +64 -0
  30. package/src/plugins/loader.js +50 -0
  31. package/src/plugins/runner.js +25 -0
  32. package/src/setup/catalog.js +13 -0
  33. package/src/setup/installer.js +192 -0
  34. package/src/setup/wizard.js +220 -0
  35. package/src/shell/detector.js +10 -0
  36. package/src/tui/app-state.js +374 -0
  37. package/src/tui/app.js +442 -0
  38. package/src/tui/autocomplete.js +57 -0
  39. package/src/tui/commands/registry.js +823 -0
  40. package/src/tui/components/SetupScreen.js +292 -0
  41. package/src/ui/animations.js +24 -0
  42. package/src/ui/spinner.js +34 -0
  43. package/src/ui/theme/cyberpunk.js +14 -0
  44. package/src/ui/theme/dark.js +14 -0
  45. package/src/ui/theme/forest.js +14 -0
  46. package/src/ui/theme/light.js +14 -0
  47. package/src/ui/theme/monokai.js +14 -0
  48. package/src/ui/theme/nord.js +14 -0
  49. package/src/ui/theme.js +49 -0
  50. package/src/utils/command-history.js +43 -0
  51. package/src/utils/dotfiles-sync.js +89 -0
  52. package/src/utils/error-handler.js +69 -0
  53. package/src/utils/file-conflict-handler.js +30 -0
  54. package/src/utils/fuzzy-search.js +20 -0
  55. package/src/utils/github-error-handler.js +26 -0
  56. package/src/utils/keychain.js +97 -0
  57. package/src/utils/open-url.js +15 -0
  58. package/src/utils/terminal-detection.js +26 -0
  59. package/src/utils/token-encryption.js +96 -0
@@ -0,0 +1,292 @@
1
+ const React = require('react');
2
+ const { loadCatalog, getToolsByCategories } = require('../../setup/catalog');
3
+ const { installTools, installToolAsync, getInstallCommand, isToolInstalled } = require('../../setup/installer');
4
+
5
+ const PAGE_SIZE = 10;
6
+
7
+ function createSetupState(opts = {}) {
8
+ const categories = loadCatalog();
9
+ const onExit = opts.onExit || (() => {});
10
+ const dryRun = opts.dryRun || false;
11
+ const execSync = opts.execSync;
12
+
13
+ const state = {
14
+ categories,
15
+ step: 'categories',
16
+ selectedCategories: [],
17
+ selectedTools: [],
18
+ summary: null,
19
+ cursor: 0,
20
+ page: 0,
21
+ installProgress: null,
22
+
23
+ toggleCategory(id) {
24
+ const idx = state.selectedCategories.indexOf(id);
25
+ if (idx === -1) state.selectedCategories.push(id);
26
+ else state.selectedCategories.splice(idx, 1);
27
+ },
28
+
29
+ toggleTool(id) {
30
+ const idx = state.selectedTools.indexOf(id);
31
+ if (idx === -1) state.selectedTools.push(id);
32
+ else state.selectedTools.splice(idx, 1);
33
+ },
34
+
35
+ getToolsForSelectedCategories() {
36
+ return getToolsByCategories(state.selectedCategories);
37
+ },
38
+
39
+ goToTools() {
40
+ state.step = 'tools';
41
+ state.cursor = 0;
42
+ state.page = 0;
43
+ },
44
+
45
+ goToInstall() {
46
+ state.step = 'install';
47
+ state.cursor = 0;
48
+ state.page = 0;
49
+ },
50
+
51
+ goToSummary(summary, results) {
52
+ state.summary = summary;
53
+ state.installResults = results || [];
54
+ state.step = 'summary';
55
+ state.page = 0;
56
+ },
57
+
58
+ getVisibleItems() {
59
+ if (state.step === 'categories') return state.categories;
60
+ if (state.step === 'tools') return state.getToolsForSelectedCategories();
61
+ if (state.step === 'summary') return state.installResults || [];
62
+ return [];
63
+ },
64
+
65
+ handleArrow(direction) {
66
+ const totalPages = state.getTotalPages();
67
+ if (direction === 'right' && state.page < totalPages - 1) {
68
+ state.page++;
69
+ } else if (direction === 'left' && state.page > 0) {
70
+ state.page--;
71
+ }
72
+ },
73
+
74
+ getTotalPages() {
75
+ return Math.max(1, Math.ceil(state.getVisibleItems().length / PAGE_SIZE));
76
+ },
77
+
78
+ cancel() {
79
+ onExit();
80
+ },
81
+
82
+ async runInstall(platform, onProgress) {
83
+ if (dryRun) {
84
+ const allTools = state.getToolsForSelectedCategories();
85
+ const selected = allTools.filter(t => state.selectedTools.includes(t.id));
86
+ const plan = selected.map(t => ({
87
+ id: t.id,
88
+ name: t.name,
89
+ command: getInstallCommand(t, platform),
90
+ }));
91
+ return { dryRun: true, plan };
92
+ }
93
+
94
+ const allTools = state.getToolsForSelectedCategories();
95
+ const selected = allTools.filter(t => state.selectedTools.includes(t.id));
96
+ const results = [];
97
+
98
+ for (const tool of selected) {
99
+ state.installProgress = { current: tool.name, done: results.length, total: selected.length };
100
+ if (onProgress) onProgress();
101
+
102
+ const result = await installToolAsync(tool, platform);
103
+ results.push(result);
104
+ if (onProgress) onProgress();
105
+ }
106
+
107
+ state.installProgress = null;
108
+
109
+ const summary = {
110
+ installed: results.filter(r => r.status === 'installed').length,
111
+ skipped: results.filter(r => r.status === 'skipped').length,
112
+ failed: results.filter(r => r.status === 'failed').length,
113
+ };
114
+
115
+ return { results, summary };
116
+ },
117
+ };
118
+
119
+ return state;
120
+ }
121
+
122
+ function createSetupScreen(ink) {
123
+ const { Box, Text, useInput, useApp } = ink;
124
+
125
+ return function SetupScreen({ onExit, dryRun, platform }) {
126
+ const stateRef = React.useRef(createSetupState({ onExit, dryRun }));
127
+ const [renderKey, setRenderKey] = React.useState(0);
128
+ const s = stateRef.current;
129
+
130
+ const triggerRender = () => setRenderKey(k => k + 1);
131
+
132
+ const allItems = s.getVisibleItems();
133
+
134
+ const totalPages = s.getTotalPages();
135
+ const pageStart = s.page * PAGE_SIZE;
136
+ const pageItems = allItems.slice(pageStart, pageStart + PAGE_SIZE);
137
+
138
+ // Clamp cursor to current page
139
+ if (s.cursor < pageStart || s.cursor >= pageStart + pageItems.length) {
140
+ s.cursor = pageStart;
141
+ }
142
+
143
+ useInput((ch, key) => {
144
+ if (key.escape) {
145
+ s.cancel();
146
+ return;
147
+ }
148
+
149
+ // Navigate up/down within current page
150
+ if (key.upArrow) {
151
+ s.cursor = Math.max(pageStart, s.cursor - 1);
152
+ triggerRender();
153
+ return;
154
+ }
155
+ if (key.downArrow) {
156
+ s.cursor = Math.min(pageStart + pageItems.length - 1, s.cursor + 1);
157
+ triggerRender();
158
+ return;
159
+ }
160
+
161
+ // Page left/right
162
+ if (key.leftArrow) {
163
+ s.handleArrow('left');
164
+ s.cursor = s.page * PAGE_SIZE;
165
+ triggerRender();
166
+ return;
167
+ }
168
+ if (key.rightArrow) {
169
+ s.handleArrow('right');
170
+ s.cursor = s.page * PAGE_SIZE;
171
+ triggerRender();
172
+ return;
173
+ }
174
+
175
+ // Space — toggle selection
176
+ if (ch === ' ') {
177
+ const item = allItems[s.cursor];
178
+ if (!item) return;
179
+ if (s.step === 'categories') {
180
+ s.toggleCategory(item.id);
181
+ } else if (s.step === 'tools') {
182
+ s.toggleTool(item.id);
183
+ }
184
+ triggerRender();
185
+ return;
186
+ }
187
+
188
+ // Enter — confirm step
189
+ if (key.return) {
190
+ if (s.step === 'categories' && s.selectedCategories.length > 0) {
191
+ s.goToTools();
192
+ triggerRender();
193
+ } else if (s.step === 'tools' && s.selectedTools.length > 0) {
194
+ s.goToInstall();
195
+ triggerRender();
196
+ s.runInstall(platform || 'darwin', triggerRender).then(result => {
197
+ if (result.summary) {
198
+ s.goToSummary(result.summary, result.results || []);
199
+ }
200
+ triggerRender();
201
+ });
202
+ }
203
+ }
204
+ });
205
+
206
+ const lines = [];
207
+ const selCount = s.step === 'categories' ? s.selectedCategories.length : s.selectedTools.length;
208
+ const pagerText = totalPages > 1 ? ` [${s.page + 1}/${totalPages}] ←→ = change page` : '';
209
+
210
+ if (s.step === 'categories') {
211
+ lines.push({ text: `Select categories (${selCount} selected) Space=toggle Enter=confirm Esc=cancel`, highlight: false });
212
+ lines.push({ text: pagerText, highlight: false });
213
+
214
+ for (let i = 0; i < pageItems.length; i++) {
215
+ const cat = pageItems[i];
216
+ const globalIdx = pageStart + i;
217
+ const marker = s.selectedCategories.includes(cat.id) ? '[x]' : '[ ]';
218
+ const active = globalIdx === s.cursor ? ' > ' : ' ';
219
+ lines.push({ text: `${active}${marker} ${cat.icon} ${cat.name}`, highlight: globalIdx === s.cursor });
220
+ }
221
+ } else if (s.step === 'tools') {
222
+ lines.push({ text: `Select tools (${selCount} selected) Space=toggle Enter=confirm Esc=cancel`, highlight: false });
223
+ lines.push({ text: pagerText, highlight: false });
224
+
225
+ for (let i = 0; i < pageItems.length; i++) {
226
+ const tool = pageItems[i];
227
+ const globalIdx = pageStart + i;
228
+ const marker = s.selectedTools.includes(tool.id) ? '[x]' : '[ ]';
229
+ const active = globalIdx === s.cursor ? ' > ' : ' ';
230
+ lines.push({ text: `${active}${marker} ${tool.name} (${tool.installMethod})`, highlight: globalIdx === s.cursor });
231
+ }
232
+ } else if (s.step === 'install') {
233
+ const p = s.installProgress;
234
+ if (p) {
235
+ lines.push({ text: `Installing (${p.done + 1}/${p.total}): ${p.current}...`, highlight: false });
236
+ } else {
237
+ lines.push({ text: 'Installing selected tools...', highlight: false });
238
+ }
239
+ } else if (s.step === 'summary') {
240
+ const sum = s.summary || {};
241
+ const results = s.installResults || [];
242
+
243
+ lines.push({ text: ' Setup complete!', highlight: false });
244
+ lines.push({ text: ` Installed: ${sum.installed || 0} Skipped: ${sum.skipped || 0} Failed: ${sum.failed || 0}`, highlight: false });
245
+ lines.push({ text: '', highlight: false });
246
+
247
+ // Show per-tool results (paginated)
248
+ const resPageStart = s.page * PAGE_SIZE;
249
+ const resPage = results.slice(resPageStart, resPageStart + PAGE_SIZE);
250
+ const resTotalPages = Math.max(1, Math.ceil(results.length / PAGE_SIZE));
251
+
252
+ for (const r of resPage) {
253
+ const icon = r.status === 'installed' ? '+' : r.status === 'skipped' ? '-' : 'x';
254
+ const color = r.status === 'installed' ? 'green' : r.status === 'skipped' ? 'yellow' : 'red';
255
+ const detail = r.status === 'failed' ? ` — ${r.error || 'unknown error'}` : '';
256
+ lines.push({ text: ` [${icon}] ${r.name} ${r.status}${detail}`, color, highlight: false });
257
+ if (r.hint) {
258
+ lines.push({ text: ` ${r.hint}`, color: 'gray', highlight: false });
259
+ }
260
+ }
261
+
262
+ if (resTotalPages > 1) {
263
+ lines.push({ text: ` [${s.page + 1}/${resTotalPages}] ←→ = more results`, highlight: false });
264
+ }
265
+
266
+ lines.push({ text: '\n Press Esc to return to ADM.', highlight: false });
267
+
268
+ if (sum.installed > 0) {
269
+ lines.push({ text: ' Tip: restart terminal (or run: source ~/.zshrc) for new tools', color: 'yellow', highlight: false });
270
+ }
271
+
272
+ if (dryRun) {
273
+ lines.push({ text: ' (dry-run mode — no tools were actually installed)', highlight: false });
274
+ }
275
+ }
276
+
277
+ return React.createElement(
278
+ Box,
279
+ { flexDirection: 'column', flexGrow: 1, paddingX: 1 },
280
+ ...lines.map((line, i) =>
281
+ React.createElement(Text, {
282
+ key: i,
283
+ wrap: 'wrap',
284
+ color: line.color || (line.highlight ? 'cyan' : undefined),
285
+ bold: line.highlight,
286
+ }, line.text)
287
+ ),
288
+ );
289
+ };
290
+ }
291
+
292
+ module.exports = { createSetupScreen, createSetupState };
@@ -0,0 +1,24 @@
1
+ function createAnimation(renderFn, options = {}) {
2
+ const interval = options.interval || 5000;
3
+ let timer = null;
4
+
5
+ return {
6
+ start() {
7
+ if (timer) return this;
8
+ timer = setInterval(renderFn, interval);
9
+ return this;
10
+ },
11
+ stop() {
12
+ if (timer) {
13
+ clearInterval(timer);
14
+ timer = null;
15
+ }
16
+ return this;
17
+ },
18
+ isRunning() {
19
+ return timer !== null;
20
+ },
21
+ };
22
+ }
23
+
24
+ module.exports = { createAnimation };
@@ -0,0 +1,34 @@
1
+ const ora = require('ora');
2
+ const { resolveTheme } = require('./theme');
3
+
4
+ const NAMED_COLORS = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white', 'gray'];
5
+
6
+ function toOraColor(color) {
7
+ if (NAMED_COLORS.includes(color)) return color;
8
+ return 'cyan';
9
+ }
10
+
11
+ function createSpinner(text, options = {}) {
12
+ const theme = resolveTheme({});
13
+ const rawColor = options.noColor ? 'white' : (options.color || theme.colors.primary);
14
+ const spinnerColor = toOraColor(rawColor);
15
+ const spinnerOpts = {
16
+ text,
17
+ color: spinnerColor,
18
+ isEnabled: options.isEnabled !== undefined ? options.isEnabled : true,
19
+ };
20
+
21
+ const spinner = ora(spinnerOpts);
22
+
23
+ return {
24
+ get text() { return spinner.text; },
25
+ set text(val) { spinner.text = val; },
26
+ start() { spinner.start(); return spinner; },
27
+ stop() { spinner.stop(); return spinner; },
28
+ succeed(msg) { spinner.succeed(msg || text); return spinner; },
29
+ fail(msg) { spinner.fail(msg || text); return spinner; },
30
+ _ora: spinner,
31
+ };
32
+ }
33
+
34
+ module.exports = { createSpinner };
@@ -0,0 +1,14 @@
1
+ module.exports = {
2
+ name: 'cyberpunk',
3
+ colors: {
4
+ primary: '#f706cf',
5
+ secondary: '#ff2a6d',
6
+ accent: '#05d9e8',
7
+ text: '#d1f7ff',
8
+ bg: '#01012b',
9
+ muted: '#7b2d8e',
10
+ error: '#ff2a6d',
11
+ success: '#01ff89',
12
+ warning: '#ff6c11',
13
+ },
14
+ };
@@ -0,0 +1,14 @@
1
+ module.exports = {
2
+ name: 'dark',
3
+ colors: {
4
+ primary: '#61dafb',
5
+ secondary: '#4fc3f7',
6
+ accent: '#ff6b6b',
7
+ text: '#e0e0e0',
8
+ bg: '#1a1a2e',
9
+ muted: '#6c757d',
10
+ error: '#ff4757',
11
+ success: '#2ed573',
12
+ warning: '#ffa502',
13
+ },
14
+ };
@@ -0,0 +1,14 @@
1
+ module.exports = {
2
+ name: 'forest',
3
+ colors: {
4
+ primary: '#a7c957',
5
+ secondary: '#6a994e',
6
+ accent: '#f2e8cf',
7
+ text: '#dda15e',
8
+ bg: '#1b2a1b',
9
+ muted: '#606c38',
10
+ error: '#bc4749',
11
+ success: '#a7c957',
12
+ warning: '#dda15e',
13
+ },
14
+ };
@@ -0,0 +1,14 @@
1
+ module.exports = {
2
+ name: 'light',
3
+ colors: {
4
+ primary: '#0066cc',
5
+ secondary: '#0099e6',
6
+ accent: '#cc3300',
7
+ text: '#1a1a1a',
8
+ bg: '#f5f5f5',
9
+ muted: '#999999',
10
+ error: '#cc0000',
11
+ success: '#008800',
12
+ warning: '#cc8800',
13
+ },
14
+ };
@@ -0,0 +1,14 @@
1
+ module.exports = {
2
+ name: 'monokai',
3
+ colors: {
4
+ primary: '#a6e22e',
5
+ secondary: '#66d9ef',
6
+ accent: '#f92672',
7
+ text: '#f8f8f2',
8
+ bg: '#272822',
9
+ muted: '#75715e',
10
+ error: '#f92672',
11
+ success: '#a6e22e',
12
+ warning: '#e6db74',
13
+ },
14
+ };
@@ -0,0 +1,14 @@
1
+ module.exports = {
2
+ name: 'nord',
3
+ colors: {
4
+ primary: '#88c0d0',
5
+ secondary: '#81a1c1',
6
+ accent: '#bf616a',
7
+ text: '#eceff4',
8
+ bg: '#2e3440',
9
+ muted: '#636f88',
10
+ error: '#bf616a',
11
+ success: '#a3be8c',
12
+ warning: '#ebcb8b',
13
+ },
14
+ };
@@ -0,0 +1,49 @@
1
+ const darkTheme = require('./theme/dark');
2
+ const lightTheme = require('./theme/light');
3
+ const cyberpunkTheme = require('./theme/cyberpunk');
4
+ const nordTheme = require('./theme/nord');
5
+ const forestTheme = require('./theme/forest');
6
+ const monokaiTheme = require('./theme/monokai');
7
+ const { isDarkMode } = require('../utils/terminal-detection');
8
+
9
+ const presets = {
10
+ dark: darkTheme,
11
+ light: lightTheme,
12
+ cyberpunk: cyberpunkTheme,
13
+ nord: nordTheme,
14
+ forest: forestTheme,
15
+ monokai: monokaiTheme,
16
+ };
17
+
18
+ function getTheme(name) {
19
+ const theme = presets[name];
20
+ if (!theme) throw new Error(`Unknown theme: "${name}". Available: ${Object.keys(presets).join(', ')}`);
21
+ return theme;
22
+ }
23
+
24
+ function listThemes() {
25
+ return Object.values(presets);
26
+ }
27
+
28
+ function resolveTheme(config = {}) {
29
+ const { theme } = config;
30
+
31
+ if (!theme || theme === 'auto') {
32
+ return isDarkMode() ? darkTheme : lightTheme;
33
+ }
34
+
35
+ if (typeof theme === 'string') {
36
+ return getTheme(theme);
37
+ }
38
+
39
+ if (typeof theme === 'object') {
40
+ return {
41
+ name: 'custom',
42
+ colors: { ...darkTheme.colors, ...theme },
43
+ };
44
+ }
45
+
46
+ return darkTheme;
47
+ }
48
+
49
+ module.exports = { resolveTheme, getTheme, listThemes };
@@ -0,0 +1,43 @@
1
+ const fs = require('fs').promises;
2
+ const path = require('path');
3
+ const { configDir } = require('../config/index');
4
+
5
+ const MAX_ENTRIES = 1000;
6
+
7
+ function historyFile() {
8
+ return path.join(configDir(), 'history');
9
+ }
10
+
11
+ async function loadHistory() {
12
+ try {
13
+ const data = await fs.readFile(historyFile(), 'utf8');
14
+ return data.split('\n').filter(Boolean);
15
+ } catch (err) {
16
+ if (err.code === 'ENOENT') return [];
17
+ throw err;
18
+ }
19
+ }
20
+
21
+ async function saveCommand(command) {
22
+ const trimmed = command.trim();
23
+ if (!trimmed) return;
24
+
25
+ const dir = configDir();
26
+ await fs.mkdir(dir, { recursive: true });
27
+
28
+ let history = await loadHistory();
29
+ history.push(trimmed);
30
+ if (history.length > MAX_ENTRIES) {
31
+ history = history.slice(history.length - MAX_ENTRIES);
32
+ }
33
+
34
+ await fs.writeFile(historyFile(), history.join('\n'), 'utf8');
35
+ }
36
+
37
+ async function searchHistory(query) {
38
+ const history = await loadHistory();
39
+ const lower = query.toLowerCase();
40
+ return history.filter(entry => entry.toLowerCase().includes(lower));
41
+ }
42
+
43
+ module.exports = { saveCommand, loadHistory, searchHistory };
@@ -0,0 +1,89 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const chalk = require('chalk');
4
+ const { handleConflict, createBackup } = require('./file-conflict-handler');
5
+
6
+ const DEFAULT_FILES = ['.bashrc', '.zshrc', '.gitconfig', '.ssh/config'];
7
+
8
+ /**
9
+ * Syncs dotfiles from repo to home directory
10
+ *
11
+ * Options:
12
+ * repo: string — dotfiles repo path/URL
13
+ * homeDir: string — home directory (defaults to os.homedir())
14
+ * copy: boolean — copy instead of symlinking
15
+ * only: string[] — file filter (without dot, e.g. ['bashrc', 'zshrc'])
16
+ * files: string[] — file list (with dot, e.g. ['.bashrc'])
17
+ * onConflict: () => 'skip' | 'backup' — conflict strategy
18
+ */
19
+ async function syncDotfiles(options = {}) {
20
+ const configDir = process.env.ADM_CONFIG_DIR || path.join(require('os').homedir(), '.adm');
21
+ const dotfilesDir = path.join(configDir, 'dotfiles');
22
+ const homeDir = options.homeDir || process.env.ADM_TEST_HOME || require('os').homedir();
23
+
24
+ // Determine file list to sync
25
+ let files = options.files || DEFAULT_FILES;
26
+
27
+ // --only filter (user provides without dot: 'bashrc' → '.bashrc')
28
+ if (options.only && options.only.length > 0) {
29
+ files = options.only.map(f => f.startsWith('.') ? f : '.' + f);
30
+ }
31
+
32
+ const result = { symlinked: [], copied: [], skipped: [], backedUp: [], errors: [] };
33
+
34
+ for (const file of files) {
35
+ const sourcePath = path.join(dotfilesDir, file);
36
+ const targetPath = path.join(homeDir, file);
37
+
38
+ // Check if source file exists in repo
39
+ if (!fs.existsSync(sourcePath)) {
40
+ continue;
41
+ }
42
+
43
+ // Create target directory if needed (e.g. .ssh/)
44
+ const targetDir = path.dirname(targetPath);
45
+ if (!fs.existsSync(targetDir)) {
46
+ fs.mkdirSync(targetDir, { recursive: true });
47
+ }
48
+
49
+ // Check for conflict
50
+ const conflict = handleConflict({ targetPath, sourcePath });
51
+ if (conflict === 'conflict') {
52
+ const strategy = options.onConflict ? options.onConflict(file) : 'skip';
53
+ if (strategy === 'skip') {
54
+ result.skipped.push(file);
55
+ console.log(chalk.yellow(` ⚠ Skipped ${file} (conflict)`));
56
+ continue;
57
+ }
58
+ if (strategy === 'backup') {
59
+ createBackup(targetPath);
60
+ result.backedUp.push(file);
61
+ fs.unlinkSync(targetPath);
62
+ console.log(chalk.blue(` ℹ Backup ${file} → ${file}.backup`));
63
+ }
64
+ }
65
+
66
+ // Symlink or copy
67
+ try {
68
+ if (options.copy) {
69
+ fs.copyFileSync(sourcePath, targetPath);
70
+ // Preserve permissions
71
+ const sourceStat = fs.statSync(sourcePath);
72
+ fs.chmodSync(targetPath, sourceStat.mode);
73
+ result.copied.push(file);
74
+ console.log(chalk.green(` ✔ Copied ${file}`));
75
+ } else {
76
+ fs.symlinkSync(sourcePath, targetPath);
77
+ result.symlinked.push(file);
78
+ console.log(chalk.green(` ✔ Symlink ${file}`));
79
+ }
80
+ } catch (err) {
81
+ result.errors.push({ file, error: err.message });
82
+ console.error(chalk.red(` ✖ Error ${file}: ${err.message}`));
83
+ }
84
+ }
85
+
86
+ return result;
87
+ }
88
+
89
+ module.exports = { syncDotfiles };
@@ -0,0 +1,69 @@
1
+ class AdmError extends Error {
2
+ constructor(type, message, exitCode = 1, remediation = null) {
3
+ super(message);
4
+ this.name = 'AdmError';
5
+ this.type = type;
6
+ this.exitCode = exitCode;
7
+ this.remediation = remediation;
8
+ }
9
+ }
10
+
11
+ function stripSensitive(msg) {
12
+ if (typeof msg !== 'string') return String(msg ?? '');
13
+ return msg
14
+ .replace(/ghp_[A-Za-z0-9]{20,}/g, '<TOKEN>')
15
+ .replace(/glpat-[A-Za-z0-9\-]{20,}/g, '<TOKEN>')
16
+ .replace(/Bearer\s+\S+/gi, 'Bearer <TOKEN>');
17
+ }
18
+
19
+ function handleAdmError(err) {
20
+ // String error
21
+ if (typeof err === 'string') {
22
+ return new AdmError('unknown', err, 1, 'Type /help for available commands.');
23
+ }
24
+
25
+ // Network errors
26
+ if (err.code === 'ENOTFOUND' || err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT') {
27
+ return new AdmError('network', 'Network error. Check your internet connection.', 1, 'Check your internet connection and try again.');
28
+ }
29
+
30
+ // Auth errors (401/403 without rate limit)
31
+ if (err.status === 401) {
32
+ return new AdmError('auth', stripSensitive('Authentication failed. Token may be expired or invalid.'), 1, 'Run /connect to reconnect.');
33
+ }
34
+
35
+ if (err.status === 403) {
36
+ if (err.headers && err.headers['x-ratelimit-remaining'] === '0') {
37
+ return new AdmError('rate_limit', 'GitHub API rate limit exceeded.', 1, 'Wait a few minutes and try again.');
38
+ }
39
+ return new AdmError('auth', stripSensitive('Authentication failed. Token may be expired or invalid.'), 1, 'Run /connect to reconnect.');
40
+ }
41
+
42
+ // Not found
43
+ if (err.status === 404) {
44
+ return new AdmError('not_found', 'Resource not found.', 1, 'Check the repository/PR number and your permissions.');
45
+ }
46
+
47
+ // User errors — validation, missing arguments
48
+ const msg = stripSensitive(err.message || 'An unexpected error occurred.');
49
+ if (/missing required argument|argument.*required|must provide/i.test(msg)) {
50
+ return new AdmError('user_error', msg, 2, 'Type /help for available commands.');
51
+ }
52
+
53
+ // Unknown / fallback
54
+ return new AdmError('unknown', msg, 1, 'If this persists, file an issue at https://github.com/adm-cli/adm/issues.');
55
+ }
56
+
57
+ function formatError(err) {
58
+ if (err instanceof AdmError) {
59
+ let msg = `Error: ${err.message}`;
60
+ if (err.remediation) {
61
+ msg += `\n → ${err.remediation}`;
62
+ }
63
+ return msg;
64
+ }
65
+ // Plain Error or anything else
66
+ return `Error: ${err.message || String(err)}`;
67
+ }
68
+
69
+ module.exports = { AdmError, handleAdmError, formatError };