oh-my-space 0.1.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/LICENSE +21 -0
- package/README.md +112 -0
- package/dist/oms.js +497 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 divlook
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# oh-my-space
|
|
2
|
+
|
|
3
|
+
`oh-my-space` provides the `oms` CLI for managing external source repositories declared in `sources.yaml`. It syncs those entries into Git submodules under `sources/<alias>/` and runs safe fetch/pull/push operations inside selected source worktrees.
|
|
4
|
+
|
|
5
|
+
Package name: `oh-my-space`
|
|
6
|
+
|
|
7
|
+
Command name: `oms`
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- [Node.js](https://nodejs.org) `>=20.19.0` for running `oms`; development uses the version in `.nvmrc` (`24`)
|
|
12
|
+
- git
|
|
13
|
+
|
|
14
|
+
For local development:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
nvm use
|
|
18
|
+
npm ci
|
|
19
|
+
npm test
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
Install globally with your package manager of choice:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install -g oh-my-space
|
|
28
|
+
pnpm add -g oh-my-space
|
|
29
|
+
yarn global add oh-my-space
|
|
30
|
+
bun install -g oh-my-space
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
Create a `sources.yaml` in your project root:
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
repos:
|
|
39
|
+
- alias: api
|
|
40
|
+
url: git@github.com:example/api.git
|
|
41
|
+
branch: main
|
|
42
|
+
- alias: web
|
|
43
|
+
url: git@github.com:example/web.git
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
List, sync, and unsync registered source repositories:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
oms doctor # check sources.yaml and git availability
|
|
50
|
+
oms sync --list # list registered source repos
|
|
51
|
+
oms sync <alias>... # add/init/update submodules by alias
|
|
52
|
+
oms sync --all # add/init/update every registered source repo
|
|
53
|
+
oms sync # interactive multi-select
|
|
54
|
+
oms unsync <alias>... # remove submodule + worktree (sources.yaml entry kept)
|
|
55
|
+
oms unsync --all # unsync every registered source repo
|
|
56
|
+
oms unsync --force ... # discard uncommitted changes in the source worktree
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Managing source repositories
|
|
60
|
+
|
|
61
|
+
`sources.yaml` declares each source repo with `alias`, `url`, and optional `branch`. Checkouts live as Git submodules under `sources/<alias>/`.
|
|
62
|
+
|
|
63
|
+
| Command | Runs in | Does | Notes |
|
|
64
|
+
| --- | --- | --- | --- |
|
|
65
|
+
| `oms doctor` | project root or child path | Validates `sources.yaml` and checks `git --version`. | Fails when `sources.yaml` is missing or invalid. |
|
|
66
|
+
| `oms sync <alias>` / `--all` | workspace root | Adds missing submodules and initializes/updates registered ones. | Syncs `sources.yaml` to `sources/<alias>/`. |
|
|
67
|
+
| `oms unsync <alias>` / `--all` | workspace root | Deinits the submodule and removes `sources/<alias>/` (leaves the staged change to commit). | Keeps the entry in `sources.yaml`; pass `--force` to discard uncommitted changes in the worktree. |
|
|
68
|
+
| `oms fetch ...` | selected checked-out submodule worktree | `git fetch --all --prune` | Does not change the superproject gitlink. |
|
|
69
|
+
| `oms pull ...` | selected checked-out submodule worktree | `git pull --ff-only` | Requires a branch with upstream; detached HEAD/no-upstream states fail. |
|
|
70
|
+
| `oms push <alias>...` | explicitly selected checked-out submodule worktree | `git push` | No `--all`, force push, or automatic upstream setup. |
|
|
71
|
+
|
|
72
|
+
To add a repo, add it under `repos:` in `sources.yaml`, run `oms sync <alias>`, then commit `sources.yaml`, `.gitmodules`, and the gitlink change.
|
|
73
|
+
|
|
74
|
+
Standard Git submodule commands still work, for example:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
git submodule update --init --recursive
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## `sources.yaml` format
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
repos:
|
|
84
|
+
- alias: service-a
|
|
85
|
+
url: git@github.com:example/service-a.git
|
|
86
|
+
branch: main
|
|
87
|
+
- alias: docs
|
|
88
|
+
url: https://github.com/example/docs.git
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Rules:
|
|
92
|
+
|
|
93
|
+
- `repos` must be a non-empty array.
|
|
94
|
+
- `alias` must be unique and match `/^[a-z0-9][a-z0-9-]*$/`.
|
|
95
|
+
- `url` is required.
|
|
96
|
+
- `branch` is optional and is passed to `git submodule add --branch` only when adding a new submodule.
|
|
97
|
+
|
|
98
|
+
## Release and versioning
|
|
99
|
+
|
|
100
|
+
`oh-my-space` uses SemVer:
|
|
101
|
+
|
|
102
|
+
- Patch: bug fixes and documentation changes.
|
|
103
|
+
- Minor: new commands/options or backward-compatible behavior additions.
|
|
104
|
+
- Major: removed/renamed CLI behavior, incompatible `sources.yaml` semantics, or a higher required runtime.
|
|
105
|
+
|
|
106
|
+
GitHub Releases are the user-facing release notes. `CHANGELOG.md` is auto-generated by Changesets and committed by the release workflow; do not edit it manually.
|
|
107
|
+
|
|
108
|
+
Publishing is automated from `main` through Changesets. Feature branches and pull requests run tests on Node.js `20.19.0` and the `.nvmrc` development line (`24`), plus `npm pack --dry-run`; publish only happens through the release workflow after the release PR is merged. The release job builds and publishes on Node.js `20.19.0` so the package is verified against the minimum supported runtime.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
[MIT](./LICENSE)
|
package/dist/oms.js
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import { cancel, isCancel, log, multiselect } from "@clack/prompts";
|
|
8
|
+
import { parse as parseYaml } from "yaml";
|
|
9
|
+
const ALIAS_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
10
|
+
const ALLOWED_TOP_KEYS = new Set(["repos"]);
|
|
11
|
+
const ALLOWED_ITEM_KEYS = new Set(["alias", "url", "branch"]);
|
|
12
|
+
const MANAGE_GIT_ARGS = {
|
|
13
|
+
fetch: ["fetch", "--all", "--prune"],
|
|
14
|
+
pull: ["pull", "--ff-only"],
|
|
15
|
+
push: ["-c", "push.autoSetupRemote=false", "push"],
|
|
16
|
+
};
|
|
17
|
+
const RESULT_BY_COMMAND = {
|
|
18
|
+
fetch: "fetched",
|
|
19
|
+
pull: "pulled",
|
|
20
|
+
push: "pushed",
|
|
21
|
+
};
|
|
22
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
23
|
+
const useColor = process.stdout.isTTY;
|
|
24
|
+
const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s);
|
|
25
|
+
function validateSources(data) {
|
|
26
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
27
|
+
throw new Error("sources.yaml: root must be a mapping");
|
|
28
|
+
}
|
|
29
|
+
const obj = data;
|
|
30
|
+
for (const key of Object.keys(obj)) {
|
|
31
|
+
if (!ALLOWED_TOP_KEYS.has(key)) {
|
|
32
|
+
throw new Error(`sources.yaml: unknown top-level key "${key}"`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const { repos } = obj;
|
|
36
|
+
if (!Array.isArray(repos)) {
|
|
37
|
+
throw new Error('sources.yaml: "repos" must be an array');
|
|
38
|
+
}
|
|
39
|
+
if (repos.length === 0) {
|
|
40
|
+
throw new Error('sources.yaml: "repos" must have at least one item');
|
|
41
|
+
}
|
|
42
|
+
const validated = [];
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
repos.forEach((item, idx) => {
|
|
45
|
+
const where = `repos[${idx}]`;
|
|
46
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
47
|
+
throw new Error(`sources.yaml: ${where} must be a mapping`);
|
|
48
|
+
}
|
|
49
|
+
const r = item;
|
|
50
|
+
for (const key of Object.keys(r)) {
|
|
51
|
+
if (!ALLOWED_ITEM_KEYS.has(key)) {
|
|
52
|
+
throw new Error(`sources.yaml: ${where} has unknown key "${key}"`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (typeof r.alias !== "string" || r.alias.length === 0) {
|
|
56
|
+
throw new Error(`sources.yaml: ${where} missing required "alias"`);
|
|
57
|
+
}
|
|
58
|
+
if (!ALIAS_PATTERN.test(r.alias)) {
|
|
59
|
+
throw new Error(`sources.yaml: ${where}.alias "${r.alias}" must match ${ALIAS_PATTERN}`);
|
|
60
|
+
}
|
|
61
|
+
if (seen.has(r.alias)) {
|
|
62
|
+
throw new Error(`sources.yaml: duplicate alias "${r.alias}"`);
|
|
63
|
+
}
|
|
64
|
+
seen.add(r.alias);
|
|
65
|
+
if (typeof r.url !== "string" || r.url.length === 0) {
|
|
66
|
+
throw new Error(`sources.yaml: ${where} missing required "url"`);
|
|
67
|
+
}
|
|
68
|
+
let branch;
|
|
69
|
+
if (r.branch !== undefined) {
|
|
70
|
+
if (typeof r.branch !== "string" || r.branch.length === 0) {
|
|
71
|
+
throw new Error(`sources.yaml: ${where}.branch must be a non-empty string`);
|
|
72
|
+
}
|
|
73
|
+
branch = r.branch;
|
|
74
|
+
}
|
|
75
|
+
validated.push({ alias: r.alias, url: r.url, branch });
|
|
76
|
+
});
|
|
77
|
+
return validated;
|
|
78
|
+
}
|
|
79
|
+
function pad(s, width) {
|
|
80
|
+
return s + " ".repeat(Math.max(0, width - s.length));
|
|
81
|
+
}
|
|
82
|
+
function printList(repos) {
|
|
83
|
+
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`));
|
|
86
|
+
for (const r of repos) {
|
|
87
|
+
console.log(`${pad(r.alias, aliasW)} ${pad(r.url, urlW)} ${r.branch ?? ""}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function selectInteractive(repos, actionLabel) {
|
|
91
|
+
const choice = await multiselect({
|
|
92
|
+
message: `Select source repos to ${actionLabel} (space to toggle, enter to confirm)`,
|
|
93
|
+
options: repos.map((r) => ({
|
|
94
|
+
value: r.alias,
|
|
95
|
+
label: r.alias,
|
|
96
|
+
hint: r.branch ? `branch: ${r.branch}` : undefined,
|
|
97
|
+
})),
|
|
98
|
+
required: true,
|
|
99
|
+
});
|
|
100
|
+
if (isCancel(choice)) {
|
|
101
|
+
cancel("Cancelled.");
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return choice
|
|
105
|
+
.map((alias) => repos.find((r) => r.alias === alias))
|
|
106
|
+
.filter((r) => r !== undefined);
|
|
107
|
+
}
|
|
108
|
+
function runGit(cwd, args, inheritOutput = false) {
|
|
109
|
+
const result = spawnSync("git", args, {
|
|
110
|
+
cwd,
|
|
111
|
+
encoding: "utf8",
|
|
112
|
+
stdio: inheritOutput ? "inherit" : ["ignore", "pipe", "pipe"],
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
exitCode: result.status,
|
|
116
|
+
success: result.status === 0,
|
|
117
|
+
stdout: inheritOutput ? "" : (result.stdout ?? ""),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function findWorkspaceRoot(options = {}) {
|
|
121
|
+
let current = resolve(options.cwd ?? process.cwd());
|
|
122
|
+
while (true) {
|
|
123
|
+
if (existsSync(join(current, "sources.yaml")))
|
|
124
|
+
return current;
|
|
125
|
+
const parent = dirname(current);
|
|
126
|
+
if (parent === current)
|
|
127
|
+
return null;
|
|
128
|
+
current = parent;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function isRegisteredSubmodule(repoRoot, sourcePath) {
|
|
132
|
+
if (!existsSync(join(repoRoot, ".gitmodules")))
|
|
133
|
+
return false;
|
|
134
|
+
const result = runGit(repoRoot, [
|
|
135
|
+
"config",
|
|
136
|
+
"--file",
|
|
137
|
+
".gitmodules",
|
|
138
|
+
"--get-regexp",
|
|
139
|
+
"^submodule\\..*\\.path$",
|
|
140
|
+
]);
|
|
141
|
+
if (!result.success)
|
|
142
|
+
return false;
|
|
143
|
+
return result.stdout
|
|
144
|
+
.split("\n")
|
|
145
|
+
.some((line) => line.trim().split(/\s+/)[1] === sourcePath);
|
|
146
|
+
}
|
|
147
|
+
function isCheckedOutGitRepo(repoRoot, sourcePath) {
|
|
148
|
+
const dest = join(repoRoot, sourcePath);
|
|
149
|
+
if (!existsSync(dest))
|
|
150
|
+
return false;
|
|
151
|
+
const result = runGit(dest, ["rev-parse", "--is-inside-work-tree"]);
|
|
152
|
+
return result.success && result.stdout.trim() === "true";
|
|
153
|
+
}
|
|
154
|
+
function removeSubmoduleArtifacts(repo, repoRoot, options = {}) {
|
|
155
|
+
const sourcePath = `sources/${repo.alias}`;
|
|
156
|
+
const dest = join(repoRoot, sourcePath);
|
|
157
|
+
const modulesDir = join(repoRoot, ".git", "modules", repo.alias);
|
|
158
|
+
const registered = isRegisteredSubmodule(repoRoot, sourcePath);
|
|
159
|
+
const worktreeExists = existsSync(dest);
|
|
160
|
+
const worktreeIsGitRepo = worktreeExists && isCheckedOutGitRepo(repoRoot, sourcePath);
|
|
161
|
+
const moduleDirExists = existsSync(modulesDir);
|
|
162
|
+
if (!registered && !worktreeExists && !moduleDirExists) {
|
|
163
|
+
return "nothing-to-remove";
|
|
164
|
+
}
|
|
165
|
+
if (worktreeExists && !registered && !moduleDirExists && !worktreeIsGitRepo) {
|
|
166
|
+
log.error(`${repo.alias}: ${sourcePath} exists but is not a git submodule. Remove it manually if intended.`);
|
|
167
|
+
return "failed";
|
|
168
|
+
}
|
|
169
|
+
if (!options.force && worktreeIsGitRepo) {
|
|
170
|
+
const status = runGit(dest, ["status", "--porcelain"]);
|
|
171
|
+
if (status.success && status.stdout.trim().length > 0) {
|
|
172
|
+
log.error(`${repo.alias}: ${sourcePath} has uncommitted changes. Commit or stash them, or pass --force to discard.`);
|
|
173
|
+
return "failed";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
let didSomething = false;
|
|
177
|
+
if (registered) {
|
|
178
|
+
const deinit = runGit(repoRoot, ["submodule", "deinit", "-f", sourcePath]);
|
|
179
|
+
if (!deinit.success) {
|
|
180
|
+
log.error(`${repo.alias}: git submodule deinit failed (exit ${deinit.exitCode}) for ${sourcePath}`);
|
|
181
|
+
return "failed";
|
|
182
|
+
}
|
|
183
|
+
didSomething = true;
|
|
184
|
+
}
|
|
185
|
+
if (registered || worktreeIsGitRepo) {
|
|
186
|
+
const rm = runGit(repoRoot, ["rm", "-f", sourcePath]);
|
|
187
|
+
if (!rm.success) {
|
|
188
|
+
log.error(`${repo.alias}: git rm failed (exit ${rm.exitCode}) for ${sourcePath}`);
|
|
189
|
+
return "failed";
|
|
190
|
+
}
|
|
191
|
+
didSomething = true;
|
|
192
|
+
}
|
|
193
|
+
runGit(repoRoot, [
|
|
194
|
+
"config",
|
|
195
|
+
"--file",
|
|
196
|
+
".gitmodules",
|
|
197
|
+
"--remove-section",
|
|
198
|
+
`submodule.${repo.alias}`,
|
|
199
|
+
]);
|
|
200
|
+
if (moduleDirExists) {
|
|
201
|
+
rmSync(modulesDir, { recursive: true, force: true });
|
|
202
|
+
didSomething = true;
|
|
203
|
+
}
|
|
204
|
+
return didSomething ? "removed" : "nothing-to-remove";
|
|
205
|
+
}
|
|
206
|
+
function syncSubmodule(repo, repoRoot) {
|
|
207
|
+
const sourcePath = `sources/${repo.alias}`;
|
|
208
|
+
const dest = join(repoRoot, sourcePath);
|
|
209
|
+
const registered = isRegisteredSubmodule(repoRoot, sourcePath);
|
|
210
|
+
if (!registered && existsSync(dest)) {
|
|
211
|
+
log.error(`${repo.alias}: ${sourcePath} already exists but is not registered as a git submodule. Move or remove it manually, then retry.`);
|
|
212
|
+
return "failed";
|
|
213
|
+
}
|
|
214
|
+
if (!registered && repo.branch) {
|
|
215
|
+
const lsRemote = runGit(repoRoot, [
|
|
216
|
+
"ls-remote",
|
|
217
|
+
"--exit-code",
|
|
218
|
+
"--heads",
|
|
219
|
+
repo.url,
|
|
220
|
+
repo.branch,
|
|
221
|
+
]);
|
|
222
|
+
if (lsRemote.exitCode === 2) {
|
|
223
|
+
log.error(`${repo.alias}: branch "${repo.branch}" not found on ${repo.url}. Push the branch upstream or fix the alias, then retry.`);
|
|
224
|
+
return "failed";
|
|
225
|
+
}
|
|
226
|
+
if (!lsRemote.success && lsRemote.exitCode !== 2) {
|
|
227
|
+
log.warn(`${repo.alias}: branch existence check failed (exit ${lsRemote.exitCode}); proceeding with submodule add.`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const args = registered
|
|
231
|
+
? ["submodule", "update", "--init", "--recursive", sourcePath]
|
|
232
|
+
: [
|
|
233
|
+
"submodule",
|
|
234
|
+
"add",
|
|
235
|
+
"--name",
|
|
236
|
+
repo.alias,
|
|
237
|
+
...(repo.branch ? ["--branch", repo.branch] : []),
|
|
238
|
+
repo.url,
|
|
239
|
+
sourcePath,
|
|
240
|
+
];
|
|
241
|
+
log.step(`git ${args.join(" ")}`);
|
|
242
|
+
const result = runGit(repoRoot, args, true);
|
|
243
|
+
if (result.success) {
|
|
244
|
+
log.success(`${repo.alias}: ${registered ? "updated" : "added"}`);
|
|
245
|
+
return registered ? "updated" : "added";
|
|
246
|
+
}
|
|
247
|
+
log.error(`${repo.alias}: submodule ${registered ? "update" : "add"} failed (exit ${result.exitCode})`);
|
|
248
|
+
if (!registered) {
|
|
249
|
+
const cleanup = removeSubmoduleArtifacts(repo, repoRoot, { force: true });
|
|
250
|
+
if (cleanup === "removed") {
|
|
251
|
+
log.info(`${repo.alias}: cleaned up partial submodule state. Retry once the upstream issue is resolved.`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return "failed";
|
|
255
|
+
}
|
|
256
|
+
function manageSubmodule(repo, repoRoot, command) {
|
|
257
|
+
const sourcePath = `sources/${repo.alias}`;
|
|
258
|
+
if (!isRegisteredSubmodule(repoRoot, sourcePath) || !isCheckedOutGitRepo(repoRoot, sourcePath)) {
|
|
259
|
+
log.error(`${repo.alias}: ${sourcePath} is not a checked-out source submodule. Run "oms sync ${repo.alias}" first.`);
|
|
260
|
+
return "failed";
|
|
261
|
+
}
|
|
262
|
+
const sourceWorktree = join(repoRoot, sourcePath);
|
|
263
|
+
if (command === "push") {
|
|
264
|
+
const upstream = runGit(sourceWorktree, [
|
|
265
|
+
"rev-parse",
|
|
266
|
+
"--abbrev-ref",
|
|
267
|
+
"--symbolic-full-name",
|
|
268
|
+
"@{u}",
|
|
269
|
+
]);
|
|
270
|
+
if (!upstream.success) {
|
|
271
|
+
log.error(`${repo.alias}: git push requires the current branch to have an upstream. Set it manually before pushing.`);
|
|
272
|
+
return "failed";
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const args = MANAGE_GIT_ARGS[command];
|
|
276
|
+
log.step(`${repo.alias}: git ${args.join(" ")}`);
|
|
277
|
+
const result = runGit(sourceWorktree, args, true);
|
|
278
|
+
if (result.success) {
|
|
279
|
+
const operation = RESULT_BY_COMMAND[command];
|
|
280
|
+
log.success(`${repo.alias}: ${operation}`);
|
|
281
|
+
return operation;
|
|
282
|
+
}
|
|
283
|
+
log.error(`${repo.alias}: git ${command} failed (exit ${result.exitCode})`);
|
|
284
|
+
return "failed";
|
|
285
|
+
}
|
|
286
|
+
function printSummary(results) {
|
|
287
|
+
const counts = {
|
|
288
|
+
added: 0,
|
|
289
|
+
updated: 0,
|
|
290
|
+
fetched: 0,
|
|
291
|
+
pulled: 0,
|
|
292
|
+
pushed: 0,
|
|
293
|
+
unsynced: 0,
|
|
294
|
+
failed: 0,
|
|
295
|
+
};
|
|
296
|
+
for (const r of results)
|
|
297
|
+
counts[r]++;
|
|
298
|
+
const parts = Object.entries(counts)
|
|
299
|
+
.filter(([, count]) => count > 0)
|
|
300
|
+
.map(([name, count]) => `${name} ${count}`);
|
|
301
|
+
log.message(`Summary: ${parts.join(", ")}`);
|
|
302
|
+
}
|
|
303
|
+
function exitFromResults(results) {
|
|
304
|
+
return results.includes("failed") ? 2 : 0;
|
|
305
|
+
}
|
|
306
|
+
function loadRepos(options = {}) {
|
|
307
|
+
const repoRoot = findWorkspaceRoot(options);
|
|
308
|
+
if (!repoRoot) {
|
|
309
|
+
log.error("Could not find sources.yaml in the current directory or its parents. Create a sources.yaml in this project, then retry.");
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const sourcesPath = join(repoRoot, "sources.yaml");
|
|
314
|
+
return { repos: validateSources(parseYaml(readFileSync(sourcesPath, "utf8"))), repoRoot };
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
log.error(e instanceof Error ? e.message : String(e));
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function uniqueAliases(aliases) {
|
|
322
|
+
const seen = new Set();
|
|
323
|
+
return aliases.filter((alias) => {
|
|
324
|
+
if (seen.has(alias))
|
|
325
|
+
return false;
|
|
326
|
+
seen.add(alias);
|
|
327
|
+
return true;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
async function selectRepos(repos, aliases, options, actionLabel) {
|
|
331
|
+
if (options.all)
|
|
332
|
+
return repos;
|
|
333
|
+
if (aliases.length === 0) {
|
|
334
|
+
return selectInteractive(repos, actionLabel);
|
|
335
|
+
}
|
|
336
|
+
const unknown = aliases.filter((a) => !repos.some((r) => r.alias === a));
|
|
337
|
+
if (unknown.length > 0) {
|
|
338
|
+
log.error(`Unknown alias(es): ${unknown.join(", ")}. Use "oms sync --list" to see available aliases.`);
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
const byAlias = new Map(repos.map((repo) => [repo.alias, repo]));
|
|
342
|
+
return uniqueAliases(aliases).map((alias) => byAlias.get(alias));
|
|
343
|
+
}
|
|
344
|
+
async function runSync(aliases, options) {
|
|
345
|
+
const loaded = loadRepos();
|
|
346
|
+
if (!loaded)
|
|
347
|
+
return 1;
|
|
348
|
+
const { repos, repoRoot } = loaded;
|
|
349
|
+
if (options.list) {
|
|
350
|
+
printList(repos);
|
|
351
|
+
return 0;
|
|
352
|
+
}
|
|
353
|
+
const picked = await selectRepos(repos, aliases, options, "sync");
|
|
354
|
+
if (!picked || picked.length === 0)
|
|
355
|
+
return 1;
|
|
356
|
+
const results = picked.map((repo) => syncSubmodule(repo, repoRoot));
|
|
357
|
+
if (results.length > 1 || options.all)
|
|
358
|
+
printSummary(results);
|
|
359
|
+
return exitFromResults(results);
|
|
360
|
+
}
|
|
361
|
+
async function runManage(command, aliases, options) {
|
|
362
|
+
const loaded = loadRepos();
|
|
363
|
+
if (!loaded)
|
|
364
|
+
return 1;
|
|
365
|
+
const { repos, repoRoot } = loaded;
|
|
366
|
+
const picked = await selectRepos(repos, aliases, options, command);
|
|
367
|
+
if (!picked || picked.length === 0)
|
|
368
|
+
return 1;
|
|
369
|
+
const results = picked.map((repo) => manageSubmodule(repo, repoRoot, command));
|
|
370
|
+
if (results.length > 1 || options.all)
|
|
371
|
+
printSummary(results);
|
|
372
|
+
return exitFromResults(results);
|
|
373
|
+
}
|
|
374
|
+
async function runUnsync(aliases, options) {
|
|
375
|
+
const loaded = loadRepos();
|
|
376
|
+
if (!loaded)
|
|
377
|
+
return 1;
|
|
378
|
+
const { repos, repoRoot } = loaded;
|
|
379
|
+
const picked = await selectRepos(repos, aliases, options, "unsync");
|
|
380
|
+
if (!picked || picked.length === 0)
|
|
381
|
+
return 1;
|
|
382
|
+
const results = picked.map((repo) => {
|
|
383
|
+
log.step(`${repo.alias}: unsync`);
|
|
384
|
+
const outcome = removeSubmoduleArtifacts(repo, repoRoot, { force: options.force });
|
|
385
|
+
if (outcome === "removed") {
|
|
386
|
+
log.success(`${repo.alias}: unsynced (commit staged changes in .gitmodules and sources/${repo.alias} to finalize)`);
|
|
387
|
+
return "unsynced";
|
|
388
|
+
}
|
|
389
|
+
if (outcome === "nothing-to-remove") {
|
|
390
|
+
log.info(`${repo.alias}: nothing to remove`);
|
|
391
|
+
return "unsynced";
|
|
392
|
+
}
|
|
393
|
+
return "failed";
|
|
394
|
+
});
|
|
395
|
+
if (results.length > 1 || options.all)
|
|
396
|
+
printSummary(results);
|
|
397
|
+
return exitFromResults(results);
|
|
398
|
+
}
|
|
399
|
+
async function runDoctor() {
|
|
400
|
+
const loaded = loadRepos();
|
|
401
|
+
if (!loaded)
|
|
402
|
+
return 1;
|
|
403
|
+
log.success(`Workspace root: ${loaded.repoRoot}`);
|
|
404
|
+
log.success(`sources.yaml: ${loaded.repos.length} repo(s) configured`);
|
|
405
|
+
const git = spawnSync("git", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
406
|
+
if (git.status === 0) {
|
|
407
|
+
log.success(`git: ${git.stdout.trim()}`);
|
|
408
|
+
return 0;
|
|
409
|
+
}
|
|
410
|
+
log.error("git: not found");
|
|
411
|
+
return 1;
|
|
412
|
+
}
|
|
413
|
+
function readJson(path) {
|
|
414
|
+
if (!existsSync(path))
|
|
415
|
+
return null;
|
|
416
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
417
|
+
}
|
|
418
|
+
function readPackageVersion() {
|
|
419
|
+
const pkg = readJson(join(packageRoot, "package.json"));
|
|
420
|
+
return pkg?.version ?? "0.0.0";
|
|
421
|
+
}
|
|
422
|
+
async function exitWith(action) {
|
|
423
|
+
try {
|
|
424
|
+
process.exit(await action);
|
|
425
|
+
}
|
|
426
|
+
catch (e) {
|
|
427
|
+
log.error(e instanceof Error ? e.message : String(e));
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const exitHelp = "\nExit codes: 0 ok | 1 usage/config error | 2 one or more git operations failed.";
|
|
432
|
+
const commandNames = new Set(["doctor", "sync", "fetch", "pull", "push", "unsync", "help"]);
|
|
433
|
+
const program = new Command();
|
|
434
|
+
program
|
|
435
|
+
.name("oms")
|
|
436
|
+
.description("Manage source repositories listed in sources.yaml under sources/<alias>/.")
|
|
437
|
+
.version(readPackageVersion())
|
|
438
|
+
.addHelpText("after", exitHelp);
|
|
439
|
+
program
|
|
440
|
+
.command("doctor")
|
|
441
|
+
.description("Check sources.yaml configuration and git availability.")
|
|
442
|
+
.addHelpText("after", exitHelp)
|
|
443
|
+
.action(async () => {
|
|
444
|
+
await exitWith(runDoctor());
|
|
445
|
+
});
|
|
446
|
+
program
|
|
447
|
+
.command("sync")
|
|
448
|
+
.description("Sync sources.yaml entries into git submodules under sources/<alias>/.")
|
|
449
|
+
.argument("[aliases...]", "repo aliases to sync (omit for interactive multi-select)")
|
|
450
|
+
.option("--all", "sync every registered source repo")
|
|
451
|
+
.option("--list", "print registered repos")
|
|
452
|
+
.addHelpText("after", exitHelp)
|
|
453
|
+
.action(async (aliases, options) => {
|
|
454
|
+
await exitWith(runSync(aliases, options));
|
|
455
|
+
});
|
|
456
|
+
program
|
|
457
|
+
.command("fetch")
|
|
458
|
+
.description("Run git fetch --all --prune inside checked-out source submodule worktrees.")
|
|
459
|
+
.argument("[aliases...]", "repo aliases to fetch (omit for interactive multi-select)")
|
|
460
|
+
.option("--all", "fetch every registered source repo")
|
|
461
|
+
.addHelpText("after", exitHelp)
|
|
462
|
+
.action(async (aliases, options) => {
|
|
463
|
+
await exitWith(runManage("fetch", aliases, options));
|
|
464
|
+
});
|
|
465
|
+
program
|
|
466
|
+
.command("pull")
|
|
467
|
+
.description("Run git pull --ff-only inside checked-out source submodule worktrees; requires branch/upstream.")
|
|
468
|
+
.argument("[aliases...]", "repo aliases to pull (omit for interactive multi-select)")
|
|
469
|
+
.option("--all", "pull every registered source repo")
|
|
470
|
+
.addHelpText("after", exitHelp)
|
|
471
|
+
.action(async (aliases, options) => {
|
|
472
|
+
await exitWith(runManage("pull", aliases, options));
|
|
473
|
+
});
|
|
474
|
+
program
|
|
475
|
+
.command("push")
|
|
476
|
+
.description("Run git push inside explicit source submodule worktrees; no --all, force, or upstream setup.")
|
|
477
|
+
.argument("<aliases...>", "repo aliases to push")
|
|
478
|
+
.addHelpText("after", exitHelp)
|
|
479
|
+
.action(async (aliases) => {
|
|
480
|
+
await exitWith(runManage("push", aliases, {}));
|
|
481
|
+
});
|
|
482
|
+
program
|
|
483
|
+
.command("unsync")
|
|
484
|
+
.description("Remove submodule registration and worktree (keeps sources.yaml entry). Leaves staged changes in .gitmodules and the submodule path to commit.")
|
|
485
|
+
.argument("[aliases...]", "repo aliases to unsync (omit for interactive multi-select)")
|
|
486
|
+
.option("--all", "unsync every registered source repo")
|
|
487
|
+
.option("--force", "discard uncommitted changes in the source worktree")
|
|
488
|
+
.addHelpText("after", exitHelp)
|
|
489
|
+
.action(async (aliases, options) => {
|
|
490
|
+
await exitWith(runUnsync(aliases, options));
|
|
491
|
+
});
|
|
492
|
+
const requestedCommand = process.argv[2];
|
|
493
|
+
if (requestedCommand && !requestedCommand.startsWith("-") && !commandNames.has(requestedCommand)) {
|
|
494
|
+
console.error(`error: unknown command '${requestedCommand}'`);
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}
|
|
497
|
+
await program.parseAsync();
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oh-my-space",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for managing external source repositories declared in sources.yaml.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/divlook/oh-my-space",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/divlook/oh-my-space.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/divlook/oh-my-space/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"sources",
|
|
16
|
+
"submodule",
|
|
17
|
+
"git",
|
|
18
|
+
"cli"
|
|
19
|
+
],
|
|
20
|
+
"author": {
|
|
21
|
+
"name": "divlook",
|
|
22
|
+
"email": "uihyeon.cha@gmail.com",
|
|
23
|
+
"url": "https://github.com/divlook"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"oms": "dist/oms.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist/",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20.19.0"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc -p tsconfig.build.json && node -e \"require('node:fs').chmodSync('dist/oms.js', 0o755)\"",
|
|
39
|
+
"test": "npm run build && node --test tests/*.test.js",
|
|
40
|
+
"prepack": "npm test",
|
|
41
|
+
"changeset": "changeset",
|
|
42
|
+
"version": "changeset version",
|
|
43
|
+
"release": "npm test && npm publish --provenance --access public"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@changesets/changelog-github": "^0.7.0",
|
|
47
|
+
"@changesets/cli": "^2.29.7",
|
|
48
|
+
"@types/node": "^20.19.0",
|
|
49
|
+
"typescript": "^5.9.3"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@clack/prompts": "^1.4.0",
|
|
53
|
+
"commander": "^14.0.3",
|
|
54
|
+
"yaml": "^2.8.1"
|
|
55
|
+
}
|
|
56
|
+
}
|