xgem-cli 2.0.0-alpha.12 → 2.0.0-alpha.15
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/README.md +8 -0
- package/bin/xgem +41 -2
- package/bin/xgem.js +22 -35
- package/lib/bootstrap.sh +165 -0
- package/lib/ci.sh +44 -0
- package/lib/create.sh +1 -9
- package/lib/git.sh +267 -17
- package/lib/registry.sh +45 -0
- package/lib/release.sh +185 -0
- package/lib/status.sh +42 -0
- package/lib/utils.sh +11 -0
- package/lib/version.sh +1 -1
- package/lib-win/bootstrap.js +189 -0
- package/lib-win/ci.js +43 -0
- package/lib-win/git.js +238 -6
- package/lib-win/registry.js +43 -0
- package/lib-win/release.js +143 -0
- package/lib-win/scaffold.js +41 -1
- package/lib-win/status.js +53 -0
- package/lib-win/utils.js +6 -1
- package/package.json +1 -1
|
@@ -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,55 @@ 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
|
+
|
|
60
|
+
async function resolveConflictsHelp() {
|
|
61
|
+
const files = gitCapture(['diff', '--name-only', '--diff-filter=U']).split(/\r?\n/).filter(Boolean);
|
|
62
|
+
if (files.length) {
|
|
63
|
+
logWarn('Files with conflicts:');
|
|
64
|
+
files.forEach((f) => console.log(` - ${f}`));
|
|
65
|
+
}
|
|
66
|
+
logWarn('Resolve them, then run: git add <files> && git rebase --continue (or give up with: git rebase --abort)');
|
|
67
|
+
|
|
68
|
+
const openEditor = (await prompt('Do you want to open VS Code to resolve this now? (y/n)')).toLowerCase();
|
|
69
|
+
if (openEditor !== 'y') return;
|
|
70
|
+
|
|
71
|
+
if (hasCmd('code')) {
|
|
72
|
+
spawnSync('code', ['.'], { stdio: 'inherit', shell: true });
|
|
73
|
+
} else {
|
|
74
|
+
logWarn("VS Code's 'code' command isn't on your PATH. Reinstall VS Code with 'Add to PATH' enabled, or open the files above manually.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
32
78
|
async function cmdCmt(commitMsg) {
|
|
33
79
|
if (!commitMsg) die('Missing commit message!');
|
|
34
80
|
|
|
@@ -62,7 +108,7 @@ async function cmdCmt(commitMsg) {
|
|
|
62
108
|
}
|
|
63
109
|
|
|
64
110
|
const currentBranch = gitCapture(['branch', '--show-current']);
|
|
65
|
-
const remoteName =
|
|
111
|
+
const remoteName = defaultRemote();
|
|
66
112
|
|
|
67
113
|
if (!remoteName) {
|
|
68
114
|
logWarn('No remote configured — sync was skipped.');
|
|
@@ -103,7 +149,7 @@ async function cmdCmt(commitMsg) {
|
|
|
103
149
|
const inConflict = fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'));
|
|
104
150
|
if (inConflict) {
|
|
105
151
|
logError('MERGE CONFLICT DETECTED!');
|
|
106
|
-
|
|
152
|
+
await resolveConflictsHelp();
|
|
107
153
|
} else {
|
|
108
154
|
logError(`Could not sync with '${remoteName}' — this looks like a connection problem, not a merge conflict (see the git error above).`);
|
|
109
155
|
logWarn(`Your commit is safe locally. Re-run 'xgem git cmt' once connectivity is restored, or push manually: git push ${remoteName} ${currentBranch}`);
|
|
@@ -265,15 +311,201 @@ async function cmdBranch() {
|
|
|
265
311
|
logSuccess(`Switched to '${selected}' and set as default for this repo.`);
|
|
266
312
|
}
|
|
267
313
|
|
|
268
|
-
async function
|
|
314
|
+
async function cmdPr(configDir) {
|
|
315
|
+
const currentBranch = gitCapture(['branch', '--show-current']);
|
|
316
|
+
if (!currentBranch) die('Not on a branch (detached HEAD?) — nothing to open a PR from.');
|
|
317
|
+
const remote = defaultRemote();
|
|
318
|
+
if (!remote) die('No remote configured.');
|
|
319
|
+
|
|
320
|
+
if (!hasCmd('gh') || !ghAuthenticated()) {
|
|
321
|
+
if (hasCmd('gh')) logWarn("GitHub CLI (gh) is installed but not authenticated — run 'gh auth login' to enable 'xgem git pr'.");
|
|
322
|
+
else logWarn('GitHub CLI (gh) is not installed — install it for \'xgem git pr\' to open PRs directly: https://cli.github.com');
|
|
323
|
+
const remoteUrl = gitCapture(['remote', 'get-url', remote]);
|
|
324
|
+
const ownerRepo = remoteUrl.replace(/^git@[^:]+:/, '').replace(/^https?:\/\/[^/]+\//, '').replace(/\.git$/, '');
|
|
325
|
+
if (!ownerRepo) die(`Could not determine owner/repo from remote '${remote}' (${remoteUrl}).`);
|
|
326
|
+
const base = baseBranch(remote) || 'main';
|
|
327
|
+
const url = `https://github.com/${ownerRepo}/compare/${base}...${currentBranch}?expand=1`;
|
|
328
|
+
logInfo(`Open this URL to create the PR manually: ${url}`);
|
|
329
|
+
if (await confirm('Open it in your browser now?')) openUrl(url);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const base = baseBranch(remote);
|
|
334
|
+
if (!base) die("Could not determine the repo's base branch.");
|
|
335
|
+
if (currentBranch === base) die(`You're on '${base}' — switch to a feature branch first.`);
|
|
336
|
+
|
|
337
|
+
logInfo(`Pushing '${currentBranch}' to '${remote}'...`);
|
|
338
|
+
if (git(['push', '-u', remote, currentBranch]).status !== 0) die('Push failed.');
|
|
339
|
+
|
|
340
|
+
if (spawnSync('gh', ['pr', 'view', '--json', 'number'], { stdio: 'ignore' }).status === 0) {
|
|
341
|
+
logSuccess(`A PR for '${currentBranch}' already exists.`);
|
|
342
|
+
if (await confirm('Open it in your browser?')) gh(['pr', 'view', '--web']);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const commits = gitCapture(['log', '--format=%s', `${remote}/${base}..HEAD`]).split(/\r?\n/).filter(Boolean);
|
|
347
|
+
let title;
|
|
348
|
+
let body;
|
|
349
|
+
if (commits.length <= 1) {
|
|
350
|
+
title = commits[0] || '';
|
|
351
|
+
body = '';
|
|
352
|
+
} else {
|
|
353
|
+
const spaced = currentBranch.replace(/[-_]/g, ' ');
|
|
354
|
+
title = spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
355
|
+
body = commits.map((c) => `- ${c}`).join('\n');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const titleOverride = await prompt(`PR title [${title}]`);
|
|
359
|
+
if (titleOverride) title = titleOverride;
|
|
360
|
+
if (!title) die('A PR title is required.');
|
|
361
|
+
|
|
362
|
+
if (gh(['pr', 'create', '--title', title, '--body', body, '--base', base]).status === 0) {
|
|
363
|
+
logSuccess('PR created.');
|
|
364
|
+
if (await confirm('Open it in your browser?')) gh(['pr', 'view', '--web']);
|
|
365
|
+
} else {
|
|
366
|
+
die('gh pr create failed.');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function cmdSync() {
|
|
371
|
+
const remote = defaultRemote();
|
|
372
|
+
if (!remote) die('No remote configured.');
|
|
373
|
+
const base = baseBranch(remote);
|
|
374
|
+
if (!base) die("Could not determine the repo's base branch.");
|
|
375
|
+
const currentBranch = gitCapture(['branch', '--show-current']);
|
|
376
|
+
|
|
377
|
+
logInfo(`Fetching '${base}' from '${remote}'...`);
|
|
378
|
+
if (git(['fetch', remote, base]).status !== 0) die('Fetch failed.');
|
|
379
|
+
|
|
380
|
+
logInfo(`Rebasing '${currentBranch}' onto '${remote}/${base}'...`);
|
|
381
|
+
if (git(['rebase', `${remote}/${base}`]).status === 0) {
|
|
382
|
+
logSuccess(`'${currentBranch}' is now up to date with '${remote}/${base}'.`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const gitDir = gitCapture(['rev-parse', '--git-dir']);
|
|
387
|
+
const inConflict = fs.existsSync(path.join(gitDir, 'rebase-merge')) || fs.existsSync(path.join(gitDir, 'rebase-apply'));
|
|
388
|
+
if (inConflict) {
|
|
389
|
+
logError('MERGE CONFLICT DETECTED!');
|
|
390
|
+
await resolveConflictsHelp();
|
|
391
|
+
} else {
|
|
392
|
+
logError('Rebase failed — this looks like a connection problem, not a merge conflict (see the git error above).');
|
|
393
|
+
}
|
|
394
|
+
process.exit(1);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function cmdCleanBranches() {
|
|
398
|
+
const remote = defaultRemote();
|
|
399
|
+
if (!remote) die('No remote configured.');
|
|
400
|
+
|
|
401
|
+
logInfo(`Pruning stale remote-tracking refs on '${remote}'...`);
|
|
402
|
+
if (git(['fetch', remote, '--prune']).status !== 0) die('Fetch failed.');
|
|
403
|
+
|
|
404
|
+
const base = baseBranch(remote);
|
|
405
|
+
if (!base) die("Could not determine the repo's base branch.");
|
|
406
|
+
const currentBranch = gitCapture(['branch', '--show-current']);
|
|
407
|
+
const protectedNames = new Set([currentBranch, 'main', 'master', 'develop', 'dev']);
|
|
408
|
+
|
|
409
|
+
const candidates = gitCapture(['branch', '--format=%(refname:short)', '--merged', `${remote}/${base}`])
|
|
410
|
+
.split(/\r?\n/)
|
|
411
|
+
.filter((b) => b && !protectedNames.has(b));
|
|
412
|
+
|
|
413
|
+
if (candidates.length === 0) {
|
|
414
|
+
logSuccess('No merged local branches to clean up.');
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
logInfo(`Local branches already merged into '${base}':`);
|
|
419
|
+
candidates.forEach((b) => console.log(` - ${b}`));
|
|
420
|
+
|
|
421
|
+
if (!(await confirm(`Delete all ${candidates.length} of these local branches?`))) {
|
|
422
|
+
logInfo('Cancelled.');
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
for (const b of candidates) {
|
|
427
|
+
if (git(['branch', '-d', b]).status === 0) {
|
|
428
|
+
logSuccess(`Deleted '${b}'.`);
|
|
429
|
+
} else {
|
|
430
|
+
logWarn(`Could not delete '${b}' (not fully merged?) — left as-is.`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const HOOK_MARKER = '# xgem-managed-hook';
|
|
436
|
+
|
|
437
|
+
async function cmdHooksInstall(configDir) {
|
|
438
|
+
const gitDir = gitCapture(['rev-parse', '--git-dir']);
|
|
439
|
+
if (!gitDir) die('Not a git repository.');
|
|
440
|
+
const hooksDir = path.join(gitDir, 'hooks');
|
|
441
|
+
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
442
|
+
|
|
443
|
+
if (fs.existsSync(hookPath) && !fs.readFileSync(hookPath, 'utf8').includes(HOOK_MARKER)) {
|
|
444
|
+
logWarn("An existing pre-commit hook was found that xgem didn't create.");
|
|
445
|
+
if (!(await confirm('Overwrite it?'))) { logInfo('Cancelled.'); return; }
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const runTests = (await prompt('Also run tests before commit (slower)? (y/N)')).toLowerCase() === 'y';
|
|
449
|
+
const xgemPath = process.argv[1];
|
|
450
|
+
|
|
451
|
+
const lines = ['#!/bin/sh', HOOK_MARKER];
|
|
452
|
+
let checksAdded = 0;
|
|
453
|
+
if (fs.existsSync(configDir)) {
|
|
454
|
+
for (const fw of fs.readdirSync(configDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)) {
|
|
455
|
+
if (fs.existsSync(path.join(configDir, fw, 'lint.mjs'))) {
|
|
456
|
+
lines.push(`node "${xgemPath}" run ${fw} lint || exit 1`);
|
|
457
|
+
checksAdded += 1;
|
|
458
|
+
}
|
|
459
|
+
if (runTests && fs.existsSync(path.join(configDir, fw, 'test.mjs'))) {
|
|
460
|
+
lines.push(`node "${xgemPath}" run ${fw} test || exit 1`);
|
|
461
|
+
checksAdded += 1;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
467
|
+
fs.writeFileSync(hookPath, lines.join('\n') + '\n', { mode: 0o755 });
|
|
468
|
+
|
|
469
|
+
if (checksAdded === 0) {
|
|
470
|
+
logWarn(`No lint/test scripts found under ${configDir} — installed a hook that doesn't check anything yet.`);
|
|
471
|
+
}
|
|
472
|
+
logSuccess(`Installed pre-commit hook at ${hookPath}.`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function cmdHooksUninstall() {
|
|
476
|
+
const gitDir = gitCapture(['rev-parse', '--git-dir']);
|
|
477
|
+
if (!gitDir) die('Not a git repository.');
|
|
478
|
+
const hookPath = path.join(gitDir, 'hooks', 'pre-commit');
|
|
479
|
+
|
|
480
|
+
if (!fs.existsSync(hookPath)) { logInfo('No pre-commit hook installed.'); return; }
|
|
481
|
+
if (!fs.readFileSync(hookPath, 'utf8').includes(HOOK_MARKER)) {
|
|
482
|
+
die(`${hookPath} wasn't created by xgem — not removing it.`);
|
|
483
|
+
}
|
|
484
|
+
fs.rmSync(hookPath);
|
|
485
|
+
logSuccess('Removed pre-commit hook.');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function cmdHooks(sub, configDir) {
|
|
489
|
+
switch (sub) {
|
|
490
|
+
case 'install': return cmdHooksInstall(configDir);
|
|
491
|
+
case 'uninstall': return cmdHooksUninstall();
|
|
492
|
+
default: die('Usage: xgem git hooks <install|uninstall>');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function cmdGit(sub, arg, configDir) {
|
|
269
497
|
switch (sub) {
|
|
270
498
|
case 'cmt': return cmdCmt(arg);
|
|
271
499
|
case 'init': return cmdInit();
|
|
272
500
|
case 'branch': return cmdBranch();
|
|
273
501
|
case 'rm-remote': return cmdRmRemote();
|
|
274
502
|
case 'rm-branch': return cmdRmBranch();
|
|
275
|
-
|
|
503
|
+
case 'pr': return cmdPr(configDir);
|
|
504
|
+
case 'sync': return cmdSync();
|
|
505
|
+
case 'clean-branches': return cmdCleanBranches();
|
|
506
|
+
case 'hooks': return cmdHooks(arg, configDir);
|
|
507
|
+
default: die(`Unknown git subcommand '${sub}'. Usage: xgem git <cmt|init|branch|rm-remote|rm-branch|pr|sync|clean-branches|hooks>`);
|
|
276
508
|
}
|
|
277
509
|
}
|
|
278
510
|
|
|
279
|
-
module.exports = { cmdGit };
|
|
511
|
+
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 };
|