instabox 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/instabox.mjs +62 -5
- package/lib/login.mjs +5 -2
- package/package.json +1 -1
- package/skills/instabox/SKILL.md +28 -4
- package/skills/instabox-basic-setup/SKILL.md +108 -0
- package/skills/instabox-basic-setup/scripts/content.mjs +98 -0
- package/skills/instabox-basic-setup/scripts/lib.mjs +233 -0
- package/skills/instabox-basic-setup/scripts/plan.mjs +295 -0
- package/skills/instabox-basic-setup/scripts/push.mjs +252 -0
- package/skills/instabox-basic-setup/scripts/scan.mjs +246 -0
- package/skills/instabox-move-repo/SKILL.md +193 -0
- package/skills/instabox-move-repo/scripts/deps.sh +370 -0
- package/skills/instabox-move-repo/scripts/history.mjs +340 -0
- package/skills/instabox-move-repo/scripts/repo-files.mjs +552 -0
- package/skills/instabox-snapshots/SKILL.md +2 -2
- package/skills/instabox-migrate/SKILL.md +0 -132
- package/skills/instabox-move-all/SKILL.md +0 -158
- package/skills/instabox-move-all/scripts/lib.mjs +0 -226
- package/skills/instabox-move-all/scripts/plan.mjs +0 -263
- package/skills/instabox-move-all/scripts/push-creds.mjs +0 -154
- package/skills/instabox-move-all/scripts/scan.mjs +0 -811
- package/skills/instabox-move-all/scripts/stage-env.mjs +0 -313
- package/skills/instabox-move-all/scripts/tools.mjs +0 -173
|
@@ -1,811 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Read-only inventory of what could move to an instabox box: git repos and
|
|
3
|
-
// their agent history, credentials (presence only, never values), dotfiles,
|
|
4
|
-
// agent config and globally installed tools. Prints JSON.
|
|
5
|
-
//
|
|
6
|
-
// node scan.mjs [--root DIR]... [--no-default-roots] [--depth N] [--no-tools] > scan.json
|
|
7
|
-
//
|
|
8
|
-
// Nothing is written and no value of a credential is read into the output:
|
|
9
|
-
// files that hold secrets are only checked for which keys they have.
|
|
10
|
-
import { readdirSync, realpathSync } from 'node:fs';
|
|
11
|
-
import { basename, dirname, join, resolve } from 'node:path';
|
|
12
|
-
import {
|
|
13
|
-
BOX_WORKSPACE,
|
|
14
|
-
MACOS_ONLY,
|
|
15
|
-
SECRET_VALUE,
|
|
16
|
-
STAGE_DIR,
|
|
17
|
-
VAULT_MAX_VALUE,
|
|
18
|
-
claudeProjectName,
|
|
19
|
-
hasCommand,
|
|
20
|
-
isMain,
|
|
21
|
-
isSecretName,
|
|
22
|
-
listFiles,
|
|
23
|
-
lstatOrNull,
|
|
24
|
-
readHead,
|
|
25
|
-
readJSON,
|
|
26
|
-
readText,
|
|
27
|
-
run,
|
|
28
|
-
safeName,
|
|
29
|
-
treeBytes,
|
|
30
|
-
vaultEnvNameOK,
|
|
31
|
-
vaultFileName,
|
|
32
|
-
vaultPathOK,
|
|
33
|
-
} from './lib.mjs';
|
|
34
|
-
|
|
35
|
-
export const DEFAULT_ROOTS = [
|
|
36
|
-
'Work',
|
|
37
|
-
'work',
|
|
38
|
-
'code',
|
|
39
|
-
'Code',
|
|
40
|
-
'src',
|
|
41
|
-
'projects',
|
|
42
|
-
'Projects',
|
|
43
|
-
'dev',
|
|
44
|
-
'Developer',
|
|
45
|
-
'repos',
|
|
46
|
-
'git',
|
|
47
|
-
'github',
|
|
48
|
-
];
|
|
49
|
-
const SKIP_DIRS = new Set([
|
|
50
|
-
'node_modules',
|
|
51
|
-
'vendor',
|
|
52
|
-
'target',
|
|
53
|
-
'dist',
|
|
54
|
-
'build',
|
|
55
|
-
'venv',
|
|
56
|
-
'__pycache__',
|
|
57
|
-
'Library',
|
|
58
|
-
'Applications',
|
|
59
|
-
'Pictures',
|
|
60
|
-
'Music',
|
|
61
|
-
'Movies',
|
|
62
|
-
]);
|
|
63
|
-
|
|
64
|
-
// Shell startup files: scanned for exported credentials, copied without them.
|
|
65
|
-
export const SHELL_RC = ['.zshrc', '.zshenv', '.zprofile', '.bashrc', '.bash_profile', '.profile', '.bash_aliases'];
|
|
66
|
-
|
|
67
|
-
// Dotfiles and config directories copied as they are (secrets and macOS-only lines filtered).
|
|
68
|
-
const DOTFILES = [
|
|
69
|
-
...SHELL_RC,
|
|
70
|
-
'.aliases',
|
|
71
|
-
'.gitconfig',
|
|
72
|
-
'.config/git/config',
|
|
73
|
-
'.config/git/ignore',
|
|
74
|
-
'.gitignore_global',
|
|
75
|
-
'.gitmessage',
|
|
76
|
-
'.tmux.conf',
|
|
77
|
-
'.config/tmux',
|
|
78
|
-
'.vimrc',
|
|
79
|
-
'.config/nvim',
|
|
80
|
-
'.config/helix',
|
|
81
|
-
'.editorconfig',
|
|
82
|
-
'.inputrc',
|
|
83
|
-
'.config/starship.toml',
|
|
84
|
-
'.ssh/config',
|
|
85
|
-
'.aws/config',
|
|
86
|
-
'.config/gh/config.yml',
|
|
87
|
-
'.npmrc',
|
|
88
|
-
'.nvmrc',
|
|
89
|
-
'.node-version',
|
|
90
|
-
'.python-version',
|
|
91
|
-
'.ruby-version',
|
|
92
|
-
'.tool-versions',
|
|
93
|
-
'.config/mise/config.toml',
|
|
94
|
-
'.psqlrc',
|
|
95
|
-
'.curlrc',
|
|
96
|
-
'.wgetrc',
|
|
97
|
-
'.gemrc',
|
|
98
|
-
];
|
|
99
|
-
const DOT_DIR_MAX = 20 * 1024 * 1024;
|
|
100
|
-
|
|
101
|
-
// Git repos: found under the roots, at most depth levels down, not inside another repo.
|
|
102
|
-
function findRepos(roots, depth) {
|
|
103
|
-
const found = [];
|
|
104
|
-
const walk = (dir, d) => {
|
|
105
|
-
const st = lstatOrNull(join(dir, '.git'));
|
|
106
|
-
if (st) {
|
|
107
|
-
found.push(dir);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
if (d >= depth) return;
|
|
111
|
-
let names = [];
|
|
112
|
-
try {
|
|
113
|
-
names = readdirSync(dir, { withFileTypes: true });
|
|
114
|
-
} catch {
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
for (const e of names) {
|
|
118
|
-
if (!e.isDirectory() || e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
|
|
119
|
-
walk(join(dir, e.name), d + 1);
|
|
120
|
-
}
|
|
121
|
-
};
|
|
122
|
-
const seen = new Set();
|
|
123
|
-
for (const r of roots) {
|
|
124
|
-
if (!lstatOrNull(r)?.isDirectory()) continue;
|
|
125
|
-
const real = realOrSelf(r);
|
|
126
|
-
if (seen.has(real)) continue;
|
|
127
|
-
seen.add(real);
|
|
128
|
-
walk(r, 0);
|
|
129
|
-
}
|
|
130
|
-
return found;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// The canonical path: realpathSync.native also settles case on macOS, where ~/Work and ~/work are one directory.
|
|
134
|
-
function realOrSelf(p) {
|
|
135
|
-
try {
|
|
136
|
-
return realpathSync.native(p);
|
|
137
|
-
} catch {
|
|
138
|
-
return p;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function git(repo, args, env) {
|
|
143
|
-
// GIT_OPTIONAL_LOCKS=0: `git status` must not refresh the index (read-only scan).
|
|
144
|
-
return run(['git', '-C', repo, ...args], { env: { ...env, GIT_OPTIONAL_LOCKS: '0' } });
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const nulList = (s) => s.split('\0').filter(Boolean);
|
|
148
|
-
const lines = (s) => s.split('\n').filter((l) => l.trim() !== '');
|
|
149
|
-
const ENV_FILE = /(^|\/)\.env(\.[^/]*)?$/;
|
|
150
|
-
const ENV_TEMPLATE = /\.(example|sample|template|dist|defaults?)$/;
|
|
151
|
-
|
|
152
|
-
function inspectRepo(path, env) {
|
|
153
|
-
const g = (args) => git(path, args, env);
|
|
154
|
-
const gitPath = join(path, '.git');
|
|
155
|
-
const worktree = !lstatOrNull(gitPath)?.isDirectory();
|
|
156
|
-
const r = { path, worktree };
|
|
157
|
-
r.branch = g(['symbolic-ref', '--short', '-q', 'HEAD']).stdout.trim() || 'detached';
|
|
158
|
-
r.head = g(['rev-parse', '--short', 'HEAD']).stdout.trim() || null;
|
|
159
|
-
r.dirty = lines(g(['status', '--porcelain', '--untracked-files=no']).stdout).length;
|
|
160
|
-
const tracked = nulList(g(['ls-files', '-z', '--cached']).stdout);
|
|
161
|
-
const untracked = nulList(
|
|
162
|
-
g([
|
|
163
|
-
'ls-files',
|
|
164
|
-
'-z',
|
|
165
|
-
'--others',
|
|
166
|
-
'--exclude-standard',
|
|
167
|
-
'--',
|
|
168
|
-
'.',
|
|
169
|
-
':(exclude,glob)**/.env',
|
|
170
|
-
':(exclude,glob)**/.env.*',
|
|
171
|
-
]).stdout,
|
|
172
|
-
);
|
|
173
|
-
r.untracked = untracked.length;
|
|
174
|
-
r.stashes = lines(g(['stash', 'list']).stdout).length;
|
|
175
|
-
r.remotes = lines(g(['remote']).stdout).length;
|
|
176
|
-
r.unpushed = r.head ? Number(g(['rev-list', '--count', '--branches', '--not', '--remotes']).stdout.trim()) || 0 : 0;
|
|
177
|
-
// What instabox-migrate copies: all of .git, tracked files and untracked files git does not ignore.
|
|
178
|
-
let bytes = worktree ? 0 : treeBytes(gitPath);
|
|
179
|
-
for (const f of new Set([...tracked, ...untracked])) {
|
|
180
|
-
const st = lstatOrNull(join(path, f));
|
|
181
|
-
if (st && !st.isDirectory()) bytes += st.size;
|
|
182
|
-
}
|
|
183
|
-
r.bytes = bytes;
|
|
184
|
-
// .env files git does not track: they stay behind with the repo, and can go to the Vault.
|
|
185
|
-
r.envFiles = nulList(
|
|
186
|
-
g(['ls-files', '-z', '--others', '--', ':(glob)**/.env', ':(glob)**/.env.*']).stdout,
|
|
187
|
-
).filter((f) => ENV_FILE.test(f) && !ENV_TEMPLATE.test(f) && !/(^|\/)(node_modules|vendor)\//.test(f));
|
|
188
|
-
if (worktree) {
|
|
189
|
-
const common = g(['rev-parse', '--path-format=absolute', '--git-common-dir']).stdout.trim();
|
|
190
|
-
r.mainCheckout = common ? dirname(common) : null;
|
|
191
|
-
}
|
|
192
|
-
return r;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Claude Code: ~/.claude/projects/<encoded cwd>/*.jsonl. The cwd is read from a session line.
|
|
196
|
-
function claudeHistory(home) {
|
|
197
|
-
const base = join(home, '.claude', 'projects');
|
|
198
|
-
const byCwd = new Map();
|
|
199
|
-
let dirs = [];
|
|
200
|
-
try {
|
|
201
|
-
dirs = readdirSync(base, { withFileTypes: true });
|
|
202
|
-
} catch {
|
|
203
|
-
return byCwd;
|
|
204
|
-
}
|
|
205
|
-
for (const d of dirs) {
|
|
206
|
-
if (!d.isDirectory()) continue;
|
|
207
|
-
const dir = join(base, d.name);
|
|
208
|
-
const sessions = listFiles(dir, () => false).filter((f) => !f.includes('/') && f.endsWith('.jsonl'));
|
|
209
|
-
if (!sessions.length) continue;
|
|
210
|
-
let cwd = null;
|
|
211
|
-
for (const s of sessions) {
|
|
212
|
-
const m = readHead(join(dir, s), 256 * 1024).match(/"cwd":("(?:[^"\\]|\\.)*")/);
|
|
213
|
-
if (m) {
|
|
214
|
-
try {
|
|
215
|
-
cwd = JSON.parse(m[1]);
|
|
216
|
-
} catch {}
|
|
217
|
-
if (cwd) break;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
if (!cwd || claudeProjectName(cwd) !== d.name) continue;
|
|
221
|
-
byCwd.set(cwd, { dir: d.name, sessions: sessions.length, bytes: treeBytes(dir) });
|
|
222
|
-
}
|
|
223
|
-
return byCwd;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Codex: ~/.codex/sessions/**/rollout-*.jsonl, each recording its cwd near the top.
|
|
227
|
-
function codexHistory(home) {
|
|
228
|
-
const base = join(home, '.codex', 'sessions');
|
|
229
|
-
const byCwd = new Map();
|
|
230
|
-
for (const f of listFiles(base)) {
|
|
231
|
-
if (!/(^|\/)rollout-[^/]*\.jsonl$/.test(f)) continue;
|
|
232
|
-
const m = readHead(join(base, f), 256 * 1024).match(/"cwd":("(?:[^"\\]|\\.)*")/);
|
|
233
|
-
if (!m) continue;
|
|
234
|
-
let cwd;
|
|
235
|
-
try {
|
|
236
|
-
cwd = JSON.parse(m[1]);
|
|
237
|
-
} catch {
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
const e = byCwd.get(cwd) || { sessions: 0, bytes: 0 };
|
|
241
|
-
e.sessions++;
|
|
242
|
-
e.bytes += lstatOrNull(join(base, f))?.size || 0;
|
|
243
|
-
byCwd.set(cwd, e);
|
|
244
|
-
}
|
|
245
|
-
return byCwd;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function scanWork({ home, cwd, roots, depth, env }) {
|
|
249
|
-
const claude = claudeHistory(home);
|
|
250
|
-
const codex = codexHistory(home);
|
|
251
|
-
const candidates = findRepos(roots, depth);
|
|
252
|
-
// The current directory's repo, and repos the agents' history points at, wherever they are.
|
|
253
|
-
for (const dir of [cwd, ...claude.keys(), ...codex.keys()]) {
|
|
254
|
-
if (!dir || !lstatOrNull(dir)?.isDirectory()) continue;
|
|
255
|
-
const top = git(dir, ['rev-parse', '--show-toplevel'], env).stdout.trim();
|
|
256
|
-
if (top) candidates.push(top);
|
|
257
|
-
}
|
|
258
|
-
const seen = new Map();
|
|
259
|
-
for (const c of candidates) {
|
|
260
|
-
const real = realOrSelf(c);
|
|
261
|
-
if (real === home || seen.has(real)) continue;
|
|
262
|
-
// Keep the spelling the agents' history uses when the path is a symlink or differs in case.
|
|
263
|
-
seen.set(real, c);
|
|
264
|
-
}
|
|
265
|
-
const repos = [...seen.values()].sort().map((p) => inspectRepo(p, env));
|
|
266
|
-
// One workspace directory each: the repo's name, -2, -3... when two repos share a name.
|
|
267
|
-
const used = new Map();
|
|
268
|
-
const work = [];
|
|
269
|
-
const history = [];
|
|
270
|
-
for (const r of repos) {
|
|
271
|
-
const base = safeName(basename(r.path));
|
|
272
|
-
const n = (used.get(base) || 0) + 1;
|
|
273
|
-
used.set(base, n);
|
|
274
|
-
r.name = n === 1 ? base : `${base}-${n}`;
|
|
275
|
-
r.dest = `${BOX_WORKSPACE}/${r.name}`;
|
|
276
|
-
const real = realOrSelf(r.path);
|
|
277
|
-
const c = claude.get(r.path) || [...claude].find(([k]) => realOrSelf(k) === real)?.[1];
|
|
278
|
-
const x = codex.get(r.path) || [...codex].find(([k]) => realOrSelf(k) === real)?.[1];
|
|
279
|
-
const reasons = [];
|
|
280
|
-
if (r.dirty) reasons.push(`${r.dirty} changed`);
|
|
281
|
-
if (r.untracked) reasons.push(`${r.untracked} untracked`);
|
|
282
|
-
if (r.stashes) reasons.push(`${r.stashes} stashed`);
|
|
283
|
-
if (r.unpushed) reasons.push(`${r.unpushed} unpushed`);
|
|
284
|
-
if (!r.remotes) reasons.push('no remote');
|
|
285
|
-
r.id = `repo:${r.path}`;
|
|
286
|
-
r.checked = reasons.length > 0 && !r.worktree;
|
|
287
|
-
r.why = r.worktree
|
|
288
|
-
? `a git worktree: move its main checkout${r.mainCheckout ? ` (${r.mainCheckout})` : ''} instead`
|
|
289
|
-
: reasons.length
|
|
290
|
-
? `unsaved work: ${reasons.join(', ')}`
|
|
291
|
-
: 'clean and pushed: can be cloned again any time';
|
|
292
|
-
work.push(r);
|
|
293
|
-
if (c || x) {
|
|
294
|
-
history.push({
|
|
295
|
-
id: `history:${r.path}`,
|
|
296
|
-
repo: r.id,
|
|
297
|
-
claude: c || null,
|
|
298
|
-
codex: x || null,
|
|
299
|
-
bytes: (c?.bytes || 0) + (x?.bytes || 0),
|
|
300
|
-
checked: r.checked,
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
return { work, history };
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// --- credentials ---------------------------------------------------------
|
|
308
|
-
|
|
309
|
-
// `export NAME=value` / `NAME=value` lines of a shell file: names and line numbers only.
|
|
310
|
-
function shellAssignments(text) {
|
|
311
|
-
const out = [];
|
|
312
|
-
text.split('\n').forEach((line, i) => {
|
|
313
|
-
const m = line.match(/^\s*(?:export\s+|declare\s+-x\s+|typeset\s+-x\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
314
|
-
if (!m) return;
|
|
315
|
-
out.push({ name: m[1], line: i + 1, secretValue: SECRET_VALUE.test(m[2]), keychain: MACOS_ONLY.test(m[2]) });
|
|
316
|
-
});
|
|
317
|
-
return out;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// Section names of an INI-style file ([default], [profile x]) and which keys appear; never values.
|
|
321
|
-
function iniShape(text) {
|
|
322
|
-
const sections = [];
|
|
323
|
-
const keys = new Set();
|
|
324
|
-
for (const line of (text || '').split('\n')) {
|
|
325
|
-
const s = line.match(/^\s*\[([^\]]+)\]\s*$/);
|
|
326
|
-
if (s) sections.push(s[1].trim());
|
|
327
|
-
const k = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=/);
|
|
328
|
-
if (k) keys.add(k[1].toLowerCase());
|
|
329
|
-
}
|
|
330
|
-
return { sections, keys };
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function scanCredentials({ home, env, procEnv, work, platform }) {
|
|
334
|
-
const creds = [];
|
|
335
|
-
const byName = new Map();
|
|
336
|
-
const add = (c) => {
|
|
337
|
-
if (c.kind === 'env') {
|
|
338
|
-
if (byName.has(c.name)) return; // the first source of a name wins; the Vault holds one value per name
|
|
339
|
-
byName.set(c.name, c);
|
|
340
|
-
}
|
|
341
|
-
creds.push(c);
|
|
342
|
-
};
|
|
343
|
-
const envItem = (name, source, from, extra = {}) => ({
|
|
344
|
-
id: `cred:env:${name}`,
|
|
345
|
-
kind: 'env',
|
|
346
|
-
name,
|
|
347
|
-
source,
|
|
348
|
-
from,
|
|
349
|
-
checked: true,
|
|
350
|
-
...extra,
|
|
351
|
-
});
|
|
352
|
-
const fileItem = (path, source, from, extra = {}) => {
|
|
353
|
-
const bytes = lstatOrNull(source.path)?.size ?? 0;
|
|
354
|
-
const item = {
|
|
355
|
-
id: `cred:file:${path}`,
|
|
356
|
-
kind: 'file',
|
|
357
|
-
name: vaultFileName(path),
|
|
358
|
-
path,
|
|
359
|
-
source,
|
|
360
|
-
from,
|
|
361
|
-
bytes,
|
|
362
|
-
checked: true,
|
|
363
|
-
...extra,
|
|
364
|
-
};
|
|
365
|
-
if (bytes > VAULT_MAX_VALUE) {
|
|
366
|
-
item.kind = 'manual';
|
|
367
|
-
item.id = `cred:manual:${path}`;
|
|
368
|
-
item.checked = false;
|
|
369
|
-
item.reason = `${bytes} bytes: over the Vault's 64 KiB limit per item`;
|
|
370
|
-
item.manual = `copy it yourself: instabox ssh <box> -- 'mkdir -p "$(dirname ~/${path})" && cat > ~/${path}' < ${source.path}`;
|
|
371
|
-
}
|
|
372
|
-
return item;
|
|
373
|
-
};
|
|
374
|
-
const manual = (key, label, reason, commands, extra = {}) => ({
|
|
375
|
-
id: `cred:manual:${key}`,
|
|
376
|
-
kind: 'manual',
|
|
377
|
-
name: label,
|
|
378
|
-
checked: false,
|
|
379
|
-
reason,
|
|
380
|
-
manual: commands,
|
|
381
|
-
...extra,
|
|
382
|
-
});
|
|
383
|
-
// A credential whose name the Vault refuses for env items (INSTABOX_*, SSH_*, reserved names):
|
|
384
|
-
// it is removed from the staged copies, so the user sets it on the box by hand.
|
|
385
|
-
// One item per name, listing every place it was found (each copy is scrubbed).
|
|
386
|
-
const unvaultableFrom = new Map();
|
|
387
|
-
const unvaultable = (name, from) => {
|
|
388
|
-
const seen = unvaultableFrom.get(name);
|
|
389
|
-
if (seen) {
|
|
390
|
-
if (seen.includes(from)) return;
|
|
391
|
-
seen.push(from);
|
|
392
|
-
creds.find((c) => c.id === `cred:manual:env:${name}`).name = `${name} (${seen.join('; ')})`;
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
395
|
-
unvaultableFrom.set(name, [from]);
|
|
396
|
-
add(
|
|
397
|
-
manual(
|
|
398
|
-
`env:${name}`,
|
|
399
|
-
`${name} (${from})`,
|
|
400
|
-
'the Vault does not store env items with this name (INSTABOX_*, SSH_* and a few system names are refused), so it is removed from the staged copy',
|
|
401
|
-
`if the box needs it: \`instabox ssh <box>\`, then set ${name} there by hand in each place listed (for a shell file, add \`export ${name}=…\` to ~/.bashrc)`,
|
|
402
|
-
),
|
|
403
|
-
);
|
|
404
|
-
};
|
|
405
|
-
const tilde = (p) => (p.startsWith(home + '/') ? '~' + p.slice(home.length) : p);
|
|
406
|
-
|
|
407
|
-
// gh: the token gh keeps (keychain or hosts.yml) goes over as GH_TOKEN.
|
|
408
|
-
if (hasCommand('gh', env)) {
|
|
409
|
-
const st = run(['gh', 'auth', 'status', '--hostname', 'github.com'], { env });
|
|
410
|
-
if (st.ok) {
|
|
411
|
-
const who = (st.stdout + st.stderr).match(/account (\S+)/)?.[1] || null;
|
|
412
|
-
add(
|
|
413
|
-
envItem('GH_TOKEN', { type: 'cmd', argv: ['gh', 'auth', 'token', '--hostname', 'github.com'] }, '`gh auth token`', {
|
|
414
|
-
note: who ? `GitHub account ${who}; gh and git over HTTPS use it on the box` : 'gh and git over HTTPS use it on the box',
|
|
415
|
-
}),
|
|
416
|
-
);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// Shell startup files: exported credentials.
|
|
421
|
-
for (const f of SHELL_RC) {
|
|
422
|
-
const text = readText(join(home, f));
|
|
423
|
-
if (text === null) continue;
|
|
424
|
-
for (const a of shellAssignments(text)) {
|
|
425
|
-
if (!isSecretName(a.name) && !a.secretValue) continue;
|
|
426
|
-
if (!vaultEnvNameOK(a.name)) {
|
|
427
|
-
unvaultable(a.name, `~/${f}:${a.line}`);
|
|
428
|
-
continue;
|
|
429
|
-
}
|
|
430
|
-
const extra = a.keychain ? { note: 'set from the macOS keychain when the shell starts; its current value goes over' } : {};
|
|
431
|
-
if (a.name === 'AWS_SESSION_TOKEN') {
|
|
432
|
-
extra.checked = false;
|
|
433
|
-
extra.reason = 'temporary AWS session token: it expires';
|
|
434
|
-
}
|
|
435
|
-
add(envItem(a.name, { type: 'rc', file: join(home, f), name: a.name }, `~/${f}:${a.line}`, extra));
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
// The environment the scan runs in.
|
|
439
|
-
for (const name of Object.keys(procEnv).sort()) {
|
|
440
|
-
// INSTABOX_* and SSH_* here belong to this machine's session (the CLI key, the agent socket).
|
|
441
|
-
if (!isSecretName(name) || !vaultEnvNameOK(name) || byName.has(name)) continue;
|
|
442
|
-
const extra = {};
|
|
443
|
-
if (name === 'AWS_SESSION_TOKEN') {
|
|
444
|
-
extra.checked = false;
|
|
445
|
-
extra.reason = 'temporary AWS session token: it expires';
|
|
446
|
-
}
|
|
447
|
-
add(envItem(name, { type: 'env', name }, 'this shell’s environment', extra));
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Claude Code: settings.json env, the sign-in, MCP servers.
|
|
451
|
-
const settingsPath = join(home, '.claude', 'settings.json');
|
|
452
|
-
const settings = readJSON(settingsPath);
|
|
453
|
-
for (const [k, v] of Object.entries(settings?.env || {})) {
|
|
454
|
-
if (typeof v !== 'string' || !(isSecretName(k) || SECRET_VALUE.test(v))) continue;
|
|
455
|
-
if (vaultEnvNameOK(k)) add(envItem(k, { type: 'json', path: settingsPath, keys: ['env', k] }, '~/.claude/settings.json env'));
|
|
456
|
-
else unvaultable(k, '~/.claude/settings.json env');
|
|
457
|
-
}
|
|
458
|
-
const claudeJSONPath = join(home, '.claude.json');
|
|
459
|
-
const claudeJSON = readJSON(claudeJSONPath);
|
|
460
|
-
if (claudeJSON?.oauthAccount || lstatOrNull(join(home, '.claude', '.credentials.json'))) {
|
|
461
|
-
add(
|
|
462
|
-
manual(
|
|
463
|
-
'claude-code-login',
|
|
464
|
-
'Claude Code sign-in',
|
|
465
|
-
platform === 'darwin'
|
|
466
|
-
? 'kept in the macOS keychain, and its OAuth refresh token rotates: a copy would sign one side out'
|
|
467
|
-
: 'its OAuth refresh token rotates: a copy would sign one side out',
|
|
468
|
-
'on the box: `claude`, then /login. Or, for a token that lasts a year: run `claude setup-token` here, then `instabox vault set CLAUDE_CODE_OAUTH_TOKEN --selector migrated=<box>` and paste it at the prompt',
|
|
469
|
-
),
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
for (const [name, s] of Object.entries(claudeJSON?.mcpServers || {})) {
|
|
473
|
-
const http = s.type === 'http' || s.type === 'sse' || (!s.command && s.url);
|
|
474
|
-
if (http) {
|
|
475
|
-
const headers = Object.keys(s.headers || {});
|
|
476
|
-
add(
|
|
477
|
-
manual(
|
|
478
|
-
`mcp:${name}`,
|
|
479
|
-
`MCP server ${name} (${s.url || 'remote'})`,
|
|
480
|
-
headers.length
|
|
481
|
-
? `sends headers (${headers.join(', ')}) that may hold a secret`
|
|
482
|
-
: 'a remote server: its OAuth sign-in stays with this machine',
|
|
483
|
-
headers.length
|
|
484
|
-
? `on the box: \`claude mcp add --scope user --transport ${s.type || 'http'} ${name} ${s.url} --header "<header>: <value>"\``
|
|
485
|
-
: `on the box, after the move: \`claude\`, then /mcp and sign in to ${name}`,
|
|
486
|
-
),
|
|
487
|
-
);
|
|
488
|
-
}
|
|
489
|
-
for (const [k, v] of Object.entries(s.env || {})) {
|
|
490
|
-
if (typeof v !== 'string' || !(isSecretName(k) || SECRET_VALUE.test(v))) continue;
|
|
491
|
-
if (!vaultEnvNameOK(k)) unvaultable(k, `MCP server ${name} env`);
|
|
492
|
-
else
|
|
493
|
-
add(
|
|
494
|
-
envItem(k, { type: 'json', path: claudeJSONPath, keys: ['mcpServers', name, 'env', k] }, `MCP server ${name} env`, {
|
|
495
|
-
note: `the ${name} MCP server reads it from the environment on the box`,
|
|
496
|
-
}),
|
|
497
|
-
);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// Codex: an API key in auth.json goes over; a ChatGPT sign-in cannot.
|
|
502
|
-
const codexAuthPath = join(home, '.codex', 'auth.json');
|
|
503
|
-
const codexAuth = readJSON(codexAuthPath);
|
|
504
|
-
if (codexAuth) {
|
|
505
|
-
if (typeof codexAuth.OPENAI_API_KEY === 'string' && codexAuth.OPENAI_API_KEY)
|
|
506
|
-
add(envItem('OPENAI_API_KEY', { type: 'json', path: codexAuthPath, keys: ['OPENAI_API_KEY'] }, '~/.codex/auth.json'));
|
|
507
|
-
if (codexAuth.tokens)
|
|
508
|
-
add(
|
|
509
|
-
manual(
|
|
510
|
-
'codex-chatgpt',
|
|
511
|
-
'Codex ChatGPT sign-in (~/.codex/auth.json)',
|
|
512
|
-
'its refresh token rotates: a copy on the box would sign this machine out, or the other way round',
|
|
513
|
-
'on the box: `codex login --device-auth`',
|
|
514
|
-
),
|
|
515
|
-
);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// AWS.
|
|
519
|
-
const awsCreds = join(home, '.aws', 'credentials');
|
|
520
|
-
if (lstatOrNull(awsCreds)) {
|
|
521
|
-
const shape = iniShape(readText(awsCreds));
|
|
522
|
-
const temp = shape.keys.has('aws_session_token');
|
|
523
|
-
add(
|
|
524
|
-
fileItem('.aws/credentials', { type: 'file', path: awsCreds }, `~/.aws/credentials (profiles: ${shape.sections.join(', ') || 'none'})`, {
|
|
525
|
-
checked: !temp,
|
|
526
|
-
...(temp ? { reason: 'holds a temporary session token: it expires' } : {}),
|
|
527
|
-
}),
|
|
528
|
-
);
|
|
529
|
-
}
|
|
530
|
-
const awsConfig = readText(join(home, '.aws', 'config'));
|
|
531
|
-
if (awsConfig) {
|
|
532
|
-
const shape = iniShape(awsConfig);
|
|
533
|
-
if (shape.keys.has('sso_start_url') || shape.keys.has('sso_session'))
|
|
534
|
-
add(
|
|
535
|
-
manual(
|
|
536
|
-
'aws-sso',
|
|
537
|
-
'AWS SSO sign-in',
|
|
538
|
-
'SSO sessions are short-lived and tied to this machine’s browser sign-in',
|
|
539
|
-
'on the box (~/.aws/config goes over with Environment): `aws sso login --use-device-code`',
|
|
540
|
-
),
|
|
541
|
-
);
|
|
542
|
-
if (shape.keys.has('credential_process'))
|
|
543
|
-
add(
|
|
544
|
-
manual(
|
|
545
|
-
'aws-credential-process',
|
|
546
|
-
'AWS credential_process',
|
|
547
|
-
'runs a local program for credentials (often a macOS keychain helper)',
|
|
548
|
-
'install the same helper on the box, or use static keys or SSO there',
|
|
549
|
-
),
|
|
550
|
-
);
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// Google Cloud.
|
|
554
|
-
const adcPath = join(home, '.config', 'gcloud', 'application_default_credentials.json');
|
|
555
|
-
const adc = readJSON(adcPath);
|
|
556
|
-
if (adc?.type === 'service_account')
|
|
557
|
-
add(
|
|
558
|
-
fileItem(
|
|
559
|
-
'.config/gcloud/application_default_credentials.json',
|
|
560
|
-
{ type: 'file', path: adcPath },
|
|
561
|
-
'~/.config/gcloud/application_default_credentials.json (service account)',
|
|
562
|
-
),
|
|
563
|
-
);
|
|
564
|
-
else if (adc)
|
|
565
|
-
add(
|
|
566
|
-
manual(
|
|
567
|
-
'gcloud-adc',
|
|
568
|
-
'gcloud application-default credentials (user)',
|
|
569
|
-
'a user sign-in’s refresh token, tied to your Google login on this machine',
|
|
570
|
-
'on the box: `gcloud auth application-default login --no-launch-browser`',
|
|
571
|
-
),
|
|
572
|
-
);
|
|
573
|
-
if (lstatOrNull(join(home, '.config', 'gcloud', 'credentials.db')))
|
|
574
|
-
add(
|
|
575
|
-
manual(
|
|
576
|
-
'gcloud-login',
|
|
577
|
-
'gcloud CLI sign-in',
|
|
578
|
-
'user OAuth credentials tied to this machine',
|
|
579
|
-
'on the box: `gcloud auth login --no-launch-browser`',
|
|
580
|
-
),
|
|
581
|
-
);
|
|
582
|
-
|
|
583
|
-
// Kubernetes.
|
|
584
|
-
const kube = join(home, '.kube', 'config');
|
|
585
|
-
if (lstatOrNull(kube)) {
|
|
586
|
-
const text = readText(kube) || '';
|
|
587
|
-
const plugins = [...new Set([...text.matchAll(/^\s*command:\s*(\S+)/gm)].map((m) => basename(m[1])))];
|
|
588
|
-
add(
|
|
589
|
-
fileItem('.kube/config', { type: 'file', path: kube }, '~/.kube/config', {
|
|
590
|
-
...(plugins.length
|
|
591
|
-
? { note: `uses exec plugins (${plugins.join(', ')}): install them on the box and sign in to their cloud` }
|
|
592
|
-
: {}),
|
|
593
|
-
}),
|
|
594
|
-
);
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// Docker: inline auths can go; a credential store is the macOS keychain.
|
|
598
|
-
const dockerPath = join(home, '.docker', 'config.json');
|
|
599
|
-
const docker = readJSON(dockerPath);
|
|
600
|
-
if (docker) {
|
|
601
|
-
const inline = Object.values(docker.auths || {}).some((a) => a && (a.auth || a.identitytoken));
|
|
602
|
-
if (inline) add(fileItem('.docker/config.json', { type: 'file', path: dockerPath }, '~/.docker/config.json'));
|
|
603
|
-
if (docker.credsStore || docker.credHelpers)
|
|
604
|
-
add(
|
|
605
|
-
manual(
|
|
606
|
-
'docker-login',
|
|
607
|
-
`Docker registry logins (${docker.credsStore ? `credsStore ${docker.credsStore}` : 'credHelpers'})`,
|
|
608
|
-
'kept by a credential helper (the macOS keychain or Docker Desktop)',
|
|
609
|
-
`on the box: \`docker login <registry>\` for each of ${Object.keys(docker.auths || {}).join(', ') || 'your registries'}`,
|
|
610
|
-
),
|
|
611
|
-
);
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// Files whose whole content is a credential.
|
|
615
|
-
const tokenFiles = [
|
|
616
|
-
['.npmrc', /_authToken|_auth\s*=|_password/],
|
|
617
|
-
['.pypirc', /password/i],
|
|
618
|
-
['.netrc', /password/],
|
|
619
|
-
['.git-credentials', /:\/\//],
|
|
620
|
-
['.cargo/credentials.toml', /token/],
|
|
621
|
-
['.cargo/credentials', /token/],
|
|
622
|
-
['.config/gh/hosts.yml', /oauth_token/],
|
|
623
|
-
];
|
|
624
|
-
for (const [rel, re] of tokenFiles) {
|
|
625
|
-
const p = join(home, rel);
|
|
626
|
-
const text = readText(p);
|
|
627
|
-
if (text === null || !re.test(text)) continue;
|
|
628
|
-
if (rel === '.config/gh/hosts.yml' && byName.has('GH_TOKEN')) continue; // GH_TOKEN covers it
|
|
629
|
-
add(fileItem(rel, { type: 'file', path: p }, `~/${rel}`));
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// SSH private keys: possible, off by default.
|
|
633
|
-
const sshDir = join(home, '.ssh');
|
|
634
|
-
let sshNames = [];
|
|
635
|
-
try {
|
|
636
|
-
sshNames = readdirSync(sshDir).sort();
|
|
637
|
-
} catch {}
|
|
638
|
-
for (const n of sshNames) {
|
|
639
|
-
const p = join(sshDir, n);
|
|
640
|
-
if (!lstatOrNull(p)?.isFile() || n.endsWith('.pub') || ['config', 'known_hosts', 'authorized_keys'].includes(n))
|
|
641
|
-
continue;
|
|
642
|
-
if (!/PRIVATE KEY-----/.test(readHead(p, 64))) continue;
|
|
643
|
-
add(
|
|
644
|
-
fileItem(`.ssh/${n}`, { type: 'file', path: p }, `~/.ssh/${n}`, {
|
|
645
|
-
checked: false,
|
|
646
|
-
reason:
|
|
647
|
-
'a private key on a cloud box: prefer GH_TOKEN for git over HTTPS, or make a new key on the box and add it where needed',
|
|
648
|
-
}),
|
|
649
|
-
);
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
// Repos' .env files: to the Vault as files, linked into the repo on the box.
|
|
653
|
-
for (const r of work) {
|
|
654
|
-
for (const rel of r.envFiles) {
|
|
655
|
-
const p = join(r.path, rel);
|
|
656
|
-
const path = `${STAGE_DIR}/env/${r.name}/${rel}`;
|
|
657
|
-
if (!vaultPathOK(path)) continue;
|
|
658
|
-
add(
|
|
659
|
-
fileItem(path, { type: 'file', path: p }, tilde(p), {
|
|
660
|
-
id: `cred:file:${path}`,
|
|
661
|
-
repo: r.id,
|
|
662
|
-
link: `${r.dest}/${rel}`,
|
|
663
|
-
checked: r.checked,
|
|
664
|
-
note: `linked from ${r.dest}/${rel}`,
|
|
665
|
-
}),
|
|
666
|
-
);
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
return creds;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// --- environment ---------------------------------------------------------
|
|
673
|
-
|
|
674
|
-
const skipJunk = (n) => n === '.git' || n === 'node_modules' || n === '.DS_Store' || n === '__pycache__';
|
|
675
|
-
|
|
676
|
-
function scanEnvironment({ home, env, creds }) {
|
|
677
|
-
const out = [];
|
|
678
|
-
const credPaths = new Set(creds.filter((c) => c.kind === 'file').map((c) => c.path));
|
|
679
|
-
for (const rel of DOTFILES) {
|
|
680
|
-
if (credPaths.has(rel)) continue; // goes to the Vault instead
|
|
681
|
-
const p = join(home, rel);
|
|
682
|
-
const st = lstatOrNull(p);
|
|
683
|
-
if (!st) continue;
|
|
684
|
-
const dir = st.isDirectory();
|
|
685
|
-
const bytes = dir ? treeBytes(p, skipJunk) : st.size;
|
|
686
|
-
const item = { id: `env:file:${rel}`, kind: 'dotfile', path: rel, dir, bytes, checked: true };
|
|
687
|
-
if (SHELL_RC.includes(rel)) item.note = 'credentials and macOS-only lines left out';
|
|
688
|
-
if (dir && bytes > DOT_DIR_MAX) {
|
|
689
|
-
item.checked = false;
|
|
690
|
-
item.note = `${Math.round(bytes / 1048576)} MB: probably caches or plugins; reinstall them on the box`;
|
|
691
|
-
}
|
|
692
|
-
out.push(item);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
const claudeDir = join(home, '.claude');
|
|
696
|
-
if (lstatOrNull(join(claudeDir, 'settings.json')))
|
|
697
|
-
out.push({ id: 'env:claude:settings', kind: 'claude', path: '.claude/settings.json', bytes: lstatOrNull(join(claudeDir, 'settings.json')).size, checked: true, note: 'merged into the box’s settings; secrets left out' });
|
|
698
|
-
if (lstatOrNull(join(claudeDir, 'CLAUDE.md')))
|
|
699
|
-
out.push({ id: 'env:claude:CLAUDE.md', kind: 'claude', path: '.claude/CLAUDE.md', bytes: lstatOrNull(join(claudeDir, 'CLAUDE.md')).size, checked: true });
|
|
700
|
-
for (const sub of ['skills', 'commands', 'agents']) {
|
|
701
|
-
const p = join(claudeDir, sub);
|
|
702
|
-
if (!lstatOrNull(p)?.isDirectory()) continue;
|
|
703
|
-
let names = readdirSync(p).filter((n) => !n.startsWith('.')).sort();
|
|
704
|
-
// Skills the instabox package installed are on the box already.
|
|
705
|
-
if (sub === 'skills') names = names.filter((n) => !(readText(join(p, n, 'SKILL.md')) || '').includes('installed-by: instabox'));
|
|
706
|
-
if (!names.length) continue;
|
|
707
|
-
out.push({ id: `env:claude:${sub}`, kind: 'claude', path: `.claude/${sub}`, names, bytes: names.reduce((s, n) => s + treeBytes(join(p, n), skipJunk), 0), checked: true });
|
|
708
|
-
}
|
|
709
|
-
const claudeJSON = readJSON(join(home, '.claude.json'));
|
|
710
|
-
const stdio = Object.entries(claudeJSON?.mcpServers || {}).filter(([, s]) => s.command);
|
|
711
|
-
if (stdio.length)
|
|
712
|
-
out.push({ id: 'env:claude:mcp', kind: 'claude', path: '.claude.json', names: stdio.map(([n]) => n).sort(), bytes: 0, checked: true, note: 'local (stdio) MCP servers, added with `claude mcp add-json` on the box; secrets come from the Vault' });
|
|
713
|
-
|
|
714
|
-
const codexDir = join(home, '.codex');
|
|
715
|
-
if (lstatOrNull(join(codexDir, 'config.toml')))
|
|
716
|
-
out.push({ id: 'env:codex:config', kind: 'codex', path: '.codex/config.toml', bytes: lstatOrNull(join(codexDir, 'config.toml')).size, checked: true, note: 'secret-looking lines left out' });
|
|
717
|
-
if (lstatOrNull(join(codexDir, 'AGENTS.md')))
|
|
718
|
-
out.push({ id: 'env:codex:AGENTS.md', kind: 'codex', path: '.codex/AGENTS.md', bytes: lstatOrNull(join(codexDir, 'AGENTS.md')).size, checked: true });
|
|
719
|
-
const agentsSkills = join(home, '.agents', 'skills');
|
|
720
|
-
if (lstatOrNull(agentsSkills)?.isDirectory()) {
|
|
721
|
-
const names = readdirSync(agentsSkills).filter((n) => !n.startsWith('.') && !(readText(join(agentsSkills, n, 'SKILL.md')) || '').includes('installed-by: instabox')).sort();
|
|
722
|
-
if (names.length)
|
|
723
|
-
out.push({ id: 'env:agents:skills', kind: 'agents', path: '.agents/skills', names, bytes: names.reduce((s, n) => s + treeBytes(join(agentsSkills, n), skipJunk), 0), checked: true });
|
|
724
|
-
}
|
|
725
|
-
return out;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
// --- tools ---------------------------------------------------------------
|
|
729
|
-
|
|
730
|
-
export function scanTools({ home, env }) {
|
|
731
|
-
const tools = [];
|
|
732
|
-
if (hasCommand('brew', env)) {
|
|
733
|
-
const r = run(['brew', 'leaves'], { env, timeout: 60_000 });
|
|
734
|
-
const names = lines(r.stdout).map((l) => l.trim()).sort();
|
|
735
|
-
if (names.length) tools.push({ id: 'tool:brew', kind: 'tools', manager: 'brew', names, checked: true });
|
|
736
|
-
}
|
|
737
|
-
if (hasCommand('npm', env)) {
|
|
738
|
-
const r = run(['npm', 'ls', '-g', '--depth=0', '--json'], { env, timeout: 60_000 });
|
|
739
|
-
let deps = {};
|
|
740
|
-
try {
|
|
741
|
-
deps = JSON.parse(r.stdout).dependencies || {};
|
|
742
|
-
} catch {}
|
|
743
|
-
const names = Object.keys(deps).sort();
|
|
744
|
-
if (names.length) tools.push({ id: 'tool:npm', kind: 'tools', manager: 'npm', names, checked: true });
|
|
745
|
-
}
|
|
746
|
-
if (hasCommand('uv', env)) {
|
|
747
|
-
const r = run(['uv', 'tool', 'list'], { env });
|
|
748
|
-
const names = lines(r.stdout).filter((l) => !/^\s|^-/.test(l)).map((l) => l.split(/\s+/)[0]).sort();
|
|
749
|
-
if (names.length) tools.push({ id: 'tool:uv', kind: 'tools', manager: 'uv', names, checked: true });
|
|
750
|
-
}
|
|
751
|
-
if (hasCommand('cargo', env)) {
|
|
752
|
-
const r = run(['cargo', 'install', '--list'], { env });
|
|
753
|
-
const names = lines(r.stdout).filter((l) => !/^\s/.test(l)).map((l) => l.split(/\s+/)[0]).sort();
|
|
754
|
-
if (names.length) tools.push({ id: 'tool:cargo', kind: 'tools', manager: 'cargo', names, checked: true, note: 'built from source on the box: slow' });
|
|
755
|
-
}
|
|
756
|
-
const gobin = env.GOBIN || join(env.GOPATH || join(home, 'go'), 'bin');
|
|
757
|
-
let gonames = [];
|
|
758
|
-
try {
|
|
759
|
-
gonames = readdirSync(gobin).filter((n) => lstatOrNull(join(gobin, n))?.isFile()).sort();
|
|
760
|
-
} catch {}
|
|
761
|
-
if (gonames.length) {
|
|
762
|
-
const modules = [];
|
|
763
|
-
const unknown = [];
|
|
764
|
-
for (const n of gonames) {
|
|
765
|
-
const m = hasCommand('go', env) ? run(['go', 'version', '-m', join(gobin, n)], { env }).stdout.match(/^\s*path\s+(\S+)/m) : null;
|
|
766
|
-
if (m) modules.push(m[1]);
|
|
767
|
-
else unknown.push(n);
|
|
768
|
-
}
|
|
769
|
-
tools.push({ id: 'tool:go', kind: 'tools', manager: 'go', names: modules.sort(), unknown, checked: true });
|
|
770
|
-
}
|
|
771
|
-
return tools;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
// --- main ----------------------------------------------------------------
|
|
775
|
-
|
|
776
|
-
export function scan(opts = {}) {
|
|
777
|
-
const env = opts.env || process.env;
|
|
778
|
-
const home = resolve(opts.home || env.HOME);
|
|
779
|
-
const cwd = opts.cwd ? resolve(opts.cwd) : null;
|
|
780
|
-
const roots = [
|
|
781
|
-
...(opts.defaultRoots === false ? [] : DEFAULT_ROOTS.map((r) => join(home, r))),
|
|
782
|
-
...(opts.roots || []).map((r) => resolve(r)),
|
|
783
|
-
];
|
|
784
|
-
const platform = opts.platform || process.platform;
|
|
785
|
-
const { work, history } = scanWork({ home, cwd, roots, depth: opts.depth ?? 3, env });
|
|
786
|
-
const credentials = scanCredentials({ home, env, procEnv: opts.procEnv || env, work, platform });
|
|
787
|
-
const environment = [...scanEnvironment({ home, env, creds: credentials }), ...(opts.tools === false ? [] : scanTools({ home, env }))];
|
|
788
|
-
return { version: 1, home, platform, roots: [...new Set(roots.filter((r) => lstatOrNull(r)?.isDirectory()).map(realOrSelf))], work, history, credentials, environment };
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
function main() {
|
|
792
|
-
const args = process.argv.slice(2);
|
|
793
|
-
const opts = { roots: [], cwd: process.cwd() };
|
|
794
|
-
for (let i = 0; i < args.length; i++) {
|
|
795
|
-
const a = args[i];
|
|
796
|
-
if (a === '--root') opts.roots.push(args[++i]);
|
|
797
|
-
else if (a === '--no-default-roots') opts.defaultRoots = false;
|
|
798
|
-
else if (a === '--depth') opts.depth = Number(args[++i]);
|
|
799
|
-
else if (a === '--no-tools') opts.tools = false;
|
|
800
|
-
else if (a === '--no-cwd') opts.cwd = null;
|
|
801
|
-
else {
|
|
802
|
-
process.stderr.write(
|
|
803
|
-
'usage: scan.mjs [--root DIR]... [--no-default-roots] [--depth N] [--no-tools] [--no-cwd]\n',
|
|
804
|
-
);
|
|
805
|
-
process.exit(2);
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
process.stdout.write(JSON.stringify(scan(opts), null, 2) + '\n');
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
if (isMain(import.meta)) main();
|