moshcode 0.24.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 +580 -0
- package/bin/moshcode.mjs +674 -0
- package/bin/moshscript.mjs +29 -0
- package/examples/alive.mosh +6 -0
- package/examples/scripting-the-cli.mosh +21 -0
- package/examples/team-secrets.mosh +20 -0
- package/examples/templates/bun-caddy-sqlite/.env.example +14 -0
- package/examples/templates/bun-caddy-sqlite/Caddyfile +18 -0
- package/examples/templates/bun-caddy-sqlite/README.md +97 -0
- package/examples/templates/bun-caddy-sqlite/deploy/moshcode-dns.service +39 -0
- package/examples/templates/bun-caddy-sqlite/deploy/moshpit-service.service +38 -0
- package/examples/templates/bun-caddy-sqlite/package.json +15 -0
- package/examples/templates/bun-caddy-sqlite/src/db.ts +47 -0
- package/examples/templates/bun-caddy-sqlite/src/server.ts +44 -0
- package/examples/templates/bun-caddy-sqlite/template.json +10 -0
- package/examples/templates/caddy-proxy/Caddyfile +36 -0
- package/examples/templates/caddy-proxy/README.md +104 -0
- package/examples/templates/caddy-proxy/deploy/moshcode-dns.service +39 -0
- package/examples/templates/caddy-proxy/template.json +8 -0
- package/examples/templates/caddy-static/Caddyfile +16 -0
- package/examples/templates/caddy-static/README.md +90 -0
- package/examples/templates/caddy-static/deploy/moshcode-dns.service +39 -0
- package/examples/templates/caddy-static/site/index.html +11 -0
- package/examples/templates/caddy-static/template.json +8 -0
- package/install.sh +194 -0
- package/package.json +28 -0
- package/prd/0000-template.md +49 -0
- package/prd/0001-wrap-ugig-and-coinpay-clis.md +121 -0
- package/prd/0002-separate-agent-and-raw-engine-launches.md +113 -0
- package/prd/0003-cross-engine-mcp-and-skill-installation.md +165 -0
- package/prd/0004-moshscript-run-programmable-moshcode.md +344 -0
- package/prd/0005-hosted-moshpit-resolver.md +192 -0
- package/prd/0006-help.md +359 -0
- package/prd/0007-profullstack-site-init.md +1183 -0
- package/prd/README.md +26 -0
- package/src/ads.mjs +58 -0
- package/src/auth.mjs +193 -0
- package/src/cli-schema.mjs +533 -0
- package/src/cli.mjs +118 -0
- package/src/commands.mjs +259 -0
- package/src/completion.mjs +594 -0
- package/src/console.mjs +244 -0
- package/src/dns-system.mjs +404 -0
- package/src/dns.mjs +2872 -0
- package/src/doh-server.mjs +256 -0
- package/src/doh.mjs +218 -0
- package/src/engines.mjs +385 -0
- package/src/escalate.mjs +85 -0
- package/src/help.mjs +443 -0
- package/src/integrations.mjs +265 -0
- package/src/mcp-catalog.mjs +50 -0
- package/src/mcp.mjs +155 -0
- package/src/mirror.mjs +187 -0
- package/src/notify.mjs +86 -0
- package/src/open-url.mjs +34 -0
- package/src/parking-http.mjs +65 -0
- package/src/pins.mjs +190 -0
- package/src/pit-url.mjs +13 -0
- package/src/prd.mjs +341 -0
- package/src/pty.mjs +176 -0
- package/src/pwd.mjs +103 -0
- package/src/registry.mjs +37 -0
- package/src/release-install.mjs +191 -0
- package/src/runtime.mjs +161 -0
- package/src/selfupdate.mjs +215 -0
- package/src/serve.mjs +502 -0
- package/src/skills.mjs +93 -0
- package/src/tabs.mjs +144 -0
- package/src/templates.mjs +456 -0
- package/src/tools.mjs +231 -0
- package/src/trade.mjs +137 -0
- package/src/trust.mjs +712 -0
- package/src/tui.mjs +736 -0
- package/src/ui.mjs +49 -0
- package/src/uninstall.mjs +113 -0
- package/src/upgrade.mjs +217 -0
package/src/prd.mjs
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// OpenPRD — DIP-style numbered product requirements docs, published in-repo.
|
|
2
|
+
// moshcode is a conductor: `/prd` publishes a numbered proposal into the local
|
|
3
|
+
// repo per the OpenPRD standard from LogicSRC, then hands it to a coding engine
|
|
4
|
+
// to author. Layout mirrors a BIP/EIP/DIP process:
|
|
5
|
+
// prd/README.md index (maintained by this tool)
|
|
6
|
+
// prd/0000-template.md the template
|
|
7
|
+
// prd/NNNN-slug.md one numbered PRD per file (committed, NOT gitignored)
|
|
8
|
+
// Standard: https://github.com/profullstack/logicsrc/blob/master/docs/openprd.md
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
|
|
13
|
+
export const OPENPRD = {
|
|
14
|
+
version: "0.2",
|
|
15
|
+
dir: "prd",
|
|
16
|
+
standard: "https://github.com/profullstack/logicsrc/blob/master/docs/openprd.md",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const INDEX_START = "<!-- PRD-INDEX:START -->";
|
|
20
|
+
const INDEX_END = "<!-- PRD-INDEX:END -->";
|
|
21
|
+
|
|
22
|
+
/** kebab-case slug from an idea/title. */
|
|
23
|
+
export function slugify(text) {
|
|
24
|
+
const s = String(text || "")
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
27
|
+
.replace(/^-+|-+$/g, "")
|
|
28
|
+
.split("-").slice(0, 8).join("-");
|
|
29
|
+
return s || "untitled";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function titleFrom(idea) {
|
|
33
|
+
const raw = String(idea || "").trim();
|
|
34
|
+
const t = raw.length > 80 ? raw.slice(0, 80).trim() + "…" : raw;
|
|
35
|
+
return t ? t.charAt(0).toUpperCase() + t.slice(1) : "Untitled PRD";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function today() {
|
|
39
|
+
return new Date().toISOString().slice(0, 10);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function yamlString(value) {
|
|
43
|
+
return JSON.stringify(String(value ?? ""));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function gitEmail(root) {
|
|
47
|
+
try {
|
|
48
|
+
return execSync("git config user.email", { cwd: root, stdio: ["ignore", "pipe", "ignore"] })
|
|
49
|
+
.toString().trim() || "you@example.com";
|
|
50
|
+
} catch { return "you@example.com"; }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function prdDir(root = process.cwd()) {
|
|
54
|
+
return path.join(root, OPENPRD.dir);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The canonical OpenPRD template (prd/0000-template.md), mirroring the standard. */
|
|
58
|
+
export function templateFile() {
|
|
59
|
+
return `---
|
|
60
|
+
openprd: "${OPENPRD.version}"
|
|
61
|
+
id: "0000"
|
|
62
|
+
title: "Short imperative title — start with a verb if possible"
|
|
63
|
+
status: Draft
|
|
64
|
+
authors:
|
|
65
|
+
- you@example.com
|
|
66
|
+
created: 2026-01-01
|
|
67
|
+
updated: 2026-01-01
|
|
68
|
+
repo:
|
|
69
|
+
discussion:
|
|
70
|
+
implementation:
|
|
71
|
+
tags:
|
|
72
|
+
supersedes:
|
|
73
|
+
superseded-by:
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Problem
|
|
77
|
+
|
|
78
|
+
The user/business problem, and why it matters now.
|
|
79
|
+
|
|
80
|
+
## Goals
|
|
81
|
+
|
|
82
|
+
What success looks like, as outcomes (not features).
|
|
83
|
+
|
|
84
|
+
## Non-Goals
|
|
85
|
+
|
|
86
|
+
Explicitly out of scope, to bound the work.
|
|
87
|
+
|
|
88
|
+
## Users
|
|
89
|
+
|
|
90
|
+
Who this is for; personas or segments.
|
|
91
|
+
|
|
92
|
+
## Requirements
|
|
93
|
+
|
|
94
|
+
- R1 [P0] First required capability.
|
|
95
|
+
- R2 [P1] Next capability.
|
|
96
|
+
|
|
97
|
+
## UX Notes
|
|
98
|
+
|
|
99
|
+
Flows, states, and constraints that shape the experience.
|
|
100
|
+
|
|
101
|
+
## Success Metrics
|
|
102
|
+
|
|
103
|
+
How the goals will be measured.
|
|
104
|
+
|
|
105
|
+
## Risks & Open Questions
|
|
106
|
+
|
|
107
|
+
- Known risk or decision still owed.
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The seed marker is a hidden HTML comment, so a `-->` inside the idea would
|
|
112
|
+
// close it early and render the rest of the idea (plus the real closer) as
|
|
113
|
+
// visible text at the top of the Problem section. Neutralise the terminator.
|
|
114
|
+
function commentSafe(text) {
|
|
115
|
+
return String(text).replace(/--+>/g, (run) => `${run.slice(0, -1)}>`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A numbered PRD body (prd/NNNN-slug.md), seeded from an idea. */
|
|
119
|
+
export function renderPrd({ id, title, idea, author }) {
|
|
120
|
+
const seed = idea && idea !== title ? `\n<!-- seed: ${commentSafe(idea)} -->` : "";
|
|
121
|
+
return `---
|
|
122
|
+
openprd: "${OPENPRD.version}"
|
|
123
|
+
id: "${id}"
|
|
124
|
+
title: ${yamlString(title)}
|
|
125
|
+
status: Draft
|
|
126
|
+
authors:
|
|
127
|
+
- ${author}
|
|
128
|
+
created: ${today()}
|
|
129
|
+
updated: ${today()}
|
|
130
|
+
repo:
|
|
131
|
+
discussion:
|
|
132
|
+
implementation:
|
|
133
|
+
tags:
|
|
134
|
+
supersedes:
|
|
135
|
+
superseded-by:
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Problem
|
|
139
|
+
${seed}
|
|
140
|
+
_Describe the user/business problem, and why it matters now._
|
|
141
|
+
|
|
142
|
+
## Goals
|
|
143
|
+
_What success looks like, as outcomes (not features)._
|
|
144
|
+
|
|
145
|
+
## Non-Goals
|
|
146
|
+
_Explicitly out of scope._
|
|
147
|
+
|
|
148
|
+
## Users
|
|
149
|
+
_Who this is for; personas or segments._
|
|
150
|
+
|
|
151
|
+
## Requirements
|
|
152
|
+
- R1 [P0] _First required capability._
|
|
153
|
+
- R2 [P1] _Next capability._
|
|
154
|
+
|
|
155
|
+
## UX Notes
|
|
156
|
+
_Flows, states, and constraints that shape the experience._
|
|
157
|
+
|
|
158
|
+
## Success Metrics
|
|
159
|
+
_How the goals will be measured._
|
|
160
|
+
|
|
161
|
+
## Risks & Open Questions
|
|
162
|
+
- _Known risk or decision still owed._
|
|
163
|
+
`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Static README preamble + the auto-maintained index markers. */
|
|
167
|
+
function readmeShell() {
|
|
168
|
+
return `# PRDs
|
|
169
|
+
|
|
170
|
+
Product requirements documents for this repo, following the
|
|
171
|
+
[OpenPRD](${OPENPRD.standard}) standard — a numbered, committed proposal
|
|
172
|
+
collection (like a BIP/EIP/DIP process).
|
|
173
|
+
|
|
174
|
+
Each PRD is one file: \`NNNN-slug.md\`. \`0000-template.md\` is the template.
|
|
175
|
+
Lifecycle: **Draft → Review → Accepted → Final** (or Rejected / Withdrawn /
|
|
176
|
+
Superseded). Status lives in each file's front-matter.
|
|
177
|
+
|
|
178
|
+
Start one with \`moshcode prd "<idea>"\` (TUI: \`/prd\`).
|
|
179
|
+
|
|
180
|
+
## Index
|
|
181
|
+
|
|
182
|
+
${INDEX_START}
|
|
183
|
+
${INDEX_END}
|
|
184
|
+
`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Read a front-matter scalar back to its plain text. renderPrd writes the title
|
|
188
|
+
// as a quoted YAML string so metacharacters stay valid, so the quotes (and any
|
|
189
|
+
// escapes inside them) are syntax, not part of the title — strip them, or every
|
|
190
|
+
// listing and index row shows `"My title"` instead of `My title`.
|
|
191
|
+
function unquoteYaml(value) {
|
|
192
|
+
const raw = String(value).trim();
|
|
193
|
+
if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
|
|
194
|
+
try { return JSON.parse(raw); } catch { return raw.slice(1, -1); }
|
|
195
|
+
}
|
|
196
|
+
if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) {
|
|
197
|
+
return raw.slice(1, -1).replace(/''/g, "'");
|
|
198
|
+
}
|
|
199
|
+
return raw;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The front matter is the block between the leading `---` and its closing
|
|
203
|
+
// `---`; everything after that is prose. Reading a fixed window of leading
|
|
204
|
+
// lines instead let a body line that merely starts with `title:`/`status:` —
|
|
205
|
+
// a YAML sample in a fenced code block, say — win, because the scan kept the
|
|
206
|
+
// last match it saw anywhere in the window. It also cut off longer front
|
|
207
|
+
// matter. Return the block itself, or nothing when the file has none.
|
|
208
|
+
function frontMatter(text) {
|
|
209
|
+
const lines = text.split(/\r?\n/);
|
|
210
|
+
if (lines[0]?.trim() !== "---") return [];
|
|
211
|
+
const end = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
|
|
212
|
+
return end === -1 ? [] : lines.slice(1, end);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** List numbered PRDs (NNNN-slug.md, excluding the 0000 template). */
|
|
216
|
+
export function listPrds(root = process.cwd()) {
|
|
217
|
+
const base = prdDir(root);
|
|
218
|
+
let entries = [];
|
|
219
|
+
try { entries = fs.readdirSync(base); } catch { return []; }
|
|
220
|
+
const out = [];
|
|
221
|
+
for (const name of entries) {
|
|
222
|
+
const m = name.match(/^(\d{4})-(.+)\.md$/);
|
|
223
|
+
if (!m || m[1] === "0000") continue;
|
|
224
|
+
const file = path.join(base, name);
|
|
225
|
+
let title = m[2], status = "?";
|
|
226
|
+
try {
|
|
227
|
+
const head = frontMatter(fs.readFileSync(file, "utf8"));
|
|
228
|
+
for (const l of head) {
|
|
229
|
+
const t = l.match(/^title:\s*(.+)$/); if (t) title = unquoteYaml(t[1]);
|
|
230
|
+
const s = l.match(/^status:\s*(.+)$/); if (s) status = unquoteYaml(s[1]);
|
|
231
|
+
}
|
|
232
|
+
// A file this cannot read is still a PRD, and its number is still taken.
|
|
233
|
+
// Dropping it contradicts what the rest of this loop does: a PRD whose
|
|
234
|
+
// front matter will not parse is deliberately kept on the slug/"?"
|
|
235
|
+
// fallbacks above rather than discarded. Worse, `nextId` takes its max
|
|
236
|
+
// from this list, so dropping one hands its number straight back out and
|
|
237
|
+
// two PRDs end up numbered the same — in a scheme where the number is the
|
|
238
|
+
// identity. A committed symlink whose target is not checked out is enough
|
|
239
|
+
// to trigger it: `readFileSync` throws ENOENT. Keep the entry on the same
|
|
240
|
+
// fallbacks; the id stays reserved and the index row keeps its link.
|
|
241
|
+
} catch { /* unreadable: keep it with the file-name fallbacks */ }
|
|
242
|
+
out.push({ id: m[1], slug: m[2], title, status, file: name, path: file });
|
|
243
|
+
}
|
|
244
|
+
return out.sort((a, b) => a.id.localeCompare(b.id));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Next zero-padded 4-digit id (max existing + 1, min 0001). */
|
|
248
|
+
export function nextId(root = process.cwd()) {
|
|
249
|
+
const ids = listPrds(root).map((p) => parseInt(p.id, 10)).filter(Number.isFinite);
|
|
250
|
+
const max = ids.length ? Math.max(...ids) : 0;
|
|
251
|
+
return String(max + 1).padStart(4, "0");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Rewrite the README index table from the current PRDs on disk. */
|
|
255
|
+
export function regenerateIndex(root = process.cwd()) {
|
|
256
|
+
const readme = path.join(prdDir(root), "README.md");
|
|
257
|
+
let body;
|
|
258
|
+
try { body = fs.readFileSync(readme, "utf8"); } catch { return false; }
|
|
259
|
+
const prds = listPrds(root);
|
|
260
|
+
// A `|` in a title would close its table cell early and shift every column
|
|
261
|
+
// after it, so escape it for the markdown table. Escape `\` first: escaping
|
|
262
|
+
// the pipe alone turns `a\|b` into `a\\|b`, which markdown reads as an escaped
|
|
263
|
+
// backslash followed by a *live* pipe — the very break this is preventing.
|
|
264
|
+
// A newline would end the row outright, so flatten it to a space.
|
|
265
|
+
const cell = (text) => String(text)
|
|
266
|
+
.replace(/\\/g, "\\\\")
|
|
267
|
+
.replace(/\|/g, "\\|")
|
|
268
|
+
.replace(/\r\n?|\n/g, " ");
|
|
269
|
+
// The same reasoning applies to the link cell, which `cell` cannot help with:
|
|
270
|
+
// a file name is a link *destination*, so it has to end up a valid URL, not
|
|
271
|
+
// just an escaped table cell. `listPrds` accepts any `NNNN-<anything>.md` on
|
|
272
|
+
// disk — the test suite treats "a doc dropped into prd/" as supported, and a
|
|
273
|
+
// doc exported from a writing tool arrives as `0002-Search Ranking.md` — so
|
|
274
|
+
// the name is not always the slug `createPrd` would have produced. A space
|
|
275
|
+
// ends the destination early and the link dies; a `|` closes the cell and
|
|
276
|
+
// shifts every column after it, dropping Status off the row entirely.
|
|
277
|
+
// Percent-encode it: `p.file` is a bare readdir entry, never a path, so
|
|
278
|
+
// encodeURIComponent is safe here, and `(`/`)` are added by hand because it
|
|
279
|
+
// leaves those alone and an unescaped `)` would close the link early.
|
|
280
|
+
const linkTarget = (file) => encodeURIComponent(file)
|
|
281
|
+
.replace(/\(/g, "%28")
|
|
282
|
+
.replace(/\)/g, "%29");
|
|
283
|
+
const rows = prds.length
|
|
284
|
+
? ["| # | Title | Status |", "|---|---|---|",
|
|
285
|
+
...prds.map((p) => `| [${p.id}](${linkTarget(p.file)}) | ${cell(p.title)} | ${cell(p.status)} |`)].join("\n")
|
|
286
|
+
: "_No PRDs yet._";
|
|
287
|
+
// Use a function replacement so `$`-sequences in a PRD title (e.g. `$&`, `$1`,
|
|
288
|
+
// `$\`` ) are inserted verbatim instead of being read as String.replace special
|
|
289
|
+
// patterns, which would otherwise splice the match back in and corrupt the index.
|
|
290
|
+
const block = `${INDEX_START}\n${rows}\n${INDEX_END}`;
|
|
291
|
+
const next = body.replace(
|
|
292
|
+
new RegExp(`${INDEX_START}[\\s\\S]*${INDEX_END}`),
|
|
293
|
+
() => block,
|
|
294
|
+
);
|
|
295
|
+
if (next === body) return false;
|
|
296
|
+
fs.writeFileSync(readme, next);
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Create prd/README.md + prd/0000-template.md if missing. Returns true if it bootstrapped. */
|
|
301
|
+
export function ensureBootstrap(root = process.cwd()) {
|
|
302
|
+
const base = prdDir(root);
|
|
303
|
+
fs.mkdirSync(base, { recursive: true });
|
|
304
|
+
let did = false;
|
|
305
|
+
const tpl = path.join(base, "0000-template.md");
|
|
306
|
+
if (!fs.existsSync(tpl)) { fs.writeFileSync(tpl, templateFile()); did = true; }
|
|
307
|
+
const readme = path.join(base, "README.md");
|
|
308
|
+
if (!fs.existsSync(readme)) { fs.writeFileSync(readme, readmeShell()); did = true; }
|
|
309
|
+
return did;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Publish a numbered PRD into the local repo. Bootstraps prd/ on first use.
|
|
314
|
+
* Returns { id, slug, path, existed, bootstrapped }.
|
|
315
|
+
*/
|
|
316
|
+
export function createPrd(idea, root = process.cwd()) {
|
|
317
|
+
const bootstrapped = ensureBootstrap(root);
|
|
318
|
+
const slug = slugify(idea);
|
|
319
|
+
const title = titleFrom(idea);
|
|
320
|
+
// Reuse an existing PRD if the same slug already has a number.
|
|
321
|
+
const existing = listPrds(root).find((p) => p.slug === slug);
|
|
322
|
+
const id = existing ? existing.id : nextId(root);
|
|
323
|
+
const file = path.join(prdDir(root), `${id}-${slug}.md`);
|
|
324
|
+
const existed = fs.existsSync(file);
|
|
325
|
+
if (!existed) fs.writeFileSync(file, renderPrd({ id, title, idea, author: gitEmail(root) }));
|
|
326
|
+
regenerateIndex(root);
|
|
327
|
+
return { id, slug, path: file, existed, bootstrapped };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Prompt handed to a coding engine to author the scaffolded PRD in place. */
|
|
331
|
+
export function authoringPrompt({ path: file, idea }) {
|
|
332
|
+
return [
|
|
333
|
+
`Author a product requirements document at ${file} following the OpenPRD standard`,
|
|
334
|
+
`(${OPENPRD.standard}).`,
|
|
335
|
+
idea ? `The idea: ${idea}.` : "",
|
|
336
|
+
`Fill every one of the 8 sections (Problem, Goals, Non-Goals, Users, Requirements,`,
|
|
337
|
+
`UX Notes, Success Metrics, Risks & Open Questions), replacing the placeholder text.`,
|
|
338
|
+
`Keep the YAML front-matter and its keys (leave status: Draft). Number requirements`,
|
|
339
|
+
`R1, R2, … each with a [P0]/[P1]/[P2] priority. Be concrete and specific to this codebase.`,
|
|
340
|
+
].filter(Boolean).join(" ");
|
|
341
|
+
}
|
package/src/pty.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// PTY capture for the session mirror.
|
|
2
|
+
//
|
|
3
|
+
// Children are launched with `stdio: "inherit"` so they own the real terminal —
|
|
4
|
+
// which is why an engine or tool feels native, and also why the mirror never
|
|
5
|
+
// saw a byte of their output: those writes go to the tty's file descriptors and
|
|
6
|
+
// never pass through this process (see src/mirror.mjs).
|
|
7
|
+
//
|
|
8
|
+
// To see them we have to be in the middle, but a plain pipe is not an option:
|
|
9
|
+
// every one of these programs checks isTTY and degrades (no colour, no prompts,
|
|
10
|
+
// no full-screen UI) the moment it is talking to a pipe. So we run the child
|
|
11
|
+
// under a real pseudo-terminal and read a copy of the stream from the side.
|
|
12
|
+
//
|
|
13
|
+
// node-pty would be the obvious tool and is deliberately not used: it is a
|
|
14
|
+
// native module, and moshcode installs by untarring a release and running node
|
|
15
|
+
// (see install.sh) — there is no compiler in that path. `script(1)` allocates
|
|
16
|
+
// the same pseudo-terminal using nothing but the base system.
|
|
17
|
+
//
|
|
18
|
+
// Capability detection is required, not optional: util-linux and BSD/macOS
|
|
19
|
+
// `script` disagree on both flag names and argument order, and anything we
|
|
20
|
+
// cannot positively identify falls back to today's plain `inherit`.
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
23
|
+
import { StringDecoder } from "node:string_decoder";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* POSIX single-quote escaping, for argv that has to survive being flattened
|
|
27
|
+
* into the single command string util-linux `script -c` accepts. A bare
|
|
28
|
+
* interpolation here would let an argument like `it's` break the command, or
|
|
29
|
+
* worse, run something else.
|
|
30
|
+
*/
|
|
31
|
+
export function shQuote(value) {
|
|
32
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Which `script(1)` this machine has: "util-linux", "bsd", or null when there
|
|
37
|
+
* is none we can drive. util-linux answers `--version`; BSD's has no version
|
|
38
|
+
* flag and exits non-zero on it, so darwin is identified by platform.
|
|
39
|
+
*/
|
|
40
|
+
export function scriptFlavor({ platform = process.platform, runner = spawnSync } = {}) {
|
|
41
|
+
let out = "";
|
|
42
|
+
try {
|
|
43
|
+
const r = runner("script", ["--version"], { encoding: "utf8" });
|
|
44
|
+
if (r?.error) return null;
|
|
45
|
+
out = `${r?.stdout || ""}${r?.stderr || ""}`;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (/util-linux/i.test(out)) return "util-linux";
|
|
50
|
+
// BSD script printed a usage error rather than a version — that is still a
|
|
51
|
+
// usable script, but only on darwin do we know the flag set for certain.
|
|
52
|
+
if (platform === "darwin") return "bsd";
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The spawn spec that runs `cmd args…` under a pseudo-terminal while recording
|
|
58
|
+
* a copy of everything to `transcript`. Returns null for an unknown flavour.
|
|
59
|
+
*
|
|
60
|
+
* -q / -Q suppress script's own "Script started/done" banner, so the transcript
|
|
61
|
+
* holds the child's bytes and nothing else. -f / -F flush on every write, which
|
|
62
|
+
* is what makes this realtime rather than a post-mortem log. util-linux -e
|
|
63
|
+
* makes script exit with the child's status, which callers rely on.
|
|
64
|
+
*/
|
|
65
|
+
export function ptySpec(cmd, args = [], transcript, flavor) {
|
|
66
|
+
if (!cmd || !transcript) return null;
|
|
67
|
+
if (flavor === "util-linux") {
|
|
68
|
+
const line = [cmd, ...args].map(shQuote).join(" ");
|
|
69
|
+
return { cmd: "script", args: ["-q", "-e", "-f", "-c", line, transcript] };
|
|
70
|
+
}
|
|
71
|
+
if (flavor === "bsd") {
|
|
72
|
+
// BSD takes the transcript first and then a real argv, so no quoting.
|
|
73
|
+
return { cmd: "script", args: ["-q", "-F", transcript, cmd, ...args] };
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Follow a transcript as it is written, handing each new slice to `onChunk`.
|
|
80
|
+
*
|
|
81
|
+
* Polls the size rather than using fs.watch: watch is unreliable across
|
|
82
|
+
* platforms and filesystems for a file being appended to by another process,
|
|
83
|
+
* and the mirror already batches on a 150ms timer, so a short poll costs
|
|
84
|
+
* nothing in perceived latency. Returns a stop() that drains whatever landed
|
|
85
|
+
* after the last tick before closing — the tail of a session is usually the
|
|
86
|
+
* part you care about.
|
|
87
|
+
*
|
|
88
|
+
* Bytes are decoded through a StringDecoder rather than per-slice toString:
|
|
89
|
+
* a slice boundary lands wherever the poll happened to catch the file, which
|
|
90
|
+
* is regularly in the middle of a multi-byte character. Engines draw their
|
|
91
|
+
* full-screen UI out of box-drawing characters (three UTF-8 bytes each), so
|
|
92
|
+
* decoding each slice independently turns them into U+FFFD in the mirror. The
|
|
93
|
+
* decoder holds the incomplete tail back until the rest of it arrives.
|
|
94
|
+
*/
|
|
95
|
+
export function followFile(file, onChunk, { intervalMs = 100 } = {}) {
|
|
96
|
+
let fd = null;
|
|
97
|
+
let offset = 0;
|
|
98
|
+
let stopped = false;
|
|
99
|
+
let decoder = new StringDecoder("utf8");
|
|
100
|
+
|
|
101
|
+
const readNew = () => {
|
|
102
|
+
try {
|
|
103
|
+
if (fd === null) {
|
|
104
|
+
if (!existsSync(file)) return;
|
|
105
|
+
fd = openSync(file, "r");
|
|
106
|
+
}
|
|
107
|
+
const { size } = statSync(file);
|
|
108
|
+
// A transcript only grows; a smaller size means it was rotated or
|
|
109
|
+
// replaced, so resync rather than read garbage from the middle. Any
|
|
110
|
+
// half-character held back belongs to the old file, so drop it too.
|
|
111
|
+
if (size < offset) {
|
|
112
|
+
offset = 0;
|
|
113
|
+
decoder = new StringDecoder("utf8");
|
|
114
|
+
}
|
|
115
|
+
while (offset < size) {
|
|
116
|
+
const buf = Buffer.allocUnsafe(Math.min(65536, size - offset));
|
|
117
|
+
const read = readSync(fd, buf, 0, buf.length, offset);
|
|
118
|
+
if (read <= 0) break;
|
|
119
|
+
offset += read;
|
|
120
|
+
const text = decoder.write(buf.subarray(0, read));
|
|
121
|
+
if (text) onChunk(text);
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
/* the child owns this file; a transient read error is not our problem */
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const timer = setInterval(readNew, intervalMs);
|
|
129
|
+
timer.unref?.(); // never hold the process open for the sake of the mirror
|
|
130
|
+
|
|
131
|
+
return function stop() {
|
|
132
|
+
if (stopped) return;
|
|
133
|
+
stopped = true;
|
|
134
|
+
clearInterval(timer);
|
|
135
|
+
readNew(); // final drain
|
|
136
|
+
// Nothing more is coming, so a character still held back really is
|
|
137
|
+
// truncated. Emit it rather than swallowing the last bytes of a session.
|
|
138
|
+
const tail = decoder.end();
|
|
139
|
+
if (tail) onChunk(tail);
|
|
140
|
+
if (fd !== null) {
|
|
141
|
+
try { closeSync(fd); } catch { /* already gone */ }
|
|
142
|
+
fd = null;
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// `script -q` silences the "Script started/done" notices on the terminal but
|
|
148
|
+
// still writes them into the transcript, so without this the mirror opens every
|
|
149
|
+
// engine session with a line of script(1) bookkeeping — including the fully
|
|
150
|
+
// quoted command line — and closes it with an exit-code footer. Both are
|
|
151
|
+
// anchored (header at the very start, footer at the very end), so this never
|
|
152
|
+
// touches output that merely happens to contain the words.
|
|
153
|
+
const HEADER = /^Script started on [^\n]*\n/;
|
|
154
|
+
const FOOTER = /\r?\nScript done on [^\n]*\n?$/;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Remove script(1)'s own bookkeeping from a transcript slice. `first` marks the
|
|
158
|
+
* opening slice, the only place a header can legitimately appear.
|
|
159
|
+
*/
|
|
160
|
+
export function stripScriptBanner(text, first = false) {
|
|
161
|
+
const withoutHeader = first ? String(text).replace(HEADER, "") : String(text);
|
|
162
|
+
return withoutHeader.replace(FOOTER, "");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Should we capture this launch? Only when someone is actually watching (a
|
|
167
|
+
* mirror sink is attached) and the box has a script(1) we understand. Users who
|
|
168
|
+
* are not mirroring keep the exact `inherit` path they have today, so the
|
|
169
|
+
* blast radius of this feature is limited to mirrored sessions.
|
|
170
|
+
* MOSHCODE_MIRROR_PTY=0 forces it off.
|
|
171
|
+
*/
|
|
172
|
+
export function ptyEnabled(sink, flavor = scriptFlavor()) {
|
|
173
|
+
if (typeof sink !== "function") return false;
|
|
174
|
+
if (process.env.MOSHCODE_MIRROR_PTY === "0") return false;
|
|
175
|
+
return Boolean(flavor);
|
|
176
|
+
}
|
package/src/pwd.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// `moshcode pwd` / `/pwd` — where am I? Shows the current directory and, when
|
|
2
|
+
// inside a git repo, the repo root, branch, and origin. Zero-dependency: we
|
|
3
|
+
// read the .git dir directly instead of spawning git (moshcode stays lean and
|
|
4
|
+
// works even when git isn't on PATH).
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
// Resolve a `.git` entry (dir or worktree/submodule file `gitdir: <path>`) to
|
|
9
|
+
// its actual git directory, or null if it can't be read.
|
|
10
|
+
function resolveGitDir(dotgit, dir) {
|
|
11
|
+
try {
|
|
12
|
+
const st = fs.statSync(dotgit);
|
|
13
|
+
if (st.isDirectory()) return dotgit;
|
|
14
|
+
if (st.isFile()) {
|
|
15
|
+
const m = /gitdir:\s*(.+)\s*/.exec(fs.readFileSync(dotgit, "utf8"));
|
|
16
|
+
return m ? path.resolve(dir, m[1].trim()) : null;
|
|
17
|
+
}
|
|
18
|
+
} catch { /* fall through */ }
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A linked worktree's git dir (.git/worktrees/<name>) holds only that
|
|
23
|
+
// worktree's own HEAD and index — the shared config, and therefore the
|
|
24
|
+
// remotes, stay in the main repo's git dir. Git names it in a `commondir`
|
|
25
|
+
// file, usually as a path relative to the worktree's git dir. Resolve it so
|
|
26
|
+
// origin is found from inside a worktree; outside one there is no such file
|
|
27
|
+
// and the git dir is already the common one.
|
|
28
|
+
function commonGitDir(gitDir) {
|
|
29
|
+
try {
|
|
30
|
+
const pointer = fs.readFileSync(path.join(gitDir, "commondir"), "utf8").trim();
|
|
31
|
+
if (pointer) return path.resolve(gitDir, pointer);
|
|
32
|
+
} catch { /* not a linked worktree */ }
|
|
33
|
+
return gitDir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Walk up from `start` until we find a `.git` with a readable HEAD (a real
|
|
37
|
+
// repo). A bare `.git` dir with no HEAD — e.g. a stray /tmp/.git — is skipped,
|
|
38
|
+
// not mistaken for a repo. Returns { root, gitDir } or null at the fs root.
|
|
39
|
+
function findGit(start) {
|
|
40
|
+
let dir = start;
|
|
41
|
+
for (;;) {
|
|
42
|
+
const dotgit = path.join(dir, ".git");
|
|
43
|
+
if (fs.existsSync(dotgit)) {
|
|
44
|
+
const gitDir = resolveGitDir(dotgit, dir);
|
|
45
|
+
if (gitDir && fs.existsSync(path.join(gitDir, "HEAD"))) return { root: dir, gitDir };
|
|
46
|
+
}
|
|
47
|
+
const parent = path.dirname(dir);
|
|
48
|
+
if (parent === dir) return null; // reached "/" (or a drive root)
|
|
49
|
+
dir = parent;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readBranch(gitDir) {
|
|
54
|
+
try {
|
|
55
|
+
const head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
|
|
56
|
+
const m = /^ref:\s*refs\/heads\/(.+)$/.exec(head);
|
|
57
|
+
if (m) return m[1];
|
|
58
|
+
return head.slice(0, 12) + " (detached)"; // detached HEAD → short sha
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readOrigin(gitDir) {
|
|
65
|
+
try {
|
|
66
|
+
const cfg = fs.readFileSync(path.join(gitDir, "config"), "utf8");
|
|
67
|
+
// Find the [remote "origin"] section's url. Fall back to the first remote.
|
|
68
|
+
const origin = /\[remote "origin"\][^[]*?url\s*=\s*(.+)/s.exec(cfg);
|
|
69
|
+
if (origin) return origin[1].split("\n")[0].trim();
|
|
70
|
+
const any = /\[remote "[^"]+"\][^[]*?url\s*=\s*(.+)/s.exec(cfg);
|
|
71
|
+
return any ? any[1].split("\n")[0].trim() : null;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve where we are. Returns { cwd, home, git }, where git is null outside a
|
|
79
|
+
* repo, else { root, name, branch, origin }.
|
|
80
|
+
*/
|
|
81
|
+
export function locate(cwd = process.cwd()) {
|
|
82
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
83
|
+
const found = findGit(cwd);
|
|
84
|
+
if (!found || !found.gitDir) return { cwd, home, git: null };
|
|
85
|
+
return {
|
|
86
|
+
cwd,
|
|
87
|
+
home,
|
|
88
|
+
git: {
|
|
89
|
+
root: found.root,
|
|
90
|
+
name: path.basename(found.root),
|
|
91
|
+
// HEAD is per-worktree; config (and its remotes) is shared.
|
|
92
|
+
branch: readBranch(found.gitDir),
|
|
93
|
+
origin: readOrigin(commonGitDir(found.gitDir)),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Collapse $HOME to `~` for tidy display.
|
|
99
|
+
export function tilde(p, home) {
|
|
100
|
+
if (!home || p === home) return home ? "~" : p;
|
|
101
|
+
const relative = p.slice(home.length);
|
|
102
|
+
return p.startsWith(home) && relative.startsWith(path.sep) ? "~" + relative : p;
|
|
103
|
+
}
|
package/src/registry.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The moshscript command registry — the single source of truth for the verbs a
|
|
2
|
+
// .mosh script can call.
|
|
3
|
+
//
|
|
4
|
+
// A command is a plain object: { name, summary, run(ctx, ...args) }.
|
|
5
|
+
// - name: the identifier the script calls, e.g. "mosh" → mosh()
|
|
6
|
+
// - summary: one-liner for `moshcode commands` / help
|
|
7
|
+
// - run: the implementation; receives the runtime ctx plus the JS call
|
|
8
|
+
// arguments spread out (so `notify("hi", "there")` → args = ["hi","there"]).
|
|
9
|
+
//
|
|
10
|
+
// The runtime (src/runtime.mjs) injects every registered command as a global of
|
|
11
|
+
// the same name, so scripts call them bare: `mosh()`, `notify("…")`. New verbs —
|
|
12
|
+
// engine launches, tool passthrough, fan-out — are added by registering more
|
|
13
|
+
// commands here (or via registry.register(...) from a host), never by touching
|
|
14
|
+
// the interpreter.
|
|
15
|
+
|
|
16
|
+
export function createRegistry(commands = []) {
|
|
17
|
+
const byName = new Map();
|
|
18
|
+
|
|
19
|
+
const register = (cmd) => {
|
|
20
|
+
if (!cmd || typeof cmd.name !== "string" || typeof cmd.run !== "function") {
|
|
21
|
+
throw new Error("moshscript: a command needs { name, run() }");
|
|
22
|
+
}
|
|
23
|
+
byName.set(cmd.name, { summary: "", ...cmd });
|
|
24
|
+
return api;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const api = {
|
|
28
|
+
register,
|
|
29
|
+
has: (name) => byName.has(name),
|
|
30
|
+
get: (name) => byName.get(name),
|
|
31
|
+
all: () => [...byName.values()],
|
|
32
|
+
names: () => [...byName.keys()],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
commands.forEach(register);
|
|
36
|
+
return api;
|
|
37
|
+
}
|