xgem-cli 2.0.0-alpha.12 → 2.0.0-alpha.14

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.
@@ -0,0 +1,189 @@
1
+ // xgem Windows engine bootstrap — port of lib/bootstrap.sh. `swift` is
2
+ // omitted from detection, matching lib-win/scaffold.js's decision that
3
+ // there's no meaningful Windows Swift toolchain story.
4
+
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const { spawnSync } = require('node:child_process');
8
+ const { logInfo, logSuccess, logWarn, logDebug, die } = require('./logger');
9
+ const { hasCmd, confirm, prompt } = require('./utils');
10
+ const scaffold = require('./scaffold');
11
+
12
+ function run(cmd, args, opts = {}) {
13
+ return spawnSync(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32', ...opts });
14
+ }
15
+
16
+ function detectFramework() {
17
+ if (fs.existsSync('pubspec.yaml')) return 'flutter';
18
+ if (fs.existsSync('go.mod')) return 'go';
19
+ if (fs.existsSync('Cargo.toml')) return 'rust';
20
+ if (fs.existsSync('pyproject.toml') || fs.existsSync('requirements.txt') || fs.existsSync('setup.py')) return 'python';
21
+ if (fs.existsSync('package.json')) {
22
+ const content = fs.readFileSync('package.json', 'utf8');
23
+ if (content.includes('"next"')) return 'next';
24
+ if (content.includes('"@angular/core"')) return 'angular';
25
+ if (content.includes('"vue"')) return 'vue';
26
+ if (content.includes('"react"')) return 'react';
27
+ return 'node';
28
+ }
29
+ if (fs.existsSync('Dockerfile')) return 'docker';
30
+ return '';
31
+ }
32
+
33
+ async function selectFrameworkManually() {
34
+ scaffold.ALL_FRAMEWORKS.forEach((fw, i) => console.log(`${i + 1}) ${fw}`));
35
+ const choice = await prompt('Enter target selection number');
36
+ const idx = parseInt(choice, 10) - 1;
37
+ if (Number.isNaN(idx) || idx < 0 || idx >= scaffold.ALL_FRAMEWORKS.length) die('Invalid selection.');
38
+ return scaffold.ALL_FRAMEWORKS[idx];
39
+ }
40
+
41
+ async function bootstrapEnvFile() {
42
+ if (fs.existsSync('.env')) { logDebug('.env already exists, leaving it as-is.'); return; }
43
+ for (const example of ['.env.example', '.env.sample']) {
44
+ if (!fs.existsSync(example)) continue;
45
+ fs.copyFileSync(example, '.env');
46
+ logSuccess(`Created .env from ${example}.`);
47
+ const missing = fs.readFileSync('.env', 'utf8')
48
+ .split(/\r?\n/)
49
+ .filter((line) => /^[A-Za-z_][A-Za-z0-9_]*=\s*$/.test(line))
50
+ .map((line) => line.split('=')[0]);
51
+ if (missing.length) {
52
+ logWarn('These .env keys are empty — fill them in:');
53
+ missing.forEach((k) => console.log(` - ${k}`));
54
+ }
55
+ return;
56
+ }
57
+ }
58
+
59
+ function nodePm() {
60
+ if (fs.existsSync('pnpm-lock.yaml')) return 'pnpm';
61
+ if (fs.existsSync('yarn.lock')) return 'yarn';
62
+ if (fs.existsSync('bun.lockb') || fs.existsSync('bun.lock')) return 'bun';
63
+ return 'npm';
64
+ }
65
+
66
+ function installWith(pm, extraArgs = []) {
67
+ switch (pm) {
68
+ case 'yarn': return run('yarn', extraArgs);
69
+ case 'pnpm': return run('pnpm', ['install', ...extraArgs]);
70
+ case 'bun': return run('bun', ['install', ...extraArgs]);
71
+ default: return run('npm', ['install', ...extraArgs]);
72
+ }
73
+ }
74
+
75
+ async function bootstrapInstall(fw) {
76
+ switch (fw) {
77
+ case 'node':
78
+ case 'react':
79
+ case 'vue':
80
+ case 'angular':
81
+ case 'next': {
82
+ const pm = nodePm();
83
+ logInfo(`Installing dependencies with ${pm}...`);
84
+ installWith(pm);
85
+ break;
86
+ }
87
+ case 'flutter':
88
+ if (!hasCmd('flutter')) { logWarn("flutter not found — skipping 'flutter pub get'."); break; }
89
+ logInfo('Running flutter pub get...');
90
+ run('flutter', ['pub', 'get']);
91
+ break;
92
+ case 'python':
93
+ if (fs.existsSync('pyproject.toml') && hasCmd('poetry')) {
94
+ logInfo('Installing dependencies with poetry...');
95
+ run('poetry', ['install']);
96
+ } else if (fs.existsSync('requirements.txt')) {
97
+ if (!hasCmd('python') && !hasCmd('python3')) { logWarn('python not found — skipping install.'); break; }
98
+ const py = hasCmd('python') ? 'python' : 'python3';
99
+ if (!fs.existsSync('.venv')) run(py, ['-m', 'venv', '.venv']);
100
+ const pipPath = process.platform === 'win32' ? path.join('.venv', 'Scripts', 'pip.exe') : path.join('.venv', 'bin', 'pip');
101
+ logInfo('Installing dependencies into .venv...');
102
+ run(pipPath, ['install', '-r', 'requirements.txt']);
103
+ }
104
+ break;
105
+ case 'go':
106
+ if (!hasCmd('go')) { logWarn("go not found — skipping 'go mod download'."); break; }
107
+ logInfo('Running go mod download...');
108
+ run('go', ['mod', 'download']);
109
+ break;
110
+ case 'rust':
111
+ if (!hasCmd('cargo')) { logWarn("cargo not found — skipping 'cargo fetch'."); break; }
112
+ logInfo('Running cargo fetch...');
113
+ run('cargo', ['fetch']);
114
+ break;
115
+ case 'docker':
116
+ logDebug('Docker project — nothing to install locally.');
117
+ break;
118
+ default:
119
+ break;
120
+ }
121
+ }
122
+
123
+ function packageJsonHasScript(name) {
124
+ try {
125
+ const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
126
+ return Boolean(pkg.scripts && pkg.scripts[name]);
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ async function bootstrapMigrations(fw) {
133
+ if (['node', 'react', 'vue', 'angular', 'next'].includes(fw) && packageJsonHasScript('migrate')) {
134
+ const pm = nodePm();
135
+ if (await confirm(`Run the project's 'migrate' script (via ${pm})?`)) {
136
+ if (pm === 'yarn') run('yarn', ['migrate']);
137
+ else if (pm === 'pnpm') run('pnpm', ['run', 'migrate']);
138
+ else if (pm === 'bun') run('bun', ['run', 'migrate']);
139
+ else run('npm', ['run', 'migrate']);
140
+ }
141
+ return;
142
+ }
143
+ if (fs.existsSync(path.join('prisma', 'schema.prisma'))) {
144
+ if (await confirm("Run 'npx prisma migrate dev'?")) run('npx', ['prisma', 'migrate', 'dev']);
145
+ } else if (fs.existsSync('manage.py')) {
146
+ const py = hasCmd('python') ? 'python' : (hasCmd('python3') ? 'python3' : '');
147
+ if (py && (await confirm(`Run '${py} manage.py migrate'?`))) run(py, ['manage.py', 'migrate']);
148
+ }
149
+ }
150
+
151
+ async function offerDevServer(fw, configDir) {
152
+ let script = '';
153
+ if (fw === 'node') script = 'start';
154
+ else if (['react', 'vue', 'angular', 'next'].includes(fw)) script = 'dev';
155
+ else return;
156
+
157
+ const target = path.join(configDir, fw, `${script}.mjs`);
158
+ if (!fs.existsSync(target)) return;
159
+ if (await confirm(`Launch the dev server now ('xgem run ${fw} ${script}')?`)) {
160
+ scaffold.runScript(fw, script, configDir);
161
+ }
162
+ }
163
+
164
+ async function cmdBootstrap(configDir) {
165
+ logInfo('Detecting project framework...');
166
+ let fw = detectFramework();
167
+ if (!fw) {
168
+ logWarn('Could not auto-detect the framework from any known marker file.');
169
+ console.log('Pick one:');
170
+ fw = await selectFrameworkManually();
171
+ } else {
172
+ logSuccess(`Detected: ${fw}`);
173
+ }
174
+
175
+ await bootstrapEnvFile();
176
+ await bootstrapInstall(fw);
177
+ await bootstrapMigrations(fw);
178
+
179
+ if (!fs.existsSync(configDir)) {
180
+ if (await confirm(`Scaffold xgem automation scripts (${configDir}) for ${fw} too?`)) {
181
+ await scaffold.bookkeeping(fw, configDir);
182
+ }
183
+ }
184
+
185
+ await offerDevServer(fw, configDir);
186
+ logSuccess('Bootstrap complete.');
187
+ }
188
+
189
+ module.exports = { cmdBootstrap };
package/lib-win/ci.js ADDED
@@ -0,0 +1,43 @@
1
+ // xgem Windows engine ci — port of lib/ci.sh: run lint -> test -> build for
2
+ // every configured framework before you push.
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+ const { logInfo, logSuccess, logWarn, logError, die } = require('./logger');
8
+
9
+ const STEPS = ['lint', 'test', 'build'];
10
+
11
+ async function cmdCi(configDir) {
12
+ if (!fs.existsSync(configDir)) die(`No ${configDir} found — run 'xgem init' or 'xgem add' first.`);
13
+
14
+ const results = [];
15
+ let overallOk = true;
16
+
17
+ for (const fw of fs.readdirSync(configDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)) {
18
+ for (const step of STEPS) {
19
+ const target = path.join(configDir, fw, `${step}.mjs`);
20
+ if (!fs.existsSync(target)) continue;
21
+ logInfo(`Running ${fw} ${step}...`);
22
+ const result = spawnSync(process.execPath, [target], { stdio: 'inherit' });
23
+ const ok = (result.status ?? 1) === 0;
24
+ results.push(`${ok ? 'PASS ' : 'FAIL '} ${fw} ${step}`);
25
+ if (!ok) overallOk = false;
26
+ }
27
+ }
28
+
29
+ if (results.length === 0) {
30
+ logWarn(`No lint/test/build scripts found under ${configDir} to run.`);
31
+ return;
32
+ }
33
+
34
+ console.log('');
35
+ console.log('=== xgem ci summary ===');
36
+ results.forEach((r) => console.log(r));
37
+
38
+ if (overallOk) logSuccess('All checks passed.');
39
+ else logError('Some checks failed.');
40
+ process.exitCode = overallOk ? 0 : 1;
41
+ }
42
+
43
+ module.exports = { cmdCi };
package/lib-win/git.js CHANGED
@@ -3,10 +3,11 @@
3
3
  // commit-exit-code check and origin-preference fix already in lib/git.sh
4
4
  // carry over here too.
5
5
 
6
+ const fs = require('node:fs');
6
7
  const path = require('node:path');
7
8
  const { spawnSync } = require('node:child_process');
8
9
  const { logInfo, logSuccess, logWarn, logError, die } = require('./logger');
9
- const { prompt, hasCmd } = require('./utils');
10
+ const { prompt, confirm, hasCmd, openUrl } = require('./utils');
10
11
 
11
12
  function git(args, opts = {}) {
12
13
  return spawnSync('git', args, { stdio: 'inherit', ...opts });
@@ -25,10 +26,37 @@ function gitCapture(args) {
25
26
  return (result.stdout || '').trim();
26
27
  }
27
28
 
29
+ function ghCapture(args) {
30
+ const result = spawnSync('gh', args, { encoding: 'utf8' });
31
+ return result.status === 0 ? (result.stdout || '').trim() : '';
32
+ }
33
+
28
34
  function remoteExists(name) {
29
35
  return gitCapture(['remote']).split(/\r?\n/).includes(name);
30
36
  }
31
37
 
38
+ // defaultRemote() -> "origin" if configured, else the first remote, else ''.
39
+ function defaultRemote() {
40
+ if (remoteExists('origin')) return 'origin';
41
+ return gitCapture(['remote']).split(/\r?\n/)[0] || '';
42
+ }
43
+
44
+ // baseBranch(remote) -> the repo's default branch, via gh's own knowledge
45
+ // where possible, else probing common names against the remote's
46
+ // tracking refs.
47
+ function baseBranch(remote) {
48
+ if (hasCmd('gh')) {
49
+ const base = ghCapture(['repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name']);
50
+ if (base) return base;
51
+ }
52
+ for (const candidate of ['main', 'master', 'develop', 'dev']) {
53
+ if (git(['show-ref', '--verify', '--quiet', `refs/remotes/${remote}/${candidate}`], { stdio: 'ignore' }).status === 0) {
54
+ return candidate;
55
+ }
56
+ }
57
+ return '';
58
+ }
59
+
32
60
  async function cmdCmt(commitMsg) {
33
61
  if (!commitMsg) die('Missing commit message!');
34
62
 
@@ -62,7 +90,7 @@ async function cmdCmt(commitMsg) {
62
90
  }
63
91
 
64
92
  const currentBranch = gitCapture(['branch', '--show-current']);
65
- const remoteName = remoteExists('origin') ? 'origin' : gitCapture(['remote']).split(/\r?\n/)[0];
93
+ const remoteName = defaultRemote();
66
94
 
67
95
  if (!remoteName) {
68
96
  logWarn('No remote configured — sync was skipped.');
@@ -265,15 +293,203 @@ async function cmdBranch() {
265
293
  logSuccess(`Switched to '${selected}' and set as default for this repo.`);
266
294
  }
267
295
 
268
- async function cmdGit(sub, arg) {
296
+ async function cmdPr(configDir) {
297
+ const currentBranch = gitCapture(['branch', '--show-current']);
298
+ if (!currentBranch) die('Not on a branch (detached HEAD?) — nothing to open a PR from.');
299
+ const remote = defaultRemote();
300
+ if (!remote) die('No remote configured.');
301
+
302
+ if (!hasCmd('gh') || !ghAuthenticated()) {
303
+ if (hasCmd('gh')) logWarn("GitHub CLI (gh) is installed but not authenticated — run 'gh auth login' to enable 'xgem git pr'.");
304
+ else logWarn('GitHub CLI (gh) is not installed — install it for \'xgem git pr\' to open PRs directly: https://cli.github.com');
305
+ const remoteUrl = gitCapture(['remote', 'get-url', remote]);
306
+ const ownerRepo = remoteUrl.replace(/^git@[^:]+:/, '').replace(/^https?:\/\/[^/]+\//, '').replace(/\.git$/, '');
307
+ if (!ownerRepo) die(`Could not determine owner/repo from remote '${remote}' (${remoteUrl}).`);
308
+ const base = baseBranch(remote) || 'main';
309
+ const url = `https://github.com/${ownerRepo}/compare/${base}...${currentBranch}?expand=1`;
310
+ logInfo(`Open this URL to create the PR manually: ${url}`);
311
+ if (await confirm('Open it in your browser now?')) openUrl(url);
312
+ return;
313
+ }
314
+
315
+ const base = baseBranch(remote);
316
+ if (!base) die("Could not determine the repo's base branch.");
317
+ if (currentBranch === base) die(`You're on '${base}' — switch to a feature branch first.`);
318
+
319
+ logInfo(`Pushing '${currentBranch}' to '${remote}'...`);
320
+ if (git(['push', '-u', remote, currentBranch]).status !== 0) die('Push failed.');
321
+
322
+ if (spawnSync('gh', ['pr', 'view', '--json', 'number'], { stdio: 'ignore' }).status === 0) {
323
+ logSuccess(`A PR for '${currentBranch}' already exists.`);
324
+ if (await confirm('Open it in your browser?')) gh(['pr', 'view', '--web']);
325
+ return;
326
+ }
327
+
328
+ const commits = gitCapture(['log', '--format=%s', `${remote}/${base}..HEAD`]).split(/\r?\n/).filter(Boolean);
329
+ let title;
330
+ let body;
331
+ if (commits.length <= 1) {
332
+ title = commits[0] || '';
333
+ body = '';
334
+ } else {
335
+ const spaced = currentBranch.replace(/[-_]/g, ' ');
336
+ title = spaced.charAt(0).toUpperCase() + spaced.slice(1);
337
+ body = commits.map((c) => `- ${c}`).join('\n');
338
+ }
339
+
340
+ const titleOverride = await prompt(`PR title [${title}]`);
341
+ if (titleOverride) title = titleOverride;
342
+ if (!title) die('A PR title is required.');
343
+
344
+ if (gh(['pr', 'create', '--title', title, '--body', body, '--base', base]).status === 0) {
345
+ logSuccess('PR created.');
346
+ if (await confirm('Open it in your browser?')) gh(['pr', 'view', '--web']);
347
+ } else {
348
+ die('gh pr create failed.');
349
+ }
350
+ }
351
+
352
+ async function cmdSync() {
353
+ const remote = defaultRemote();
354
+ if (!remote) die('No remote configured.');
355
+ const base = baseBranch(remote);
356
+ if (!base) die("Could not determine the repo's base branch.");
357
+ const currentBranch = gitCapture(['branch', '--show-current']);
358
+
359
+ logInfo(`Fetching '${base}' from '${remote}'...`);
360
+ if (git(['fetch', remote, base]).status !== 0) die('Fetch failed.');
361
+
362
+ logInfo(`Rebasing '${currentBranch}' onto '${remote}/${base}'...`);
363
+ if (git(['rebase', `${remote}/${base}`]).status === 0) {
364
+ logSuccess(`'${currentBranch}' is now up to date with '${remote}/${base}'.`);
365
+ return;
366
+ }
367
+
368
+ const gitDir = gitCapture(['rev-parse', '--git-dir']);
369
+ const inConflict = fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'));
370
+ if (inConflict) {
371
+ logError('MERGE CONFLICT DETECTED!');
372
+ logWarn("Execution paused. Resolve conflicts, then 'git rebase --continue'.");
373
+ const openEditor = (await prompt('Do you want to open VS Code to resolve this now? (y/n)')).toLowerCase();
374
+ if (openEditor === 'y') spawnSync('code', ['.'], { stdio: 'inherit' });
375
+ } else {
376
+ logError('Rebase failed — this looks like a connection problem, not a merge conflict (see the git error above).');
377
+ }
378
+ process.exit(1);
379
+ }
380
+
381
+ async function cmdCleanBranches() {
382
+ const remote = defaultRemote();
383
+ if (!remote) die('No remote configured.');
384
+
385
+ logInfo(`Pruning stale remote-tracking refs on '${remote}'...`);
386
+ if (git(['fetch', remote, '--prune']).status !== 0) die('Fetch failed.');
387
+
388
+ const base = baseBranch(remote);
389
+ if (!base) die("Could not determine the repo's base branch.");
390
+ const currentBranch = gitCapture(['branch', '--show-current']);
391
+ const protectedNames = new Set([currentBranch, 'main', 'master', 'develop', 'dev']);
392
+
393
+ const candidates = gitCapture(['branch', '--format=%(refname:short)', '--merged', `${remote}/${base}`])
394
+ .split(/\r?\n/)
395
+ .filter((b) => b && !protectedNames.has(b));
396
+
397
+ if (candidates.length === 0) {
398
+ logSuccess('No merged local branches to clean up.');
399
+ return;
400
+ }
401
+
402
+ logInfo(`Local branches already merged into '${base}':`);
403
+ candidates.forEach((b) => console.log(` - ${b}`));
404
+
405
+ if (!(await confirm(`Delete all ${candidates.length} of these local branches?`))) {
406
+ logInfo('Cancelled.');
407
+ return;
408
+ }
409
+
410
+ for (const b of candidates) {
411
+ if (git(['branch', '-d', b]).status === 0) {
412
+ logSuccess(`Deleted '${b}'.`);
413
+ } else {
414
+ logWarn(`Could not delete '${b}' (not fully merged?) — left as-is.`);
415
+ }
416
+ }
417
+ }
418
+
419
+ const HOOK_MARKER = '# xgem-managed-hook';
420
+
421
+ async function cmdHooksInstall(configDir) {
422
+ const gitDir = gitCapture(['rev-parse', '--git-dir']);
423
+ if (!gitDir) die('Not a git repository.');
424
+ const hooksDir = path.join(gitDir, 'hooks');
425
+ const hookPath = path.join(hooksDir, 'pre-commit');
426
+
427
+ if (fs.existsSync(hookPath) && !fs.readFileSync(hookPath, 'utf8').includes(HOOK_MARKER)) {
428
+ logWarn("An existing pre-commit hook was found that xgem didn't create.");
429
+ if (!(await confirm('Overwrite it?'))) { logInfo('Cancelled.'); return; }
430
+ }
431
+
432
+ const runTests = (await prompt('Also run tests before commit (slower)? (y/N)')).toLowerCase() === 'y';
433
+ const xgemPath = process.argv[1];
434
+
435
+ const lines = ['#!/bin/sh', HOOK_MARKER];
436
+ let checksAdded = 0;
437
+ if (fs.existsSync(configDir)) {
438
+ for (const fw of fs.readdirSync(configDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)) {
439
+ if (fs.existsSync(path.join(configDir, fw, 'lint.mjs'))) {
440
+ lines.push(`node "${xgemPath}" run ${fw} lint || exit 1`);
441
+ checksAdded += 1;
442
+ }
443
+ if (runTests && fs.existsSync(path.join(configDir, fw, 'test.mjs'))) {
444
+ lines.push(`node "${xgemPath}" run ${fw} test || exit 1`);
445
+ checksAdded += 1;
446
+ }
447
+ }
448
+ }
449
+
450
+ fs.mkdirSync(hooksDir, { recursive: true });
451
+ fs.writeFileSync(hookPath, lines.join('\n') + '\n', { mode: 0o755 });
452
+
453
+ if (checksAdded === 0) {
454
+ logWarn(`No lint/test scripts found under ${configDir} — installed a hook that doesn't check anything yet.`);
455
+ }
456
+ logSuccess(`Installed pre-commit hook at ${hookPath}.`);
457
+ }
458
+
459
+ async function cmdHooksUninstall() {
460
+ const gitDir = gitCapture(['rev-parse', '--git-dir']);
461
+ if (!gitDir) die('Not a git repository.');
462
+ const hookPath = path.join(gitDir, 'hooks', 'pre-commit');
463
+
464
+ if (!fs.existsSync(hookPath)) { logInfo('No pre-commit hook installed.'); return; }
465
+ if (!fs.readFileSync(hookPath, 'utf8').includes(HOOK_MARKER)) {
466
+ die(`${hookPath} wasn't created by xgem — not removing it.`);
467
+ }
468
+ fs.rmSync(hookPath);
469
+ logSuccess('Removed pre-commit hook.');
470
+ }
471
+
472
+ async function cmdHooks(sub, configDir) {
473
+ switch (sub) {
474
+ case 'install': return cmdHooksInstall(configDir);
475
+ case 'uninstall': return cmdHooksUninstall();
476
+ default: die('Usage: xgem git hooks <install|uninstall>');
477
+ }
478
+ }
479
+
480
+ async function cmdGit(sub, arg, configDir) {
269
481
  switch (sub) {
270
482
  case 'cmt': return cmdCmt(arg);
271
483
  case 'init': return cmdInit();
272
484
  case 'branch': return cmdBranch();
273
485
  case 'rm-remote': return cmdRmRemote();
274
486
  case 'rm-branch': return cmdRmBranch();
275
- default: die(`Unknown git subcommand '${sub}'. Usage: xgem git <cmt|init|branch|rm-remote|rm-branch>`);
487
+ case 'pr': return cmdPr(configDir);
488
+ case 'sync': return cmdSync();
489
+ case 'clean-branches': return cmdCleanBranches();
490
+ case 'hooks': return cmdHooks(arg, configDir);
491
+ default: die(`Unknown git subcommand '${sub}'. Usage: xgem git <cmt|init|branch|rm-remote|rm-branch|pr|sync|clean-branches|hooks>`);
276
492
  }
277
493
  }
278
494
 
279
- module.exports = { cmdGit };
495
+ module.exports = { cmdGit, defaultRemote };
@@ -0,0 +1,43 @@
1
+ // xgem's global (cross-project) registry — port of lib/registry.sh. Same
2
+ // plain newline-delimited path file, so a dual-boot user's registry is
3
+ // readable from either platform.
4
+
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const os = require('node:os');
8
+
9
+ const REGISTRY_FILE = process.env.XGEM_REGISTRY_FILE || path.join(os.homedir(), '.xgem', 'projects');
10
+
11
+ function readLines() {
12
+ if (!fs.existsSync(REGISTRY_FILE)) return [];
13
+ return fs.readFileSync(REGISTRY_FILE, 'utf8').split(/\r?\n/).filter(Boolean);
14
+ }
15
+
16
+ function writeLines(lines) {
17
+ fs.mkdirSync(path.dirname(REGISTRY_FILE), { recursive: true });
18
+ fs.writeFileSync(REGISTRY_FILE, lines.length ? lines.join('\n') + '\n' : '');
19
+ }
20
+
21
+ function registryAdd(projectPath) {
22
+ const lines = readLines();
23
+ if (!lines.includes(projectPath)) {
24
+ lines.push(projectPath);
25
+ writeLines(lines);
26
+ }
27
+ }
28
+
29
+ function registryRemove(projectPath) {
30
+ writeLines(readLines().filter((l) => l !== projectPath));
31
+ }
32
+
33
+ // registryList(configDir) — returns live project paths, pruning (and
34
+ // rewriting the registry for) any entry that no longer exists or is no
35
+ // longer xgem-tracked.
36
+ function registryList(configDir) {
37
+ const lines = readLines();
38
+ const live = lines.filter((p) => fs.existsSync(path.join(p, configDir)));
39
+ if (live.length !== lines.length) writeLines(live);
40
+ return live;
41
+ }
42
+
43
+ module.exports = { registryAdd, registryRemove, registryList };