oh-my-space 0.6.0 → 0.7.0

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
@@ -49,10 +49,13 @@ Run `oms init` to scaffold a starter `oms.yaml`, or create one by hand in your p
49
49
  # yaml-language-server: $schema=https://raw.githubusercontent.com/divlook/oh-my-space/main/oms.schema.json
50
50
  repos:
51
51
  - alias: api
52
- url: git@github.com:example/api.git
52
+ remotes:
53
+ origin: git@github.com:example/api.git
53
54
  branch: main
54
55
  - alias: web
55
- url: git@github.com:example/web.git
56
+ remotes:
57
+ origin: git@github.com:example/web.git
58
+ upstream: git@github.com:upstream/web.git
56
59
  ```
57
60
 
58
61
  Sync and work:
@@ -66,6 +69,7 @@ oms sync --all # sync every registered source repo
66
69
  oms status # branch / pointer / dirty / ahead-behind per submodule
67
70
  oms checkout <alias> <branch> # switch (or create a brand-new local branch)
68
71
  oms push <alias>... # push the branch (lazy remote) + stage the pointer
72
+ oms push <alias> --remote upstream # target a specific remote (repeatable; defaults to origin)
69
73
  oms unsync <alias>... # deinitialize and remove a submodule
70
74
  ```
71
75
 
@@ -83,7 +87,7 @@ git add oms/api && git commit # record the pointer in your project history
83
87
  For an alias `api` checked out on `main`:
84
88
 
85
89
  ```
86
- .gitmodules # registers oms/api -> url, branch
90
+ .gitmodules # registers oms/api -> origin url, branch
87
91
  oms/
88
92
  └── api/ # git submodule (a normal working tree, on a branch)
89
93
  ```
@@ -92,7 +96,7 @@ oms/
92
96
 
93
97
  ## Managing source repositories
94
98
 
95
- `oms.yaml` declares each source repo with `alias`, `url`, and optional `branch` (the baseline).
99
+ `oms.yaml` declares each source repo with `alias`, a `remotes` mapping (which must include `origin`), and optional `branch` (the baseline).
96
100
 
97
101
  | Command | Runs in | Does | Notes |
98
102
  | --- | --- | --- | --- |
@@ -101,9 +105,9 @@ oms/
101
105
  | `oms sync <alias>` / `--all` | workspace root | Registers missing repos with `git submodule add`, initializes registered-but-uninitialized ones, fetches, and attaches the baseline branch. | Reproduces the recorded pointer on a fresh clone. |
102
106
  | `oms status [alias...]` / `--all` | anywhere under root | Prints branch, pointer state (`ok`/`moved`/`uninit`), dirtiness, and ahead/behind for each submodule. | `moved` means the working commit differs from the recorded pointer — stage/commit it. |
103
107
  | `oms checkout <alias> <branch>` | workspace root | `git switch` to the branch; creates it locally if it exists nowhere yet (no remote required). | `--from <ref>` sets the start point for a new branch. |
104
- | `oms fetch ...` | workspace root | `git fetch origin --prune` in each submodule. | |
105
- | `oms pull ...` | workspace root | `git pull --ff-only` on each submodule's current branch, then stages the moved pointer. | Requires the submodule to be on a branch with upstream. |
106
- | `oms push <alias>...` | workspace root | `git push -u origin <branch>` (creating the remote branch on first push), then stages the moved pointer. | `--commit` also commits the pointer update in the parent. |
108
+ | `oms fetch ...` | workspace root | `git fetch <remote> --prune` in each submodule. | `--remote <name>` (repeatable) picks the remote(s); omit to choose interactively, defaults to `origin`. |
109
+ | `oms pull ...` | workspace root | `git pull --ff-only <remote>` on each submodule's current branch, then stages the moved pointer. | Requires the submodule to be on a branch. `--remote <name>` selects a single remote (defaults to `origin`). |
110
+ | `oms push <alias>...` | workspace root | `git push <remote> <branch>` (creating the remote branch on first push), then stages the moved pointer. | `--remote <name>` (repeatable) picks the remote(s), defaults to `origin`; upstream is set only for `origin`. `--commit` also commits the pointer update in the parent. |
107
111
  | `oms unsync <alias>` / `--all` | workspace root | `git submodule deinit` + `git rm` for the alias; drops an empty `.gitmodules`. | Keeps the `oms.yaml` entry. Use `--force` to discard uncommitted changes. |
