xgem-cli 2.0.0-alpha.10 → 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.
Files changed (43) hide show
  1. package/README.md +37 -63
  2. package/bin/xgem +46 -15
  3. package/bin/xgem.js +22 -20
  4. package/lib/bootstrap.sh +165 -0
  5. package/lib/ci.sh +44 -0
  6. package/lib/create.sh +140 -71
  7. package/lib/flutter.sh +10 -8
  8. package/lib/git.sh +291 -29
  9. package/lib/registry.sh +45 -0
  10. package/lib/release.sh +185 -0
  11. package/lib/scaffold.sh +21 -2
  12. package/lib/status.sh +42 -0
  13. package/lib/utils.sh +11 -0
  14. package/lib/version.sh +1 -1
  15. package/lib-win/bootstrap.js +189 -0
  16. package/lib-win/ci.js +43 -0
  17. package/lib-win/git.js +275 -21
  18. package/lib-win/registry.js +43 -0
  19. package/lib-win/release.js +143 -0
  20. package/lib-win/scaffold.js +64 -2
  21. package/lib-win/status.js +53 -0
  22. package/lib-win/utils.js +6 -1
  23. package/package.json +1 -1
  24. package/templates/node/build.sh.tmpl +9 -1
  25. package/templates/node/hard-clean.sh.tmpl +34 -2
  26. package/templates/node/lint.sh.tmpl +10 -0
  27. package/templates/node/start.sh.tmpl +9 -1
  28. package/templates/node/test.sh.tmpl +10 -0
  29. package/templates/webframework/build.sh.tmpl +10 -1
  30. package/templates/webframework/dev.sh.tmpl +9 -1
  31. package/templates/webframework/hard-clean.sh.tmpl +35 -2
  32. package/templates/webframework/lint.sh.tmpl +10 -0
  33. package/templates/webframework/test.sh.tmpl +10 -0
  34. package/templates-win/node/build.mjs.tmpl +8 -2
  35. package/templates-win/node/hard-clean.mjs.tmpl +24 -5
  36. package/templates-win/node/lint.mjs.tmpl +10 -0
  37. package/templates-win/node/start.mjs.tmpl +8 -1
  38. package/templates-win/node/test.mjs.tmpl +10 -0
  39. package/templates-win/webframework/build.mjs.tmpl +8 -1
  40. package/templates-win/webframework/dev.mjs.tmpl +8 -1
  41. package/templates-win/webframework/hard-clean.mjs.tmpl +28 -5
  42. package/templates-win/webframework/lint.mjs.tmpl +10 -0
  43. package/templates-win/webframework/test.mjs.tmpl +10 -0
package/lib/version.sh CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/bin/bash
2
2
  # xgem version info.
3
3
 
4
- XGEM_VERSION="2.0.0-alpha.10"
4
+ XGEM_VERSION="2.0.0-alpha.14"
5
5
 
