@sealant/mend 0.1.1 → 0.3.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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `mend service init` — the mend.toml scaffolder (docs/SESSION-SERVICES.md).
3
+ * Static suggestion, not detection: an offline read of manifests the project
4
+ * already ships (package.json scripts, compose files), producing a proposal
5
+ * the user confirms and commits. Nothing runs; nothing is observed.
6
+ *
7
+ * Pure functions here; the command in main.ts does the I/O.
8
+ */
9
+ /** Scripts that mean "a server" in practice; dev first, the rest as extras. */
10
+ const SERVER_SCRIPTS = ["dev", "start", "serve", "preview"];
11
+ /** Tool defaults for when the script names no port. */
12
+ const TOOL_PORTS = [
13
+ [/\bvite\b(?!.*build)/, 5173],
14
+ [/\bnext dev\b/, 3000],
15
+ [/\bnuxt\b/, 3000],
16
+ [/\bastro dev\b/, 4321],
17
+ [/\bremix\b/, 3000],
18
+ ];
19
+ const portFromScript = (script) => {
20
+ const explicit = /(?:--port[= ]|(?<![\w-])-p[= ])(\d{2,5})/.exec(script) ?? /\bPORT=(\d{2,5})/.exec(script);
21
+ if (explicit?.[1] !== undefined) {
22
+ const port = Number(explicit[1]);
23
+ if (port >= 1 && port <= 65535)
24
+ return { port, guessed: false };
25
+ }
26
+ for (const [pattern, port] of TOOL_PORTS) {
27
+ if (pattern.test(script))
28
+ return { port, guessed: true };
29
+ }
30
+ return null;
31
+ };
32
+ const packageManagerOf = (lockfiles) => {
33
+ if (lockfiles.includes("pnpm-lock.yaml"))
34
+ return "pnpm";
35
+ if (lockfiles.includes("yarn.lock"))
36
+ return "yarn";
37
+ if (lockfiles.includes("bun.lockb") || lockfiles.includes("bun.lock"))
38
+ return "bun";
39
+ return "npm";
40
+ };
41
+ /** Proposals from package.json scripts. `files` = names present in the repo root. */
42
+ export const proposeFromPackageJson = (packageJsonText, rootFiles) => {
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(packageJsonText);
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ if (typeof parsed !== "object" || parsed === null)
51
+ return [];
52
+ const scripts = parsed.scripts;
53
+ if (typeof scripts !== "object" || scripts === null)
54
+ return [];
55
+ const pm = packageManagerOf(rootFiles);
56
+ const proposals = [];
57
+ for (const scriptName of SERVER_SCRIPTS) {
58
+ const script = scripts[scriptName];
59
+ if (typeof script !== "string")
60
+ continue;
61
+ const port = portFromScript(script);
62
+ if (port === null)
63
+ continue; // no port, no Service — the user can add one by hand
64
+ proposals.push({
65
+ name: scriptName === "dev" ? "web" : scriptName,
66
+ command: `${pm} run ${scriptName}`,
67
+ port: port.port,
68
+ protocol: "tcp",
69
+ guessed: port.guessed,
70
+ source: `package.json "${scriptName}": ${script}`,
71
+ });
72
+ break; // one entry from scripts — the first server-ish one wins
73
+ }
74
+ return proposals;
75
+ };
76
+ /**
77
+ * Proposals from a compose file: every service with a published port becomes
78
+ * `docker compose up <name>` at the HOST side of the first mapping — that is
79
+ * where the workspace's docker sidecar publishes it.
80
+ */
81
+ export const proposeFromCompose = (composeText) => {
82
+ // Deliberately narrow YAML reading: top-level `services:`, two-space
83
+ // indented names, `ports:` entries as "- host:container". A full YAML
84
+ // parser is not worth a dependency for a suggestion.
85
+ const proposals = [];
86
+ const lines = composeText.split("\n");
87
+ let inServices = false;
88
+ let current = null;
89
+ let inPorts = false;
90
+ for (const line of lines) {
91
+ if (/^services:\s*$/.test(line)) {
92
+ inServices = true;
93
+ continue;
94
+ }
95
+ if (inServices && /^\S/.test(line)) {
96
+ inServices = false; // left the services block
97
+ }
98
+ if (!inServices)
99
+ continue;
100
+ const serviceHead = /^ {2}([A-Za-z0-9._-]+):\s*$/.exec(line);
101
+ if (serviceHead?.[1] !== undefined) {
102
+ current = serviceHead[1];
103
+ inPorts = false;
104
+ continue;
105
+ }
106
+ if (current === null)
107
+ continue;
108
+ if (/^\s+ports:\s*$/.test(line)) {
109
+ inPorts = true;
110
+ continue;
111
+ }
112
+ if (inPorts) {
113
+ const mapping = /^\s+-\s+["']?(\d{2,5}):/.exec(line) ??
114
+ /^\s+-\s+["']?\$\{[A-Z_a-z0-9]+:-(\d{2,5})\}:/.exec(line) ??
115
+ /^\s+-\s+["']?(\d{2,5})["']?\s*$/.exec(line);
116
+ if (mapping?.[1] !== undefined) {
117
+ const port = Number(mapping[1]);
118
+ const protocol = /\/udp["']?\s*$/.test(line) ? "udp" : "tcp";
119
+ if (port >= 1 && port <= 65535 && !proposals.some((p) => p.name === current)) {
120
+ proposals.push({
121
+ name: current,
122
+ command: `docker compose up ${current}`,
123
+ port,
124
+ protocol,
125
+ guessed: false,
126
+ source: `compose service "${current}" publishes :${mapping[1]}${protocol === "udp" ? "/udp" : ""}`,
127
+ });
128
+ }
129
+ inPorts = false; // first mapping wins
130
+ continue;
131
+ }
132
+ if (!/^\s+-/.test(line))
133
+ inPorts = false;
134
+ }
135
+ }
136
+ return proposals;
137
+ };
138
+ /** Any compose flavor counts: compose.yaml, docker-compose.yml, compose.dev.yaml… */
139
+ export const isComposeFile = (name) => /^(docker-)?compose(\.[\w.-]+)?\.ya?ml$/.test(name);
140
+ /** Workspace dir globs from pnpm-workspace.yaml / package.json "workspaces". Single-level only. */
141
+ export const workspaceGlobs = (pnpmWorkspaceText, packageJsonText) => {
142
+ const globs = [];
143
+ if (pnpmWorkspaceText !== null) {
144
+ let inPackages = false;
145
+ for (const line of pnpmWorkspaceText.split("\n")) {
146
+ if (/^packages:\s*$/.test(line)) {
147
+ inPackages = true;
148
+ continue;
149
+ }
150
+ if (inPackages && /^\S/.test(line))
151
+ inPackages = false;
152
+ if (!inPackages)
153
+ continue;
154
+ const entry = /^\s+-\s+["']?([^"'#]+?)["']?\s*$/.exec(line);
155
+ if (entry?.[1] !== undefined && !entry[1].startsWith("!"))
156
+ globs.push(entry[1].trim());
157
+ }
158
+ }
159
+ if (packageJsonText !== null) {
160
+ try {
161
+ const parsed = JSON.parse(packageJsonText);
162
+ if (Array.isArray(parsed.workspaces)) {
163
+ globs.push(...parsed.workspaces.filter((g) => typeof g === "string"));
164
+ }
165
+ }
166
+ catch {
167
+ // no workspaces to read
168
+ }
169
+ }
170
+ return globs;
171
+ };
172
+ /** A workspace package's proposal: named after its folder, run through the package manager. */
173
+ export const proposeFromWorkspacePackage = (dirName, packageJsonText, rootFiles) => {
174
+ let parsed;
175
+ try {
176
+ parsed = JSON.parse(packageJsonText);
177
+ }
178
+ catch {
179
+ return [];
180
+ }
181
+ if (typeof parsed !== "object" || parsed === null)
182
+ return [];
183
+ const pkg = parsed;
184
+ const scripts = pkg.scripts;
185
+ if (typeof scripts !== "object" || scripts === null)
186
+ return [];
187
+ const pkgName = typeof pkg.name === "string" ? pkg.name : dirName;
188
+ const pm = packageManagerOf(rootFiles);
189
+ const runner = pm === "pnpm"
190
+ ? (script) => `pnpm --filter ${pkgName} ${script}`
191
+ : pm === "yarn"
192
+ ? (script) => `yarn workspace ${pkgName} ${script}`
193
+ : (script) => `${pm} run ${script} --workspace ${pkgName}`;
194
+ for (const scriptName of SERVER_SCRIPTS) {
195
+ const script = scripts[scriptName];
196
+ if (typeof script !== "string")
197
+ continue;
198
+ const port = portFromScript(script);
199
+ if (port === null)
200
+ continue;
201
+ return [
202
+ {
203
+ name: dirName,
204
+ command: runner(scriptName),
205
+ port: port.port,
206
+ protocol: "tcp",
207
+ guessed: port.guessed,
208
+ source: `${dirName}/package.json "${scriptName}": ${script}`,
209
+ },
210
+ ];
211
+ }
212
+ return [];
213
+ };
214
+ /** The proposal as the file the user will commit. */
215
+ export const renderMendToml = (proposals) => {
216
+ const blocks = proposals.map((proposal) => {
217
+ const lines = [
218
+ `# ${proposal.source}`,
219
+ `[service.${proposal.name}]`,
220
+ `command = ${JSON.stringify(proposal.command)}`,
221
+ proposal.guessed ? `port = ${proposal.port} # guessed — verify` : `port = ${proposal.port}`,
222
+ ...(proposal.protocol === "udp" ? [`protocol = "udp"`] : []),
223
+ ];
224
+ return lines.join("\n");
225
+ });
226
+ return `${blocks.join("\n\n")}\n`;
227
+ };
package/dist/shared.js CHANGED
@@ -9,6 +9,8 @@ export const HARNESS_COMMANDS = {
9
9
  codex: ["codex"],
10
10
  claude: ["claude"],
11
11
  opencode: ["opencode"],
12
+ // Not a coding agent: a plain bash session in its own recorded worktree.
13
+ shell: ["bash"],
12
14
  };
13
15
  /** How each harness takes an instruction as its opening prompt. */
14
16
  export const CONTINUE_COMMANDS = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
5
5
  "keywords": [
6
6
  "claude-code",