108
112
 
109
113
  ## `oms.yaml` format
@@ -112,17 +116,20 @@ oms/
112
116
  # yaml-language-server: $schema=https://raw.githubusercontent.com/divlook/oh-my-space/main/oms.schema.json
113
117
  repos:
114
118
  - alias: service-a
115
- url: git@github.com:example/service-a.git
119
+ remotes:
120
+ origin: git@github.com:example/service-a.git
116
121
  branch: main
117
122
  - alias: docs
118
- url: https://github.com/example/docs.git
123
+ remotes:
124
+ origin: https://github.com/example/docs.git
125
+ upstream: https://github.com/upstream/docs.git
119
126
  ```
120
127
 
121
128
  Rules:
122
129
 
123
130
  - `repos` must be a non-empty array.
124
131
  - `alias` must be unique and match `/^[a-z0-9][a-z0-9-]*$/`.
125
- - `url` is required.
132
+ - `remotes` is required and must include an `origin` entry; each value is a clonable git URL. `origin` becomes the submodule's primary remote, and additional remotes are configured on `oms sync`.
126
133
  - `branch` is optional; when omitted the remote's default branch is used as the baseline.
127
134
 
128
135
  JSON schema: [`oms.schema.json`](./oms.schema.json) (also reachable at `https://raw.githubusercontent.com/divlook/oh-my-space/main/oms.schema.json` for YAML LSPs).