6
6
  print_version() {
7
7
  echo "xgem $XGEM_VERSION"
@@ -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,23 +3,60 @@
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');
7
+ const path = require('node:path');
6
8
  const { spawnSync } = require('node:child_process');
7
9
  const { logInfo, logSuccess, logWarn, logError, die } = require('./logger');
8
- const { prompt } = require('./utils');
10
+ const { prompt, confirm, hasCmd, openUrl } = require('./utils');
9
11
 
10
12
  function git(args, opts = {}) {
11
13
  return spawnSync('git', args, { stdio: 'inherit', ...opts });
12
14
  }
13
15
 
16
+ function gh(args, opts = {}) {
17
+ return spawnSync('gh', args, { stdio: 'inherit', ...opts });
18
+ }
19
+
20
+ function ghAuthenticated() {
21
+ return spawnSync('gh', ['auth', 'status'], { stdio: 'ignore' }).status === 0;
22
+ }
23
+
14
24
  function gitCapture(args) {
15
25
  const result = spawnSync('git', args, { encoding: 'utf8' });
16
26
  return (result.stdout || '').trim();
17
27
  }
18
28
 
29
+ function ghCapture(args) {
30
+ const result = spawnSync('gh', args, { encoding: 'utf8' });
31
+ return result.status === 0 ? (result.stdout || '').trim() : '';
32
+ }
33
+
19
34
  function remoteExists(name) {
20
35
  return gitCapture(['remote']).split(/\r?\n/).includes(name);
21
36
  }
22
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
+
23
60
  async function cmdCmt(commitMsg) {
24
61
  if (!commitMsg) die('Missing commit message!');
25
62
 
@@ -53,7 +90,7 @@ async function cmdCmt(commitMsg) {
53
90
  }
54
91
 
55
92
  const currentBranch = gitCapture(['branch', '--show-current']);
56
- const remoteName = remoteExists('origin') ? 'origin' : gitCapture(['remote']).split(/\r?\n/)[0];
93
+ const remoteName = defaultRemote();
57
94
 
58
95
  if (!remoteName) {
59
96
  logWarn('No remote configured — sync was skipped.');
@@ -90,7 +127,6 @@ async function cmdCmt(commitMsg) {
90
127
  const pullResult = git(['pull', '--rebase', remoteName, currentBranch]);
91
128
  if (pullResult.status !== 0) {
92
129
  const gitDir = gitCapture(['rev-parse', '--git-dir']);
93
- const path = require('node:path');
94
130
  const fs = require('node:fs');
95
131
  const inConflict = fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'));
96
132
  if (inConflict) {
@@ -112,30 +148,60 @@ async function cmdCmt(commitMsg) {
112
148
  }
113
149
  }
114
150
 
115
- async function cmdInit() {
116
- logInfo('Initializing local Git repository...');
117
- git(['init']);
151
+ async function manualRemoteSetup(defaultRemoteName) {
152
+ let remoteName = defaultRemoteName;
153
+ const remoteUrl = await prompt('Enter remote repository URL (or leave blank to skip)');
154
+ if (!remoteUrl) return;
118
155
 
119
- let remoteName = 'origin';
120
- const remoteUrl = await prompt('Enter remote repository URL');
121
- if (remoteUrl) {
122
- const userRemoteName = await prompt('Enter remote name (default: origin)');
123
- if (userRemoteName) remoteName = userRemoteName;
156
+ const userRemoteName = await prompt(`Enter remote name (default: ${remoteName})`);
157
+ if (userRemoteName) remoteName = userRemoteName;
124
158
 
125
- if (remoteExists(remoteName)) {
126
- git(['remote', 'set-url', remoteName, remoteUrl]);
127
- logSuccess(`Remote '${remoteName}' already existed. URL updated.`);
128
- } else {
129
- git(['remote', 'add', remoteName, remoteUrl]);
130
- logSuccess(`Remote '${remoteName}' successfully added.`);
131
- }
159
+ if (remoteExists(remoteName)) {
160
+ git(['remote', 'set-url', remoteName, remoteUrl]);
161
+ logSuccess(`Remote '${remoteName}' already existed. URL updated.`);
162
+ } else {
163
+ git(['remote', 'add', remoteName, remoteUrl]);
164
+ logSuccess(`Remote '${remoteName}' successfully added.`);
132
165
  }
166
+ }
167
+
168
+ async function cmdInit() {
169
+ logInfo('Initializing local Git repository...');
170
+ git(['init']);
133
171
 
134
172
  const branchName = (await prompt('Enter branch name (default: main)')) || 'main';
135
173
  git(['branch', '-M', branchName]);
136
174
 
137
175
  git(['add', '.']);
138
176
  git(['commit', '-m', 'initial changes']);
177
+
178
+ // Real GitHub repo creation, not just wiring a remote to a URL you
179
+ // already had to go create by hand — the whole point of `xgem git
180
+ // init` over plain `git init`. Falls back to the manual-URL flow if
181
+ // `gh` isn't installed/authenticated.
182
+ if (hasCmd('gh') && ghAuthenticated()) {
183
+ const createChoice = (await prompt('Create a new GitHub repository for this project right now? (Y/n)')) || 'y';
184
+ if (createChoice.toLowerCase() === 'y') {
185
+ const cwdName = path.basename(process.cwd());
186
+ const repoName = (await prompt(`Repository name (default: ${cwdName})`)) || cwdName;
187
+ const visibility = ((await prompt('Public or private? [public/private] (default: private)')) || 'private').toLowerCase();
188
+ const visFlag = visibility.startsWith('pub') ? '--public' : '--private';
189
+
190
+ const result = gh(['repo', 'create', repoName, visFlag, '--source=.', '--remote=origin', '--push']);
191
+ if (result.status === 0) {
192
+ logSuccess(`Created GitHub repo '${repoName}' and pushed '${branchName}' to it.`);
193
+ } else {
194
+ logError('gh repo create failed — falling back to manual remote setup.');
195
+ await manualRemoteSetup('origin');
196
+ }
197
+ logSuccess('Local baseline configuration setup completed.');
198
+ return;
199
+ }
200
+ } else if (hasCmd('gh')) {
201
+ logWarn("GitHub CLI (gh) is installed but not authenticated — run 'gh auth login' to enable one-step repo creation next time.");
202
+ }
203
+
204
+ await manualRemoteSetup('origin');
139
205
  logSuccess('Local baseline configuration setup completed.');
140
206
  }
141
207
 
@@ -227,15 +293,203 @@ async function cmdBranch() {
227
293
  logSuccess(`Switched to '${selected}' and set as default for this repo.`);
228
294
  }
229
295
 
230
- 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) {
231
481
  switch (sub) {
232
482
  case 'cmt': return cmdCmt(arg);
233
483
  case 'init': return cmdInit();
234
484
  case 'branch': return cmdBranch();
235
485
  case 'rm-remote': return cmdRmRemote();
236
486
  case 'rm-branch': return cmdRmBranch();
237
- 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>`);
238
492
  }
239
493
  }
240
494
 
241
- 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 };