@sealant/mend 0.22.0 → 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.
Files changed (80) hide show
  1. package/README.md +133 -6
  2. package/dist/chunk-4MX7ZC34.js +27894 -0
  3. package/dist/dashboard-YEU7SDYB.js +2951 -0
  4. package/dist/main.js +6917 -2580
  5. package/man/mend-accounts.1 +1 -1
  6. package/man/mend-adopt.1 +4 -4
  7. package/man/mend-attach.1 +1 -1
  8. package/man/mend-codex.1 +1 -1
  9. package/man/mend-completions.1 +1 -1
  10. package/man/mend-connect.1 +1 -1
  11. package/man/mend-continue.1 +1 -1
  12. package/man/mend-doctor.1 +1 -1
  13. package/man/mend-dotfiles-sync.1 +1 -1
  14. package/man/mend-dotfiles.1 +1 -1
  15. package/man/mend-env-cluster.1 +1 -1
  16. package/man/mend-env-load.1 +1 -1
  17. package/man/mend-env-show.1 +1 -1
  18. package/man/mend-help.1 +1 -1
  19. package/man/mend-keys-init.1 +1 -1
  20. package/man/mend-keys-mode.1 +1 -1
  21. package/man/mend-keys-share.1 +1 -1
  22. package/man/mend-keys-show.1 +1 -1
  23. package/man/mend-login.1 +1 -1
  24. package/man/mend-logout.1 +1 -1
  25. package/man/mend-man.1 +1 -1
  26. package/man/mend-pair.1 +1 -1
  27. package/man/mend-projects.1 +1 -1
  28. package/man/mend-refresh.1 +1 -1
  29. package/man/mend-rejoin.1 +1 -1
  30. package/man/mend-resume.1 +1 -1
  31. package/man/mend-run.1 +1 -1
  32. package/man/mend-server-logs.1 +15 -0
  33. package/man/mend-server-restart.1 +15 -0
  34. package/man/mend-server-setup.1 +64 -0
  35. package/man/mend-server-start.1 +15 -0
  36. package/man/mend-server-status.1 +11 -0
  37. package/man/mend-server-stop.1 +11 -0
  38. package/man/mend-server-upgrade.1 +30 -0
  39. package/man/mend-server.1 +11 -0
  40. package/man/mend-service-add.1 +1 -1
  41. package/man/mend-service-connect.1 +1 -1
  42. package/man/mend-service-init.1 +1 -1
  43. package/man/mend-service-list.1 +1 -1
  44. package/man/mend-service-logs.1 +1 -1
  45. package/man/mend-service-restart.1 +1 -1
  46. package/man/mend-service-run.1 +1 -1
  47. package/man/mend-service-stop.1 +1 -1
  48. package/man/mend-sessions.1 +1 -1
  49. package/man/mend-shell.1 +1 -1
  50. package/man/mend-skills-push.1 +1 -1
  51. package/man/mend-skills.1 +1 -1
  52. package/man/mend-ssh-setup.1 +6 -3
  53. package/man/mend-ssh.1 +1 -1
  54. package/man/mend-stop.1 +1 -1
  55. package/man/mend-ui.1 +1 -1
  56. package/man/mend-version.1 +1 -1
  57. package/man/mend-worktrees.1 +1 -1
  58. package/man/mend.1 +26 -2
  59. package/package.json +7 -3
  60. package/dist/agent-share.js +0 -150
  61. package/dist/clipboard.js +0 -73
  62. package/dist/dashboard-model.js +0 -308
  63. package/dist/dashboard.js +0 -1200
  64. package/dist/doctor.js +0 -233
  65. package/dist/dotfiles.js +0 -90
  66. package/dist/env.js +0 -17
  67. package/dist/help.js +0 -839
  68. package/dist/login.js +0 -240
  69. package/dist/pair.js +0 -83
  70. package/dist/review-model.js +0 -212
  71. package/dist/review-workflow.js +0 -8
  72. package/dist/review.js +0 -690
  73. package/dist/service-init.js +0 -227
  74. package/dist/shared.js +0 -338
  75. package/dist/skills.js +0 -134
  76. package/dist/ssh-setup.js +0 -181
  77. package/dist/terminal.js +0 -8
  78. package/dist/tui-theme.js +0 -41
  79. package/dist/version.js +0 -48
  80. package/dist/workbench-events.js +0 -72