@@ -0,0 +1 @@
1
+ {"commit":"23dd80c9eef2a1ba62b193a9497f564dbe116739"}
package/dist/oms.js CHANGED
@@ -4,11 +4,12 @@ import { existsSync, readFileSync, readdirSync, rmSync, rmdirSync, writeFileSync
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { Command } from "commander";
7
- import { cancel, isCancel, log, multiselect } from "@clack/prompts";
7
+ import { cancel, isCancel, log, multiselect, select } from "@clack/prompts";
8
8
  import { parse as parseYaml } from "yaml";
9
9
  const ALIAS_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
10
+ const REMOTE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
10
11
  const ALLOWED_TOP_KEYS = new Set(["repos"]);
11
- const ALLOWED_ITEM_KEYS = new Set(["alias", "url", "branch"]);
12
+ const ALLOWED_ITEM_KEYS = new Set(["alias", "remotes", "branch"]);
12
13
  const MANIFEST_FILENAME = "oms.yaml";
13
14
  const DATA_DIRNAME = "oms";
14
15
  const GITIGNORE_ENTRY = `${DATA_DIRNAME}/`;
@@ -17,6 +18,9 @@ const LEGACY_MANIFEST = "sources.yaml";
17
18
  const LEGACY_DATA_DIRNAME = "sources";
18
19
  const RENAME_MIGRATION_DOC = "docs/migrations/0.3.x-to-0.4.0.md";
19
20
  const WORKTREE_MIGRATION_DOC = "docs/migrations/0.5.x-to-0.6.0.md";
21
+ const REMOTES_MIGRATION_DOC = "docs/migrations/0.6.x-to-0.7.0.md";
22
+ /** GitHub blob base for the clickable doc permalinks shown in CLI messages. */
23
+ const DOCS_REPO_BLOB_BASE = "https://github.com/divlook/oh-my-space/blob";
20
24
  const MIN_GIT_MAJOR = 2;
21
25
  const MIN_GIT_MINOR = 40;
22
26
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -47,6 +51,10 @@ function validateSources(data) {
47
51
  throw new Error(`${MANIFEST_FILENAME}: ${where} must be a mapping`);
48
52
  }
49
53
  const r = item;
54
+ // Friendlier than "unknown key url": the 0.7.0 manifest replaced url with a remotes mapping.
55
+ if ("url" in r && !("remotes" in r)) {
56
+ throw new Error(`${MANIFEST_FILENAME}: ${where}.url is no longer supported; use a "remotes" mapping with an "origin" entry. See ${docUrl(REMOTES_MIGRATION_DOC)}`);
57
+ }
50
58
  for (const key of Object.keys(r)) {
51
59
  if (!ALLOWED_ITEM_KEYS.has(key)) {
52
60
  throw new Error(`${MANIFEST_FILENAME}: ${where} has unknown key "${key}"`);
@@ -62,8 +70,25 @@ function validateSources(data) {
62
70
  throw new Error(`${MANIFEST_FILENAME}: duplicate alias "${r.alias}"`);
63
71
  }
64
72
  seen.add(r.alias);
65
- if (typeof r.url !== "string" || r.url.length === 0) {
66
- throw new Error(`${MANIFEST_FILENAME}: ${where} missing required "url"`);
73
+ if (!r.remotes || typeof r.remotes !== "object" || Array.isArray(r.remotes)) {
74
+ throw new Error(`${MANIFEST_FILENAME}: ${where} missing required "remotes" mapping`);
75
+ }
76
+ const remoteEntries = Object.entries(r.remotes);
77
+ if (remoteEntries.length === 0) {
78
+ throw new Error(`${MANIFEST_FILENAME}: ${where}.remotes must have at least one remote`);
79
+ }
80
+ const remotes = {};
81
+ for (const [name, url] of remoteEntries) {
82
+ if (!REMOTE_NAME_PATTERN.test(name)) {
83
+ throw new Error(`${MANIFEST_FILENAME}: ${where}.remotes name "${name}" must match ${REMOTE_NAME_PATTERN}`);
84
+ }
85
+ if (typeof url !== "string" || url.length === 0) {
86
+ throw new Error(`${MANIFEST_FILENAME}: ${where}.remotes.${name} must be a non-empty string URL`);
87
+ }
88
+ remotes[name] = url;
89
+ }
90
+ if (!remotes.origin) {
91
+ throw new Error(`${MANIFEST_FILENAME}: ${where}.remotes must include an "origin" entry`);
67
92
  }
68
93
  let branch;
69
94
  if (r.branch !== undefined) {
@@ -72,19 +97,27 @@ function validateSources(data) {
72
97
  }
73
98
  branch = r.branch;
74
99
  }
75
- validated.push({ alias: r.alias, url: r.url, branch });
100
+ validated.push({ alias: r.alias, remotes, branch });
76
101
  });
77
102
  return validated;
78
103
  }
79
104
  function pad(s, width) {
80
105
  return s + " ".repeat(Math.max(0, width - s.length));
81
106
  }
107
+ /** Names of a repo's non-origin remotes, in declared order (origin is shown via its URL column). */
108
+ function extraRemoteNames(repo) {
109
+ return Object.keys(repo.remotes).filter((name) => name !== "origin");
110
+ }
82
111
  function printList(repos) {
112
+ const extras = (r) => {
113
+ const names = extraRemoteNames(r);
114
+ return names.length > 0 ? ` (+${names.join(",")})` : "";
115
+ };
83
116
  const aliasW = Math.max("ALIAS".length, ...repos.map((r) => r.alias.length));
84
- const urlW = Math.max("URL".length, ...repos.map((r) => r.url.length));
85
- console.log(dim(`${pad("ALIAS", aliasW)} ${pad("URL", urlW)} BRANCH`));
117
+ const urlW = Math.max("ORIGIN".length, ...repos.map((r) => (r.remotes.origin + extras(r)).length));
118
+ console.log(dim(`${pad("ALIAS", aliasW)} ${pad("ORIGIN", urlW)} BRANCH`));
86
119
  for (const r of repos) {
87
- console.log(`${pad(r.alias, aliasW)} ${pad(r.url, urlW)} ${r.branch ?? ""}`);
120
+ console.log(`${pad(r.alias, aliasW)} ${pad(r.remotes.origin + extras(r), urlW)} ${r.branch ?? ""}`);
88
121
  }
89
122
  }
90
123
  async function selectInteractive(repos, actionLabel) {
@@ -105,6 +138,56 @@ async function selectInteractive(repos, actionLabel) {
105
138
  .map((alias) => repos.find((r) => r.alias === alias))
106
139
  .filter((r) => r !== undefined);
107
140
  }
141
+ /**
142
+ * Decide which remote(s) a fetch/pull/push targets for one repo. Honors an explicit --remote list,
143
+ * otherwise prompts interactively on a TTY (origin preselected) and falls back to origin off-TTY.
144
+ * pull is restricted to a single remote since --ff-only can advance to at most one. Returns the
145
+ * resolved remote names, or null when the request is invalid or the prompt was cancelled.
146
+ */
147
+ async function resolveRemotes(repo, requested, command) {
148
+ const declared = Object.keys(repo.remotes);
149
+ if (requested && requested.length > 0) {
150
+ const unique = uniqueAliases(requested);
151
+ const unknown = unique.filter((name) => !declared.includes(name));
152
+ if (unknown.length > 0) {
153
+ log.error(`${repo.alias}: unknown remote(s): ${unknown.join(", ")}. Declared: ${declared.join(", ")}.`);
154
+ return null;
155
+ }
156
+ if (command === "pull" && unique.length > 1) {
157
+ log.error(`${repo.alias}: pull targets a single remote (git pull --ff-only can advance only one).`);
158
+ return null;
159
+ }
160
+ return unique;
161
+ }
162
+ // No explicit remote: a lone origin needs no prompt, and a non-interactive shell defaults to origin.
163
+ if (declared.length === 1)
164
+ return declared;
165
+ if (!process.stdin.isTTY)
166
+ return ["origin"];
167
+ if (command === "pull") {
168
+ const choice = await select({
169
+ message: `${repo.alias}: select a remote to ${command}`,
170
+ options: declared.map((name) => ({ value: name, label: name, hint: repo.remotes[name] })),
171
+ initialValue: "origin",
172
+ });
173
+ if (isCancel(choice)) {
174
+ cancel("Cancelled.");
175
+ return null;
176
+ }
177
+ return [choice];
178
+ }
179
+ const choice = await multiselect({
180
+ message: `${repo.alias}: select remote(s) to ${command} (space to toggle, enter to confirm)`,
181
+ options: declared.map((name) => ({ value: name, label: name, hint: repo.remotes[name] })),
182
+ initialValues: ["origin"],
183
+ required: true,
184
+ });
185
+ if (isCancel(choice)) {
186
+ cancel("Cancelled.");
187
+ return null;
188
+ }
189
+ return choice;
190
+ }
108
191
  function runGit(cwd, args, inheritOutput = false) {
109
192
  const result = spawnSync("git", args, {
110
193
  cwd,
@@ -232,6 +315,21 @@ function attachBranch(repoRoot, alias, branch) {
232
315
  runSub(repoRoot, alias, ["branch", "--set-upstream-to", `origin/${branch}`, branch]);
233
316
  }
234
317
  }
318
+ /**
319
+ * Reconcile the submodule's git remotes with the declared `remotes` map: add missing remotes and
320
+ * update URLs that drifted. Non-destructive — remotes no longer in oms.yaml are left untouched.
321
+ */
322
+ function ensureRemotes(repoRoot, alias, remotes) {
323
+ for (const [name, url] of Object.entries(remotes)) {
324
+ const existing = runSub(repoRoot, alias, ["remote", "get-url", name]);
325
+ if (!existing.success) {
326
+ runSub(repoRoot, alias, ["remote", "add", name, url]);
327
+ }
328
+ else if (existing.stdout.trim() !== url) {
329
+ runSub(repoRoot, alias, ["remote", "set-url", name, url]);
330
+ }
331
+ }
332
+ }
235
333
  /** Stage the submodule gitlink in the parent so a moved pointer shows up and is ready to commit. */
236
334
  function stagePointer(repoRoot, alias) {
237
335
  runGit(repoRoot, ["add", "--", submodulePath(alias)]);
@@ -245,7 +343,7 @@ function emitLegacyRenameMessage(dir, found) {
245
343
  .join(" and ");
246
344
  log.error(`detected legacy ${artifacts} at ${dir}.\n` +
247
345
  ` oh-my-space 0.4.0 renamed the manifest to ${MANIFEST_FILENAME} and the data directory to ${DATA_DIRNAME}/.\n` +
248
- ` See ${RENAME_MIGRATION_DOC} for the manual steps. Aborting to avoid destructive change.`);
346
+ ` See ${docUrl(RENAME_MIGRATION_DOC)} for the manual steps. Aborting to avoid destructive change.`);
249
347
  }
250
348
  /** Block when the active oms.yaml workspace still has 0.3.x rename artifacts at its root. */
251
349
  function abortOnLegacyRenameAt(repoRoot) {
@@ -283,7 +381,7 @@ function abortOnLegacyWorktree(repoRoot, repos) {
283
381
  if (!stale)
284
382
  return false;
285
383
  log.error(`detected a legacy bare clone at ${DATA_DIRNAME}/${stale.alias}/.bare. oh-my-space 0.6.0 manages sources as git submodules.\n` +
286
- ` See ${WORKTREE_MIGRATION_DOC} for the manual steps. Aborting to avoid destructive change.`);
384
+ ` See ${docUrl(WORKTREE_MIGRATION_DOC)} for the manual steps. Aborting to avoid destructive change.`);
287
385
  return true;
288
386
  }
289
387
  /** Submodules live inside the parent's history, so oms/ must not be gitignored. Strip a managed entry. */
@@ -347,11 +445,11 @@ function syncRepo(repo, repoRoot) {
347
445
  "ls-remote",
348
446
  "--exit-code",
349
447
  "--heads",
350
- repo.url,
448
+ repo.remotes.origin,
351
449
  repo.branch,
352
450
  ]);
353
451
  if (lsRemote.exitCode === 2) {
354
- log.error(`${alias}: branch "${repo.branch}" not found on ${repo.url}. Push the branch upstream or fix the alias, then retry.`);
452
+ log.error(`${alias}: branch "${repo.branch}" not found on ${repo.remotes.origin}. Push the branch upstream or fix the alias, then retry.`);
355
453
  return "failed";
356
454
  }
357
455
  if (!lsRemote.success && lsRemote.exitCode !== 2) {
@@ -364,14 +462,15 @@ function syncRepo(repo, repoRoot) {
364
462
  if (!existsSync(gitmodules) && runGit(repoRoot, ["cat-file", "-e", "HEAD:.gitmodules"]).success) {
365
463
  writeFileSync(gitmodules, "");
366
464
  }
367
- log.step(`${alias}: git submodule add${repo.branch ? ` -b ${repo.branch}` : ""} ${repo.url} ${path}`);
368
- const args = ["submodule", "add", ...(repo.branch ? ["-b", repo.branch] : []), "--", repo.url, path];
465
+ log.step(`${alias}: git submodule add${repo.branch ? ` -b ${repo.branch}` : ""} ${repo.remotes.origin} ${path}`);
466
+ const args = ["submodule", "add", ...(repo.branch ? ["-b", repo.branch] : []), "--", repo.remotes.origin, path];
369
467
  const add = runGit(repoRoot, args, true);
370
468
  if (!add.success) {
371
469
  log.error(`${alias}: git submodule add failed (exit ${add.exitCode})`);
372
470
  cleanupFailedAdd(repoRoot, alias);
373
471
  return "failed";
374
472
  }
473
+ ensureRemotes(repoRoot, alias, repo.remotes);
375
474
  const branch = repo.branch ?? currentBranch(aliasDir(repoRoot, alias));
376
475
  if (branch)
377
476
  attachBranch(repoRoot, alias, branch);
@@ -385,12 +484,14 @@ function syncRepo(repo, repoRoot) {
385
484
  log.error(`${alias}: git submodule update --init failed (exit ${upd.exitCode})`);
386
485
  return "failed";
387
486
  }
487
+ ensureRemotes(repoRoot, alias, repo.remotes);
388
488
  const branch = gitmodulesBranch(repoRoot, alias) ?? repo.branch;
389
489
  if (branch)
390
490
  attachBranch(repoRoot, alias, branch);
391
491
  log.success(`${alias}: initialized${branch ? ` (branch=${branch})` : ""}`);
392
492
  return "added";
393
493
  }
494
+ ensureRemotes(repoRoot, alias, repo.remotes);
394
495
  log.step(`${alias}: git fetch origin --prune`);
395
496
  const fetch = runSub(repoRoot, alias, ["fetch", "origin", "--prune"], true);
396
497
  if (!fetch.success) {
@@ -446,21 +547,23 @@ function unsyncRepo(repo, repoRoot, force) {
446
547
  }
447
548
  return "removed";
448
549
  }
449
- function fetchRepo(repo, repoRoot) {
550
+ function fetchRepo(repo, repoRoot, remotes) {
450
551
  if (!submoduleInitialized(repoRoot, repo.alias)) {
451
552
  log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
452
553
  return "failed";
453
554
  }
454
- log.step(`${repo.alias}: git fetch origin --prune`);
455
- const r = runSub(repoRoot, repo.alias, ["fetch", "origin", "--prune"], true);
456
- if (r.success) {
457
- log.success(`${repo.alias}: fetched`);
458
- return "fetched";
555
+ for (const remote of remotes) {
556
+ log.step(`${repo.alias}: git fetch ${remote} --prune`);
557
+ const r = runSub(repoRoot, repo.alias, ["fetch", remote, "--prune"], true);
558
+ if (!r.success) {
559
+ log.error(`${repo.alias}: fetch ${remote} failed (exit ${r.exitCode})`);
560
+ return "failed";
561
+ }
459
562
  }
460
- log.error(`${repo.alias}: fetch failed (exit ${r.exitCode})`);
461
- return "failed";
563
+ log.success(`${repo.alias}: fetched (${remotes.join(", ")})`);
564
+ return "fetched";
462
565
  }
463
- function pullRepo(repo, repoRoot) {
566
+ function pullRepo(repo, repoRoot, remote) {
464
567
  if (!submoduleInitialized(repoRoot, repo.alias)) {
465
568
  log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
466
569
  return "failed";
@@ -470,17 +573,17 @@ function pullRepo(repo, repoRoot) {
470
573
  log.error(`${repo.alias}: detached HEAD. Run "oms checkout ${repo.alias} <branch>" before pulling.`);
471
574
  return "failed";
472
575
  }
473
- log.step(`${repo.alias}/${branch}: git pull --ff-only`);
474
- const r = runSub(repoRoot, repo.alias, ["pull", "--ff-only"], true);
576
+ log.step(`${repo.alias}/${branch}: git pull --ff-only ${remote} ${branch}`);
577
+ const r = runSub(repoRoot, repo.alias, ["pull", "--ff-only", remote, branch], true);
475
578
  if (r.success) {
476
579
  stagePointer(repoRoot, repo.alias);
477
- log.success(`${repo.alias}/${branch}: pulled`);
580
+ log.success(`${repo.alias}/${branch}: pulled from ${remote}`);
478
581
  return "pulled";
479
582
  }
480
- log.error(`${repo.alias}/${branch}: pull failed (exit ${r.exitCode})`);
583
+ log.error(`${repo.alias}/${branch}: pull from ${remote} failed (exit ${r.exitCode})`);
481
584
  return "failed";
482
585
  }
483
- function pushRepo(repo, repoRoot, commit) {
586
+ function pushRepo(repo, repoRoot, remotes, commit) {
484
587
  if (!submoduleInitialized(repoRoot, repo.alias)) {
485
588
  log.error(`${repo.alias}: not synced. Run "oms sync ${repo.alias}" first.`);
486
589
  return "failed";
@@ -490,18 +593,22 @@ function pushRepo(repo, repoRoot, commit) {
490
593
  log.error(`${repo.alias}: detached HEAD. Run "oms checkout ${repo.alias} <branch>" before pushing.`);
491
594
  return "failed";
492
595
  }
493
- log.step(`${repo.alias}/${branch}: git push -u origin ${branch}`);
494
- const r = runSub(repoRoot, repo.alias, ["push", "-u", "origin", branch], true);
495
- if (!r.success) {
496
- log.error(`${repo.alias}/${branch}: push failed (exit ${r.exitCode})`);
497
- return "failed";
596
+ for (const remote of remotes) {
597
+ // Only origin sets upstream — repointing @{u} to a fork would skew "oms status" ahead/behind.
598
+ const args = remote === "origin" ? ["push", "-u", "origin", branch] : ["push", remote, branch];
599
+ log.step(`${repo.alias}/${branch}: git ${args.join(" ")}`);
600
+ const r = runSub(repoRoot, repo.alias, args, true);
601
+ if (!r.success) {
602
+ log.error(`${repo.alias}/${branch}: push to ${remote} failed (exit ${r.exitCode})`);
603
+ return "failed";
604
+ }
498
605
  }
499
606
  stagePointer(repoRoot, repo.alias);
500
607
  if (commit) {
501
608
  const sha = shortSha(aliasDir(repoRoot, repo.alias));
502
609
  runGit(repoRoot, ["commit", "-m", `oms: bump ${repo.alias} to ${sha}`, "--", submodulePath(repo.alias)]);
503
610
  }
504
- log.success(`${repo.alias}/${branch}: pushed${commit ? " and recorded pointer" : ""}`);
611
+ log.success(`${repo.alias}/${branch}: pushed to ${remotes.join(", ")}${commit ? " and recorded pointer" : ""}`);
505
612
  return "pushed";
506
613
  }
507
614
  function printSummary(results) {
@@ -616,13 +723,20 @@ async function runManage(command, aliases, options) {
616
723
  const picked = await selectRepos(repos, aliases, options, command);
617
724
  if (!picked || picked.length === 0)
618
725
  return 1;
619
- const results = picked.map((repo) => {
726
+ const results = [];
727
+ for (const repo of picked) {
728
+ const remotes = await resolveRemotes(repo, options.remote, command);
729
+ if (!remotes || remotes.length === 0) {
730
+ results.push("failed");
731
+ continue;
732
+ }
620
733
  if (command === "fetch")
621
- return fetchRepo(repo, repoRoot);
622
- if (command === "pull")
623
- return pullRepo(repo, repoRoot);
624
- return pushRepo(repo, repoRoot, options.commit ?? false);
625
- });
734
+ results.push(fetchRepo(repo, repoRoot, remotes));
735
+ else if (command === "pull")
736
+ results.push(pullRepo(repo, repoRoot, remotes[0]));
737
+ else
738
+ results.push(pushRepo(repo, repoRoot, remotes, options.commit ?? false));
739
+ }
626
740
  if (results.length > 1 || options.all)
627
741
  printSummary(results);
628
742
  return exitFromResults(results);
@@ -763,7 +877,9 @@ async function runStatus(aliases, options) {
763
877
  const INIT_TEMPLATE = `# yaml-language-server: $schema=https://raw.githubusercontent.com/divlook/oh-my-space/main/oms.schema.json
764
878
  repos:
765
879
  - alias: example
766
- url: git@github.com:example/repo.git
880
+ remotes:
881
+ origin: git@github.com:example/repo.git
882
+ # upstream: git@github.com:upstream/repo.git
767
883
  branch: main
768
884
  `;
769
885
  /** 현재 디렉터리에 기본 oms.yaml 템플릿을 생성합니다. */
@@ -780,7 +896,7 @@ async function runInit(options) {
780
896
  if (!isGitRepo(process.cwd())) {
781
897
  log.info(`oms manages sources as git submodules; run "git init" here if this is not a git repo yet.`);
782
898
  }
783
- log.info(`edit alias/url/branch, then run "oms sync".`);
899
+ log.info(`edit alias/remotes/branch, then run "oms sync".`);
784
900
  return 0;
785
901
  }
786
902
  async function runDoctor() {
@@ -857,6 +973,16 @@ function readPackageVersion() {
857
973
  const pkg = readJson(join(packageRoot, "package.json"));
858
974
  return pkg?.version ?? "0.0.0";
859
975
  }
976
+ /** The commit baked in at build time, or null when unavailable (dev/no-git build). */
977
+ function readBuildCommit() {
978
+ const info = readJson(join(dirname(fileURLToPath(import.meta.url)), "build-info.json"));
979
+ return info?.commit ?? null;
980
+ }
981
+ /** Clickable GitHub permalink for a repo doc, pinned to the build commit; falls back to the version tag. */
982
+ function docUrl(relPath) {
983
+ const ref = readBuildCommit() ?? `v${readPackageVersion()}`;
984
+ return `${DOCS_REPO_BLOB_BASE}/${ref}/${relPath}`;
985
+ }
860
986
  async function exitWith(action) {
861
987
  try {
862
988
  process.exit(await action);
@@ -929,29 +1055,33 @@ program
929
1055
  .action(async (alias, branch, options) => {
930
1056
  await exitWith(runCheckout(alias, branch, options));
931
1057
  });
1058
+ const collectRemote = (value, acc) => [...acc, value];
932
1059
  program
933
1060
  .command("fetch")
934
- .description("Run git fetch origin --prune in each submodule.")
1061
+ .description("Run git fetch <remote> --prune in each submodule (defaults to origin).")
935
1062
  .argument("[aliases...]", "repo aliases to fetch (omit for interactive multi-select)")
936
1063
  .option("--all", "fetch every registered source repo")
1064
+ .option("--remote <name>", "remote to fetch (repeatable; omit to choose interactively)", collectRemote, [])
937
1065
  .addHelpText("after", exitHelp)
938
1066
  .action(async (aliases, options) => {
939
1067
  await exitWith(runManage("fetch", aliases, options));
940
1068
  });
941
1069
  program
942
1070
  .command("pull")
943
- .description("Run git pull --ff-only on each submodule's current branch and stage the moved pointer.")
1071
+ .description("Run git pull --ff-only <remote> on each submodule's current branch and stage the moved pointer (defaults to origin).")
944
1072
  .argument("[aliases...]", "repo aliases to pull (omit for interactive multi-select)")
945
1073
  .option("--all", "pull every registered source repo")
1074
+ .option("--remote <name>", "remote to pull from (single; omit to choose interactively)", collectRemote, [])
946
1075
  .addHelpText("after", exitHelp)
947
1076
  .action(async (aliases, options) => {
948
1077
  await exitWith(runManage("pull", aliases, options));
949
1078
  });
950
1079
  program
951
1080
  .command("push")
952
- .description("Run git push -u origin <branch> on each listed submodule (creating the remote branch on first push) and stage the moved pointer.")
1081
+ .description("Run git push <remote> <branch> on each listed submodule (creating the remote branch on first push) and stage the moved pointer (defaults to origin).")
953
1082
  .argument("<aliases...>", "repo aliases to push")
954
1083
  .option("--commit", "also commit the pointer update in the parent repo")
1084
+ .option("--remote <name>", "remote to push to (repeatable; omit to choose interactively)", collectRemote, [])
955
1085
  .addHelpText("after", exitHelp)
956
1086
  .action(async (aliases, options) => {
957
1087
  await exitWith(runManage("push", aliases, options));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oh-my-space",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "CLI for managing external source repositories declared in oms.yaml.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/divlook/oh-my-space",
@@ -35,7 +35,7 @@
35
35
  "node": ">=20.19.0"
36
36
  },
37
37
  "scripts": {
38
- "build": "tsc -p tsconfig.build.json && node -e \"require('node:fs').chmodSync('dist/oms.js', 0o755)\"",
38
+ "build": "tsc -p tsconfig.build.json && node -e \"require('node:fs').chmodSync('dist/oms.js', 0o755)\" && node scripts/gen-build-info.mjs",
39
39
  "test": "npm run build && node --test tests/*.test.js",
40
40
  "prepack": "npm test",
41
41
  "changeset": "changeset",