@roulabs/mx 1.0.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 +53 -0
- package/bin/mx.js +869 -0
- package/package.json +32 -0
- package/templates/CLAUDE.md +113 -0
- package/templates/work.json +5 -0
- package/templates/workspace.code-workspace +4 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rousan Ali
|
|
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,53 @@
|
|
|
1
|
+
# mx
|
|
2
|
+
|
|
3
|
+
**mx** ("multiplexer") runs several features in parallel across shared repos using git worktrees. Each feature gets an isolated environment — its own worktrees, branches, and ports — so you switch between features instantly without stashing or branch-juggling.
|
|
4
|
+
|
|
5
|
+
mx manages a **runtime**: a single `mx/` folder holding pristine repo clones (`repos/`) and one folder per feature (`works/`), each with git worktrees on its own branch. mx owns the per-work manifest (`work.json`) and a VS Code workspace file; you drive everything through `mx` commands.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @roulabs/mx # provides the `mx` command
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Node >= 22 and git.
|
|
14
|
+
|
|
15
|
+
## Point mx at a runtime
|
|
16
|
+
|
|
17
|
+
mx resolves its runtime in this order: `--runtime <path>` flag, then `$MX_RUNTIME`, then the default `~/mx`. Set it once in your shell:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
export MX_RUNTIME="$HOME/mx"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
mx init # scaffold the runtime (at $MX_RUNTIME or ~/mx)
|
|
27
|
+
mx repo add git@github.com:you/app.git # clone a pristine repo into the runtime
|
|
28
|
+
mx work new my-feature # create a work (prints its folder path)
|
|
29
|
+
mx work -n my-feature worktree add app # add a worktree on branch my-feature
|
|
30
|
+
mx work -n my-feature port set app web # allocate a free port (across all works)
|
|
31
|
+
mx status # see repos, works, worktrees, ports
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Inside a work folder or worktree you can drop `-n` — mx infers the work/repo from your cwd. Read commands accept `--porcelain` for stable JSON; errors are `{"error","code"}` with a non-zero exit.
|
|
35
|
+
|
|
36
|
+
## Commands
|
|
37
|
+
|
|
38
|
+
| command | does |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `mx init [path]` | scaffold/adopt a runtime (`repos/`, `works/`, `.mx-root`, `CLAUDE.md`) |
|
|
41
|
+
| `mx status [--porcelain]` | list repos, works, worktrees, ports |
|
|
42
|
+
| `mx update` | re-stamp the runtime's `CLAUDE.md` |
|
|
43
|
+
| `mx repo add <git-url> [--name <n>]` | clone a pristine repo |
|
|
44
|
+
| `mx repo ls` / `mx repo -n <name> fetch\|info\|rm` | manage pristine repos |
|
|
45
|
+
| `mx work new <name> [--description <t>]` | create a work |
|
|
46
|
+
| `mx work ls` / `mx work -n <name> info\|describe\|path` | manage works |
|
|
47
|
+
| `mx work -n <name> worktree add\|ls\|rm <repo> [--branch <b>] [--base <ref>]` | manage worktrees |
|
|
48
|
+
| `mx work -n <name> port set\|unset\|ls <repo> <service> [<port>]` | allocate/release ports |
|
|
49
|
+
| `mx work -n <name> destroy` | remove worktrees + work folder (keeps branches) |
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
MIT
|
package/bin/mx.js
ADDED
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../../packages/core/src/errors.ts
|
|
4
|
+
var MxError = class extends Error {
|
|
5
|
+
/**
|
|
6
|
+
* Stable error code identifying the failure category. Surfaced verbatim in
|
|
7
|
+
* the CLI's `--porcelain` error output.
|
|
8
|
+
*/
|
|
9
|
+
code;
|
|
10
|
+
/**
|
|
11
|
+
* @param message - Human-readable explanation of the failure.
|
|
12
|
+
* @param code - Stable category code; defaults to a generic `ERROR`.
|
|
13
|
+
*/
|
|
14
|
+
constructor(message, code = "ERROR") {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "MxError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// ../../packages/core/src/runtime.ts
|
|
22
|
+
import * as fs4 from "fs";
|
|
23
|
+
import * as os from "os";
|
|
24
|
+
import * as path3 from "path";
|
|
25
|
+
|
|
26
|
+
// ../../packages/core/src/fsutil.ts
|
|
27
|
+
import * as fs from "fs";
|
|
28
|
+
import * as path from "path";
|
|
29
|
+
function exists(p) {
|
|
30
|
+
return fs.existsSync(p);
|
|
31
|
+
}
|
|
32
|
+
function isGitRepo(dir) {
|
|
33
|
+
return exists(path.join(dir, ".git"));
|
|
34
|
+
}
|
|
35
|
+
function listDirs(dir) {
|
|
36
|
+
if (!exists(dir)) return [];
|
|
37
|
+
return fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith(".")).map((d) => d.name).sort();
|
|
38
|
+
}
|
|
39
|
+
function realpath(p) {
|
|
40
|
+
try {
|
|
41
|
+
return fs.realpathSync(p);
|
|
42
|
+
} catch {
|
|
43
|
+
return path.resolve(p);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ../../packages/core/src/json.ts
|
|
48
|
+
import * as fs2 from "fs";
|
|
49
|
+
function readJson(file) {
|
|
50
|
+
return JSON.parse(fs2.readFileSync(file, "utf8"));
|
|
51
|
+
}
|
|
52
|
+
function writeJson(file, obj) {
|
|
53
|
+
const tmp = file + ".tmp";
|
|
54
|
+
fs2.writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n");
|
|
55
|
+
fs2.renameSync(tmp, file);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ../../packages/core/src/templates.ts
|
|
59
|
+
import * as fs3 from "fs";
|
|
60
|
+
import * as path2 from "path";
|
|
61
|
+
function stampClaudeMd(targetDir, templatesDir2) {
|
|
62
|
+
const src = path2.join(templatesDir2, "CLAUDE.md");
|
|
63
|
+
if (!exists(src)) throw new MxError(`missing template: ${src}`, "NO_TEMPLATE");
|
|
64
|
+
const dest = path2.join(targetDir, "CLAUDE.md");
|
|
65
|
+
fs3.copyFileSync(src, dest);
|
|
66
|
+
return dest;
|
|
67
|
+
}
|
|
68
|
+
function removeStaleRuntimeReadme(targetDir) {
|
|
69
|
+
const readme = path2.join(targetDir, "README.md");
|
|
70
|
+
if (exists(readme)) {
|
|
71
|
+
fs3.rmSync(readme);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ../../packages/core/src/runtime.ts
|
|
78
|
+
var DEFAULT_RUNTIME = path3.join(os.homedir(), "mx");
|
|
79
|
+
function defaultRuntime() {
|
|
80
|
+
return path3.resolve(DEFAULT_RUNTIME);
|
|
81
|
+
}
|
|
82
|
+
var reposDir = (root) => path3.join(root, "repos");
|
|
83
|
+
var worksDir = (root) => path3.join(root, "works");
|
|
84
|
+
var repoPath = (root, name) => path3.join(reposDir(root), name);
|
|
85
|
+
var workDir = (root, name) => path3.join(worksDir(root), name);
|
|
86
|
+
var workManifest = (root, name) => path3.join(workDir(root, name), "work.json");
|
|
87
|
+
var workspaceFile = (root, name) => path3.join(workDir(root, name), `${name}.code-workspace`);
|
|
88
|
+
function discoverRuntime(opts = {}) {
|
|
89
|
+
const p = opts.runtime || process.env.MX_RUNTIME || DEFAULT_RUNTIME;
|
|
90
|
+
return path3.resolve(p);
|
|
91
|
+
}
|
|
92
|
+
function requireRuntime(opts = {}) {
|
|
93
|
+
const root = discoverRuntime(opts);
|
|
94
|
+
if (!exists(path3.join(root, ".mx-root"))) {
|
|
95
|
+
throw new MxError(`not an mx runtime (no .mx-root): ${root} \u2014 run \`mx init\``, "NO_RUNTIME");
|
|
96
|
+
}
|
|
97
|
+
return root;
|
|
98
|
+
}
|
|
99
|
+
function listRepoNames(root) {
|
|
100
|
+
return listDirs(reposDir(root)).filter((n) => isGitRepo(repoPath(root, n)));
|
|
101
|
+
}
|
|
102
|
+
function listWorkNames(root) {
|
|
103
|
+
return listDirs(worksDir(root)).filter((n) => exists(workManifest(root, n)));
|
|
104
|
+
}
|
|
105
|
+
function readWork(root, name) {
|
|
106
|
+
const file = workManifest(root, name);
|
|
107
|
+
if (!exists(file)) {
|
|
108
|
+
if (!exists(workDir(root, name))) throw new MxError(`no such work: ${name}`, "NO_WORK");
|
|
109
|
+
throw new MxError(
|
|
110
|
+
`work "${name}" has no work.json \u2014 recreate it with \`mx work new\``,
|
|
111
|
+
"NO_MANIFEST"
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return readJson(file);
|
|
115
|
+
}
|
|
116
|
+
function writeWork(root, work) {
|
|
117
|
+
writeJson(workManifest(root, work.name), work);
|
|
118
|
+
}
|
|
119
|
+
function findWorktree(work, repo) {
|
|
120
|
+
return (work.worktrees ?? []).find((w) => w.repo === repo) ?? null;
|
|
121
|
+
}
|
|
122
|
+
function inferContext(root) {
|
|
123
|
+
const cwd = realpath(process.cwd());
|
|
124
|
+
const segmentsUnder = (base) => {
|
|
125
|
+
if (!exists(base)) return null;
|
|
126
|
+
const rel = path3.relative(realpath(base), cwd);
|
|
127
|
+
if (rel === "" || rel.startsWith("..") || path3.isAbsolute(rel)) return null;
|
|
128
|
+
return rel.split(path3.sep);
|
|
129
|
+
};
|
|
130
|
+
const w = segmentsUnder(worksDir(root));
|
|
131
|
+
if (w) return { work: w[0], repo: w[1] ?? null };
|
|
132
|
+
const r = segmentsUnder(reposDir(root));
|
|
133
|
+
if (r) return { work: null, repo: r[0] };
|
|
134
|
+
return { work: null, repo: null };
|
|
135
|
+
}
|
|
136
|
+
function initRuntime(target0, templatesDir2) {
|
|
137
|
+
const target = path3.resolve(target0);
|
|
138
|
+
const created = [];
|
|
139
|
+
for (const d of [target, reposDir(target), worksDir(target)]) {
|
|
140
|
+
if (!exists(d)) {
|
|
141
|
+
fs4.mkdirSync(d, { recursive: true });
|
|
142
|
+
created.push(d);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const marker = path3.join(target, ".mx-root");
|
|
146
|
+
if (!exists(marker)) {
|
|
147
|
+
fs4.writeFileSync(marker, "");
|
|
148
|
+
created.push(marker);
|
|
149
|
+
}
|
|
150
|
+
created.push(stampClaudeMd(target, templatesDir2));
|
|
151
|
+
removeStaleRuntimeReadme(target);
|
|
152
|
+
return { runtime: target, created };
|
|
153
|
+
}
|
|
154
|
+
function updateRuntime(root, templatesDir2) {
|
|
155
|
+
const dest = stampClaudeMd(root, templatesDir2);
|
|
156
|
+
removeStaleRuntimeReadme(root);
|
|
157
|
+
return { runtime: root, updated: [dest] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ../../packages/core/src/repos.ts
|
|
161
|
+
import * as fs5 from "fs";
|
|
162
|
+
|
|
163
|
+
// ../../packages/core/src/git.ts
|
|
164
|
+
import { execFileSync } from "child_process";
|
|
165
|
+
function git(args, opts = {}) {
|
|
166
|
+
try {
|
|
167
|
+
const out = execFileSync("git", args, {
|
|
168
|
+
encoding: "utf8",
|
|
169
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
170
|
+
...opts
|
|
171
|
+
});
|
|
172
|
+
return (out == null ? "" : String(out)).trim();
|
|
173
|
+
} catch (e) {
|
|
174
|
+
const err = e;
|
|
175
|
+
const msg = (err.stderr ?? err.stdout ?? err.message ?? "").toString().trim();
|
|
176
|
+
throw new MxError(`git ${args.join(" ")} failed: ${msg}`, "GIT");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function gitQuiet(args, opts = {}) {
|
|
180
|
+
try {
|
|
181
|
+
const out = execFileSync("git", args, {
|
|
182
|
+
encoding: "utf8",
|
|
183
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
184
|
+
...opts
|
|
185
|
+
});
|
|
186
|
+
return (out == null ? "" : String(out)).trim();
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function currentBranch(repoPath2) {
|
|
192
|
+
return gitQuiet(["-C", repoPath2, "rev-parse", "--abbrev-ref", "HEAD"]) ?? "(detached)";
|
|
193
|
+
}
|
|
194
|
+
function remoteUrl(repoPath2) {
|
|
195
|
+
return gitQuiet(["-C", repoPath2, "remote", "get-url", "origin"]) ?? gitQuiet(["-C", repoPath2, "config", "--get", "remote.origin.url"]) ?? null;
|
|
196
|
+
}
|
|
197
|
+
function branchExists(repoPath2, branch) {
|
|
198
|
+
return gitQuiet(["-C", repoPath2, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]) !== null;
|
|
199
|
+
}
|
|
200
|
+
function isDirty(worktreePath) {
|
|
201
|
+
const s = gitQuiet(["-C", worktreePath, "status", "--porcelain"]);
|
|
202
|
+
return s == null ? false : s.length > 0;
|
|
203
|
+
}
|
|
204
|
+
function remoteBranchList(repoPath2) {
|
|
205
|
+
const out = gitQuiet(["-C", repoPath2, "for-each-ref", "--format=%(refname)", "refs/remotes/origin"]);
|
|
206
|
+
if (!out) return [];
|
|
207
|
+
return out.split("\n").map((s) => s.trim()).filter((s) => s && !s.endsWith("/HEAD")).map((s) => s.replace(/^refs\/remotes\/origin\//, ""));
|
|
208
|
+
}
|
|
209
|
+
function resolveBase(repoPath2, base) {
|
|
210
|
+
return gitQuiet(["-C", repoPath2, "rev-parse", "--verify", `${base}^{commit}`]) ?? gitQuiet(["-C", repoPath2, "rev-parse", "--verify", `origin/${base}^{commit}`]) ?? null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ../../packages/core/src/repos.ts
|
|
214
|
+
function repoNameFromUrl(url) {
|
|
215
|
+
const base = url.split("/").pop() || url;
|
|
216
|
+
return base.replace(/\.git$/, "");
|
|
217
|
+
}
|
|
218
|
+
function repoAdd(root, url, name0) {
|
|
219
|
+
const name = name0 || repoNameFromUrl(url);
|
|
220
|
+
const dest = repoPath(root, name);
|
|
221
|
+
if (exists(dest)) throw new MxError(`repo already exists: ${name}`, "EXISTS");
|
|
222
|
+
git(["clone", url, dest], { stdio: ["ignore", "inherit", "inherit"] });
|
|
223
|
+
return { name, path: dest, remote: remoteUrl(dest), branch: currentBranch(dest) };
|
|
224
|
+
}
|
|
225
|
+
function listReposInfo(root) {
|
|
226
|
+
return listRepoNames(root).map((name) => ({
|
|
227
|
+
name,
|
|
228
|
+
branch: currentBranch(repoPath(root, name)),
|
|
229
|
+
remote: remoteUrl(repoPath(root, name))
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
function repoFetch(root, name) {
|
|
233
|
+
const rp = repoPath(root, name);
|
|
234
|
+
if (!isGitRepo(rp)) throw new MxError(`no such repo: ${name}`, "NO_REPO");
|
|
235
|
+
git(["-C", rp, "fetch", "--all", "--prune", "--tags"]);
|
|
236
|
+
gitQuiet(["-C", rp, "merge", "--ff-only", "@{u}"]);
|
|
237
|
+
return { name, branch: currentBranch(rp), remoteBranches: remoteBranchList(rp) };
|
|
238
|
+
}
|
|
239
|
+
function repoInfo(root, name) {
|
|
240
|
+
const rp = repoPath(root, name);
|
|
241
|
+
if (!isGitRepo(rp)) throw new MxError(`no such repo: ${name}`, "NO_REPO");
|
|
242
|
+
const usedBy = listWorkNames(root).filter((w) => findWorktree(readWork(root, w), name));
|
|
243
|
+
return {
|
|
244
|
+
name,
|
|
245
|
+
path: rp,
|
|
246
|
+
branch: currentBranch(rp),
|
|
247
|
+
remote: remoteUrl(rp),
|
|
248
|
+
worktreesInWorks: usedBy
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function repoRemove(root, name) {
|
|
252
|
+
const rp = repoPath(root, name);
|
|
253
|
+
if (!isGitRepo(rp)) throw new MxError(`no such repo: ${name}`, "NO_REPO");
|
|
254
|
+
const usedBy = listWorkNames(root).filter((w) => findWorktree(readWork(root, w), name));
|
|
255
|
+
if (usedBy.length) {
|
|
256
|
+
throw new MxError(
|
|
257
|
+
`repo "${name}" still has worktrees in: ${usedBy.join(", ")} \u2014 remove those first`,
|
|
258
|
+
"IN_USE"
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
fs5.rmSync(rp, { recursive: true, force: true });
|
|
262
|
+
return { name, removed: true };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ../../packages/core/src/works.ts
|
|
266
|
+
import * as fs6 from "fs";
|
|
267
|
+
import * as path4 from "path";
|
|
268
|
+
function addFolderToWorkspace(root, name, repo) {
|
|
269
|
+
const file = workspaceFile(root, name);
|
|
270
|
+
const ws = exists(file) ? readJson(file) : { folders: [], settings: {} };
|
|
271
|
+
ws.folders = ws.folders ?? [];
|
|
272
|
+
if (!ws.folders.some((f) => f.path === repo)) ws.folders.push({ name: repo, path: repo });
|
|
273
|
+
writeJson(file, ws);
|
|
274
|
+
}
|
|
275
|
+
function removeFolderFromWorkspace(root, name, repo) {
|
|
276
|
+
const file = workspaceFile(root, name);
|
|
277
|
+
if (!exists(file)) return;
|
|
278
|
+
const ws = readJson(file);
|
|
279
|
+
ws.folders = (ws.folders ?? []).filter((f) => f.path !== repo);
|
|
280
|
+
writeJson(file, ws);
|
|
281
|
+
}
|
|
282
|
+
function workNew(root, name, description = "") {
|
|
283
|
+
const dir = workDir(root, name);
|
|
284
|
+
if (exists(dir)) throw new MxError(`work already exists: ${name}`, "EXISTS");
|
|
285
|
+
fs6.mkdirSync(dir, { recursive: true });
|
|
286
|
+
const work = { name, description, worktrees: [] };
|
|
287
|
+
writeWork(root, work);
|
|
288
|
+
writeJson(workspaceFile(root, name), { folders: [], settings: {} });
|
|
289
|
+
return { ...work, path: dir };
|
|
290
|
+
}
|
|
291
|
+
function listWorksInfo(root) {
|
|
292
|
+
return listWorkNames(root).map((name) => {
|
|
293
|
+
const w = readWork(root, name);
|
|
294
|
+
return { name, description: w.description ?? "", worktrees: (w.worktrees ?? []).length };
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
function workInfo(root, name) {
|
|
298
|
+
return readWork(root, name);
|
|
299
|
+
}
|
|
300
|
+
function workDescribe(root, name, text) {
|
|
301
|
+
const work = readWork(root, name);
|
|
302
|
+
work.description = text;
|
|
303
|
+
writeWork(root, work);
|
|
304
|
+
return work;
|
|
305
|
+
}
|
|
306
|
+
function workPath(root, name) {
|
|
307
|
+
const dir = workDir(root, name);
|
|
308
|
+
if (!exists(dir)) throw new MxError(`no such work: ${name}`, "NO_WORK");
|
|
309
|
+
return { name, path: dir };
|
|
310
|
+
}
|
|
311
|
+
function worktreeAdd(root, name, repo, opts = {}) {
|
|
312
|
+
const work = readWork(root, name);
|
|
313
|
+
const rp = repoPath(root, repo);
|
|
314
|
+
if (!isGitRepo(rp)) throw new MxError(`no such repo: ${repo}`, "NO_REPO");
|
|
315
|
+
if (findWorktree(work, repo)) {
|
|
316
|
+
throw new MxError(`work "${name}" already has worktree for ${repo}`, "EXISTS");
|
|
317
|
+
}
|
|
318
|
+
const branch = opts.branch || name;
|
|
319
|
+
const dest = path4.join(workDir(root, name), repo);
|
|
320
|
+
if (branchExists(rp, branch)) {
|
|
321
|
+
git(["-C", rp, "worktree", "add", dest, branch]);
|
|
322
|
+
} else {
|
|
323
|
+
const args = ["-C", rp, "worktree", "add", "-b", branch, dest];
|
|
324
|
+
if (opts.base) {
|
|
325
|
+
const sha = resolveBase(rp, opts.base);
|
|
326
|
+
if (!sha) {
|
|
327
|
+
throw new MxError(`base ref not found: ${opts.base} (tried also origin/${opts.base})`, "NO_REF");
|
|
328
|
+
}
|
|
329
|
+
args.push(sha);
|
|
330
|
+
}
|
|
331
|
+
git(args);
|
|
332
|
+
}
|
|
333
|
+
work.worktrees = work.worktrees ?? [];
|
|
334
|
+
work.worktrees.push({ repo, branch, ports: {} });
|
|
335
|
+
writeWork(root, work);
|
|
336
|
+
addFolderToWorkspace(root, name, repo);
|
|
337
|
+
return { work: name, repo, branch, path: dest, ports: {} };
|
|
338
|
+
}
|
|
339
|
+
function worktreeList(root, name) {
|
|
340
|
+
return readWork(root, name).worktrees ?? [];
|
|
341
|
+
}
|
|
342
|
+
function worktreeRemove(root, name, repo) {
|
|
343
|
+
const work = readWork(root, name);
|
|
344
|
+
const wt = findWorktree(work, repo);
|
|
345
|
+
if (!wt) throw new MxError(`work "${name}" has no worktree for ${repo}`, "NO_WORKTREE");
|
|
346
|
+
const dest = path4.join(workDir(root, name), repo);
|
|
347
|
+
if (exists(dest) && isDirty(dest)) {
|
|
348
|
+
throw new MxError(`worktree ${repo} has uncommitted changes \u2014 commit or discard them first`, "DIRTY");
|
|
349
|
+
}
|
|
350
|
+
git(["-C", repoPath(root, repo), "worktree", "remove", dest]);
|
|
351
|
+
work.worktrees = work.worktrees.filter((w) => w.repo !== repo);
|
|
352
|
+
writeWork(root, work);
|
|
353
|
+
removeFolderFromWorkspace(root, name, repo);
|
|
354
|
+
return { work: name, repo, branch: wt.branch, removed: true };
|
|
355
|
+
}
|
|
356
|
+
function workDestroy(root, name) {
|
|
357
|
+
const work = readWork(root, name);
|
|
358
|
+
const dirty = [];
|
|
359
|
+
for (const wt of work.worktrees ?? []) {
|
|
360
|
+
const dest = path4.join(workDir(root, name), wt.repo);
|
|
361
|
+
if (exists(dest) && isDirty(dest)) dirty.push(wt.repo);
|
|
362
|
+
}
|
|
363
|
+
if (dirty.length) {
|
|
364
|
+
throw new MxError(
|
|
365
|
+
`cannot destroy "${name}" \u2014 uncommitted changes in: ${dirty.join(", ")}. Commit or discard, then retry.`,
|
|
366
|
+
"DIRTY"
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
const removed = [];
|
|
370
|
+
for (const wt of work.worktrees ?? []) {
|
|
371
|
+
const dest = path4.join(workDir(root, name), wt.repo);
|
|
372
|
+
if (exists(dest)) git(["-C", repoPath(root, wt.repo), "worktree", "remove", dest]);
|
|
373
|
+
removed.push(wt.repo);
|
|
374
|
+
}
|
|
375
|
+
fs6.rmSync(workDir(root, name), { recursive: true, force: true });
|
|
376
|
+
return { work: name, removedWorktrees: removed, branchesKept: true };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ../../packages/core/src/ports.ts
|
|
380
|
+
var PORT_BASE = 3e3;
|
|
381
|
+
function allocatedPorts(root, except = null) {
|
|
382
|
+
const used = /* @__PURE__ */ new Map();
|
|
383
|
+
for (const name of listWorkNames(root)) {
|
|
384
|
+
let work;
|
|
385
|
+
try {
|
|
386
|
+
work = readWork(root, name);
|
|
387
|
+
} catch {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
for (const wt of work.worktrees ?? []) {
|
|
391
|
+
for (const [service, port] of Object.entries(wt.ports ?? {})) {
|
|
392
|
+
if (except && except.work === name && except.repo === wt.repo && except.service === service) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
used.set(Number(port), `${name}/${wt.repo}/${service}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return used;
|
|
400
|
+
}
|
|
401
|
+
function nextFreePort(used) {
|
|
402
|
+
let p = PORT_BASE;
|
|
403
|
+
while (used.has(p)) p++;
|
|
404
|
+
return p;
|
|
405
|
+
}
|
|
406
|
+
function portSet(root, name, repo, service, port) {
|
|
407
|
+
const work = readWork(root, name);
|
|
408
|
+
const wt = findWorktree(work, repo);
|
|
409
|
+
if (!wt) throw new MxError(`work "${name}" has no worktree for ${repo} \u2014 add it first`, "NO_WORKTREE");
|
|
410
|
+
wt.ports = wt.ports ?? {};
|
|
411
|
+
const used = allocatedPorts(root, { work: name, repo, service });
|
|
412
|
+
let chosen;
|
|
413
|
+
if (port != null) {
|
|
414
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
415
|
+
throw new MxError(`invalid port: ${port}`, "BAD_ARGS");
|
|
416
|
+
}
|
|
417
|
+
if (used.has(port)) {
|
|
418
|
+
throw new MxError(`port ${port} already allocated to ${used.get(port)}`, "PORT_TAKEN");
|
|
419
|
+
}
|
|
420
|
+
chosen = port;
|
|
421
|
+
} else {
|
|
422
|
+
chosen = nextFreePort(used);
|
|
423
|
+
}
|
|
424
|
+
wt.ports[service] = chosen;
|
|
425
|
+
writeWork(root, work);
|
|
426
|
+
return { work: name, repo, service, port: chosen };
|
|
427
|
+
}
|
|
428
|
+
function portUnset(root, name, repo, service) {
|
|
429
|
+
const work = readWork(root, name);
|
|
430
|
+
const wt = findWorktree(work, repo);
|
|
431
|
+
if (!wt || !wt.ports || !(service in wt.ports)) {
|
|
432
|
+
throw new MxError(`no port set for ${repo}.${service} in ${name}`, "NO_PORT");
|
|
433
|
+
}
|
|
434
|
+
const released = wt.ports[service];
|
|
435
|
+
delete wt.ports[service];
|
|
436
|
+
writeWork(root, work);
|
|
437
|
+
return { work: name, repo, service, released };
|
|
438
|
+
}
|
|
439
|
+
function portList(root, name) {
|
|
440
|
+
const work = readWork(root, name);
|
|
441
|
+
const map = {};
|
|
442
|
+
for (const wt of work.worktrees ?? []) map[wt.repo] = wt.ports ?? {};
|
|
443
|
+
return map;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ../../packages/core/src/status.ts
|
|
447
|
+
function statusRuntime(root) {
|
|
448
|
+
const repos = listReposInfo(root);
|
|
449
|
+
const works = listWorkNames(root).map((name) => readWork(root, name));
|
|
450
|
+
return { runtime: root, repos, works };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// src/args.ts
|
|
454
|
+
var VALUE_FLAGS = {
|
|
455
|
+
"-n": "name",
|
|
456
|
+
"--name": "name",
|
|
457
|
+
"--runtime": "runtime",
|
|
458
|
+
"--description": "description",
|
|
459
|
+
"--branch": "branch",
|
|
460
|
+
"--base": "base"
|
|
461
|
+
};
|
|
462
|
+
function parseArgs(argv) {
|
|
463
|
+
const positionals = [];
|
|
464
|
+
const flags = { porcelain: false, help: false, version: false };
|
|
465
|
+
for (let i = 0; i < argv.length; i++) {
|
|
466
|
+
const a = argv[i];
|
|
467
|
+
if (a === "--porcelain" || a === "--json") {
|
|
468
|
+
flags.porcelain = true;
|
|
469
|
+
} else if (a === "--help" || a === "-h") {
|
|
470
|
+
flags.help = true;
|
|
471
|
+
} else if (a === "--version" || a === "-v") {
|
|
472
|
+
flags.version = true;
|
|
473
|
+
} else if (a.startsWith("--") && a.includes("=")) {
|
|
474
|
+
const eq = a.indexOf("=");
|
|
475
|
+
const key = VALUE_FLAGS[a.slice(0, eq)];
|
|
476
|
+
if (!key) throw new MxError(`unknown flag: ${a.slice(0, eq)}`, "BAD_ARGS");
|
|
477
|
+
flags[key] = a.slice(eq + 1);
|
|
478
|
+
} else {
|
|
479
|
+
const key = VALUE_FLAGS[a];
|
|
480
|
+
if (key) flags[key] = argv[++i];
|
|
481
|
+
else positionals.push(a);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return { positionals, flags };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/output.ts
|
|
488
|
+
var porcelain = false;
|
|
489
|
+
function setPorcelain(value) {
|
|
490
|
+
porcelain = value;
|
|
491
|
+
}
|
|
492
|
+
function emit(human, data) {
|
|
493
|
+
if (porcelain) {
|
|
494
|
+
process.stdout.write(JSON.stringify(data ?? null, null, 2) + "\n");
|
|
495
|
+
} else if (typeof human === "function") {
|
|
496
|
+
human();
|
|
497
|
+
} else if (human != null) {
|
|
498
|
+
process.stdout.write(human + "\n");
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function fail(err) {
|
|
502
|
+
const code = err instanceof MxError ? err.code : "INTERNAL";
|
|
503
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
504
|
+
if (porcelain) {
|
|
505
|
+
process.stdout.write(JSON.stringify({ error: message, code }, null, 2) + "\n");
|
|
506
|
+
} else {
|
|
507
|
+
process.stderr.write(`mx: ${message}
|
|
508
|
+
`);
|
|
509
|
+
}
|
|
510
|
+
process.exit(1);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// src/help.ts
|
|
514
|
+
var HELP = `mx \u2014 control panel for the mx runtime
|
|
515
|
+
|
|
516
|
+
Global:
|
|
517
|
+
mx init [path] scaffold/adopt a runtime (default ~/mx)
|
|
518
|
+
mx status [--porcelain] show runtime, repos, works, ports
|
|
519
|
+
mx update re-stamp runtime CLAUDE.md from templates
|
|
520
|
+
mx help | version
|
|
521
|
+
|
|
522
|
+
Repos (pristine clones):
|
|
523
|
+
mx repo add <git-url> [--name <n>] clone a repo into the runtime
|
|
524
|
+
mx repo ls [--porcelain]
|
|
525
|
+
mx repo -n <name> fetch git fetch (+ ff current branch)
|
|
526
|
+
mx repo -n <name> info [--porcelain]
|
|
527
|
+
mx repo -n <name> rm refuses if any work uses it
|
|
528
|
+
|
|
529
|
+
Works (features):
|
|
530
|
+
mx work new <name> [--description <t>]
|
|
531
|
+
mx work ls [--porcelain]
|
|
532
|
+
mx work -n <name> info [--porcelain]
|
|
533
|
+
mx work -n <name> path print the work folder path (cd "$(mx work -n <name> path)")
|
|
534
|
+
mx work -n <name> describe <text>
|
|
535
|
+
mx work -n <name> worktree add <repo> [--branch <b>] [--base <ref>]
|
|
536
|
+
mx work -n <name> worktree ls [--porcelain]
|
|
537
|
+
mx work -n <name> worktree rm <repo> refuses on uncommitted changes; keeps branch
|
|
538
|
+
mx work -n <name> port set <repo> <service> [<port>] auto-picks a free port if omitted
|
|
539
|
+
mx work -n <name> port unset <repo> <service>
|
|
540
|
+
mx work -n <name> port ls [--porcelain]
|
|
541
|
+
mx work -n <name> destroy removes worktrees + folder; keeps branches
|
|
542
|
+
|
|
543
|
+
The -n <name> selector may be omitted when your cwd implies it: inside a work folder or
|
|
544
|
+
worktree (works/<work>/...) the work is inferred; inside repos/<repo>/... the repo is inferred.
|
|
545
|
+
|
|
546
|
+
Runtime discovery: --runtime <path> -> $MX_RUNTIME -> default ~/mx.
|
|
547
|
+
--porcelain emits stable JSON on reads; errors are {"error","code"} with a non-zero exit.
|
|
548
|
+
`;
|
|
549
|
+
|
|
550
|
+
// src/commands/global.ts
|
|
551
|
+
import * as path6 from "path";
|
|
552
|
+
|
|
553
|
+
// src/paths.ts
|
|
554
|
+
import * as path5 from "path";
|
|
555
|
+
import { fileURLToPath } from "url";
|
|
556
|
+
function templatesDir() {
|
|
557
|
+
if (process.env.MX_TEMPLATES_DIR) return process.env.MX_TEMPLATES_DIR;
|
|
558
|
+
const here = path5.dirname(fileURLToPath(import.meta.url));
|
|
559
|
+
return path5.join(here, "..", "templates");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// src/commands/global.ts
|
|
563
|
+
function runtimeEnvHint(runtime) {
|
|
564
|
+
const envRuntime = process.env.MX_RUNTIME ? path6.resolve(process.env.MX_RUNTIME) : null;
|
|
565
|
+
if (envRuntime === runtime) {
|
|
566
|
+
return ["", `$MX_RUNTIME already points here \u2014 you're set.`];
|
|
567
|
+
}
|
|
568
|
+
if (runtime === defaultRuntime() && !envRuntime) {
|
|
569
|
+
return ["", `This is the default mx runtime (~/mx) \u2014 no MX_RUNTIME setup needed.`];
|
|
570
|
+
}
|
|
571
|
+
return [
|
|
572
|
+
"",
|
|
573
|
+
`Point mx at this runtime by adding to your shell config (~/.zshrc, ~/.bashrc):`,
|
|
574
|
+
"",
|
|
575
|
+
` export MX_RUNTIME="${runtime}"`,
|
|
576
|
+
"",
|
|
577
|
+
`Without it, future \`mx\` commands fall back to the default ~/mx.`
|
|
578
|
+
];
|
|
579
|
+
}
|
|
580
|
+
function runGlobal(positionals, flags) {
|
|
581
|
+
switch (positionals[0]) {
|
|
582
|
+
case "init": {
|
|
583
|
+
const target = positionals[1] || discoverRuntime({ runtime: flags.runtime });
|
|
584
|
+
const res = initRuntime(target, templatesDir());
|
|
585
|
+
emit(() => {
|
|
586
|
+
console.log(`Runtime ready at ${res.runtime}`);
|
|
587
|
+
for (const c of res.created) console.log(` + ${c}`);
|
|
588
|
+
for (const line of runtimeEnvHint(res.runtime)) console.log(line);
|
|
589
|
+
}, res);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
case "status": {
|
|
593
|
+
const root = requireRuntime({ runtime: flags.runtime });
|
|
594
|
+
const data = statusRuntime(root);
|
|
595
|
+
emit(() => {
|
|
596
|
+
console.log(`runtime: ${data.runtime}
|
|
597
|
+
`);
|
|
598
|
+
console.log(`repos (${data.repos.length}):`);
|
|
599
|
+
for (const r of data.repos) {
|
|
600
|
+
console.log(` ${r.name} [${r.branch}] ${r.remote ?? "(no remote)"}`);
|
|
601
|
+
}
|
|
602
|
+
console.log(`
|
|
603
|
+
works (${data.works.length}):`);
|
|
604
|
+
for (const w of data.works) {
|
|
605
|
+
console.log(` ${w.name}${w.description ? ` \u2014 ${w.description}` : ""}`);
|
|
606
|
+
for (const wt of w.worktrees) {
|
|
607
|
+
const ports = Object.entries(wt.ports).map(([s, p]) => `${s}:${p}`).join(", ");
|
|
608
|
+
console.log(` ${wt.repo} [${wt.branch}]${ports ? ` (${ports})` : ""}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}, data);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
case "update": {
|
|
615
|
+
const root = requireRuntime({ runtime: flags.runtime });
|
|
616
|
+
const res = updateRuntime(root, templatesDir());
|
|
617
|
+
emit(() => console.log(`Re-stamped CLAUDE.md into ${res.runtime}`), res);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
default:
|
|
621
|
+
throw new MxError(`unknown command: ${positionals[0]}`, "BAD_ARGS");
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// src/commands/repo.ts
|
|
626
|
+
function need(v, msg) {
|
|
627
|
+
if (v == null || v === "") throw new MxError(msg, "BAD_ARGS");
|
|
628
|
+
return v;
|
|
629
|
+
}
|
|
630
|
+
function dispatchRepo(positionals, flags) {
|
|
631
|
+
const action = positionals[1];
|
|
632
|
+
const root = requireRuntime({ runtime: flags.runtime });
|
|
633
|
+
const ctxRepo = inferContext(root).repo;
|
|
634
|
+
switch (action) {
|
|
635
|
+
case "add": {
|
|
636
|
+
const url = need(positionals[2], "usage: mx repo add <git-url> [--name <n>]");
|
|
637
|
+
const res = repoAdd(root, url, flags.name);
|
|
638
|
+
emit(() => console.log(`cloned ${res.name} -> ${res.path}`), res);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
case "ls": {
|
|
642
|
+
const repos = listReposInfo(root);
|
|
643
|
+
emit(() => {
|
|
644
|
+
for (const r of repos) console.log(`${r.name} [${r.branch}] ${r.remote ?? "(no remote)"}`);
|
|
645
|
+
}, repos);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
case "fetch": {
|
|
649
|
+
const name = need(
|
|
650
|
+
flags.name || ctxRepo,
|
|
651
|
+
"which repo? pass -n <name> or run inside a repo (mx repo -n <name> fetch)"
|
|
652
|
+
);
|
|
653
|
+
const res = repoFetch(root, name);
|
|
654
|
+
emit(
|
|
655
|
+
() => console.log(
|
|
656
|
+
`fetched ${res.name} \u2014 ${res.remoteBranches.length} branch(es) on origin, now on ${res.branch}`
|
|
657
|
+
),
|
|
658
|
+
res
|
|
659
|
+
);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
case "info": {
|
|
663
|
+
const name = need(
|
|
664
|
+
flags.name || ctxRepo,
|
|
665
|
+
"which repo? pass -n <name> or run inside a repo (mx repo -n <name> info)"
|
|
666
|
+
);
|
|
667
|
+
const res = repoInfo(root, name);
|
|
668
|
+
emit(() => {
|
|
669
|
+
console.log(
|
|
670
|
+
`${res.name}
|
|
671
|
+
path: ${res.path}
|
|
672
|
+
branch: ${res.branch}
|
|
673
|
+
remote: ${res.remote ?? "(none)"}`
|
|
674
|
+
);
|
|
675
|
+
console.log(
|
|
676
|
+
` used by works: ${res.worktreesInWorks.length ? res.worktreesInWorks.join(", ") : "(none)"}`
|
|
677
|
+
);
|
|
678
|
+
}, res);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
case "rm": {
|
|
682
|
+
const name = need(
|
|
683
|
+
flags.name || ctxRepo,
|
|
684
|
+
"which repo? pass -n <name> or run inside a repo (mx repo -n <name> rm)"
|
|
685
|
+
);
|
|
686
|
+
const res = repoRemove(root, name);
|
|
687
|
+
emit(() => console.log(`removed repo ${res.name}`), res);
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
default:
|
|
691
|
+
throw new MxError(`unknown repo command: ${action ?? "(none)"}`, "BAD_ARGS");
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// src/commands/work.ts
|
|
696
|
+
function need2(v, msg) {
|
|
697
|
+
if (v == null || v === "") throw new MxError(msg, "BAD_ARGS");
|
|
698
|
+
return v;
|
|
699
|
+
}
|
|
700
|
+
function dispatchWork(positionals, flags) {
|
|
701
|
+
const action = positionals[1];
|
|
702
|
+
if (action === "new") {
|
|
703
|
+
const root2 = requireRuntime({ runtime: flags.runtime });
|
|
704
|
+
const name2 = need2(positionals[2], "usage: mx work new <name> [--description <text>]");
|
|
705
|
+
const res = workNew(root2, name2, flags.description ?? "");
|
|
706
|
+
emit(() => {
|
|
707
|
+
console.log(`created work ${res.name}`);
|
|
708
|
+
console.log(` ${res.path}`);
|
|
709
|
+
}, res);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (action === "ls") {
|
|
713
|
+
const root2 = requireRuntime({ runtime: flags.runtime });
|
|
714
|
+
const works = listWorksInfo(root2);
|
|
715
|
+
emit(() => {
|
|
716
|
+
for (const w of works) {
|
|
717
|
+
console.log(
|
|
718
|
+
`${w.name} (${w.worktrees} worktree${w.worktrees === 1 ? "" : "s"})${w.description ? ` \u2014 ${w.description}` : ""}`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
}, works);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const root = requireRuntime({ runtime: flags.runtime });
|
|
725
|
+
const name = need2(
|
|
726
|
+
flags.name || inferContext(root).work,
|
|
727
|
+
`which work? pass -n <name> or run inside a work folder (mx work -n <name> ${action ?? "<command>"})`
|
|
728
|
+
);
|
|
729
|
+
switch (action) {
|
|
730
|
+
case "info": {
|
|
731
|
+
const work = workInfo(root, name);
|
|
732
|
+
emit(() => console.log(JSON.stringify(work, null, 2)), work);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
case "path": {
|
|
736
|
+
const res = workPath(root, name);
|
|
737
|
+
emit(() => console.log(res.path), res);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
case "describe": {
|
|
741
|
+
const text = need2(positionals[2], "usage: mx work -n <name> describe <text>");
|
|
742
|
+
const work = workDescribe(root, name, text);
|
|
743
|
+
emit(() => console.log(`updated description of ${name}`), work);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
case "worktree":
|
|
747
|
+
return workWorktree(root, name, positionals, flags);
|
|
748
|
+
case "port":
|
|
749
|
+
return workPort(root, name, positionals);
|
|
750
|
+
case "destroy": {
|
|
751
|
+
const res = workDestroy(root, name);
|
|
752
|
+
emit(
|
|
753
|
+
() => console.log(
|
|
754
|
+
`destroyed work ${name} (worktrees removed: ${res.removedWorktrees.join(", ") || "none"}; branches kept)`
|
|
755
|
+
),
|
|
756
|
+
res
|
|
757
|
+
);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
default:
|
|
761
|
+
throw new MxError(`unknown work command: ${action ?? "(none)"}`, "BAD_ARGS");
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
function workWorktree(root, name, positionals, flags) {
|
|
765
|
+
const sub = positionals[2];
|
|
766
|
+
switch (sub) {
|
|
767
|
+
case "add": {
|
|
768
|
+
const repo = need2(
|
|
769
|
+
positionals[3],
|
|
770
|
+
"usage: mx work -n <name> worktree add <repo> [--branch <b>] [--base <ref>]"
|
|
771
|
+
);
|
|
772
|
+
const res = worktreeAdd(root, name, repo, { branch: flags.branch, base: flags.base });
|
|
773
|
+
emit(() => console.log(`added worktree ${res.repo} [${res.branch}] -> ${res.path}`), res);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
case "ls": {
|
|
777
|
+
const list = worktreeList(root, name);
|
|
778
|
+
emit(() => {
|
|
779
|
+
for (const wt of list) {
|
|
780
|
+
const ports = Object.entries(wt.ports ?? {}).map(([s, p]) => `${s}:${p}`).join(", ");
|
|
781
|
+
console.log(`${wt.repo} [${wt.branch}]${ports ? ` (${ports})` : ""}`);
|
|
782
|
+
}
|
|
783
|
+
}, list);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
case "rm": {
|
|
787
|
+
const repo = need2(positionals[3], "usage: mx work -n <name> worktree rm <repo>");
|
|
788
|
+
const res = worktreeRemove(root, name, repo);
|
|
789
|
+
emit(() => console.log(`removed worktree ${res.repo} from ${name} (branch ${res.branch} kept)`), res);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
default:
|
|
793
|
+
throw new MxError(`unknown worktree command: ${sub ?? "(none)"}`, "BAD_ARGS");
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
function workPort(root, name, positionals) {
|
|
797
|
+
const sub = positionals[2];
|
|
798
|
+
switch (sub) {
|
|
799
|
+
case "set": {
|
|
800
|
+
const usage = "usage: mx work -n <name> port set <repo> <service> [<port>]";
|
|
801
|
+
const repo = need2(positionals[3], usage);
|
|
802
|
+
const service = need2(positionals[4], usage);
|
|
803
|
+
const portArg = positionals[5];
|
|
804
|
+
let port;
|
|
805
|
+
if (portArg != null) {
|
|
806
|
+
port = Number(portArg);
|
|
807
|
+
if (!Number.isInteger(port)) throw new MxError(`invalid port: ${portArg}`, "BAD_ARGS");
|
|
808
|
+
}
|
|
809
|
+
const res = portSet(root, name, repo, service, port);
|
|
810
|
+
emit(() => console.log(`${res.repo}.${res.service} -> ${res.port}`), res);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
case "unset": {
|
|
814
|
+
const usage = "usage: mx work -n <name> port unset <repo> <service>";
|
|
815
|
+
const repo = need2(positionals[3], usage);
|
|
816
|
+
const service = need2(positionals[4], usage);
|
|
817
|
+
const res = portUnset(root, name, repo, service);
|
|
818
|
+
emit(() => console.log(`unset ${res.repo}.${res.service} (was ${res.released})`), res);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
case "ls": {
|
|
822
|
+
const map = portList(root, name);
|
|
823
|
+
emit(() => {
|
|
824
|
+
for (const [repo, ports] of Object.entries(map)) {
|
|
825
|
+
for (const [service, port] of Object.entries(ports)) {
|
|
826
|
+
console.log(`${repo}.${service} -> ${port}`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}, map);
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
default:
|
|
833
|
+
throw new MxError(`unknown port command: ${sub ?? "(none)"}`, "BAD_ARGS");
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// src/main.ts
|
|
838
|
+
var VERSION = "0.1.0";
|
|
839
|
+
function main() {
|
|
840
|
+
const { positionals, flags } = parseArgs(process.argv.slice(2));
|
|
841
|
+
setPorcelain(flags.porcelain);
|
|
842
|
+
try {
|
|
843
|
+
if (flags.version || positionals[0] === "version") {
|
|
844
|
+
emit(() => console.log(`mx ${VERSION}`), { version: VERSION });
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (flags.help || positionals.length === 0 || positionals[0] === "help") {
|
|
848
|
+
process.stdout.write(HELP);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
switch (positionals[0]) {
|
|
852
|
+
case "init":
|
|
853
|
+
case "status":
|
|
854
|
+
case "update":
|
|
855
|
+
return runGlobal(positionals, flags);
|
|
856
|
+
case "repo":
|
|
857
|
+
return dispatchRepo(positionals, flags);
|
|
858
|
+
case "work":
|
|
859
|
+
return dispatchWork(positionals, flags);
|
|
860
|
+
default:
|
|
861
|
+
throw new MxError(`unknown command: ${positionals[0]}`, "BAD_ARGS");
|
|
862
|
+
}
|
|
863
|
+
} catch (e) {
|
|
864
|
+
fail(e);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// src/bin/mx.ts
|
|
869
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@roulabs/mx",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "mx — run several features in parallel across shared repos using git worktrees",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mx": "./bin/mx.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/roulabs/mx.git"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/roulabs/mx#readme",
|
|
18
|
+
"bugs": "https://github.com/roulabs/mx/issues",
|
|
19
|
+
"keywords": [
|
|
20
|
+
"git",
|
|
21
|
+
"worktree",
|
|
22
|
+
"monorepo",
|
|
23
|
+
"cli",
|
|
24
|
+
"multiplexer",
|
|
25
|
+
"parallel",
|
|
26
|
+
"workflow",
|
|
27
|
+
"dev"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<!-- Installed by the mx CLI. Don't hand-edit this file in the runtime —
|
|
2
|
+
edit templates/CLAUDE.md in the mx source repo and run `mx update`. -->
|
|
3
|
+
|
|
4
|
+
# mx — multiplexed parallel work across repos
|
|
5
|
+
|
|
6
|
+
**mx** ("multiplexer") is a system for running several features in parallel across shared repos,
|
|
7
|
+
using git worktrees. You are running inside an mx runtime. Read this fully before acting — the
|
|
8
|
+
rules here are not optional.
|
|
9
|
+
|
|
10
|
+
## The one idea that governs everything
|
|
11
|
+
|
|
12
|
+
**`mx` owns all runtime state.** The work manifest (`work.json`) and the VS Code workspace file
|
|
13
|
+
are managed *only* through `mx` commands. You **never** hand-edit `work.json` or the
|
|
14
|
+
`.code-workspace`, and you **never** create or remove worktrees with raw `git`. Whenever you need
|
|
15
|
+
to read or change the work — its repos, branches, ports — you call an `mx` command. Treat
|
|
16
|
+
`work.json` as read-only build output: look at it for orientation, but mutate it through `mx`.
|
|
17
|
+
|
|
18
|
+
Every read command takes `--porcelain` for stable JSON; parse that instead of scraping text.
|
|
19
|
+
|
|
20
|
+
## What this runtime is for
|
|
21
|
+
|
|
22
|
+
`mx/` is where **feature work** happens. Sessions launched here implement a feature inside a
|
|
23
|
+
`works/<feature>/` folder. mx *itself* — this template, the `mx` CLI — is maintained in a separate
|
|
24
|
+
**mx source checkout** (the `github.com/roulabs/mx` repo), outside this tree. If you were opened
|
|
25
|
+
here to change how mx works, you're in the wrong place: switch to that repo. Don't edit `repos/`,
|
|
26
|
+
`works/`, or the runtime files from here.
|
|
27
|
+
|
|
28
|
+
## Layout
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
mx/
|
|
32
|
+
├── CLAUDE.md # this file (installed by the mx CLI)
|
|
33
|
+
├── .mx-root # empty marker: "this is the mx root"
|
|
34
|
+
├── repos/ # PRISTINE reference clones, each on its default branch
|
|
35
|
+
│ ├── repo-a/
|
|
36
|
+
│ └── repo-b/
|
|
37
|
+
└── works/ # one folder per feature/work
|
|
38
|
+
└── feature-a/
|
|
39
|
+
├── work.json # manifest — owned by `mx`, do not hand-edit
|
|
40
|
+
├── feature-a.code-workspace
|
|
41
|
+
├── repo-a/ # worktree of repo-a on this feature's branch
|
|
42
|
+
└── repo-b/ # worktree of repo-b on this feature's branch
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `repos/<repo>` are **source-of-truth clones** — read-only reference. Worktrees fork from them
|
|
46
|
+
and share their `.git` object store. Never edit, commit, or run dev servers in `repos/`.
|
|
47
|
+
- `works/<feature>/<repo>` are **worktrees**, each on its own feature branch. All work happens here.
|
|
48
|
+
|
|
49
|
+
## work.json (per-work manifest, owned by mx)
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"name": "feature-a",
|
|
54
|
+
"description": "Add a chatbox to the answer panel",
|
|
55
|
+
"worktrees": [
|
|
56
|
+
{ "repo": "repo-a", "branch": "feature-a", "ports": { "web": 3000, "api": 3001 } },
|
|
57
|
+
{ "repo": "repo-b", "branch": "feature-a", "ports": { "worker": 3002 } }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- One worktree per repo. `ports` is a `service -> port` map local to that worktree.
|
|
63
|
+
- The work's `name` is immutable. There is no port-block concept — each port is allocated
|
|
64
|
+
individually and is unique across **all** works.
|
|
65
|
+
|
|
66
|
+
## Orient yourself at the start of every session
|
|
67
|
+
|
|
68
|
+
1. You are launched from a **work folder** (`works/<feature>/`), not a single repo. There is no "main repo."
|
|
69
|
+
2. Read the work's state with `mx work -n <feature> info --porcelain` to learn its repos, branches, and ports.
|
|
70
|
+
3. When you edit a repo's worktree, follow that repo's own `CLAUDE.md`, linters, and conventions —
|
|
71
|
+
its instructions live inside the worktree and apply.
|
|
72
|
+
4. The work root is **not** a git repo. Run build/test/git commands from inside the relevant worktree.
|
|
73
|
+
5. If several sessions share one work, the user gives each a lane (usually one repo). Stay in your lane.
|
|
74
|
+
|
|
75
|
+
## How to do things (always via mx)
|
|
76
|
+
|
|
77
|
+
You are launched from the work folder, so you can **omit `-n <feature>`** — mx infers the work from
|
|
78
|
+
your cwd (and the repo, when you're inside a worktree). The commands below show `-n <feature>` for
|
|
79
|
+
clarity; dropping it works while you're inside the work.
|
|
80
|
+
|
|
81
|
+
- **See the work:** `mx work info --porcelain` (or `mx work -n <feature> info --porcelain`)
|
|
82
|
+
- **Add a repo to the work (needs a worktree):** if a repo you need has no worktree yet, **stop and
|
|
83
|
+
ask the user.** Only when they say so, run:
|
|
84
|
+
```
|
|
85
|
+
mx work -n <feature> worktree add <repo> [--branch <b>] [--base <ref>]
|
|
86
|
+
```
|
|
87
|
+
This creates the worktree from the pristine clone, registers it in `work.json`, and adds it to the
|
|
88
|
+
workspace — all at once. Never run `git worktree add` yourself.
|
|
89
|
+
- `--branch <b>` is the **new** branch to create (defaults to the work name; if it already exists, it's reused).
|
|
90
|
+
- `--base <ref>` is where to **fork from** — any ref. A bare branch name (e.g. `main`,
|
|
91
|
+
`migration-to-mt-service-from-cf`) resolves to that local branch or, failing that, `origin/<name>`.
|
|
92
|
+
Run `mx repo -n <repo> fetch` first if you want the base at its latest upstream commit. Omit
|
|
93
|
+
`--base` to fork from the pristine clone's current HEAD.
|
|
94
|
+
- **Allocate a port:** `mx work -n <feature> port set <repo> <service>` returns a free port (unique
|
|
95
|
+
across all works). This only records the port in `work.json` — **you** must then wire that port
|
|
96
|
+
into the repo's own env/config (`.env`, `PORT=`, etc.) and remap any outbound URL to a sibling
|
|
97
|
+
service to its allocated port too. Release with `port unset`.
|
|
98
|
+
- **Tear down (user-initiated, after merge):** `mx work -n <feature> destroy` removes the worktrees
|
|
99
|
+
and the work folder but **keeps the branches**. It refuses if any worktree has uncommitted changes.
|
|
100
|
+
|
|
101
|
+
## Hard rules
|
|
102
|
+
|
|
103
|
+
1. **Never edit, stage, commit, or run dev servers inside `repos/`.** Those clones are read-only base for worktrees.
|
|
104
|
+
2. **Never hand-edit `work.json` or the `.code-workspace`.** Change them only through `mx` commands.
|
|
105
|
+
3. **Never create or remove worktrees with raw `git`.** Use `mx work ... worktree add/rm`.
|
|
106
|
+
4. **Creating a worktree requires the user in the loop** — only when they explicitly tell you to in this session.
|
|
107
|
+
5. **Don't destroy anything unless asked.** Worktrees stay until the user confirms the feature is merged.
|
|
108
|
+
Teardown keeps feature branches; never delete them.
|
|
109
|
+
|
|
110
|
+
## The one rule that matters most
|
|
111
|
+
|
|
112
|
+
`repos/` is read-only reference; real work lives in worktrees under `works/<feature>/`; and `mx`
|
|
113
|
+
owns the manifest. If a repo you need has no worktree yet, ask before adding one — then add it with `mx`.
|