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.
- package/README.md +37 -63
- package/bin/xgem +46 -15
- package/bin/xgem.js +22 -20
- package/lib/bootstrap.sh +165 -0
- package/lib/ci.sh +44 -0
- package/lib/create.sh +140 -71
- package/lib/flutter.sh +10 -8
- package/lib/git.sh +291 -29
- package/lib/registry.sh +45 -0
- package/lib/release.sh +185 -0
- package/lib/scaffold.sh +21 -2
- 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 +275 -21
- package/lib-win/registry.js +43 -0
- package/lib-win/release.js +143 -0
- package/lib-win/scaffold.js +64 -2
- package/lib-win/status.js +53 -0
- package/lib-win/utils.js +6 -1
- package/package.json +1 -1
- package/templates/node/build.sh.tmpl +9 -1
- package/templates/node/hard-clean.sh.tmpl +34 -2
- package/templates/node/lint.sh.tmpl +10 -0
- package/templates/node/start.sh.tmpl +9 -1
- package/templates/node/test.sh.tmpl +10 -0
- package/templates/webframework/build.sh.tmpl +10 -1
- package/templates/webframework/dev.sh.tmpl +9 -1
- package/templates/webframework/hard-clean.sh.tmpl +35 -2
- package/templates/webframework/lint.sh.tmpl +10 -0
- package/templates/webframework/test.sh.tmpl +10 -0
- package/templates-win/node/build.mjs.tmpl +8 -2
- package/templates-win/node/hard-clean.mjs.tmpl +24 -5
- package/templates-win/node/lint.mjs.tmpl +10 -0
- package/templates-win/node/start.mjs.tmpl +8 -1
- package/templates-win/node/test.mjs.tmpl +10 -0
- package/templates-win/webframework/build.mjs.tmpl +8 -1
- package/templates-win/webframework/dev.mjs.tmpl +8 -1
- package/templates-win/webframework/hard-clean.mjs.tmpl +28 -5
- package/templates-win/webframework/lint.mjs.tmpl +10 -0
- package/templates-win/webframework/test.mjs.tmpl +10 -0
package/lib/version.sh
CHANGED
|
@@ -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 =
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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
|
-
|
|
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 };
|