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/git.sh
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# xgem git workflow helper: cmt / init / rm-remote / rm-branch
|
|
3
|
-
#
|
|
2
|
+
# xgem git workflow helper: cmt / init / branch / rm-remote / rm-branch /
|
|
3
|
+
# pr / sync / clean-branches / hooks.
|
|
4
|
+
# Depends on lib/logger.sh, lib/utils.sh.
|
|
5
|
+
|
|
6
|
+
# _git_default_remote -> "origin" if configured, else the first remote,
|
|
7
|
+
# else empty.
|
|
8
|
+
_git_default_remote() {
|
|
9
|
+
if git remote | grep -q "^origin$"; then
|
|
10
|
+
echo origin
|
|
11
|
+
else
|
|
12
|
+
git remote | head -n 1
|
|
13
|
+
fi
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# _git_base_branch <remote> -> the repo's default branch. Prefers gh's own
|
|
17
|
+
# knowledge of it (accurate, no guessing); falls back to probing common
|
|
18
|
+
# names against the remote's tracking refs when gh is unavailable/offline.
|
|
19
|
+
_git_base_branch() {
|
|
20
|
+
local remote=$1
|
|
21
|
+
local base
|
|
22
|
+
if has_cmd gh; then
|
|
23
|
+
base=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null)
|
|
24
|
+
[ -n "$base" ] && { echo "$base"; return 0; }
|
|
25
|
+
fi
|
|
26
|
+
local candidate
|
|
27
|
+
for candidate in main master develop dev; do
|
|
28
|
+
if git show-ref --verify --quiet "refs/remotes/$remote/$candidate"; then
|
|
29
|
+
echo "$candidate"
|
|
30
|
+
return 0
|
|
31
|
+
fi
|
|
32
|
+
done
|
|
33
|
+
return 1
|
|
34
|
+
}
|
|
4
35
|
|
|
5
36
|
cmd_git_cmt() {
|
|
6
37
|
local commit_msg=$1
|
|
@@ -35,11 +66,7 @@ cmd_git_cmt() {
|
|
|
35
66
|
|
|
36
67
|
local current_branch remote_name
|
|
37
68
|
current_branch=$(git branch --show-current 2>/dev/null)
|
|
38
|
-
|
|
39
|
-
remote_name="origin"
|
|
40
|
-
else
|
|
41
|
-
remote_name=$(git remote | head -n 1)
|
|
42
|
-
fi
|
|
69
|
+
remote_name=$(_git_default_remote)
|
|
43
70
|
|
|
44
71
|
if [ -z "$remote_name" ]; then
|
|
45
72
|
log_warn "No remote configured — sync was skipped."
|
|
@@ -102,31 +129,67 @@ cmd_git_cmt() {
|
|
|
102
129
|
fi
|
|
103
130
|
}
|
|
104
131
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
132
|
+
_cmd_git_init_manual_remote() {
|
|
133
|
+
local remote_name=$1
|
|
134
|
+
local remote_url user_remote_name
|
|
135
|
+
read -r -p "Enter remote repository URL (or leave blank to skip): " remote_url
|
|
136
|
+
[ -n "$remote_url" ] || return 0
|
|
108
137
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if [ -n "$remote_url" ]; then
|
|
112
|
-
read -r -p "Enter remote name (default: origin): " user_remote_name
|
|
113
|
-
[ -n "$user_remote_name" ] && remote_name="$user_remote_name"
|
|
138
|
+
read -r -p "Enter remote name (default: $remote_name): " user_remote_name
|
|
139
|
+
[ -n "$user_remote_name" ] && remote_name="$user_remote_name"
|
|
114
140
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
fi
|
|
141
|
+
if git remote | grep -q "^$remote_name$"; then
|
|
142
|
+
git remote set-url "$remote_name" "$remote_url"
|
|
143
|
+
log_success "Remote '$remote_name' already existed. URL updated."
|
|
144
|
+
else
|
|
145
|
+
git remote add "$remote_name" "$remote_url"
|
|
146
|
+
log_success "Remote '$remote_name' successfully added."
|
|
122
147
|
fi
|
|
148
|
+
}
|
|
123
149
|
|
|
150
|
+
cmd_git_init() {
|
|
151
|
+
log_info "Initializing local Git repository..."
|
|
152
|
+
git init
|
|
153
|
+
|
|
154
|
+
local branch_name
|
|
124
155
|
read -r -p "Enter branch name (default: main): " branch_name
|
|
125
156
|
branch_name=${branch_name:-main}
|
|
126
157
|
git branch -M "$branch_name"
|
|
127
158
|
|
|
128
159
|
git add .
|
|
129
160
|
git commit -m "initial changes" 2>/dev/null
|
|
161
|
+
|
|
162
|
+
# Real GitHub repo creation, not just wiring a remote to a URL you
|
|
163
|
+
# already had to go create by hand — this is the whole point of
|
|
164
|
+
# `xgem git init` over plain `git init`. Falls back to the manual-URL
|
|
165
|
+
# flow if `gh` isn't installed/authenticated.
|
|
166
|
+
if has_cmd gh && gh auth status >/dev/null 2>&1; then
|
|
167
|
+
local create_choice
|
|
168
|
+
read -r -p "Create a new GitHub repository for this project right now? (Y/n): " create_choice
|
|
169
|
+
create_choice=${create_choice:-y}
|
|
170
|
+
if [[ "$create_choice" == "y" || "$create_choice" == "Y" ]]; then
|
|
171
|
+
local repo_name visibility vis_flag
|
|
172
|
+
read -r -p "Repository name (default: $(basename "$PWD")): " repo_name
|
|
173
|
+
repo_name=${repo_name:-$(basename "$PWD")}
|
|
174
|
+
read -r -p "Public or private? [public/private] (default: private): " visibility
|
|
175
|
+
visibility=${visibility:-private}
|
|
176
|
+
vis_flag="--private"
|
|
177
|
+
[[ "$visibility" == pub* || "$visibility" == Pub* ]] && vis_flag="--public"
|
|
178
|
+
|
|
179
|
+
if gh repo create "$repo_name" "$vis_flag" --source=. --remote=origin --push; then
|
|
180
|
+
log_success "Created GitHub repo '$repo_name' and pushed '$branch_name' to it."
|
|
181
|
+
else
|
|
182
|
+
log_error "gh repo create failed — falling back to manual remote setup."
|
|
183
|
+
_cmd_git_init_manual_remote origin
|
|
184
|
+
fi
|
|
185
|
+
log_success "Local baseline configuration setup completed."
|
|
186
|
+
return 0
|
|
187
|
+
fi
|
|
188
|
+
elif has_cmd gh; then
|
|
189
|
+
log_warn "GitHub CLI (gh) is installed but not authenticated — run 'gh auth login' to enable one-step repo creation next time."
|
|
190
|
+
fi
|
|
191
|
+
|
|
192
|
+
_cmd_git_init_manual_remote origin
|
|
130
193
|
log_success "Local baseline configuration setup completed."
|
|
131
194
|
}
|
|
132
195
|
|
|
@@ -241,14 +304,213 @@ cmd_git_branch() {
|
|
|
241
304
|
log_success "Switched to '$selected' and set as default for this repo."
|
|
242
305
|
}
|
|
243
306
|
|
|
244
|
-
|
|
307
|
+
cmd_git_pr() {
|
|
308
|
+
local current_branch remote base
|
|
309
|
+
current_branch=$(git branch --show-current 2>/dev/null)
|
|
310
|
+
[ -n "$current_branch" ] || die "Not on a branch (detached HEAD?) — nothing to open a PR from."
|
|
311
|
+
remote=$(_git_default_remote)
|
|
312
|
+
[ -n "$remote" ] || die "No remote configured."
|
|
313
|
+
|
|
314
|
+
if ! has_cmd gh || ! gh auth status >/dev/null 2>&1; then
|
|
315
|
+
has_cmd gh && log_warn "GitHub CLI (gh) is installed but not authenticated — run 'gh auth login' to enable 'xgem git pr'." \
|
|
316
|
+
|| log_warn "GitHub CLI (gh) is not installed — install it for 'xgem git pr' to open PRs directly: https://cli.github.com"
|
|
317
|
+
local url owner_repo remote_url
|
|
318
|
+
remote_url=$(git remote get-url "$remote" 2>/dev/null)
|
|
319
|
+
owner_repo=$(echo "$remote_url" | sed -E 's#^git@[^:]+:##; s#^https?://[^/]+/##; s#\.git$##')
|
|
320
|
+
[ -n "$owner_repo" ] || die "Could not determine owner/repo from remote '$remote' ($remote_url)."
|
|
321
|
+
base=$(_git_base_branch "$remote")
|
|
322
|
+
url="https://github.com/$owner_repo/compare/${base:-main}...$current_branch?expand=1"
|
|
323
|
+
log_info "Open this URL to create the PR manually: $url"
|
|
324
|
+
confirm "Open it in your browser now?" && _open_url "$url"
|
|
325
|
+
return 0
|
|
326
|
+
fi
|
|
327
|
+
|
|
328
|
+
base=$(_git_base_branch "$remote") || die "Could not determine the repo's base branch."
|
|
329
|
+
[ "$current_branch" != "$base" ] || die "You're on '$base' — switch to a feature branch first."
|
|
330
|
+
|
|
331
|
+
log_info "Pushing '$current_branch' to '$remote'..."
|
|
332
|
+
git push -u "$remote" "$current_branch" || die "Push failed."
|
|
333
|
+
|
|
334
|
+
if gh pr view --json number >/dev/null 2>&1; then
|
|
335
|
+
log_success "A PR for '$current_branch' already exists."
|
|
336
|
+
confirm "Open it in your browser?" && gh pr view --web
|
|
337
|
+
return 0
|
|
338
|
+
fi
|
|
339
|
+
|
|
340
|
+
local commits commit_count title body
|
|
341
|
+
commits=$(git log --format='%s' "$remote/$base..HEAD" 2>/dev/null)
|
|
342
|
+
commit_count=$(echo "$commits" | grep -c .)
|
|
343
|
+
|
|
344
|
+
if [ "$commit_count" -le 1 ]; then
|
|
345
|
+
title=$(echo "$commits" | head -1)
|
|
346
|
+
body=""
|
|
347
|
+
else
|
|
348
|
+
# sed's \U (uppercase-first) is GNU-only — BSD sed on macOS passes
|
|
349
|
+
# it through literally instead of applying it. Capitalize via tr
|
|
350
|
+
# on just the first character instead, which is portable.
|
|
351
|
+
title=$(echo "$current_branch" | sed -E 's/[-_]/ /g')
|
|
352
|
+
title="$(echo "${title:0:1}" | tr '[:lower:]' '[:upper:]')${title:1}"
|
|
353
|
+
body=$(echo "$commits" | sed 's/^/- /')
|
|
354
|
+
fi
|
|
355
|
+
|
|
356
|
+
local title_override
|
|
357
|
+
read -r -p "PR title [$title]: " title_override
|
|
358
|
+
[ -n "$title_override" ] && title="$title_override"
|
|
359
|
+
[ -n "$title" ] || die "A PR title is required."
|
|
360
|
+
|
|
361
|
+
if gh pr create --title "$title" --body "$body" --base "$base"; then
|
|
362
|
+
log_success "PR created."
|
|
363
|
+
confirm "Open it in your browser?" && gh pr view --web
|
|
364
|
+
else
|
|
365
|
+
die "gh pr create failed."
|
|
366
|
+
fi
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
cmd_git_sync() {
|
|
370
|
+
local remote base current_branch
|
|
371
|
+
remote=$(_git_default_remote)
|
|
372
|
+
[ -n "$remote" ] || die "No remote configured."
|
|
373
|
+
base=$(_git_base_branch "$remote") || die "Could not determine the repo's base branch."
|
|
374
|
+
current_branch=$(git branch --show-current 2>/dev/null)
|
|
375
|
+
|
|
376
|
+
log_info "Fetching '$base' from '$remote'..."
|
|
377
|
+
git fetch "$remote" "$base" || die "Fetch failed."
|
|
378
|
+
|
|
379
|
+
log_info "Rebasing '$current_branch' onto '$remote/$base'..."
|
|
380
|
+
if git rebase "$remote/$base"; then
|
|
381
|
+
log_success "'$current_branch' is now up to date with '$remote/$base'."
|
|
382
|
+
return 0
|
|
383
|
+
fi
|
|
384
|
+
|
|
385
|
+
local git_dir
|
|
386
|
+
git_dir=$(git rev-parse --git-dir 2>/dev/null)
|
|
387
|
+
if [ -d "$git_dir/rebase-merge" ] || [ -d "$git_dir/rebase-apply" ]; then
|
|
388
|
+
log_error "MERGE CONFLICT DETECTED!"
|
|
389
|
+
log_warn "Execution paused. Resolve conflicts, then 'git rebase --continue'."
|
|
390
|
+
local open_editor
|
|
391
|
+
read -r -p "Do you want to open VS Code to resolve this now? (y/n): " open_editor
|
|
392
|
+
[[ "$open_editor" == "y" || "$open_editor" == "Y" ]] && code .
|
|
393
|
+
else
|
|
394
|
+
log_error "Rebase failed — this looks like a connection problem, not a merge conflict (see the git error above)."
|
|
395
|
+
fi
|
|
396
|
+
exit 1
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
cmd_git_clean_branches() {
|
|
400
|
+
local remote base current_branch
|
|
401
|
+
remote=$(_git_default_remote)
|
|
402
|
+
[ -n "$remote" ] || die "No remote configured."
|
|
403
|
+
|
|
404
|
+
log_info "Pruning stale remote-tracking refs on '$remote'..."
|
|
405
|
+
git fetch "$remote" --prune || die "Fetch failed."
|
|
406
|
+
|
|
407
|
+
base=$(_git_base_branch "$remote") || die "Could not determine the repo's base branch."
|
|
408
|
+
current_branch=$(git branch --show-current 2>/dev/null)
|
|
409
|
+
|
|
410
|
+
local -a candidates=()
|
|
411
|
+
while IFS= read -r b; do
|
|
412
|
+
[ -n "$b" ] || continue
|
|
413
|
+
case "$b" in
|
|
414
|
+
"$current_branch"|main|master|develop|dev) continue ;;
|
|
415
|
+
esac
|
|
416
|
+
candidates+=("$b")
|
|
417
|
+
done < <(git branch --format='%(refname:short)' --merged "$remote/$base" 2>/dev/null)
|
|
418
|
+
|
|
419
|
+
if [ ${#candidates[@]} -eq 0 ]; then
|
|
420
|
+
log_success "No merged local branches to clean up."
|
|
421
|
+
return 0
|
|
422
|
+
fi
|
|
423
|
+
|
|
424
|
+
log_info "Local branches already merged into '$base':"
|
|
425
|
+
printf ' - %s\n' "${candidates[@]}"
|
|
426
|
+
|
|
427
|
+
confirm "Delete all ${#candidates[@]} of these local branches?" || { log_info "Cancelled."; return 0; }
|
|
428
|
+
|
|
429
|
+
local b
|
|
430
|
+
for b in "${candidates[@]}"; do
|
|
431
|
+
if git branch -d "$b" >/dev/null 2>&1; then
|
|
432
|
+
log_success "Deleted '$b'."
|
|
433
|
+
else
|
|
434
|
+
log_warn "Could not delete '$b' (not fully merged?) — left as-is."
|
|
435
|
+
fi
|
|
436
|
+
done
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
_GIT_HOOK_MARKER="# xgem-managed-hook"
|
|
440
|
+
|
|
441
|
+
cmd_git_hooks_install() {
|
|
442
|
+
local git_dir hook_path
|
|
443
|
+
git_dir=$(git rev-parse --git-dir 2>/dev/null) || die "Not a git repository."
|
|
444
|
+
hook_path="$git_dir/hooks/pre-commit"
|
|
445
|
+
|
|
446
|
+
if [ -f "$hook_path" ] && ! grep -qF "$_GIT_HOOK_MARKER" "$hook_path"; then
|
|
447
|
+
log_warn "An existing pre-commit hook was found that xgem didn't create."
|
|
448
|
+
confirm "Overwrite it?" || { log_info "Cancelled."; return 0; }
|
|
449
|
+
fi
|
|
450
|
+
|
|
451
|
+
local run_tests xgem_path
|
|
452
|
+
read -r -p "Also run tests before commit (slower)? (y/N): " run_tests
|
|
453
|
+
xgem_path=$(command -v xgem)
|
|
454
|
+
[ -n "$xgem_path" ] || die "Could not resolve xgem's own path via 'command -v xgem'."
|
|
455
|
+
|
|
456
|
+
local checks_added=0
|
|
457
|
+
{
|
|
458
|
+
echo "#!/bin/bash"
|
|
459
|
+
echo "$_GIT_HOOK_MARKER"
|
|
460
|
+
local fw
|
|
461
|
+
for fw in "$CONFIG_DIR"/*/; do
|
|
462
|
+
[ -d "$fw" ] || continue
|
|
463
|
+
fw=$(basename "$fw")
|
|
464
|
+
if [ -f "$CONFIG_DIR/$fw/lint.sh" ]; then
|
|
465
|
+
echo "\"$xgem_path\" run $fw lint || exit 1"
|
|
466
|
+
checks_added=$((checks_added + 1))
|
|
467
|
+
fi
|
|
468
|
+
if [[ "$run_tests" == "y" || "$run_tests" == "Y" ]] && [ -f "$CONFIG_DIR/$fw/test.sh" ]; then
|
|
469
|
+
echo "\"$xgem_path\" run $fw test || exit 1"
|
|
470
|
+
checks_added=$((checks_added + 1))
|
|
471
|
+
fi
|
|
472
|
+
done
|
|
473
|
+
} > "$hook_path"
|
|
474
|
+
chmod +x "$hook_path"
|
|
475
|
+
|
|
476
|
+
if [ "$checks_added" -eq 0 ]; then
|
|
477
|
+
log_warn "No lint/test scripts found under $CONFIG_DIR — installed a hook that doesn't check anything yet."
|
|
478
|
+
fi
|
|
479
|
+
log_success "Installed pre-commit hook at $hook_path."
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
cmd_git_hooks_uninstall() {
|
|
483
|
+
local git_dir hook_path
|
|
484
|
+
git_dir=$(git rev-parse --git-dir 2>/dev/null) || die "Not a git repository."
|
|
485
|
+
hook_path="$git_dir/hooks/pre-commit"
|
|
486
|
+
|
|
487
|
+
[ -f "$hook_path" ] || { log_info "No pre-commit hook installed."; return 0; }
|
|
488
|
+
grep -qF "$_GIT_HOOK_MARKER" "$hook_path" || die "$hook_path wasn't created by xgem — not removing it."
|
|
489
|
+
|
|
490
|
+
rm -f "$hook_path"
|
|
491
|
+
log_success "Removed pre-commit hook."
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
cmd_git_hooks() {
|
|
495
|
+
case "$1" in
|
|
496
|
+
install) cmd_git_hooks_install ;;
|
|
497
|
+
uninstall) cmd_git_hooks_uninstall ;;
|
|
498
|
+
*) die "Usage: xgem git hooks <install|uninstall>" ;;
|
|
499
|
+
esac
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
# xgem git <cmt|init|rm-remote|rm-branch|branch|pr|sync|clean-branches|hooks> ...
|
|
245
503
|
cmd_git() {
|
|
246
504
|
case "$1" in
|
|
247
|
-
cmt)
|
|
248
|
-
init)
|
|
249
|
-
rm-remote)
|
|
250
|
-
rm-branch)
|
|
251
|
-
branch)
|
|
252
|
-
|
|
505
|
+
cmt) cmd_git_cmt "$2" ;;
|
|
506
|
+
init) cmd_git_init ;;
|
|
507
|
+
rm-remote) cmd_git_rm_remote ;;
|
|
508
|
+
rm-branch) cmd_git_rm_branch ;;
|
|
509
|
+
branch) cmd_git_branch ;;
|
|
510
|
+
pr) cmd_git_pr ;;
|
|
511
|
+
sync) cmd_git_sync ;;
|
|
512
|
+
clean-branches) cmd_git_clean_branches ;;
|
|
513
|
+
hooks) cmd_git_hooks "$2" ;;
|
|
514
|
+
*) die "Unknown git subcommand '$1'. Usage: xgem git <cmt|init|rm-remote|rm-branch|branch|pr|sync|clean-branches|hooks>" ;;
|
|
253
515
|
esac
|
|
254
516
|
}
|
package/lib/registry.sh
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# xgem's global (cross-project) registry: which directories have been
|
|
3
|
+
# scaffolded with `xgem init`/`xgem add`, so `xgem status` can find them
|
|
4
|
+
# without the user having to remember or list them by hand.
|
|
5
|
+
# Depends on lib/logger.sh.
|
|
6
|
+
|
|
7
|
+
REGISTRY_FILE="${XGEM_REGISTRY_FILE:-$HOME/.xgem/projects}"
|
|
8
|
+
|
|
9
|
+
registry_add() {
|
|
10
|
+
local path=$1
|
|
11
|
+
mkdir -p "$(dirname "$REGISTRY_FILE")"
|
|
12
|
+
touch "$REGISTRY_FILE"
|
|
13
|
+
grep -qxF "$path" "$REGISTRY_FILE" || echo "$path" >> "$REGISTRY_FILE"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
registry_remove() {
|
|
17
|
+
local path=$1
|
|
18
|
+
[ -f "$REGISTRY_FILE" ] || return 0
|
|
19
|
+
local tmp
|
|
20
|
+
tmp=$(mktemp)
|
|
21
|
+
grep -vxF "$path" "$REGISTRY_FILE" > "$tmp" && mv "$tmp" "$REGISTRY_FILE"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# registry_list — prints one project path per line, pruning (and rewriting
|
|
25
|
+
# the registry for) any entry that no longer exists or is no longer
|
|
26
|
+
# xgem-tracked.
|
|
27
|
+
registry_list() {
|
|
28
|
+
[ -f "$REGISTRY_FILE" ] || return 0
|
|
29
|
+
local -a live=()
|
|
30
|
+
local path
|
|
31
|
+
while IFS= read -r path; do
|
|
32
|
+
[ -n "$path" ] || continue
|
|
33
|
+
if [ -d "$path/$CONFIG_DIR" ]; then
|
|
34
|
+
live+=("$path")
|
|
35
|
+
fi
|
|
36
|
+
done < "$REGISTRY_FILE"
|
|
37
|
+
|
|
38
|
+
printf '%s\n' "${live[@]}"
|
|
39
|
+
|
|
40
|
+
local count
|
|
41
|
+
count=$(wc -l < "$REGISTRY_FILE" | tr -d ' ')
|
|
42
|
+
if [ "$count" != "${#live[@]}" ]; then
|
|
43
|
+
printf '%s\n' "${live[@]}" > "$REGISTRY_FILE"
|
|
44
|
+
fi
|
|
45
|
+
}
|
package/lib/release.sh
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# xgem release: framework-aware version bump + changelog + tag + push.
|
|
3
|
+
# Depends on lib/logger.sh, lib/utils.sh, lib/git.sh (_git_default_remote).
|
|
4
|
+
|
|
5
|
+
_release_detect_version_files() {
|
|
6
|
+
local -a found=()
|
|
7
|
+
[ -f package.json ] && found+=(package.json)
|
|
8
|
+
[ -f pubspec.yaml ] && found+=(pubspec.yaml)
|
|
9
|
+
[ -f Cargo.toml ] && found+=(Cargo.toml)
|
|
10
|
+
[ -f pyproject.toml ] && found+=(pyproject.toml)
|
|
11
|
+
printf '%s\n' "${found[@]}"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
# _release_read_version <file> -> current version string (empty on failure)
|
|
15
|
+
_release_read_version() {
|
|
16
|
+
case "$1" in
|
|
17
|
+
package.json)
|
|
18
|
+
grep -m1 '"version"' package.json | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/'
|
|
19
|
+
;;
|
|
20
|
+
pubspec.yaml)
|
|
21
|
+
grep '^version:' pubspec.yaml | head -1 | awk '{print $2}'
|
|
22
|
+
;;
|
|
23
|
+
Cargo.toml|pyproject.toml)
|
|
24
|
+
grep -m1 -E '^version[[:space:]]*=' "$1" | sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/'
|
|
25
|
+
;;
|
|
26
|
+
esac
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# _release_write_version <file> <new_version>
|
|
30
|
+
# Uses awk (not sed's GNU-only "0,/re/" first-match addressing, which BSD
|
|
31
|
+
# sed on macOS doesn't support) to replace only the first matching line.
|
|
32
|
+
_release_write_version() {
|
|
33
|
+
local file=$1 new=$2 tmp
|
|
34
|
+
tmp=$(mktemp)
|
|
35
|
+
case "$file" in
|
|
36
|
+
package.json)
|
|
37
|
+
awk -v new="$new" '
|
|
38
|
+
!done && /"version"[[:space:]]*:[[:space:]]*"[^"]*"/ {
|
|
39
|
+
sub(/"version"[[:space:]]*:[[:space:]]*"[^"]*"/, "\"version\": \"" new "\"")
|
|
40
|
+
done = 1
|
|
41
|
+
}
|
|
42
|
+
{ print }
|
|
43
|
+
' "$file" > "$tmp" && mv "$tmp" "$file"
|
|
44
|
+
;;
|
|
45
|
+
pubspec.yaml)
|
|
46
|
+
sed -E "s/^version:.*/version: $new/" "$file" > "$tmp" && mv "$tmp" "$file"
|
|
47
|
+
;;
|
|
48
|
+
Cargo.toml|pyproject.toml)
|
|
49
|
+
awk -v new="$new" '
|
|
50
|
+
!done && /^version[[:space:]]*=[[:space:]]*"[^"]*"/ {
|
|
51
|
+
sub(/^version[[:space:]]*=[[:space:]]*"[^"]*"/, "version = \"" new "\"")
|
|
52
|
+
done = 1
|
|
53
|
+
}
|
|
54
|
+
{ print }
|
|
55
|
+
' "$file" > "$tmp" && mv "$tmp" "$file"
|
|
56
|
+
;;
|
|
57
|
+
esac
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# _release_bump <version> <major|minor|patch> -> new version, or empty if
|
|
61
|
+
# <version> isn't a plain X.Y.Z.
|
|
62
|
+
_release_bump() {
|
|
63
|
+
local version=$1 kind=$2 suffix="" core
|
|
64
|
+
core=${version%%+*}
|
|
65
|
+
[ "$core" != "$version" ] && suffix="+${version#*+}"
|
|
66
|
+
|
|
67
|
+
[[ "$core" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 1
|
|
68
|
+
local major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]} patch=${BASH_REMATCH[3]}
|
|
69
|
+
case "$kind" in
|
|
70
|
+
major) major=$((major + 1)); minor=0; patch=0 ;;
|
|
71
|
+
minor) minor=$((minor + 1)); patch=0 ;;
|
|
72
|
+
patch) patch=$((patch + 1)) ;;
|
|
73
|
+
esac
|
|
74
|
+
echo "${major}.${minor}.${patch}${suffix}"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_release_write_changelog() {
|
|
78
|
+
local version=$1 last_tag=$2
|
|
79
|
+
local -a features=() fixes=() other=()
|
|
80
|
+
local subject
|
|
81
|
+
|
|
82
|
+
while IFS= read -r subject; do
|
|
83
|
+
[ -n "$subject" ] || continue
|
|
84
|
+
case "$subject" in
|
|
85
|
+
feat*) features+=("$subject") ;;
|
|
86
|
+
fix*) fixes+=("$subject") ;;
|
|
87
|
+
*) other+=("$subject") ;;
|
|
88
|
+
esac
|
|
89
|
+
done < <(git log --format='%s' "${last_tag:+$last_tag..}HEAD" 2>/dev/null)
|
|
90
|
+
|
|
91
|
+
local tmp
|
|
92
|
+
tmp=$(mktemp)
|
|
93
|
+
{
|
|
94
|
+
echo "## v$version - $(date +%Y-%m-%d)"
|
|
95
|
+
if [ ${#features[@]} -gt 0 ]; then
|
|
96
|
+
echo ""
|
|
97
|
+
echo "### Features"
|
|
98
|
+
printf -- '- %s\n' "${features[@]}"
|
|
99
|
+
fi
|
|
100
|
+
if [ ${#fixes[@]} -gt 0 ]; then
|
|
101
|
+
echo ""
|
|
102
|
+
echo "### Fixes"
|
|
103
|
+
printf -- '- %s\n' "${fixes[@]}"
|
|
104
|
+
fi
|
|
105
|
+
if [ ${#other[@]} -gt 0 ]; then
|
|
106
|
+
echo ""
|
|
107
|
+
echo "### Other"
|
|
108
|
+
printf -- '- %s\n' "${other[@]}"
|
|
109
|
+
fi
|
|
110
|
+
echo ""
|
|
111
|
+
if [ -f CHANGELOG.md ]; then
|
|
112
|
+
tail -n +2 CHANGELOG.md
|
|
113
|
+
fi
|
|
114
|
+
} > "$tmp"
|
|
115
|
+
{ echo "# Changelog"; echo ""; cat "$tmp"; } > CHANGELOG.md
|
|
116
|
+
rm -f "$tmp"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
cmd_release() {
|
|
120
|
+
local -a version_files=()
|
|
121
|
+
while IFS= read -r f; do version_files+=("$f"); done < <(_release_detect_version_files)
|
|
122
|
+
[ ${#version_files[@]} -gt 0 ] || die "No recognized version file (package.json/pubspec.yaml/Cargo.toml/pyproject.toml) found here."
|
|
123
|
+
|
|
124
|
+
local file
|
|
125
|
+
if [ ${#version_files[@]} -eq 1 ]; then
|
|
126
|
+
file="${version_files[0]}"
|
|
127
|
+
else
|
|
128
|
+
echo "Multiple version files found:"
|
|
129
|
+
local i=1
|
|
130
|
+
for f in "${version_files[@]}"; do
|
|
131
|
+
echo "$i) $f"
|
|
132
|
+
i=$((i + 1))
|
|
133
|
+
done
|
|
134
|
+
local choice
|
|
135
|
+
read -r -p "Which is the canonical one to bump? [1-${#version_files[@]}]: " choice
|
|
136
|
+
[[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le ${#version_files[@]} ] \
|
|
137
|
+
|| die "Invalid selection."
|
|
138
|
+
file="${version_files[$((choice - 1))]}"
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
local current new_version bump_kind
|
|
142
|
+
current=$(_release_read_version "$file")
|
|
143
|
+
[ -n "$current" ] || die "Could not read a version from $file."
|
|
144
|
+
log_info "Current version in $file: $current"
|
|
145
|
+
|
|
146
|
+
bump_kind=$1
|
|
147
|
+
case "$bump_kind" in
|
|
148
|
+
major|minor|patch) ;;
|
|
149
|
+
"")
|
|
150
|
+
read -r -p "Bump [major/minor/patch] (default: patch): " bump_kind
|
|
151
|
+
bump_kind=${bump_kind:-patch}
|
|
152
|
+
;;
|
|
153
|
+
*) die "Unknown bump kind '$bump_kind'. Usage: xgem release [major|minor|patch]" ;;
|
|
154
|
+
esac
|
|
155
|
+
|
|
156
|
+
new_version=$(_release_bump "$current" "$bump_kind")
|
|
157
|
+
if [ -z "$new_version" ]; then
|
|
158
|
+
log_warn "'$current' isn't a plain X.Y.Z version — can't bump it automatically."
|
|
159
|
+
read -r -p "Enter the new version directly: " new_version
|
|
160
|
+
[ -n "$new_version" ] || die "A new version is required."
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
log_info "$current -> $new_version"
|
|
164
|
+
confirm "Write this version, update CHANGELOG.md, commit, and tag?" || { log_info "Cancelled."; return 0; }
|
|
165
|
+
|
|
166
|
+
_release_write_version "$file" "$new_version"
|
|
167
|
+
|
|
168
|
+
local last_tag
|
|
169
|
+
last_tag=$(git describe --tags --abbrev=0 2>/dev/null)
|
|
170
|
+
_release_write_changelog "$new_version" "$last_tag"
|
|
171
|
+
|
|
172
|
+
git add "$file" CHANGELOG.md
|
|
173
|
+
git commit -m "chore(release): v$new_version" || die "Commit failed."
|
|
174
|
+
git tag -a "v$new_version" -m "v$new_version" || die "Tag failed."
|
|
175
|
+
log_success "Committed and tagged v$new_version."
|
|
176
|
+
|
|
177
|
+
local remote current_branch
|
|
178
|
+
remote=$(_git_default_remote)
|
|
179
|
+
if [ -n "$remote" ] && confirm "Push commit and tag to '$remote'?"; then
|
|
180
|
+
current_branch=$(git branch --show-current 2>/dev/null)
|
|
181
|
+
git push "$remote" "$current_branch" && git push "$remote" "v$new_version" \
|
|
182
|
+
&& log_success "Pushed v$new_version to '$remote'." \
|
|
183
|
+
|| die "Push failed — the commit and tag are safe locally."
|
|
184
|
+
fi
|
|
185
|
+
}
|
package/lib/scaffold.sh
CHANGED
|
@@ -13,12 +13,31 @@
|
|
|
13
13
|
|
|
14
14
|
ALL_FRAMEWORKS=(flutter node python react vue angular next go rust docker swift)
|
|
15
15
|
|
|
16
|
+
# _package_json_has_script <script> — checks the CURRENT directory's
|
|
17
|
+
# package.json, not a guess. Used to only offer lint/test automation when
|
|
18
|
+
# the project actually has those scripts, instead of always generating
|
|
19
|
+
# scripts that fail with "Missing script" on projects that don't.
|
|
20
|
+
_package_json_has_script() {
|
|
21
|
+
[ -f package.json ] || return 1
|
|
22
|
+
grep -qE "\"$1\"[[:space:]]*:" package.json 2>/dev/null
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
framework_scripts() {
|
|
17
26
|
case "$1" in
|
|
18
27
|
flutter) echo "hard-clean build build-runner" ;;
|
|
19
|
-
node)
|
|
28
|
+
node)
|
|
29
|
+
local scripts="hard-clean build start"
|
|
30
|
+
_package_json_has_script lint && scripts="$scripts lint"
|
|
31
|
+
_package_json_has_script test && scripts="$scripts test"
|
|
32
|
+
echo "$scripts"
|
|
33
|
+
;;
|
|
20
34
|
python) echo "hard-clean install" ;;
|
|
21
|
-
react|vue|angular|next)
|
|
35
|
+
react|vue|angular|next)
|
|
36
|
+
local scripts="hard-clean build dev"
|
|
37
|
+
_package_json_has_script lint && scripts="$scripts lint"
|
|
38
|
+
_package_json_has_script test && scripts="$scripts test"
|
|
39
|
+
echo "$scripts"
|
|
40
|
+
;;
|
|
22
41
|
go) echo "hard-clean build" ;;
|
|
23
42
|
rust) echo "hard-clean build" ;;
|
|
24
43
|
docker) echo "hard-clean build-up" ;;
|
package/lib/status.sh
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# xgem status: a one-screen dashboard across every project xgem has ever
|
|
3
|
+
# scaffolded, so a developer doesn't have to `cd` into each one just to see
|
|
4
|
+
# what's dirty. Depends on lib/logger.sh, lib/registry.sh.
|
|
5
|
+
|
|
6
|
+
# _status_row <path> — prints one tab-separated row: path, branch, dirty
|
|
7
|
+
# count, ahead/behind, frameworks. Run in a subshell so `cd` never leaks.
|
|
8
|
+
_status_row() {
|
|
9
|
+
local path=$1
|
|
10
|
+
(
|
|
11
|
+
cd "$path" || exit 0
|
|
12
|
+
local branch dirty ahead_behind frameworks
|
|
13
|
+
branch=$(git branch --show-current 2>/dev/null)
|
|
14
|
+
[ -n "$branch" ] || branch="-"
|
|
15
|
+
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
|
16
|
+
ahead_behind=$(git rev-list --left-right --count '@{u}...HEAD' 2>/dev/null | awk '{print "-"$1" +"$2}')
|
|
17
|
+
[ -n "$ahead_behind" ] || ahead_behind="-"
|
|
18
|
+
frameworks=$(ls "$CONFIG_DIR" 2>/dev/null | tr '\n' ',' | sed 's/,$//')
|
|
19
|
+
[ -n "$frameworks" ] || frameworks="-"
|
|
20
|
+
printf '%s\t%s\t%s\t%s\t%s\n' "$path" "$branch" "$dirty" "$ahead_behind" "$frameworks"
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
cmd_status() {
|
|
25
|
+
local -a projects=()
|
|
26
|
+
while IFS= read -r p; do
|
|
27
|
+
[ -n "$p" ] && projects+=("$p")
|
|
28
|
+
done < <(registry_list)
|
|
29
|
+
|
|
30
|
+
if [ ${#projects[@]} -eq 0 ]; then
|
|
31
|
+
log_info "No xgem-tracked projects found yet — run 'xgem init' in a project to start tracking it."
|
|
32
|
+
return 0
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
printf '%-45s %-20s %-6s %-10s %s\n' "PROJECT" "BRANCH" "DIRTY" "AHEAD/BEHIND" "FRAMEWORKS"
|
|
36
|
+
local p row
|
|
37
|
+
for p in "${projects[@]}"; do
|
|
38
|
+
row=$(_status_row "$p")
|
|
39
|
+
IFS=$'\t' read -r path branch dirty ahead_behind frameworks <<< "$row"
|
|
40
|
+
printf '%-45s %-20s %-6s %-10s %s\n' "$path" "$branch" "$dirty" "$ahead_behind" "$frameworks"
|
|
41
|
+
done
|
|
42
|
+
}
|
package/lib/utils.sh
CHANGED
|
@@ -42,6 +42,17 @@ require_cmd() {
|
|
|
42
42
|
fi
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
# _open_url <url> — best-effort browser open, silent no-op on an OS xgem
|
|
46
|
+
# doesn't know how to open a browser on.
|
|
47
|
+
_open_url() {
|
|
48
|
+
local url=$1
|
|
49
|
+
case "$(detect_os)" in
|
|
50
|
+
darwin) open "$url" 2>/dev/null ;;
|
|
51
|
+
linux) xdg-open "$url" 2>/dev/null ;;
|
|
52
|
+
*) log_debug "Don't know how to open a browser on this OS — visit $url manually." ;;
|
|
53
|
+
esac
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
# confirm "prompt text" -> 0 if approved, 1 otherwise
|
|
46
57
|
# Honors XGEM_YES=1 (from --yes) to auto-approve, and always returns 1
|
|
47
58
|
# (declines) under XGEM_DRY_RUN so callers never apply changes in dry-run mode.
|