@@ -1,227 +0,0 @@
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 DELETED
@@ -1,338 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import * as path from "node:path";
3
- /**
4
- * Facts both entry points need: how each harness launches and resumes, which
5
- * session statuses mean "live", and how a cwd resolves to an adopted project.
6
- * Kept dependency-free so main.ts stays runnable on plain Node >= 22.
7
- */
8
- export const HARNESS_COMMANDS = {
9
- codex: ["codex"],
10
- claude: ["claude"],
11
- opencode: ["opencode"],
12
- // Not a coding agent: a plain bash session in its own recorded worktree.
13
- shell: ["bash"],
14
- };
15
- /** How each harness takes an instruction as its opening prompt. */
16
- export const CONTINUE_COMMANDS = {
17
- codex: (instruction) => ["codex", instruction],
18
- claude: (instruction) => ["claude", instruction],
19
- opencode: (instruction) => ["opencode", "run", instruction],
20
- };
21
- /** Mirror of @mend/domain/workbench harness-launch.ts — the CLI ships dependency-free. */
22
- export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
23
- /**
24
- * Ids of optimistic cache rows the server has not named yet — a session still
25
- * provisioning, a review comment still saving. Anything that would send such
26
- * an id to the server checks here first and waits instead.
27
- */
28
- export const PENDING_ID_PREFIX = "pending:";
29
- export const pendingId = () => `${PENDING_ID_PREFIX}${crypto.randomUUID()}`;
30
- export const isPendingId = (id) => id.startsWith(PENDING_ID_PREFIX);
31
- const LAUNCH_ERROR = {
32
- project: null,
33
- prompt: null,
34
- model: null,
35
- effort: null,
36
- base: null,
37
- name: null,
38
- worktree: null,
39
- ask: false,
40
- fast: false,
41
- detach: false,
42
- foreground: false,
43
- custom: [],
44
- };
45
- /**
46
- * `mend claude|codex|opencode ["prompt"] [--model <id>] [--effort <level>]
47
- * [--base <ref>] [--ask] [--detach|-d] [--foreground] [--project <p>]`, plus
48
- * `mend run … -- <command...>`.
49
- * The first non-flag positional is the prompt; a second one is an error so a
50
- * forgotten quote fails loudly instead of launching with half a sentence.
51
- */
52
- export const parseLaunchArgs = (args) => {
53
- const dashdash = args.indexOf("--");
54
- const custom = dashdash === -1 ? [] : args.slice(dashdash + 1);
55
- const flagArgs = dashdash === -1 ? args : args.slice(0, dashdash);
56
- let project = null;
57
- let prompt = null;
58
- let model = null;
59
- let effort = null;
60
- let base = null;
61
- let workName = null;
62
- let joinWorktree = null;
63
- let ask = false;
64
- let fast = false;
65
- let detach = false;
66
- let foreground = false;
67
- for (let index = 0; index < flagArgs.length; index += 1) {
68
- const arg = flagArgs[index] ?? "";
69
- if (arg === "--ask") {
70
- ask = true;
71
- continue;
72
- }
73
- if (arg === "--fast") {
74
- fast = true;
75
- continue;
76
- }
77
- if (arg === "--detach" || arg === "-d") {
78
- detach = true;
79
- continue;
80
- }
81
- if (arg === "--foreground") {
82
- foreground = true;
83
- continue;
84
- }
85
- if (arg === "--project" ||
86
- arg === "--model" ||
87
- arg === "--effort" ||
88
- arg === "--base" ||
89
- arg === "--name" ||
90
- arg === "--worktree") {
91
- const value = flagArgs[index + 1];
92
- if (value === undefined)
93
- return { ...LAUNCH_ERROR, error: `${arg} needs a value` };
94
- if (arg === "--project")
95
- project = value;
96
- else if (arg === "--model")
97
- model = value;
98
- else if (arg === "--effort")
99
- effort = value;
100
- else if (arg === "--name")
101
- workName = normalizeProjectName(value);
102
- else if (arg === "--worktree")
103
- joinWorktree = value;
104
- else
105
- base = value;
106
- index += 1;
107
- continue;
108
- }
109
- if (arg.startsWith("-")) {
110
- // Also catches a prompt starting with "-", which the harness would
111
- // otherwise read as a flag of its own.
112
- return {
113
- ...LAUNCH_ERROR,
114
- error: `unknown flag ${arg} — quote the prompt if it starts with "-"`,
115
- };
116
- }
117
- if (prompt !== null) {
118
- return {
119
- ...LAUNCH_ERROR,
120
- error: 'one prompt only — quote it: mend claude "fix the auth test"',
121
- };
122
- }
123
- prompt = arg;
124
- }
125
- if (effort !== null && !EFFORT_LEVELS.includes(effort)) {
126
- return { ...LAUNCH_ERROR, error: `--effort must be one of ${EFFORT_LEVELS.join(", ")}` };
127
- }
128
- if (detach && foreground) {
129
- return { ...LAUNCH_ERROR, error: "--detach and --foreground contradict — pick one" };
130
- }
131
- if (workName !== null && joinWorktree !== null) {
132
- return {
133
- ...LAUNCH_ERROR,
134
- error: "--name and --worktree contradict — --name names (or joins), --worktree only joins",
135
- };
136
- }
137
- return {
138
- project,
139
- prompt,
140
- model,
141
- effort,
142
- base,
143
- name: workName,
144
- worktree: joinWorktree,
145
- ask,
146
- fast,
147
- detach,
148
- foreground,
149
- custom,
150
- error: null,
151
- };
152
- };
153
- /**
154
- * Whether a raw stdin chunk carries the detach key (Ctrl+]). Two encodings
155
- * reach us: the legacy control byte 0x1d — and, once an inner TUI (claude)
156
- * pushes the kitty keyboard protocol through the PTY and the user's own
157
- * terminal honors it, the CSI-u escape `ESC [ 93 ; 5 u` (`]` is code 93,
158
- * ctrl is modifier 5), optionally with a kitty event-type suffix. Only press
159
- * (`:1`) and repeat (`:2`) are intent; a release (`:3`) is not.
160
- */
161
- export const isDetachChunk = (data) => {
162
- if (data.includes(0x1d))
163
- return true;
164
- return csiUKeysOf(data).some((key) => key.code === 93 && isCtrlChord(key) && isPressOrRepeat(key));
165
- };
166
- /**
167
- * What to call a session's worktree in banners and rows: a NAMED worktree is
168
- * its branch minus the `mend/` prefix; an unnamed one (`mend/session/<uuid>`)
169
- * is called by its auto-name label, or its short id before one lands.
170
- */
171
- export const sessionDisplayName = (session) => {
172
- if (session.branch.startsWith("mend/") && !session.branch.startsWith("mend/session/")) {
173
- return session.branch.slice("mend/".length);
174
- }
175
- if (!session.branch.startsWith("mend/session/"))
176
- return session.branch;
177
- return session.label ?? `session ${session.id.slice(0, 8)}`;
178
- };
179
- export const LIVE_STATUSES = new Set([
180
- "starting",
181
- "running",
182
- "waiting",
183
- "idle",
184
- ]);
185
- /**
186
- * Whether the session's AGENT is live. Session status is a fold over every process — a session
187
- * reads `idle` while a shell holds the workspace after its agent ended — so the agent's own row
188
- * answers when one exists; `starting` is a launch with no row yet.
189
- */
190
- export const agentIsLive = (session, currentAgent) => currentAgent === null
191
- ? LIVE_STATUSES.has(session.status)
192
- : session.status === "starting" ||
193
- (currentAgent.exitedAt === null &&
194
- (currentAgent.status === "starting" || currentAgent.status === "running"));
195
- /** What an ended agent process's exit says about the work; null while it runs. */
196
- export const agentOutcome = (currentAgent) => {
197
- if (currentAgent === null || currentAgent.exitedAt === null)
198
- return null;
199
- if (currentAgent.status === "stopped")
200
- return "stopped";
201
- if (currentAgent.harness === "shell")
202
- return "completed";
203
- return currentAgent.exitCode === null || currentAgent.exitCode === 0 ? "completed" : "failed";
204
- };
205
- /**
206
- * A git remote URL reduced to `host/owner/name` so https, ssh, scp-style, and
207
- * `.git` spellings of the same repository compare equal. Null for anything
208
- * that is not a URL-ish remote (a local path stays a path).
209
- */
210
- export const normalizeRemoteUrl = (raw) => {
211
- if (raw === null)
212
- return null;
213
- const trimmed = raw.trim();
214
- if (trimmed === "")
215
- return null;
216
- const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/)(.+)$/.exec(trimmed);
217
- const url = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/\s:]+)(?::\d+)?\/(.+)$/i.exec(trimmed);
218
- const parts = url ?? scp;
219
- if (parts === null)
220
- return null;
221
- const [, host, rest] = parts;
222
- if (host === undefined || rest === undefined)
223
- return null;
224
- const repoPath = rest.replace(/\/+$/, "").replace(/\.git$/i, "");
225
- return `${host.toLowerCase()}/${repoPath.toLowerCase()}`;
226
- };
227
- /**
228
- * The cwd's adopted project, in order: a project adopted from this very
229
- * path; a project whose origin is the same remote as the cwd repo's origin
230
- * (normalized, so a GitHub-adopted project matches a clone of it anywhere);
231
- * a project named like the repository root (through the same normalization
232
- * adopt uses, so a checkout called "Mend" matches the project "mend").
233
- */
234
- export const matchProjectByCwd = (projects, facts) => {
235
- const root = facts.repoRoot ?? facts.cwd;
236
- const byPath = projects.find((p) => p.originUrl !== null &&
237
- !p.originUrl.includes("://") &&
238
- (root === p.originUrl ||
239
- facts.cwd === p.originUrl ||
240
- facts.cwd.startsWith(`${p.originUrl}/`)));
241
- if (byPath !== undefined)
242
- return byPath;
243
- const remote = normalizeRemoteUrl(facts.originUrl);
244
- const byRemote = remote === null ? undefined : projects.find((p) => normalizeRemoteUrl(p.originUrl) === remote);
245
- if (byRemote !== undefined)
246
- return byRemote;
247
- const name = normalizeProjectName(path.basename(root));
248
- return projects.find((p) => p.name === name);
249
- };
250
- /** Read the cwd's facts from git once; callers pass them to the matcher. */
251
- export const cwdFacts = (cwd) => ({
252
- cwd,
253
- repoRoot: gitTopLevel(cwd),
254
- originUrl: gitOriginUrl(cwd),
255
- });
256
- /** The branch checked out at `cwd`, or null outside a repo or on a detached HEAD. */
257
- export const gitCurrentBranch = (cwd) => {
258
- const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, encoding: "utf8" });
259
- const out = result.status === 0 ? result.stdout.trim() : "";
260
- return out === "" || out === "HEAD" ? null : out;
261
- };
262
- /** The cwd repo's origin remote, or null when there is no repo or no origin. */
263
- export const gitOriginUrl = (cwd) => {
264
- const result = spawnSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" });
265
- const out = result.status === 0 ? result.stdout.trim() : "";
266
- return out === "" ? null : out;
267
- };
268
- /** The repository root of `cwd`, or null when it is not inside a git repo. */
269
- export const gitTopLevel = (cwd) => {
270
- const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
271
- return result.status === 0 ? result.stdout.trim() : null;
272
- };
273
- /**
274
- * The store's name charset is /^[a-z0-9][a-z0-9._-]{0,63}$/ (server-enforced).
275
- * Derived defaults (a directory called "Mend") get normalized instead of
276
- * bounced; an explicit --name is sent as typed so the server's rule teaches.
277
- */
278
- export const normalizeProjectName = (raw) => {
279
- const slug = raw
280
- .toLowerCase()
281
- .replace(/[^a-z0-9._-]+/g, "-")
282
- .replace(/^[^a-z0-9]+/, "")
283
- .replace(/-+$/, "");
284
- return slug === "" ? "project" : slug.slice(0, 64);
285
- };
286
- // Built from a string: an escape byte inside a regex literal trips lint.
287
- const CSI_U = new RegExp(`${String.fromCharCode(0x1b)}\\[(\\d+)(?::\\d*)*(?:;(\\d+)(?::(\\d+))?)?(?:;[\\d:]+)?u`, "g");
288
- const LOCK_MODIFIERS = 64 | 128;
289
- /** Every CSI-u key report inside a chunk, in order. */
290
- export const csiUKeysOf = (data) => {
291
- // latin1 maps bytes 1:1, so the scan sees exactly the wire bytes.
292
- const text = data.toString("latin1");
293
- const keys = [];
294
- for (const match of text.matchAll(CSI_U)) {
295
- const code = Number(match[1]);
296
- const mods = (match[2] === undefined ? 1 : Number(match[2])) - 1;
297
- const event = match[3] === undefined ? 1 : Number(match[3]);
298
- keys.push({ code, mods: mods & ~LOCK_MODIFIERS, event });
299
- }
300
- return keys;
301
- };
302
- const CTRL = 4;
303
- const SHIFT = 1;
304
- /** Ctrl held, optionally shift, nothing else (alt/super/hyper/meta make it another chord). */
305
- const isCtrlChord = (key) => (key.mods & ~SHIFT) === CTRL;
306
- const isPressOrRepeat = (key) => key.event === 1 || key.event === 2;
307
- /**
308
- * Whether one stdin chunk is Ctrl+V — the raw 0x16 byte on its own, or one
309
- * CSI-u report of `v` (118) or `V` (86) with ctrl, on press or repeat. Exact
310
- * for the raw byte: a paste that happens to carry 0x16 is not a request.
311
- */
312
- export const isPasteChunk = (data) => {
313
- if (data.length === 1 && data[0] === 0x16)
314
- return true;
315
- const keys = csiUKeysOf(data);
316
- const key = keys[0];
317
- return (keys.length === 1 &&
318
- key !== undefined &&
319
- (key.code === 118 || key.code === 86) &&
320
- isCtrlChord(key) &&
321
- isPressOrRepeat(key) &&
322
- data.toString("latin1").startsWith("\u001b["));
323
- };
324
- /**
325
- * Follow the remote app's bracketed-paste mode through the bytes it writes:
326
- * the last `ESC [ ? 2004 h|l` in a chunk wins; a chunk with neither keeps the
327
- * current state. A pasted image path is wrapped only when the app asked.
328
- */
329
- export const trackBracketedPaste = (output, current) => {
330
- const text = output.toString("latin1");
331
- const on = text.lastIndexOf("\u001b[?2004h");
332
- const off = text.lastIndexOf("\u001b[?2004l");
333
- if (on === -1 && off === -1)
334
- return current;
335
- return on > off;
336
- };
337
- /** The bytes that paste `text` into the remote app, bracketed when it asked. */
338
- export const pasteBytes = (text, bracketed) => Buffer.from(bracketed ? `\u001b[200~${text}\u001b[201~` : text, "utf8");