pan-stack-cli 0.1.4 → 0.1.5

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 CHANGED
@@ -40,11 +40,11 @@ skill pre-installed for all three agent folders.
40
40
  ## Requirements
41
41
 
42
42
  - Node.js 18+
43
- - Git (for `pan clone`, `pull`, `fetch`, `push`, `grv`, `gst`)
43
+ - Git (for `pan clone`, `pull`, `sync-main`, `fetch`, `push`, `grv`, `gst`)
44
44
 
45
45
  ## Windows support
46
46
 
47
- Built-in commands (`create`, `clone`, `pull`, `push`, `fetch`, `grv`, `gst`) run on
47
+ Built-in commands (`create`, `clone`, `pull`, `sync-main`, `push`, `fetch`, `grv`, `gst`) run on
48
48
  Windows, macOS, and Linux. Notes for Windows:
49
49
 
50
50
  - `shell:` blocks in `.pan.yaml` run through **bash** on all platforms (Git Bash on
@@ -158,6 +158,7 @@ Read `repositories` from the nearest `.project.yaml` (walk-up from cwd):
158
158
  |---------|--------|
159
159
  | `pan clone` | Clone all repositories in the manifest |
160
160
  | `pan pull` | Pull all cloned repositories |
161
+ | `pan sync-main [repo\|all]` (`pan sm`) | Fetch + merge `origin/main` into current branch (interactive picker; conflict summary) |
161
162
  | `pan fetch` | Fetch all remotes |
162
163
  | `pan push` | Push all repos (`-u origin HEAD` by default) |
163
164
  | `pan grv` | `git remote -v` for workspace + each child repo |
@@ -202,7 +203,7 @@ pan-stack-cli/
202
203
  platform.js # Windows/cross-platform helpers
203
204
  manifest.js # YAML parsing
204
205
  run-command.js # command execution
205
- git/ # clone, pull, push, fetch, grv, gst
206
+ git/ # clone, pull, sync-main, push, fetch, grv, gst
206
207
  templates/workspace/ # pan create template
207
208
  references/ # npm publish docs
208
209
  ```
package/bin/pan.js CHANGED
@@ -19,6 +19,8 @@ const GIT_SCRIPT_MAP = {
19
19
  push: "git-push.js",
20
20
  grv: "git-grv.js",
21
21
  gst: "git-gst.js",
22
+ "sync-main": "git-sync-main.js",
23
+ sm: "git-sync-main.js",
22
24
  };
23
25
 
24
26
  function parseGlobalArgs(argv) {
@@ -54,6 +56,7 @@ workspace commands (built-in):
54
56
  create Scaffold a new PAN Stack workspace (prompts for folder name)
55
57
  clone Clone all repositories from .project.yaml
56
58
  pull Pull latest changes for all repositories
59
+ sync-main (sm) Fetch origin/main and merge into current branch (pick repo or all)
57
60
  fetch Fetch all remotes for all repositories
58
61
  push Push all repositories and set upstream
59
62
  grv List git remotes -v for workspace + child repos
package/lib/discover.js CHANGED
@@ -12,7 +12,16 @@ const {
12
12
  cleanValue,
13
13
  } = require("./manifest");
14
14
 
15
- const BUILTIN_GIT_COMMANDS = new Set(["clone", "pull", "fetch", "push", "grv", "gst"]);
15
+ const BUILTIN_GIT_COMMANDS = new Set([
16
+ "clone",
17
+ "pull",
18
+ "sync-main",
19
+ "sm",
20
+ "fetch",
21
+ "push",
22
+ "grv",
23
+ "gst",
24
+ ]);
16
25
  const BUILTIN_WORKSPACE_COMMANDS = new Set(["create", ...BUILTIN_GIT_COMMANDS]);
17
26
 
18
27
  function readProjectRoots() {
@@ -10,7 +10,7 @@
10
10
  const fs = require("fs");
11
11
  const path = require("path");
12
12
  const { spawnSync } = require("child_process");
13
- const { spawnOptions } = require("../../platform");
13
+ const { spawnOptions } = require("../platform");
14
14
  const { parseCommonArgs, loadManifestRepos, printCommonOptions } = require("./helpers/cli");
15
15
 
16
16
  function cloneRepo({ name, url, destination, dryRun }) {
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Sync origin/main (or --ref) into the current branch for selected PAN Stack repos.
4
+ *
5
+ * Fetches first, then merge (default) or rebase (--rebase). Interactive repo picker
6
+ * when TTY; pass repo name or "all" as first positional argument.
7
+ */
8
+
9
+ const {
10
+ looksLikeManifest,
11
+ loadManifestRepos,
12
+ printCommonOptions,
13
+ } = require("./helpers/cli");
14
+ const colors = require("./helpers/colors");
15
+ const { resolveTargets } = require("./helpers/repo-select");
16
+ const {
17
+ ensureRepoExists,
18
+ runGit,
19
+ runGitCapture,
20
+ getCurrentBranch,
21
+ refExists,
22
+ listConflictedFiles,
23
+ isMergeOrRebaseInProgress,
24
+ countDirtyFiles,
25
+ } = require("./helpers/git");
26
+
27
+ const ICON = {
28
+ ok: "✅",
29
+ warn: "⚠️",
30
+ conflict: "⛔",
31
+ skip: "⏭️",
32
+ info: "ℹ️",
33
+ };
34
+
35
+ function printHelp() {
36
+ console.log(`usage: pan sync-main [repo|all] [options] (alias: pan sm)
37
+
38
+ Fetch remote main (or --ref) and merge into the current branch for workspace and/or child repos.
39
+
40
+ positional:
41
+ repo|all Repo name from .project.yaml, or "all". Interactive picker if omitted (TTY).
42
+
43
+ ${printCommonOptions()}
44
+ --ref REF Remote-tracking ref to merge (default: origin/main)
45
+ --rebase Use git rebase instead of merge
46
+ --no-workspace Only manifest child repos (skip workspace stack root)`);
47
+ }
48
+
49
+ function parseSyncMainArgs(argv) {
50
+ const args = {
51
+ help: false,
52
+ manifest: "",
53
+ baseDir: ".",
54
+ dryRun: false,
55
+ ref: "origin/main",
56
+ rebase: false,
57
+ noWorkspace: false,
58
+ selection: "",
59
+ };
60
+
61
+ for (let index = 0; index < argv.length; index += 1) {
62
+ const arg = argv[index];
63
+ if (arg === "-h" || arg === "--help") {
64
+ args.help = true;
65
+ } else if (arg === "--base-dir") {
66
+ args.baseDir = argv[++index] || ".";
67
+ } else if (arg === "--dry-run") {
68
+ args.dryRun = true;
69
+ } else if (arg === "--ref") {
70
+ args.ref = argv[++index] || "origin/main";
71
+ } else if (arg === "--rebase") {
72
+ args.rebase = true;
73
+ } else if (arg === "--no-workspace") {
74
+ args.noWorkspace = true;
75
+ } else if (!arg.startsWith("-")) {
76
+ if (looksLikeManifest(arg) && !args.manifest) {
77
+ args.manifest = arg;
78
+ } else if (!args.selection) {
79
+ args.selection = arg;
80
+ } else {
81
+ throw new Error(`Unexpected argument: ${arg}`);
82
+ }
83
+ } else {
84
+ throw new Error(`Unexpected argument: ${arg}`);
85
+ }
86
+ }
87
+
88
+ return args;
89
+ }
90
+
91
+ function printEarlyConflict({ name, branch, ref, files, mode }) {
92
+ const headline = colors.bold(
93
+ colors.red(`${ICON.conflict} [conflict] ${name} (${branch})`)
94
+ );
95
+ const detail = colors.yellow(
96
+ `${mode} ${ref} thất bại — ${files.length} file conflict`
97
+ );
98
+ console.log(`${headline} — ${detail}`);
99
+ for (const file of files.slice(0, 8)) {
100
+ console.log(colors.dim(` • ${file}`));
101
+ }
102
+ if (files.length > 8) {
103
+ console.log(colors.dim(` … và ${files.length - 8} file khác`));
104
+ }
105
+ }
106
+
107
+ function syncMainRepo({
108
+ name,
109
+ destination,
110
+ dryRun,
111
+ ref,
112
+ rebase,
113
+ }) {
114
+ const result = {
115
+ name,
116
+ destination,
117
+ branch: "",
118
+ status: "skipped",
119
+ message: "",
120
+ conflictFiles: [],
121
+ mode: rebase ? "rebase" : "merge",
122
+ };
123
+
124
+ if (!ensureRepoExists({ name, destination })) {
125
+ result.message = "repo không tồn tại hoặc chưa clone";
126
+ return result;
127
+ }
128
+
129
+ const branch = getCurrentBranch(destination);
130
+ result.branch = branch || "(detached HEAD)";
131
+ if (!branch) {
132
+ result.message = "đang detached HEAD — bỏ qua";
133
+ console.log(`${ICON.skip} [skip] ${name}: ${result.message}`);
134
+ return result;
135
+ }
136
+
137
+ const inProgress = isMergeOrRebaseInProgress(destination);
138
+ if (inProgress) {
139
+ const files = listConflictedFiles(destination);
140
+ result.status = "conflict";
141
+ result.conflictFiles = files;
142
+ result.message = `đang dở ${inProgress} — cần resolve trước`;
143
+ printEarlyConflict({ name, branch, ref, files, mode: inProgress });
144
+ return result;
145
+ }
146
+
147
+ const dirtyCount = countDirtyFiles(destination);
148
+ if (dirtyCount > 0) {
149
+ console.log(
150
+ `${ICON.warn} ${colors.yellow(`[warn] ${name}: working tree có ${dirtyCount} thay đổi chưa commit`)}`
151
+ );
152
+ }
153
+
154
+ if (dryRun) {
155
+ console.log(
156
+ `[dry-run] (in ${destination}) git fetch origin && git ${rebase ? "rebase" : "merge"} ${ref}`
157
+ );
158
+ result.status = "dry-run";
159
+ result.message = "dry-run";
160
+ return result;
161
+ }
162
+
163
+ console.log(`[fetch] ${name}: ${destination}`);
164
+ const fetch = runGitCapture(["fetch", "origin"], { cwd: destination });
165
+ if (fetch.status !== 0) {
166
+ result.status = "error";
167
+ result.message = fetch.stderr || "git fetch origin thất bại";
168
+ console.log(`${ICON.warn} ${colors.yellow(`[warn] ${name}: ${result.message}`)}`);
169
+ return result;
170
+ }
171
+
172
+ if (!refExists(destination, ref)) {
173
+ result.status = "error";
174
+ result.message = `không tìm thấy ref ${ref} (chạy fetch trước?)`;
175
+ console.log(`${ICON.warn} ${colors.yellow(`[warn] ${name}: ${result.message}`)}`);
176
+ return result;
177
+ }
178
+
179
+ const gitArgs = rebase ? ["rebase", ref] : ["merge", "--no-edit", ref];
180
+ console.log(`[${result.mode}] ${name} (${branch}) ← ${ref}`);
181
+ const action = runGit(gitArgs, {
182
+ cwd: destination,
183
+ dryRun: false,
184
+ action: result.mode,
185
+ name,
186
+ });
187
+
188
+ const conflictFiles = listConflictedFiles(destination);
189
+ if (conflictFiles.length > 0 || action !== 0) {
190
+ result.status = "conflict";
191
+ result.conflictFiles = conflictFiles.length > 0 ? conflictFiles : ["(xem git status)"];
192
+ result.message = `${result.mode} conflict`;
193
+ printEarlyConflict({
194
+ name,
195
+ branch,
196
+ ref,
197
+ files: result.conflictFiles,
198
+ mode: result.mode,
199
+ });
200
+ return result;
201
+ }
202
+
203
+ result.status = "ok";
204
+ result.message = "đã cập nhật";
205
+ console.log(`${ICON.ok} ${colors.green(`[ok] ${name}: ${branch} đã sync ${ref}`)}`);
206
+ return result;
207
+ }
208
+
209
+ function printSummary(results, { ref, rebase }) {
210
+ const ok = results.filter((item) => item.status === "ok");
211
+ const conflicts = results.filter((item) => item.status === "conflict");
212
+ const errors = results.filter((item) => item.status === "error");
213
+ const skipped = results.filter(
214
+ (item) => item.status === "skipped" || item.status === "dry-run"
215
+ );
216
+
217
+ console.log("");
218
+ console.log(colors.bold("─── sync-main summary ───"));
219
+ console.log(
220
+ `${ICON.info} ref: ${ref} · mode: ${rebase ? "rebase" : "merge"} · repos: ${results.length}`
221
+ );
222
+ console.log(
223
+ ` ${ICON.ok} ok: ${ok.length} · ${ICON.conflict} conflict: ${conflicts.length} · ${ICON.warn} error: ${errors.length} · ${ICON.skip} skip: ${skipped.length}`
224
+ );
225
+
226
+ if (conflicts.length > 0) {
227
+ console.log("");
228
+ console.log(colors.bold(colors.red(`${ICON.conflict} CONFLICT — cần xử lý thủ công`)));
229
+ for (const item of conflicts) {
230
+ console.log(colors.yellow(`\n ${item.name} (${item.branch})`));
231
+ console.log(colors.dim(` ${item.destination}`));
232
+ for (const file of item.conflictFiles) {
233
+ console.log(` • ${file}`);
234
+ }
235
+ const hint = item.mode === "rebase"
236
+ ? "git add <file>… && git rebase --continue (hoặc git rebase --abort)"
237
+ : "git add <file>… && git commit";
238
+ console.log(colors.dim(` → ${hint}`));
239
+ }
240
+ }
241
+
242
+ if (errors.length > 0) {
243
+ console.log("");
244
+ console.log(colors.bold(colors.yellow(`${ICON.warn} LỖI`)));
245
+ for (const item of errors) {
246
+ console.log(` • ${item.name}: ${item.message}`);
247
+ }
248
+ }
249
+
250
+ if (conflicts.length > 0) {
251
+ console.log("");
252
+ console.log(
253
+ colors.bold(
254
+ colors.red(
255
+ `${ICON.conflict} Kết thúc với conflict — resolve từng repo ở trên rồi commit/rebase --continue`
256
+ )
257
+ )
258
+ );
259
+ } else if (errors.length > 0) {
260
+ console.log("");
261
+ console.log(
262
+ colors.bold(colors.yellow(`${ICON.warn} Kết thúc với lỗi — xem chi tiết ở trên`))
263
+ );
264
+ } else if (ok.length > 0) {
265
+ console.log("");
266
+ console.log(colors.green(`${ICON.ok} Tất cả repo đã chọn đã sync ${ref}`));
267
+ }
268
+ }
269
+
270
+ async function main() {
271
+ const argv = process.argv.slice(2);
272
+ const args = parseSyncMainArgs(argv);
273
+ if (args.help) {
274
+ printHelp();
275
+ return;
276
+ }
277
+
278
+ const { repos, workspaceRoot } = loadManifestRepos({
279
+ manifestPath: args.manifest,
280
+ baseDir: args.baseDir,
281
+ });
282
+
283
+ const targets = await resolveTargets({
284
+ repos,
285
+ workspaceRoot,
286
+ includeWorkspace: !args.noWorkspace,
287
+ selection: args.selection,
288
+ });
289
+
290
+ console.log(
291
+ `${ICON.info} sync-main: ${targets.length} repo · ${args.rebase ? "rebase" : "merge"} ← ${args.ref}`
292
+ );
293
+
294
+ const results = [];
295
+ for (const target of targets) {
296
+ results.push(
297
+ syncMainRepo({
298
+ name: target.name,
299
+ destination: target.destination,
300
+ dryRun: args.dryRun,
301
+ ref: args.ref,
302
+ rebase: args.rebase,
303
+ })
304
+ );
305
+ console.log("");
306
+ }
307
+
308
+ printSummary(results, { ref: args.ref, rebase: args.rebase });
309
+
310
+ const hasConflict = results.some((item) => item.status === "conflict");
311
+ const hasError = results.some((item) => item.status === "error");
312
+ if (hasConflict || hasError) {
313
+ process.exit(1);
314
+ }
315
+ }
316
+
317
+ main().catch((error) => {
318
+ console.error(error.message);
319
+ process.exit(1);
320
+ });
@@ -146,6 +146,7 @@ options:
146
146
 
147
147
  module.exports = {
148
148
  parseCommonArgs,
149
+ looksLikeManifest,
149
150
  resolveProjectWorkspace,
150
151
  loadManifestRepos,
151
152
  printCommonOptions,
@@ -0,0 +1,19 @@
1
+ const supportsColor =
2
+ Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && process.env.FORCE_COLOR !== "0";
3
+
4
+ function wrap(code, text) {
5
+ if (!supportsColor || text == null) {
6
+ return String(text ?? "");
7
+ }
8
+ return `\u001b[${code}m${text}\u001b[0m`;
9
+ }
10
+
11
+ module.exports = {
12
+ supportsColor,
13
+ bold: (text) => wrap("1", text),
14
+ red: (text) => wrap("31", text),
15
+ green: (text) => wrap("32", text),
16
+ yellow: (text) => wrap("33", text),
17
+ cyan: (text) => wrap("36", text),
18
+ dim: (text) => wrap("2", text),
19
+ };
@@ -53,9 +53,83 @@ function runGitStatus(directory, gitArgs = []) {
53
53
  return result.status ?? 1;
54
54
  }
55
55
 
56
+ function runGitCapture(gitArgs, { cwd } = {}) {
57
+ const result = spawnSync("git", gitArgs, spawnOptions({
58
+ cwd,
59
+ encoding: "utf8",
60
+ }));
61
+ return {
62
+ status: result.status ?? 1,
63
+ stdout: (result.stdout || "").trim(),
64
+ stderr: (result.stderr || "").trim(),
65
+ };
66
+ }
67
+
68
+ function getCurrentBranch(directory) {
69
+ const branch = runGitCapture(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: directory });
70
+ if (branch.status !== 0) {
71
+ return "";
72
+ }
73
+ if (branch.stdout === "HEAD") {
74
+ return "";
75
+ }
76
+ return branch.stdout;
77
+ }
78
+
79
+ function refExists(directory, ref) {
80
+ const result = runGitCapture(["rev-parse", "--verify", ref], { cwd: directory });
81
+ return result.status === 0;
82
+ }
83
+
84
+ function listConflictedFiles(directory) {
85
+ const result = runGitCapture(
86
+ ["diff", "--name-only", "--diff-filter=U"],
87
+ { cwd: directory }
88
+ );
89
+ if (result.status !== 0) {
90
+ return [];
91
+ }
92
+ return result.stdout
93
+ .split(/\r?\n/)
94
+ .map((line) => line.trim())
95
+ .filter(Boolean);
96
+ }
97
+
98
+ function isMergeOrRebaseInProgress(directory) {
99
+ const merge = runGitCapture(["rev-parse", "-q", "--verify", "MERGE_HEAD"], {
100
+ cwd: directory,
101
+ });
102
+ if (merge.status === 0) {
103
+ return "merge";
104
+ }
105
+ const rebaseMerge = fs.existsSync(path.join(directory, ".git", "rebase-merge"));
106
+ const rebaseApply = fs.existsSync(path.join(directory, ".git", "rebase-apply"));
107
+ if (rebaseMerge || rebaseApply) {
108
+ return "rebase";
109
+ }
110
+ return "";
111
+ }
112
+
113
+ function countDirtyFiles(directory) {
114
+ const result = runGitCapture(["status", "--porcelain"], { cwd: directory });
115
+ if (result.status !== 0) {
116
+ return 0;
117
+ }
118
+ return result.stdout
119
+ .split(/\r?\n/)
120
+ .map((line) => line.trim())
121
+ .filter(Boolean).length;
122
+ }
123
+
56
124
  module.exports = {
57
125
  isGitRepo,
58
126
  runGit,
127
+ runGitCapture,
128
+ getCurrentBranch,
129
+ refExists,
130
+ listConflictedFiles,
131
+ isMergeOrRebaseInProgress,
132
+ countDirtyFiles,
59
133
  ensureRepoExists,
60
134
  listRemotesVerbose,
61
135
  runGitStatus,
@@ -0,0 +1,97 @@
1
+ const readline = require("readline");
2
+ const { isGitRepo } = require("./git");
3
+
4
+ function buildTargets({ repos, workspaceRoot, includeWorkspace }) {
5
+ const targets = [];
6
+ if (includeWorkspace && isGitRepo(workspaceRoot)) {
7
+ targets.push({
8
+ name: "workspace",
9
+ destination: workspaceRoot,
10
+ label: "workspace (stack root)",
11
+ });
12
+ }
13
+ for (const repo of repos) {
14
+ targets.push({
15
+ name: repo.name,
16
+ destination: repo.destination,
17
+ label: repo.name,
18
+ });
19
+ }
20
+ return targets;
21
+ }
22
+
23
+ function findTargetByName(targets, name) {
24
+ const needle = String(name || "").trim();
25
+ if (!needle) {
26
+ return [];
27
+ }
28
+ return targets.filter((target) => target.name === needle);
29
+ }
30
+
31
+ function promptRepoChoice(targets) {
32
+ return new Promise((resolve, reject) => {
33
+ const rl = readline.createInterface({
34
+ input: process.stdin,
35
+ output: process.stdout,
36
+ });
37
+
38
+ console.log("");
39
+ console.log("Chọn repo để sync-main:");
40
+ console.log(` 1. all (${targets.length} repo)`);
41
+ targets.forEach((target, index) => {
42
+ console.log(` ${index + 2}. ${target.label}`);
43
+ console.log(` ${target.destination}`);
44
+ });
45
+ console.log("");
46
+
47
+ rl.question("Nhập số (1 = all): ", (answer) => {
48
+ rl.close();
49
+ const choice = Number.parseInt(String(answer).trim(), 10);
50
+ if (!Number.isFinite(choice) || choice < 1 || choice > targets.length + 1) {
51
+ reject(new Error("Lựa chọn không hợp lệ."));
52
+ return;
53
+ }
54
+ if (choice === 1) {
55
+ resolve(targets);
56
+ return;
57
+ }
58
+ resolve([targets[choice - 2]]);
59
+ });
60
+ });
61
+ }
62
+
63
+ async function resolveTargets({
64
+ repos,
65
+ workspaceRoot,
66
+ includeWorkspace,
67
+ selection,
68
+ }) {
69
+ const targets = buildTargets({ repos, workspaceRoot, includeWorkspace });
70
+ if (targets.length === 0) {
71
+ throw new Error("Không có repo git nào để sync-main.");
72
+ }
73
+
74
+ const picked = String(selection || "").trim();
75
+ if (picked === "all") {
76
+ return targets;
77
+ }
78
+ if (picked) {
79
+ const matches = findTargetByName(targets, picked);
80
+ if (matches.length === 0) {
81
+ const names = targets.map((target) => target.name).join(", ");
82
+ throw new Error(`Repo '${picked}' không có trong manifest. Có: ${names}`);
83
+ }
84
+ return matches;
85
+ }
86
+
87
+ if (process.stdin.isTTY) {
88
+ return promptRepoChoice(targets);
89
+ }
90
+
91
+ return targets;
92
+ }
93
+
94
+ module.exports = {
95
+ buildTargets,
96
+ resolveTargets,
97
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pan-stack-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "PAN Stack CLI — multi-repo git and project commands from .project.yaml",
5
5
  "license": "MIT",
6
6
  "bin": {