mockpit 0.0.0-stage → 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 +22 -0
- package/README.md +232 -2
- package/bin/demoData.js +132 -0
- package/bin/initDesign.js +846 -0
- package/bin/mockpit.js +1830 -0
- package/bin/serveUrl.d.ts +1 -0
- package/bin/serveUrl.js +7 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +230 -0
- package/dist/server/apiViews.d.ts +329 -0
- package/dist/server/apiViews.js +135 -0
- package/dist/server/app.d.ts +53 -0
- package/dist/server/app.js +3112 -0
- package/dist/server/base64.d.ts +1 -0
- package/dist/server/base64.js +16 -0
- package/dist/server/cdn.d.ts +6 -0
- package/dist/server/cdn.js +33 -0
- package/dist/server/codeRunner.d.ts +5 -0
- package/dist/server/codeRunner.js +89 -0
- package/dist/server/codeWorker.d.ts +1 -0
- package/dist/server/codeWorker.js +196 -0
- package/dist/server/designGuide.d.ts +16 -0
- package/dist/server/designGuide.js +311 -0
- package/dist/server/events.d.ts +53 -0
- package/dist/server/events.js +14 -0
- package/dist/server/feedbackBatch.d.ts +43 -0
- package/dist/server/feedbackBatch.js +98 -0
- package/dist/server/icons.d.ts +50 -0
- package/dist/server/icons.js +225 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +64 -0
- package/dist/server/kits.d.ts +36 -0
- package/dist/server/kits.js +279 -0
- package/dist/server/knobs.d.ts +15 -0
- package/dist/server/knobs.js +333 -0
- package/dist/server/mcpHttp.d.ts +53 -0
- package/dist/server/mcpHttp.js +143 -0
- package/dist/server/mcpSpec.d.ts +191 -0
- package/dist/server/mcpSpec.js +402 -0
- package/dist/server/parts.d.ts +31 -0
- package/dist/server/parts.js +338 -0
- package/dist/server/postMarkdown.d.ts +10 -0
- package/dist/server/postMarkdown.js +271 -0
- package/dist/server/postSurfaces.d.ts +40 -0
- package/dist/server/postSurfaces.js +407 -0
- package/dist/server/public.d.ts +5 -0
- package/dist/server/public.js +5 -0
- package/dist/server/push.d.ts +45 -0
- package/dist/server/push.js +234 -0
- package/dist/server/richRender.d.ts +15 -0
- package/dist/server/richRender.js +392 -0
- package/dist/server/run.d.ts +82 -0
- package/dist/server/run.js +290 -0
- package/dist/server/runApi.d.ts +6 -0
- package/dist/server/runApi.js +82 -0
- package/dist/server/slots.d.ts +14 -0
- package/dist/server/slots.js +71 -0
- package/dist/server/sqlStore.d.ts +119 -0
- package/dist/server/sqlStore.js +954 -0
- package/dist/server/sqliteStorage.d.ts +2 -0
- package/dist/server/sqliteStorage.js +90 -0
- package/dist/server/surfacePage.d.ts +48 -0
- package/dist/server/surfacePage.js +1099 -0
- package/dist/server/themes.d.ts +48 -0
- package/dist/server/themes.js +305 -0
- package/dist/server/types.d.ts +599 -0
- package/dist/server/types.js +299 -0
- package/dist/workers/public.d.ts +1 -0
- package/dist/workers/public.js +3 -0
- package/extensions/mockpit.js +703 -0
- package/guide/AGENT_SETUP.md +23 -0
- package/guide/topics/asks.md +87 -0
- package/guide/topics/feedback.md +127 -0
- package/guide/topics/html.md +175 -0
- package/guide/topics/http.md +76 -0
- package/guide/topics/knobs.md +72 -0
- package/guide/topics/scripts.md +129 -0
- package/guide/topics/surfaces.md +100 -0
- package/package.json +165 -4
- package/skills/launch-video/SKILL.md +159 -0
- package/skills/mockpit/SKILL.md +40 -0
- package/viewer/dist/index.html +1922 -0
package/bin/mockpit.js
ADDED
|
@@ -0,0 +1,1830 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
writeSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { tmpdir, userInfo } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { parseArgs } from "node:util";
|
|
16
|
+
import { serveUrl } from "./serveUrl.js";
|
|
17
|
+
|
|
18
|
+
const BASE = (process.env.MOCKPIT_URL ?? "http://localhost:8228").replace(/\/$/, "");
|
|
19
|
+
const TOKEN = process.env.MOCKPIT_TOKEN;
|
|
20
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
21
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version;
|
|
22
|
+
|
|
23
|
+
const HELP = `mockpit — a live visual surface for terminal coding agents
|
|
24
|
+
|
|
25
|
+
vocabulary: project › mock › state › variant › version. A project is a repo; a
|
|
26
|
+
mock is the page or component on stage; a state is one moment of it ("Lab
|
|
27
|
+
open"); a variant is a parallel design of a state; a version is its history.
|
|
28
|
+
Mark the parts you want feedback on with data-part="name" in the html.
|
|
29
|
+
|
|
30
|
+
the loop (never blocks on the user):
|
|
31
|
+
1. mockpit init once per repo; read the brief (mockpit guide).
|
|
32
|
+
2. mockpit publish once per variant; mark parts with data-part.
|
|
33
|
+
3. A choice is several variants plus one ask that binds them: mockpit ask
|
|
34
|
+
with options bound to variants. One render plus a control: --knobs.
|
|
35
|
+
4. Tell the user in one line where to look, then end your turn. The question
|
|
36
|
+
itself lives in the mock (ask), never in chat. The user answers in the
|
|
37
|
+
browser at their own pace. Never poll.
|
|
38
|
+
5. When the user says they answered (or a mockpit watch line wakes you), run
|
|
39
|
+
mockpit feedback. Every write also returns feedback; read it.
|
|
40
|
+
6. mockpit publish the revision; mockpit export once a variant is accepted.
|
|
41
|
+
In Claude Code, arm mockpit watch under Monitor after asking, then end the
|
|
42
|
+
turn; elsewhere the user's next message is the wake-up.
|
|
43
|
+
|
|
44
|
+
verbs:
|
|
45
|
+
mockpit init [--project name] [--kit <id>] [--kit-url <url> --kit-doc <file|text>]
|
|
46
|
+
detect the repo's design system, store
|
|
47
|
+
palette/kit/icon sets, write .mockpit/starter.html
|
|
48
|
+
mockpit publish --mock <slug> --html <file> [options]
|
|
49
|
+
create or version one variant of one state
|
|
50
|
+
--state <label> state, in the user's words (omit for a single-state mock)
|
|
51
|
+
--variant <name> variant label (default "default")
|
|
52
|
+
--html <file|-> the html body (or a positional file)
|
|
53
|
+
--surfaces <json|file> instead: the full ordered surface list; {"id"}
|
|
54
|
+
alone keeps a surface, a missing id removes it
|
|
55
|
+
--parts <name=file|-> instead: replace just that data-part's element in
|
|
56
|
+
the latest version (repeatable; name#key targets one instance)
|
|
57
|
+
--title <t> mock title
|
|
58
|
+
--kind <k> component|page (default component)
|
|
59
|
+
--knobs <json|file> knobs in tunekit usePane shape, keyed by path
|
|
60
|
+
--from <N> branch from version N
|
|
61
|
+
--prompt <text> what prompted this version
|
|
62
|
+
--project <name> project (default: git remote, else directory name)
|
|
63
|
+
--kit <id> opt the html surface into a kit (repeatable; see "mockpit kits")
|
|
64
|
+
--md/--mermaid/--diff/--terminal/--data/--code/--image <file|->
|
|
65
|
+
add a surface of that kind after the html (repeatable;
|
|
66
|
+
--data is a JSON tree)
|
|
67
|
+
mockpit ask --mock <slug> "<question>" --option <label[=variant]> ...
|
|
68
|
+
ask the user; bind options to variants
|
|
69
|
+
--option <l[=v]> an option (repeatable); "=variant" binds it to a variant
|
|
70
|
+
--scope <s> mock|state|part (default mock)
|
|
71
|
+
--state <label> the state a state-scoped ask is about
|
|
72
|
+
--part <name> the part a part-scoped ask is about
|
|
73
|
+
--multi allow several answers
|
|
74
|
+
--id <id> stable id; reusing one replaces that ask
|
|
75
|
+
--asks <json|file> several asks at once, full shape
|
|
76
|
+
mockpit read [slug] [--body] [--history]
|
|
77
|
+
no slug: every mock plus what the user
|
|
78
|
+
is doing now; a slug: that mock
|
|
79
|
+
mockpit feedback what the user sent since you last heard,
|
|
80
|
+
plus pending; returns at once
|
|
81
|
+
mockpit say "<text>" --mock <slug> [--state s] [--variant v]
|
|
82
|
+
a plain-text message in a mock's thread
|
|
83
|
+
mockpit export --mock <slug> [--state s] [--variant v] [--out <dir>]
|
|
84
|
+
write the accepted html + history per state
|
|
85
|
+
to .mockpit/accepted/<mock>/<state>/
|
|
86
|
+
mockpit upload <file> [--kind image|file]
|
|
87
|
+
upload an asset, print its id and URL
|
|
88
|
+
mockpit guide [--topic <id>] the project-aware brief, or one reference topic
|
|
89
|
+
mockpit run <file|-> run a script against the mockpit API on the
|
|
90
|
+
server (mockpit guide --topic scripts)
|
|
91
|
+
mockpit watch [--session <id>] stream user feedback, one line per Send
|
|
92
|
+
(for a background monitor)
|
|
93
|
+
|
|
94
|
+
other commands:
|
|
95
|
+
mockpit serve [--port N] [--host H] [--open]
|
|
96
|
+
start the server (API + viewer)
|
|
97
|
+
mockpit demo seed the Writer mock to explore the viewer
|
|
98
|
+
mockpit icons [add|remove <set>...] list the icon sets html surfaces can name
|
|
99
|
+
(icon="prefix:name"); add or remove an
|
|
100
|
+
Iconify set for this project
|
|
101
|
+
mockpit asset-url <file> print the URL a file will have (no upload)
|
|
102
|
+
mockpit kits list the opt-in html kits (bundled + project)
|
|
103
|
+
mockpit kit add <id> --url <css> --doc <file|text> [--script <js>]
|
|
104
|
+
define a project kit from a CDN stylesheet
|
|
105
|
+
mockpit kit remove <id> remove a project kit
|
|
106
|
+
mockpit setup print the AGENTS.md integration block
|
|
107
|
+
mockpit version show version and check for updates
|
|
108
|
+
mockpit mcp run the stdio MCP server (for agent configs)
|
|
109
|
+
|
|
110
|
+
flags:
|
|
111
|
+
--version, -V print version and exit
|
|
112
|
+
--json print the raw server response
|
|
113
|
+
--quiet print nothing on success
|
|
114
|
+
--help, -h per-command help (mockpit publish --help)
|
|
115
|
+
|
|
116
|
+
environment:
|
|
117
|
+
MOCKPIT_PROJECT project name; overrides the git-remote/directory default
|
|
118
|
+
MOCKPIT_URL server base URL (default http://localhost:8228)
|
|
119
|
+
MOCKPIT_TOKEN bearer token for a deployed instance
|
|
120
|
+
MOCKPIT_HOST address serve binds to (default: every interface)
|
|
121
|
+
MOCKPIT_SESSION fixed session id (overrides auto-detection)
|
|
122
|
+
MOCKPIT_AGENT agent name used when creating sessions
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
// Per-command help, so `mockpit publish --help` costs a few lines instead of
|
|
126
|
+
// the whole manual. Commands without an entry fall back to HELP.
|
|
127
|
+
const COMMAND_HELP = {
|
|
128
|
+
init: `mockpit init [--project <name>] [--kit <id>]
|
|
129
|
+
[--kit-url <https url> [--kit-script <url>] --kit-doc <file.md|text>]
|
|
130
|
+
Detect the repo's design system, store palette + kit for the project, add the
|
|
131
|
+
Iconify sets matching its icon packages, and write .mockpit/starter.html
|
|
132
|
+
(gitignored). --kit makes a bundled or project kit the default for every html
|
|
133
|
+
surface. --kit-url defines the project's own kit (id from --kit, else
|
|
134
|
+
"project") and makes it the default; the doc is the cheat sheet the brief prints.`,
|
|
135
|
+
kit: `mockpit kit add <id> --url <https url> --doc <file.md|text> [--script <url>]
|
|
136
|
+
mockpit kit remove <id>
|
|
137
|
+
A project kit is a stylesheet (and optional script) on the CDN allowlist plus a
|
|
138
|
+
class cheat sheet of at most 1,200 chars. Surfaces name it in kits like a
|
|
139
|
+
bundled kit.`,
|
|
140
|
+
icons: `mockpit icons [--project <name>]
|
|
141
|
+
mockpit icons add <set> [<set>...]
|
|
142
|
+
mockpit icons remove <set> [<set>...]
|
|
143
|
+
html surfaces write <i icon="lucide:check"></i>; the server inlines the svg.
|
|
144
|
+
lucide and mage are bundled. add finds @iconify-json/<set> in node_modules,
|
|
145
|
+
the global npm root or bun's cache, else fetches it from jsDelivr.`,
|
|
146
|
+
publish: `mockpit publish --mock <slug> --html <file> [options]
|
|
147
|
+
mockpit publish --mock <slug> --surfaces <json|file> [options]
|
|
148
|
+
mockpit publish --mock <slug> --parts <name=file|-> ... [options]
|
|
149
|
+
Creates the mock, state or variant, or the next version of an existing one.
|
|
150
|
+
--state <label> state in the user's words (omit for a single-state mock)
|
|
151
|
+
--variant <name> variant label (default "default")
|
|
152
|
+
--surfaces <json|file> the full ordered list: {"id"} alone keeps a surface,
|
|
153
|
+
a missing id removes it, order is the list order
|
|
154
|
+
--parts <name=file|-> replace only the element carrying data-part="name"
|
|
155
|
+
(name#key for one instance) in the latest version; repeatable
|
|
156
|
+
--title <t> mock title
|
|
157
|
+
--kind <k> component|page (default component)
|
|
158
|
+
--knobs <json|file> knobs in tunekit usePane shape
|
|
159
|
+
--from <N> branch from version N
|
|
160
|
+
--prompt <text> what prompted this version
|
|
161
|
+
--project <name> project (default: git remote, else directory name)
|
|
162
|
+
--md/--mermaid/--diff/--terminal/--data/--code/--image <file> more surfaces
|
|
163
|
+
--json / --quiet
|
|
164
|
+
Prints the parts found per state, parts that vanished or were renamed,
|
|
165
|
+
nudges, a ready-to-run "mockpit ask" when variants have no ask, and feedback.`,
|
|
166
|
+
ask: `mockpit ask --mock <slug> "<question>" --option <label[=variant]> ... [--scope mock|state|part]
|
|
167
|
+
[--state s] [--part p] [--multi] [--id id]
|
|
168
|
+
mockpit ask --mock <slug> --asks <json|file>
|
|
169
|
+
Ask the user. A choice is several variants plus one ask that binds them.
|
|
170
|
+
Then tell the user where to look and end your turn.`,
|
|
171
|
+
read: `mockpit read [slug] [--project <name>] [--body] [--history]
|
|
172
|
+
No slug: one line per mock (states, variants, open asks) plus pending, what
|
|
173
|
+
the user is doing now. A slug: the mock's states, variants, asks with
|
|
174
|
+
answers, parts, knobs and last tuned values, plus its pending, as JSON.
|
|
175
|
+
--body includes surfaces, --history version rows. Never consumes feedback.`,
|
|
176
|
+
feedback: `mockpit feedback [--project <name>] [--session <id>]
|
|
177
|
+
Returns at once with {feedback, pending} as JSON: one batch per mock the user
|
|
178
|
+
sent since you last heard ({mock, reply: {asks, mix, tuned, comments},
|
|
179
|
+
comments, accepted, archived}), delivered once across feedback, watch and
|
|
180
|
+
every write; and pending ({mock, viewerOpen, draft}) so you can tell the
|
|
181
|
+
user "take your time" or "2 of 3 answered". Run it when the user says they
|
|
182
|
+
answered.`,
|
|
183
|
+
say: `mockpit say "<text>" --mock <slug> [--state s] [--variant v]
|
|
184
|
+
A plain-text message in the mock's thread, as your session's agent.`,
|
|
185
|
+
run: `mockpit run <file|-> [--session <id>] [--project <name>] [--json]
|
|
186
|
+
Run a script (the body of an async function, plain JavaScript) in the server's
|
|
187
|
+
sandbox against the mockpit API; see mockpit guide --topic scripts. Prints each
|
|
188
|
+
host call in order, the script's prints, its value or error, and any user
|
|
189
|
+
feedback the run received. --json prints the whole result. Exits 2 when the
|
|
190
|
+
script failed.`,
|
|
191
|
+
watch: `mockpit watch [--session <id>] [--after <seq>]
|
|
192
|
+
Stream user feedback, one line per Send, for a background monitor (in
|
|
193
|
+
Claude Code, arm it under Monitor after asking, then end the turn).
|
|
194
|
+
Delivery is shared with feedback and every write: each Send arrives once.`,
|
|
195
|
+
export: `mockpit export --mock <slug> [--state s] [--variant v] [--out <dir>]
|
|
196
|
+
Write index.html + history.json per state to .mockpit/accepted/<mock>/<state>/.`,
|
|
197
|
+
upload: `mockpit upload <file> [--kind image|file] [--session <id>]
|
|
198
|
+
Upload an asset; prints its id and URL. Use the id as an image surface's assetId.`,
|
|
199
|
+
guide: `mockpit guide [--topic <id>] [--project <name>]
|
|
200
|
+
No flag prints the brief: the loop, parts, asks and knobs, feedback, and
|
|
201
|
+
this project's palette, kit and icons. --topic prints one reference topic
|
|
202
|
+
(knobs, asks, surfaces, html, feedback, http, scripts).`,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// `console.log(...)` on a pipe is asynchronous, so exiting on the next line
|
|
206
|
+
// truncates anything past the pipe buffer (~8 KB) — which is most of the help
|
|
207
|
+
// text. Write to fd 1 synchronously, retrying the non-blocking EAGAIN, then exit.
|
|
208
|
+
function printAndExit(text) {
|
|
209
|
+
let buf = Buffer.from(text.endsWith("\n") ? text : `${text}\n`);
|
|
210
|
+
while (buf.length > 0) {
|
|
211
|
+
try {
|
|
212
|
+
buf = buf.subarray(writeSync(1, buf));
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (err?.code !== "EAGAIN") throw err;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
process.exit(0);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function fail(msg) {
|
|
221
|
+
console.error(`mockpit: ${msg}`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The agent-facing error format: name the thing, offer one fix, say nothing was
|
|
226
|
+
// written, exit 2. No stack traces, no prose.
|
|
227
|
+
function die(what, fix) {
|
|
228
|
+
console.error(`error ${what}`);
|
|
229
|
+
if (fix) console.error(` fix: ${fix}`);
|
|
230
|
+
process.exit(2);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// `report` decides how a failure reads: plumbing verbs keep the one-line
|
|
234
|
+
// `mockpit: …` (exit 1); the mock verbs pass `die` so the error / fix / exit 2
|
|
235
|
+
// format is what an agent parses.
|
|
236
|
+
async function api(path, init = {}, { report, fix } = {}) {
|
|
237
|
+
const bail = (what, hint) => (report === "die" ? die(what, hint ?? fix) : fail(what));
|
|
238
|
+
let res;
|
|
239
|
+
try {
|
|
240
|
+
res = await fetch(`${BASE}${path}`, {
|
|
241
|
+
...init,
|
|
242
|
+
headers: {
|
|
243
|
+
"content-type": "application/json",
|
|
244
|
+
...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
|
|
245
|
+
...init.headers,
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
} catch {
|
|
249
|
+
if (report === "die") die(`cannot reach mockpit at ${BASE}`, "mockpit serve");
|
|
250
|
+
fail(`server not reachable at ${BASE} — start it with: mockpit serve`);
|
|
251
|
+
}
|
|
252
|
+
const body = await res.json().catch(() => ({}));
|
|
253
|
+
if (!res.ok) {
|
|
254
|
+
// A surface validation failure carries typed issues; the first one is the
|
|
255
|
+
// actionable line ("error … : <message>") the agent needs.
|
|
256
|
+
const issue = Array.isArray(body.issues) ? body.issues[0] : null;
|
|
257
|
+
// The server names the valid choices when a call is ambiguous; say them.
|
|
258
|
+
const choices = body.variants ?? body.states ?? body.projects;
|
|
259
|
+
const main =
|
|
260
|
+
(body.error ?? `${res.status} ${res.statusText}`) +
|
|
261
|
+
(Array.isArray(choices) ? ` (${choices.join(", ")})` : "");
|
|
262
|
+
const extra = issue?.message ?? issue?.code;
|
|
263
|
+
bail(extra && !main.includes(extra) ? `${main}: ${extra}` : main);
|
|
264
|
+
}
|
|
265
|
+
return body;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Session state is keyed by (agent process pid, cwd). Many agents spawn a
|
|
269
|
+
// fresh shell per command, so the immediate parent is unstable — walk up the
|
|
270
|
+
// process tree past shells to the agent process itself. Falls back to
|
|
271
|
+
// cwd-only keying where `ps` is unavailable.
|
|
272
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
|
|
273
|
+
|
|
274
|
+
function getParentPosix(pid) {
|
|
275
|
+
const out = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], {
|
|
276
|
+
encoding: "utf8",
|
|
277
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
278
|
+
}).trim();
|
|
279
|
+
const m = out.match(/^\s*(\d+)\s+(.*)$/);
|
|
280
|
+
if (!m) return { ppid: 0, isShell: false };
|
|
281
|
+
const ppid = Number(m[1]);
|
|
282
|
+
const comm = m[2].trim().split("/").pop() ?? "";
|
|
283
|
+
return { ppid, isShell: SHELLS.has(comm.replace(/^-/, "")) };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function agentPidWindows(startPid) {
|
|
287
|
+
// wmic is removed in Windows 11. Walk the process tree in a single
|
|
288
|
+
// PowerShell call to avoid repeated startup overhead (~300ms per spawn).
|
|
289
|
+
// $procId, not $pid: $PID is a PowerShell automatic variable holding the
|
|
290
|
+
// host process's own id, and reassigning it is confusing at best.
|
|
291
|
+
const script = `
|
|
292
|
+
$procId = ${startPid}
|
|
293
|
+
$shells = @('cmd.exe','powershell.exe','pwsh.exe')
|
|
294
|
+
for ($i = 0; $i -lt 10; $i++) {
|
|
295
|
+
$p = Get-CimInstance Win32_Process -Filter "ProcessId=$procId"
|
|
296
|
+
if (!$p) { break }
|
|
297
|
+
if ($shells -notcontains $p.Name.ToLower()) { break }
|
|
298
|
+
if ($p.ParentProcessId -le 1) { break }
|
|
299
|
+
$procId = $p.ParentProcessId
|
|
300
|
+
}
|
|
301
|
+
$procId
|
|
302
|
+
`;
|
|
303
|
+
const out = execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
304
|
+
encoding: "utf8",
|
|
305
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
306
|
+
}).trim();
|
|
307
|
+
return Number(out) || startPid;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function agentPid() {
|
|
311
|
+
try {
|
|
312
|
+
if (process.platform === "win32") return agentPidWindows(process.ppid);
|
|
313
|
+
let pid = process.ppid;
|
|
314
|
+
for (let hops = 0; hops < 10; hops++) {
|
|
315
|
+
const { ppid, isShell } = getParentPosix(pid);
|
|
316
|
+
if (!isShell || !ppid || ppid <= 1) return pid;
|
|
317
|
+
pid = ppid;
|
|
318
|
+
}
|
|
319
|
+
return pid;
|
|
320
|
+
} catch {
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function stateFile() {
|
|
326
|
+
const dir = join(tmpdir(), `mockpit-${userInfo().username}`);
|
|
327
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
328
|
+
const key = createHash("sha1")
|
|
329
|
+
.update(`${agentPid()}:${process.cwd()}`)
|
|
330
|
+
.digest("hex")
|
|
331
|
+
.slice(0, 12);
|
|
332
|
+
return join(dir, `${key}.json`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function readState() {
|
|
336
|
+
try {
|
|
337
|
+
return JSON.parse(readFileSync(stateFile(), "utf8"));
|
|
338
|
+
} catch {
|
|
339
|
+
return {};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function writeState(patch) {
|
|
344
|
+
const next = { ...readState(), ...patch };
|
|
345
|
+
writeFileSync(stateFile(), JSON.stringify(next));
|
|
346
|
+
return next;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function agentName(flags) {
|
|
350
|
+
return flags.agent ?? process.env.MOCKPIT_AGENT ?? readState().agent ?? "agent";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// The state file is keyed by the agent's pid, which a resumed conversation
|
|
354
|
+
// doesn't keep; Claude Code's session id does, so the server can hand back the
|
|
355
|
+
// same mockpit session (and its feedback cursor).
|
|
356
|
+
function harnessKey() {
|
|
357
|
+
const id = process.env.CLAUDE_CODE_SESSION_ID;
|
|
358
|
+
return id ? `claude-code:${id}` : undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function resolveSession(flags, { create = false } = {}) {
|
|
362
|
+
if (flags.session) return flags.session;
|
|
363
|
+
if (process.env.MOCKPIT_SESSION) return process.env.MOCKPIT_SESSION;
|
|
364
|
+
const state = readState();
|
|
365
|
+
if (state.session && !flags["new-session"]) {
|
|
366
|
+
const ok = await fetch(`${BASE}/api/sessions/${state.session}`, {
|
|
367
|
+
headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
|
|
368
|
+
}).then(
|
|
369
|
+
(r) => r.ok,
|
|
370
|
+
() => false,
|
|
371
|
+
);
|
|
372
|
+
if (ok) return state.session;
|
|
373
|
+
}
|
|
374
|
+
if (!create) return null;
|
|
375
|
+
const session = await api("/api/sessions", {
|
|
376
|
+
method: "POST",
|
|
377
|
+
body: JSON.stringify({
|
|
378
|
+
agent: agentName(flags),
|
|
379
|
+
title: flags["session-title"],
|
|
380
|
+
cwd: process.cwd(),
|
|
381
|
+
project: resolveProject(flags).name,
|
|
382
|
+
key: harnessKey(),
|
|
383
|
+
}),
|
|
384
|
+
});
|
|
385
|
+
writeState({ session: session.id, agent: agentName(flags) });
|
|
386
|
+
return session.id;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// A monitor process (e.g. the Claude Code plugin) may not share the local
|
|
390
|
+
// state file written by the agent's CLI calls — different spawn tree, so
|
|
391
|
+
// `agentPid()` can hash to a different key. Fall back to asking the server for
|
|
392
|
+
// the most recently active session whose cwd matches ours. Uses raw fetch (not
|
|
393
|
+
// `api()`) so a transient failure returns null instead of exiting the process.
|
|
394
|
+
async function resolveSessionByCwd(cwd = process.cwd()) {
|
|
395
|
+
try {
|
|
396
|
+
const res = await fetch(`${BASE}/api/sessions`, {
|
|
397
|
+
headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
|
|
398
|
+
});
|
|
399
|
+
if (!res.ok) return null;
|
|
400
|
+
const sessions = await res.json();
|
|
401
|
+
return (
|
|
402
|
+
sessions
|
|
403
|
+
.filter((s) => s.cwd === cwd)
|
|
404
|
+
.sort((a, b) => String(b.lastActiveAt).localeCompare(String(a.lastActiveAt)))[0]?.id ?? null
|
|
405
|
+
);
|
|
406
|
+
} catch {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function readContent(arg) {
|
|
412
|
+
if (!arg || arg === "-") {
|
|
413
|
+
try {
|
|
414
|
+
return readFileSync(0, "utf8");
|
|
415
|
+
} catch {
|
|
416
|
+
fail("no input — pass a file path or pipe HTML on stdin");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
return readFileSync(arg, "utf8");
|
|
421
|
+
} catch {
|
|
422
|
+
fail(`cannot read file: ${arg}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function out(value) {
|
|
427
|
+
console.log(JSON.stringify(value, null, 2));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const CONTENT_TYPES = {
|
|
431
|
+
png: "image/png",
|
|
432
|
+
jpg: "image/jpeg",
|
|
433
|
+
jpeg: "image/jpeg",
|
|
434
|
+
gif: "image/gif",
|
|
435
|
+
webp: "image/webp",
|
|
436
|
+
avif: "image/avif",
|
|
437
|
+
svg: "image/svg+xml",
|
|
438
|
+
json: "application/json",
|
|
439
|
+
jsonl: "application/x-ndjson",
|
|
440
|
+
ndjson: "application/x-ndjson",
|
|
441
|
+
txt: "text/plain",
|
|
442
|
+
log: "text/plain",
|
|
443
|
+
csv: "text/csv",
|
|
444
|
+
pdf: "application/pdf",
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
function contentTypeFor(file) {
|
|
448
|
+
const ext = file.split(".").pop()?.toLowerCase() ?? "";
|
|
449
|
+
return CONTENT_TYPES[ext] ?? "application/octet-stream";
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Map a filename extension to a shiki language id. Only common languages —
|
|
453
|
+
// shiki knows many more, but this covers the files an agent is likely to
|
|
454
|
+
// `mockpit code`. Unmapped extensions return undefined (shiki "text").
|
|
455
|
+
const LANG_BY_EXT = {
|
|
456
|
+
ts: "typescript",
|
|
457
|
+
tsx: "tsx",
|
|
458
|
+
mts: "typescript",
|
|
459
|
+
cts: "typescript",
|
|
460
|
+
js: "javascript",
|
|
461
|
+
jsx: "jsx",
|
|
462
|
+
mjs: "javascript",
|
|
463
|
+
cjs: "javascript",
|
|
464
|
+
py: "python",
|
|
465
|
+
rb: "ruby",
|
|
466
|
+
go: "go",
|
|
467
|
+
rs: "rust",
|
|
468
|
+
java: "java",
|
|
469
|
+
kt: "kotlin",
|
|
470
|
+
swift: "swift",
|
|
471
|
+
c: "c",
|
|
472
|
+
h: "c",
|
|
473
|
+
cpp: "cpp",
|
|
474
|
+
cc: "cpp",
|
|
475
|
+
hpp: "cpp",
|
|
476
|
+
cs: "csharp",
|
|
477
|
+
php: "php",
|
|
478
|
+
sh: "bash",
|
|
479
|
+
bash: "bash",
|
|
480
|
+
zsh: "bash",
|
|
481
|
+
fish: "bash",
|
|
482
|
+
yml: "yaml",
|
|
483
|
+
yaml: "yaml",
|
|
484
|
+
toml: "toml",
|
|
485
|
+
json: "json",
|
|
486
|
+
jsonl: "json",
|
|
487
|
+
html: "html",
|
|
488
|
+
htm: "html",
|
|
489
|
+
css: "css",
|
|
490
|
+
scss: "scss",
|
|
491
|
+
sql: "sql",
|
|
492
|
+
md: "markdown",
|
|
493
|
+
markdown: "markdown",
|
|
494
|
+
dockerfile: "docker",
|
|
495
|
+
makefile: "make",
|
|
496
|
+
lua: "lua",
|
|
497
|
+
r: "r",
|
|
498
|
+
scala: "scala",
|
|
499
|
+
clj: "clojure",
|
|
500
|
+
ex: "elixir",
|
|
501
|
+
exs: "elixir",
|
|
502
|
+
erl: "erlang",
|
|
503
|
+
hs: "haskell",
|
|
504
|
+
ml: "ocaml",
|
|
505
|
+
nim: "nim",
|
|
506
|
+
dart: "dart",
|
|
507
|
+
groovy: "groovy",
|
|
508
|
+
gradle: "groovy",
|
|
509
|
+
vue: "vue",
|
|
510
|
+
svelte: "svelte",
|
|
511
|
+
xml: "xml",
|
|
512
|
+
graphql: "graphql",
|
|
513
|
+
gql: "graphql",
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
function inferLang(file) {
|
|
517
|
+
const base = file.split("/").pop() ?? file;
|
|
518
|
+
if (/^Dockerfile/i.test(base)) return "docker";
|
|
519
|
+
if (/^Makefile/i.test(base)) return "make";
|
|
520
|
+
const ext = base.split(".").pop()?.toLowerCase() ?? "";
|
|
521
|
+
return LANG_BY_EXT[ext];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// POST raw bytes to /api/assets. Returns { id, url, contentType, ... }.
|
|
525
|
+
async function uploadBytes(bytes, { filename, contentType, session, kind } = {}) {
|
|
526
|
+
const params = new URLSearchParams();
|
|
527
|
+
params.set("filename", filename ?? "upload");
|
|
528
|
+
if (session) params.set("session", session);
|
|
529
|
+
if (kind) params.set("kind", kind);
|
|
530
|
+
let res;
|
|
531
|
+
try {
|
|
532
|
+
res = await fetch(`${BASE}/api/assets?${params}`, {
|
|
533
|
+
method: "POST",
|
|
534
|
+
headers: {
|
|
535
|
+
"content-type": contentType ?? "application/octet-stream",
|
|
536
|
+
...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
|
|
537
|
+
},
|
|
538
|
+
body: bytes,
|
|
539
|
+
});
|
|
540
|
+
} catch {
|
|
541
|
+
fail(`server not reachable at ${BASE} — start it with: mockpit serve`);
|
|
542
|
+
}
|
|
543
|
+
const body = await res.json().catch(() => ({}));
|
|
544
|
+
if (!res.ok) fail(body.error ?? `${res.status} ${res.statusText}`);
|
|
545
|
+
return body;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Upload a file from disk to /api/assets.
|
|
549
|
+
async function uploadFile(file, { session, kind } = {}) {
|
|
550
|
+
let bytes;
|
|
551
|
+
try {
|
|
552
|
+
bytes = readFileSync(file);
|
|
553
|
+
} catch {
|
|
554
|
+
fail(`cannot read file: ${file}`);
|
|
555
|
+
}
|
|
556
|
+
return uploadBytes(bytes, {
|
|
557
|
+
filename: file.split(/[\\/]/).pop() ?? "upload",
|
|
558
|
+
contentType: contentTypeFor(file),
|
|
559
|
+
session,
|
|
560
|
+
kind,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Served by every mockpit server without an install (server/icons.ts).
|
|
565
|
+
const BUNDLED_ICON_SETS = ["lucide", "mage"];
|
|
566
|
+
|
|
567
|
+
// Find each Iconify set, upload it as a file asset, and record them all on the
|
|
568
|
+
// project's design in one PUT. A set that cannot be found is reported in
|
|
569
|
+
// `errors` and does not stop the others.
|
|
570
|
+
async function installIconSets(project, prefixes) {
|
|
571
|
+
const { findIconSet } = await import("./initDesign.js");
|
|
572
|
+
const added = [];
|
|
573
|
+
const errors = [];
|
|
574
|
+
for (const prefix of new Set(prefixes)) {
|
|
575
|
+
try {
|
|
576
|
+
const { set } = await findIconSet(prefix, process.cwd());
|
|
577
|
+
const asset = await uploadBytes(new TextEncoder().encode(JSON.stringify(set)), {
|
|
578
|
+
filename: `icons-${prefix}.json`,
|
|
579
|
+
contentType: "application/json",
|
|
580
|
+
kind: "file",
|
|
581
|
+
});
|
|
582
|
+
added.push({ prefix, assetId: asset.id });
|
|
583
|
+
} catch (err) {
|
|
584
|
+
errors.push({ prefix, message: err instanceof Error ? err.message : String(err) });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (added.length === 0) return { added: [], errors, design: null };
|
|
588
|
+
const stored = (await api(`${projectPath(project)}/design`)) ?? {};
|
|
589
|
+
const keep = (stored.iconSets ?? []).filter((s) => !added.some((a) => a.prefix === s.prefix));
|
|
590
|
+
const design = await api(`${projectPath(project)}/design`, {
|
|
591
|
+
method: "PUT",
|
|
592
|
+
body: JSON.stringify({ ...stored, iconSets: [...keep, ...added] }),
|
|
593
|
+
});
|
|
594
|
+
return {
|
|
595
|
+
added: design.iconSets.filter((s) => added.some((a) => a.prefix === s.prefix)),
|
|
596
|
+
errors,
|
|
597
|
+
design,
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// `--doc` is a file when one exists at that path, else the cheat sheet itself.
|
|
602
|
+
function readKitDoc(doc) {
|
|
603
|
+
if (doc === undefined) return undefined;
|
|
604
|
+
try {
|
|
605
|
+
if (existsSync(doc)) return readFileSync(doc, "utf8");
|
|
606
|
+
} catch {}
|
|
607
|
+
return doc;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// The server checks the URLs against the CDN allowlist and the doc's length;
|
|
611
|
+
// its one-line error is the message.
|
|
612
|
+
function putProjectKit(project, id, { url, script, doc }) {
|
|
613
|
+
return api(`${projectPath(project)}/kits/${encodeURIComponent(id)}`, {
|
|
614
|
+
method: "PUT",
|
|
615
|
+
body: JSON.stringify({ href: url, script, doc: readKitDoc(doc) }),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Normalize repeated/comma-joined --kit flags into a deduped id list (or
|
|
620
|
+
// undefined). The server allowlists the ids; an unknown one is a clean 400.
|
|
621
|
+
function normalizeKits(flag) {
|
|
622
|
+
if (!flag) return undefined;
|
|
623
|
+
const ids = (Array.isArray(flag) ? flag : [flag])
|
|
624
|
+
.flatMap((s) => String(s).split(","))
|
|
625
|
+
.map((s) => s.trim())
|
|
626
|
+
.filter(Boolean);
|
|
627
|
+
return ids.length > 0 ? [...new Set(ids)] : undefined;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Surface-kind flags accepted by `publish` and `surface add` (the two commands
|
|
631
|
+
// that compose a post from one flag per surface kind). Each is declared
|
|
632
|
+
// `multiple: true` in the parser, so a repeated flag yields an array — letting
|
|
633
|
+
// an author emit several surfaces of the same kind (--diff a --diff b).
|
|
634
|
+
const SURFACE_FLAGS = new Map([
|
|
635
|
+
["md", "markdown"],
|
|
636
|
+
["mermaid", "mermaid"],
|
|
637
|
+
["diff", "diff"],
|
|
638
|
+
["terminal", "terminal"],
|
|
639
|
+
["data", "json"],
|
|
640
|
+
["code", "code"],
|
|
641
|
+
["image", "image"],
|
|
642
|
+
]);
|
|
643
|
+
|
|
644
|
+
// Build a single surface object from one flag value. Mirrors the per-kind
|
|
645
|
+
// construction that used to be inlined in `publish` and `surface add`.
|
|
646
|
+
async function buildSurface(kind, value, { session, layout }) {
|
|
647
|
+
const file = value || "-";
|
|
648
|
+
if (kind === "markdown") return { kind: "markdown", markdown: readContent(file) };
|
|
649
|
+
if (kind === "mermaid") return { kind: "mermaid", mermaid: readContent(file) };
|
|
650
|
+
if (kind === "diff")
|
|
651
|
+
return {
|
|
652
|
+
kind: "diff",
|
|
653
|
+
patch: readContent(file),
|
|
654
|
+
...(layout === "split" && { layout: "split" }),
|
|
655
|
+
};
|
|
656
|
+
if (kind === "terminal") return { kind: "terminal", text: readContent(file) };
|
|
657
|
+
if (kind === "json") {
|
|
658
|
+
const text = readContent(file);
|
|
659
|
+
try {
|
|
660
|
+
return { kind: "json", data: JSON.parse(text) };
|
|
661
|
+
} catch {
|
|
662
|
+
fail(`--data: invalid JSON${value && value !== "-" ? ` in ${value}` : ""}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (kind === "code") {
|
|
666
|
+
const part = { kind: "code", code: readContent(file) };
|
|
667
|
+
const codeLang = value && value !== "-" ? inferLang(value) : undefined;
|
|
668
|
+
if (codeLang) part.language = codeLang;
|
|
669
|
+
if (value && value !== "-") part.title = value.split("/").pop() || value;
|
|
670
|
+
return part;
|
|
671
|
+
}
|
|
672
|
+
if (kind === "image") {
|
|
673
|
+
const asset = await uploadFile(value, { session, kind: "image" });
|
|
674
|
+
return { kind: "image", assetId: asset.id };
|
|
675
|
+
}
|
|
676
|
+
fail(`unknown surface kind: ${kind}`);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Walk parseArgs `tokens` (which preserve command-line order, including
|
|
680
|
+
// repeats when a surface flag is `multiple: true`) and build one surface per
|
|
681
|
+
// flag occurrence, pulling successive values from each flag's value array.
|
|
682
|
+
// Surfaces render top-to-bottom, so order is user-visible — this honors the
|
|
683
|
+
// order the author wrote the flags, repeats included.
|
|
684
|
+
async function surfacesFromFlags(flags, tokens, { session, layout }) {
|
|
685
|
+
const idx = new Map();
|
|
686
|
+
const out = [];
|
|
687
|
+
for (const t of tokens ?? []) {
|
|
688
|
+
if (t.kind !== "option" || !SURFACE_FLAGS.has(t.name)) continue;
|
|
689
|
+
const flagName = t.name;
|
|
690
|
+
const arr = flags[flagName];
|
|
691
|
+
if (!Array.isArray(arr)) continue;
|
|
692
|
+
const i = idx.get(flagName) ?? 0;
|
|
693
|
+
const value = arr[i];
|
|
694
|
+
if (value === undefined) continue;
|
|
695
|
+
idx.set(flagName, i + 1);
|
|
696
|
+
out.push(await buildSurface(SURFACE_FLAGS.get(flagName), value, { session, layout }));
|
|
697
|
+
}
|
|
698
|
+
return out;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// A project is the repo the agent runs in. Resolution order is explicit flag,
|
|
702
|
+
// environment, git remote (owner/repo), then the directory name — so an agent
|
|
703
|
+
// that passes nothing still lands in a stable, human-recognizable project.
|
|
704
|
+
function resolveProject(flags = {}) {
|
|
705
|
+
if (flags.project) return { name: String(flags.project), source: "flag" };
|
|
706
|
+
if (process.env.MOCKPIT_PROJECT) {
|
|
707
|
+
return { name: process.env.MOCKPIT_PROJECT, source: "MOCKPIT_PROJECT" };
|
|
708
|
+
}
|
|
709
|
+
const remote = gitRemoteProject();
|
|
710
|
+
if (remote) return { name: remote, source: "git remote" };
|
|
711
|
+
return { name: process.cwd().split(/[\\/]/).filter(Boolean).pop() || "workspace", source: "cwd" };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function gitRemoteProject() {
|
|
715
|
+
let url;
|
|
716
|
+
try {
|
|
717
|
+
url = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
718
|
+
encoding: "utf8",
|
|
719
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
720
|
+
}).trim();
|
|
721
|
+
} catch {
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
if (!url) return null;
|
|
725
|
+
const m = url.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?\/?$/);
|
|
726
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function slugify(text) {
|
|
730
|
+
return String(text)
|
|
731
|
+
.normalize("NFKD")
|
|
732
|
+
.replace(/[^\w\s-]/g, "")
|
|
733
|
+
.trim()
|
|
734
|
+
.replace(/[\s_]+/g, "-")
|
|
735
|
+
.replace(/-+/g, "-")
|
|
736
|
+
.toLowerCase();
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const projectPath = (project) => `/api/projects/${encodeURIComponent(project)}`;
|
|
740
|
+
|
|
741
|
+
const mockPath = (slug) => `/api/mocks/${encodeURIComponent(slug)}`;
|
|
742
|
+
|
|
743
|
+
const query = (params) => {
|
|
744
|
+
const q = new URLSearchParams();
|
|
745
|
+
for (const [k, v] of Object.entries(params)) {
|
|
746
|
+
if (v !== undefined && v !== null && v !== false) q.set(k, v === true ? "1" : String(v));
|
|
747
|
+
}
|
|
748
|
+
const s = q.toString();
|
|
749
|
+
return s ? `?${s}` : "";
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
// The flags every mock verb shares: which mock, which state, which variant.
|
|
753
|
+
const MOCK_FLAGS = {
|
|
754
|
+
mock: { type: "string" },
|
|
755
|
+
state: { type: "string" },
|
|
756
|
+
variant: { type: "string" },
|
|
757
|
+
project: { type: "string" },
|
|
758
|
+
session: { type: "string" },
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
function requireMock(flags, example) {
|
|
762
|
+
const slug = flags.mock ? slugify(flags.mock) : "";
|
|
763
|
+
if (!slug) die("--mock needs a slug", example);
|
|
764
|
+
return slug;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// A value that may be inline JSON or a file holding it.
|
|
768
|
+
function readJsonFlag(name, value) {
|
|
769
|
+
const text = existsSync(value) ? readFileSync(value, "utf8") : value;
|
|
770
|
+
try {
|
|
771
|
+
return JSON.parse(text);
|
|
772
|
+
} catch {
|
|
773
|
+
die(`--${name} is not valid JSON`, `--${name} '{"size":[16,8,48,1]}'`);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
const stateLabel = (s) => (s === null || s === undefined ? "" : `${s}/`);
|
|
778
|
+
|
|
779
|
+
// A shell-quoted argument for the ready-to-run lines the CLI prints.
|
|
780
|
+
const shq = (s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${String(s).replace(/'/g, `'\\''`)}'`);
|
|
781
|
+
|
|
782
|
+
// The server's suggested ask as the command that sends it: an agent that was
|
|
783
|
+
// nudged can run it as is. --asks carries ids and scope, which flags can't.
|
|
784
|
+
function suggestedAskLine(slug, ask) {
|
|
785
|
+
return `mockpit ask --mock ${shq(slug)} --asks ${shq(JSON.stringify([ask]))}`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// One line per write: what was published, its URL, the parts found per state,
|
|
789
|
+
// anything that moved, and feedback the user left while the agent worked.
|
|
790
|
+
function printPublished(result, flags) {
|
|
791
|
+
if (flags.json) return out(result);
|
|
792
|
+
if (flags.quiet) return;
|
|
793
|
+
const { mock, post } = result;
|
|
794
|
+
const branch = post.from !== undefined && post.from !== post.version - 1;
|
|
795
|
+
console.log(
|
|
796
|
+
`${mock.slug}/${stateLabel(post.state)}${post.variant} v${post.version}${branch && post.from ? ` (from v${post.from})` : ""} · ${result.url}`,
|
|
797
|
+
);
|
|
798
|
+
for (const { state, parts } of result.parts ?? []) {
|
|
799
|
+
if (parts.length === 0) continue;
|
|
800
|
+
console.log(`parts${state ? ` (${state})` : ""}: ${parts.map((p) => p.name).join(", ")}`);
|
|
801
|
+
}
|
|
802
|
+
if (result.applied?.length) console.log(`applied: ${result.applied.join(", ")}`);
|
|
803
|
+
const changes = result.partChanges;
|
|
804
|
+
if (changes?.renamed?.length) {
|
|
805
|
+
console.log(`renamed: ${changes.renamed.map((r) => `${r.from} → ${r.to}`).join(", ")}`);
|
|
806
|
+
}
|
|
807
|
+
if (changes?.vanished?.length) console.log(`vanished: ${changes.vanished.join(", ")}`);
|
|
808
|
+
for (const nudge of result.nudges ?? []) console.log(`nudge: ${nudge}`);
|
|
809
|
+
if (result.suggestedAsk)
|
|
810
|
+
console.log(`suggested: ${suggestedAskLine(mock.slug, result.suggestedAsk)}`);
|
|
811
|
+
for (const warning of result.warnings ?? []) console.log(`warning: ${warning}`);
|
|
812
|
+
printFeedback(result.feedback);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Only speak when there IS feedback: an empty line per write is pure token
|
|
816
|
+
// cost, and the cursor guarantees anything pending arrives on some write.
|
|
817
|
+
function printFeedback(feedback) {
|
|
818
|
+
if (feedback?.length) console.log(`feedback: ${JSON.stringify(feedback)}`);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// What the user is doing right now. viewerOpen is workspace-wide, so it is
|
|
822
|
+
// said once rather than per mock.
|
|
823
|
+
function pendingLines(pending) {
|
|
824
|
+
const rows = pending ?? [];
|
|
825
|
+
const lines = rows
|
|
826
|
+
.filter((p) => p.draft)
|
|
827
|
+
.map(
|
|
828
|
+
(p) =>
|
|
829
|
+
`pending: ${p.mock} — the user is answering (${p.draft.answered} of ${p.draft.of} answered, ${p.draft.comments} comment${p.draft.comments === 1 ? "" : "s"})`,
|
|
830
|
+
);
|
|
831
|
+
if (lines.length === 0 && rows.some((p) => p.viewerOpen))
|
|
832
|
+
lines.push("pending: the viewer is open");
|
|
833
|
+
return lines;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function parsePublishFlags() {
|
|
837
|
+
return parse({
|
|
838
|
+
tokens: true,
|
|
839
|
+
allowPositionals: true,
|
|
840
|
+
options: {
|
|
841
|
+
...MOCK_FLAGS,
|
|
842
|
+
title: { type: "string" },
|
|
843
|
+
kind: { type: "string" },
|
|
844
|
+
html: { type: "string" },
|
|
845
|
+
surfaces: { type: "string" },
|
|
846
|
+
parts: { type: "string", multiple: true },
|
|
847
|
+
knobs: { type: "string" },
|
|
848
|
+
from: { type: "string" },
|
|
849
|
+
prompt: { type: "string" },
|
|
850
|
+
kit: { type: "string", multiple: true },
|
|
851
|
+
layout: { type: "string" },
|
|
852
|
+
md: { type: "string", multiple: true },
|
|
853
|
+
mermaid: { type: "string", multiple: true },
|
|
854
|
+
diff: { type: "string", multiple: true },
|
|
855
|
+
terminal: { type: "string", multiple: true },
|
|
856
|
+
data: { type: "string", multiple: true },
|
|
857
|
+
code: { type: "string", multiple: true },
|
|
858
|
+
image: { type: "string", multiple: true },
|
|
859
|
+
"session-title": { type: "string" },
|
|
860
|
+
agent: { type: "string" },
|
|
861
|
+
"new-session": { type: "boolean" },
|
|
862
|
+
},
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// `--parts name=file` (repeatable; `name#key` targets one instance): the new
|
|
867
|
+
// outer html of one part, spliced server-side into the latest version.
|
|
868
|
+
function readPartFlags(values, slug) {
|
|
869
|
+
if (!values?.length) return undefined;
|
|
870
|
+
const example = `mockpit publish --mock ${slug} --parts body=body.html`;
|
|
871
|
+
const parts = {};
|
|
872
|
+
let stdin = false;
|
|
873
|
+
for (const raw of values) {
|
|
874
|
+
const at = raw.indexOf("=");
|
|
875
|
+
if (at <= 0 || at === raw.length - 1) die(`--parts needs name=file (got "${raw}")`, example);
|
|
876
|
+
const name = raw.slice(0, at);
|
|
877
|
+
const file = raw.slice(at + 1);
|
|
878
|
+
if (file === "-") {
|
|
879
|
+
if (stdin) die("only one --parts can read stdin", example);
|
|
880
|
+
stdin = true;
|
|
881
|
+
} else if (!existsSync(file)) die(`cannot read ${file}`, `ls ${file}`);
|
|
882
|
+
parts[name] = readContent(file);
|
|
883
|
+
}
|
|
884
|
+
return parts;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function publishMock() {
|
|
888
|
+
const { values: flags, positionals, tokens } = parsePublishFlags();
|
|
889
|
+
const slug = requireMock(flags, "mockpit publish --mock writer --html writer.html");
|
|
890
|
+
const project = resolveProject(flags).name;
|
|
891
|
+
const from = flags.from === undefined ? undefined : Number(flags.from);
|
|
892
|
+
if (from !== undefined && !Number.isInteger(from)) {
|
|
893
|
+
die(`--from must be a version number (got "${flags.from}")`, `mockpit read ${slug} --history`);
|
|
894
|
+
}
|
|
895
|
+
const file = flags.html ?? positionals[0];
|
|
896
|
+
const composed =
|
|
897
|
+
file !== undefined || tokens.some((t) => t.kind === "option" && SURFACE_FLAGS.has(t.name));
|
|
898
|
+
const given = [
|
|
899
|
+
composed && "--html",
|
|
900
|
+
flags.surfaces !== undefined && "--surfaces",
|
|
901
|
+
flags.parts?.length && "--parts",
|
|
902
|
+
].filter(Boolean);
|
|
903
|
+
if (given.length > 1) {
|
|
904
|
+
die(
|
|
905
|
+
`pass one of --html, --surfaces or --parts, not ${given.join(" and ")}`,
|
|
906
|
+
`mockpit publish --help`,
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
// Checked before the session exists so a typo'd path leaves nothing behind on the server.
|
|
910
|
+
if (file && file !== "-" && !existsSync(file)) die(`cannot read ${file}`, `ls ${file}`);
|
|
911
|
+
const parts = readPartFlags(flags.parts, slug);
|
|
912
|
+
let surfaces =
|
|
913
|
+
flags.surfaces === undefined ? undefined : readJsonFlag("surfaces", flags.surfaces);
|
|
914
|
+
if (surfaces !== undefined && !Array.isArray(surfaces)) {
|
|
915
|
+
die(
|
|
916
|
+
"--surfaces must be a JSON array",
|
|
917
|
+
`--surfaces '[{"id":"<id>"},{"kind":"markdown","markdown":"# notes"}]'`,
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
const session = await resolveSession(flags, { create: true });
|
|
921
|
+
if (composed) {
|
|
922
|
+
surfaces = [];
|
|
923
|
+
if (file) {
|
|
924
|
+
const html = { kind: "html", html: readContent(file) };
|
|
925
|
+
const kits = normalizeKits(flags.kit);
|
|
926
|
+
if (kits) html.kits = kits;
|
|
927
|
+
surfaces.push(html);
|
|
928
|
+
}
|
|
929
|
+
surfaces.push(...(await surfacesFromFlags(flags, tokens, { session, layout: flags.layout })));
|
|
930
|
+
}
|
|
931
|
+
if (!surfaces && !parts)
|
|
932
|
+
die(`no html for ${slug}`, `mockpit publish --mock ${slug} --html <file>`);
|
|
933
|
+
const body = {
|
|
934
|
+
session,
|
|
935
|
+
project,
|
|
936
|
+
mock: slug,
|
|
937
|
+
state: flags.state,
|
|
938
|
+
variant: flags.variant,
|
|
939
|
+
title: flags.title,
|
|
940
|
+
kind: flags.kind,
|
|
941
|
+
...(flags.knobs !== undefined && { knobs: readJsonFlag("knobs", flags.knobs) }),
|
|
942
|
+
...(from !== undefined && { from }),
|
|
943
|
+
prompt: flags.prompt,
|
|
944
|
+
...(surfaces && { surfaces }),
|
|
945
|
+
...(parts && { parts }),
|
|
946
|
+
};
|
|
947
|
+
const result = await api(
|
|
948
|
+
"/api/mocks",
|
|
949
|
+
{ method: "POST", body: JSON.stringify(body) },
|
|
950
|
+
{ report: "die", fix: `mockpit read ${slug}` },
|
|
951
|
+
);
|
|
952
|
+
printPublished(result, flags);
|
|
953
|
+
return result;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// "Quiet" is an unbound option; "Quiet=quiet" binds it to the quiet variant.
|
|
957
|
+
function parseOption(raw) {
|
|
958
|
+
const at = raw.lastIndexOf("=");
|
|
959
|
+
if (at <= 0) return { label: raw };
|
|
960
|
+
return { label: raw.slice(0, at), variant: raw.slice(at + 1) };
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// One feedback batch, one line — for a background monitor's notifications.
|
|
964
|
+
// The run envelope for a reader: what landed, what the script said, how it
|
|
965
|
+
// ended, and the feedback it took (which no later read will return).
|
|
966
|
+
function printRun(r) {
|
|
967
|
+
for (const c of r.calls ?? []) console.log(`${c.ok ? "ok " : "fail"} ${c.fn} ${c.summary}`);
|
|
968
|
+
for (const line of r.prints ?? []) console.log(`> ${line}`);
|
|
969
|
+
if (r.error) {
|
|
970
|
+
const at = r.error.line
|
|
971
|
+
? ` at ${r.error.line}${r.error.column ? `:${r.error.column}` : ""}`
|
|
972
|
+
: "";
|
|
973
|
+
console.log(`error ${r.error.kind}${at}: ${r.error.message}`);
|
|
974
|
+
} else if (r.value !== undefined) {
|
|
975
|
+
console.log(typeof r.value === "string" ? r.value : JSON.stringify(r.value, null, 2));
|
|
976
|
+
}
|
|
977
|
+
if (r.feedback?.length) console.log(`feedback\n${JSON.stringify(r.feedback, null, 2)}`);
|
|
978
|
+
if (r.truncated) console.log("(output truncated)");
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function watchLines(batch) {
|
|
982
|
+
const lines = [];
|
|
983
|
+
const name = batch.mock ?? "a mock";
|
|
984
|
+
if (batch.reply) {
|
|
985
|
+
const r = batch.reply;
|
|
986
|
+
const bits = [
|
|
987
|
+
r.asks?.length
|
|
988
|
+
? r.asks
|
|
989
|
+
.map(
|
|
990
|
+
(a) =>
|
|
991
|
+
`${a.text || a.ask}: ${a.chosen.map((o) => (o.other ? `“${o.label}”` : o.label)).join("+")}${a.note ? ` (“${a.note}”)` : ""}`,
|
|
992
|
+
)
|
|
993
|
+
.join("; ")
|
|
994
|
+
: "",
|
|
995
|
+
Object.keys(r.tuned ?? {}).length ? `${Object.keys(r.tuned).length} tuned` : "",
|
|
996
|
+
Object.keys(r.mix ?? {}).length
|
|
997
|
+
? `mix ${Object.entries(r.mix)
|
|
998
|
+
.map(([p, v]) => `${p}←${v}`)
|
|
999
|
+
.join(", ")}`
|
|
1000
|
+
: "",
|
|
1001
|
+
r.comments?.length ? `${r.comments.length} comment${r.comments.length === 1 ? "" : "s"}` : "",
|
|
1002
|
+
r.decision ? r.decision.kind : "",
|
|
1003
|
+
r.text ?? "",
|
|
1004
|
+
].filter(Boolean);
|
|
1005
|
+
lines.push(`mockpit reply on ${name}: ${bits.join(" · ").replace(/\s+/g, " ")}`);
|
|
1006
|
+
}
|
|
1007
|
+
for (const c of batch.comments ?? []) {
|
|
1008
|
+
lines.push(
|
|
1009
|
+
`mockpit comment on ${name}: “${String(c.text ?? "")
|
|
1010
|
+
.replace(/\s+/g, " ")
|
|
1011
|
+
.trim()}”`,
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
return lines;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// GET where "not there yet" is an answer, not an exit — a project or mock that
|
|
1018
|
+
// doesn't exist is the normal state before the first publish.
|
|
1019
|
+
async function apiSoft(path) {
|
|
1020
|
+
let res;
|
|
1021
|
+
try {
|
|
1022
|
+
res = await fetch(`${BASE}${path}`, {
|
|
1023
|
+
headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {},
|
|
1024
|
+
});
|
|
1025
|
+
} catch {
|
|
1026
|
+
die(`cannot reach mockpit at ${BASE}`, "mockpit serve");
|
|
1027
|
+
}
|
|
1028
|
+
if (!res.ok) return null;
|
|
1029
|
+
return res.json().catch(() => null);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Keep the scratch directory out of the repo. Returns true when .gitignore was
|
|
1033
|
+
// touched, so init can report it.
|
|
1034
|
+
function ignoreMockpitDir() {
|
|
1035
|
+
const file = join(process.cwd(), ".gitignore");
|
|
1036
|
+
let current = "";
|
|
1037
|
+
try {
|
|
1038
|
+
current = readFileSync(file, "utf8");
|
|
1039
|
+
} catch {
|
|
1040
|
+
// no .gitignore yet — create one
|
|
1041
|
+
}
|
|
1042
|
+
if (/^\.mockpit\/?$/m.test(current)) return false;
|
|
1043
|
+
writeFileSync(
|
|
1044
|
+
file,
|
|
1045
|
+
current && !current.endsWith("\n") ? `${current}\n.mockpit/\n` : `${current}.mockpit/\n`,
|
|
1046
|
+
);
|
|
1047
|
+
return true;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
1051
|
+
|
|
1052
|
+
// Semver greater-than for plain x.y.z (mirrors server/app.ts versionGt).
|
|
1053
|
+
function versionGt(a, b) {
|
|
1054
|
+
const pa = a.split("-")[0].split(".").map(Number);
|
|
1055
|
+
const pb = b.split("-")[0].split(".").map(Number);
|
|
1056
|
+
for (let i = 0; i < 3; i++) {
|
|
1057
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
1058
|
+
if (d !== 0) return d > 0;
|
|
1059
|
+
}
|
|
1060
|
+
return false;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// Disk-cached update check so `mockpit version` doesn't hit the registry every
|
|
1064
|
+
// time. TTL = 24 hours; stale/missing/corrupt cache is silently ignored.
|
|
1065
|
+
const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
1066
|
+
|
|
1067
|
+
function updateCachePath() {
|
|
1068
|
+
const dir = join(tmpdir(), `mockpit-${userInfo().username}`);
|
|
1069
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1070
|
+
return join(dir, "update-check.json");
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function readUpdateCache() {
|
|
1074
|
+
try {
|
|
1075
|
+
const data = JSON.parse(readFileSync(updateCachePath(), "utf8"));
|
|
1076
|
+
if (Date.now() - data.at < UPDATE_CACHE_TTL_MS && typeof data.version === "string") {
|
|
1077
|
+
return data.version;
|
|
1078
|
+
}
|
|
1079
|
+
} catch {}
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function writeUpdateCache(version) {
|
|
1084
|
+
try {
|
|
1085
|
+
writeFileSync(updateCachePath(), JSON.stringify({ at: Date.now(), version }));
|
|
1086
|
+
} catch {}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
1090
|
+
|
|
1091
|
+
// Subcommand flag parsing. parseArgs is strict, so without this --help (or
|
|
1092
|
+
// any typo) throws a raw stack trace; instead --help/-h prints usage and
|
|
1093
|
+
// exits 0, and an unknown option fails with a one-line hint.
|
|
1094
|
+
//
|
|
1095
|
+
// Ids are base64url and can start with - or _ (~1/64 each). parseArgs strict
|
|
1096
|
+
// mode treats those as unknown options ("Unknown option '-6'" for an id like
|
|
1097
|
+
// "-6K4AJsKD4M"). We swap any id-shaped token that starts with a separator
|
|
1098
|
+
// for a sentinel before parsing, then restore it in the result — so positionals,
|
|
1099
|
+
// tokens, and option values all get the original id back, in the right order.
|
|
1100
|
+
const ID_LIKE = /^[-_](?![-_])[A-Za-z0-9_-]{7,}$/;
|
|
1101
|
+
function parse(config = {}) {
|
|
1102
|
+
const rescued = new Map();
|
|
1103
|
+
const args = rest.map((a) => {
|
|
1104
|
+
if (ID_LIKE.test(a)) {
|
|
1105
|
+
const s = `\x00${rescued.size}\x00`;
|
|
1106
|
+
rescued.set(s, a);
|
|
1107
|
+
return s;
|
|
1108
|
+
}
|
|
1109
|
+
return a;
|
|
1110
|
+
});
|
|
1111
|
+
let parsed;
|
|
1112
|
+
try {
|
|
1113
|
+
parsed = parseArgs({
|
|
1114
|
+
args,
|
|
1115
|
+
...config,
|
|
1116
|
+
// --json/--quiet are global, but a command may redefine them (legacy
|
|
1117
|
+
// `publish --json <file>` adds a json surface), so config wins.
|
|
1118
|
+
options: {
|
|
1119
|
+
json: { type: "boolean" },
|
|
1120
|
+
quiet: { type: "boolean" },
|
|
1121
|
+
...config.options,
|
|
1122
|
+
help: { type: "boolean", short: "h" },
|
|
1123
|
+
},
|
|
1124
|
+
});
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
if (!String(err?.code).startsWith("ERR_PARSE_ARGS")) throw err;
|
|
1127
|
+
fail(`${err.message.split(". ")[0]} — run "mockpit help"`);
|
|
1128
|
+
}
|
|
1129
|
+
if (parsed.values.help) printAndExit(COMMAND_HELP[cmd] ?? HELP);
|
|
1130
|
+
const restore = (v) => (typeof v === "string" && rescued.has(v) ? rescued.get(v) : v);
|
|
1131
|
+
if (parsed.positionals) parsed.positionals = parsed.positionals.map(restore);
|
|
1132
|
+
if (parsed.tokens) {
|
|
1133
|
+
parsed.tokens = parsed.tokens.map((t) =>
|
|
1134
|
+
t.kind === "positional" && rescued.has(t.value) ? { ...t, value: rescued.get(t.value) } : t,
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
for (const k of Object.keys(parsed.values ?? {})) {
|
|
1138
|
+
const v = parsed.values[k];
|
|
1139
|
+
parsed.values[k] = Array.isArray(v) ? v.map(restore) : restore(v);
|
|
1140
|
+
}
|
|
1141
|
+
return parsed;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Development checkouts run TypeScript directly (Node strips types), but Node
|
|
1145
|
+
// refuses to type-strip files under node_modules — installed packages ship
|
|
1146
|
+
// compiled JS in dist/ (built on prepack) and must use it.
|
|
1147
|
+
function entrypoint(...parts) {
|
|
1148
|
+
const built = join(ROOT, "dist", ...parts).replace(/\.ts$/, ".js");
|
|
1149
|
+
return existsSync(built) ? built : join(ROOT, ...parts);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const commands = {
|
|
1153
|
+
async serve() {
|
|
1154
|
+
const { values: flags } = parse({
|
|
1155
|
+
options: {
|
|
1156
|
+
port: { type: "string" },
|
|
1157
|
+
host: { type: "string" },
|
|
1158
|
+
open: { type: "boolean" },
|
|
1159
|
+
},
|
|
1160
|
+
});
|
|
1161
|
+
const port = flags.port ?? process.env.PORT ?? "8228";
|
|
1162
|
+
const host = flags.host ?? process.env.MOCKPIT_HOST;
|
|
1163
|
+
const child = spawn(process.execPath, [entrypoint("server", "index.ts")], {
|
|
1164
|
+
stdio: "inherit",
|
|
1165
|
+
env: { ...process.env, PORT: port, ...(host ? { MOCKPIT_HOST: host } : {}) },
|
|
1166
|
+
});
|
|
1167
|
+
if (flags.open) {
|
|
1168
|
+
const url = serveUrl(host, port);
|
|
1169
|
+
const { opener, openerArgs } =
|
|
1170
|
+
process.platform === "darwin"
|
|
1171
|
+
? { opener: "open", openerArgs: [url] }
|
|
1172
|
+
: process.platform === "win32"
|
|
1173
|
+
? { opener: "cmd", openerArgs: ["/c", "start", url] }
|
|
1174
|
+
: { opener: "xdg-open", openerArgs: [url] };
|
|
1175
|
+
setTimeout(() => spawn(opener, openerArgs, { stdio: "ignore" }), 700);
|
|
1176
|
+
}
|
|
1177
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
1178
|
+
},
|
|
1179
|
+
|
|
1180
|
+
async mcp() {
|
|
1181
|
+
parse();
|
|
1182
|
+
const child = spawn(process.execPath, [entrypoint("mcp", "server.ts")], {
|
|
1183
|
+
stdio: "inherit",
|
|
1184
|
+
env: process.env,
|
|
1185
|
+
});
|
|
1186
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
1187
|
+
},
|
|
1188
|
+
|
|
1189
|
+
// Detect the repo's design system once, store it on the project, and leave a
|
|
1190
|
+
// starter file behind — so every later publish is markup only, with no CSS or
|
|
1191
|
+
// icon paths pasted into the agent's context.
|
|
1192
|
+
async init() {
|
|
1193
|
+
const { values: flags } = parse({
|
|
1194
|
+
options: {
|
|
1195
|
+
project: { type: "string" },
|
|
1196
|
+
kit: { type: "string" },
|
|
1197
|
+
"kit-url": { type: "string" },
|
|
1198
|
+
"kit-script": { type: "string" },
|
|
1199
|
+
"kit-doc": { type: "string" },
|
|
1200
|
+
},
|
|
1201
|
+
});
|
|
1202
|
+
const { name: project, source } = resolveProject(flags);
|
|
1203
|
+
const say = (label, text) => {
|
|
1204
|
+
if (!flags.quiet && !flags.json) console.log(`${label.padEnd(8)} ${text}`);
|
|
1205
|
+
};
|
|
1206
|
+
const { detectDesign, detectIconSets, renderStarter } = await import("./initDesign.js");
|
|
1207
|
+
say("project:", `${project} (from ${source})`);
|
|
1208
|
+
|
|
1209
|
+
const design = await detectDesign(process.cwd());
|
|
1210
|
+
const d = design.detected;
|
|
1211
|
+
// `detected` is always an object, so emptiness has to be read off the
|
|
1212
|
+
// fields — otherwise a repo with no design system printed a blank line.
|
|
1213
|
+
const stripped = design.strippedImports?.length
|
|
1214
|
+
? ` (stripped: ${design.strippedImports.join(", ")})`
|
|
1215
|
+
: "";
|
|
1216
|
+
const found = [
|
|
1217
|
+
d?.tailwind &&
|
|
1218
|
+
(design.tailwindSource ? `tailwind: ${design.tailwindSource}${stripped}` : "tailwind"),
|
|
1219
|
+
d?.shadcn && "shadcn",
|
|
1220
|
+
d?.cssVars ? `${d.cssVars} css vars${design.source ? ` from ${design.source}` : ""}` : null,
|
|
1221
|
+
d?.fonts?.length ? `fonts ${d.fonts.join(", ")}` : null,
|
|
1222
|
+
].filter(Boolean);
|
|
1223
|
+
say(
|
|
1224
|
+
"design:",
|
|
1225
|
+
found.length ? found.join(" · ") : "nothing detected — using the built-in palette",
|
|
1226
|
+
);
|
|
1227
|
+
let kit = flags.kit ?? design.kit ?? "builtin";
|
|
1228
|
+
// The project kit is stored first so the design PUT can name it as default.
|
|
1229
|
+
if (flags["kit-url"] !== undefined) {
|
|
1230
|
+
kit = flags.kit ?? "project";
|
|
1231
|
+
await putProjectKit(project, kit, {
|
|
1232
|
+
url: flags["kit-url"],
|
|
1233
|
+
script: flags["kit-script"],
|
|
1234
|
+
doc: flags["kit-doc"],
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
say("kit:", kit);
|
|
1238
|
+
const files = design.designFiles;
|
|
1239
|
+
if (files) {
|
|
1240
|
+
const sh = files.shadcn;
|
|
1241
|
+
say(
|
|
1242
|
+
"files:",
|
|
1243
|
+
[
|
|
1244
|
+
files.designMd && "DESIGN.md",
|
|
1245
|
+
files.tokens && `${files.tokens.count} tokens`,
|
|
1246
|
+
sh && `shadcn${sh.style ? ` ${sh.style}` : ""} (${sh.components.length} components)`,
|
|
1247
|
+
]
|
|
1248
|
+
.filter(Boolean)
|
|
1249
|
+
.join(" · "),
|
|
1250
|
+
);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
let stored = await api(`${projectPath(project)}/design`, {
|
|
1254
|
+
method: "PUT",
|
|
1255
|
+
body: JSON.stringify({
|
|
1256
|
+
detected: design.detected ?? null,
|
|
1257
|
+
palette: design.palette ?? null,
|
|
1258
|
+
cssVars: design.cssVars ?? "",
|
|
1259
|
+
tailwindCss: design.tailwindCss ?? "",
|
|
1260
|
+
strippedImports: design.strippedImports ?? [],
|
|
1261
|
+
kit,
|
|
1262
|
+
designFiles: design.designFiles ?? null,
|
|
1263
|
+
}),
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
// Sets the repo already uses are added the same way `icons add` does; one
|
|
1267
|
+
// that cannot be fetched (offline) is reported, never fatal to init.
|
|
1268
|
+
const usedSets = detectIconSets(process.cwd());
|
|
1269
|
+
const wanted = usedSets.filter((f) => !BUNDLED_ICON_SETS.includes(f.prefix));
|
|
1270
|
+
for (const f of usedSets) {
|
|
1271
|
+
if (BUNDLED_ICON_SETS.includes(f.prefix))
|
|
1272
|
+
say("icons:", `${f.prefix} (bundled, from ${f.from})`);
|
|
1273
|
+
}
|
|
1274
|
+
if (wanted.length) {
|
|
1275
|
+
const result = await installIconSets(
|
|
1276
|
+
project,
|
|
1277
|
+
wanted.map((f) => f.prefix),
|
|
1278
|
+
);
|
|
1279
|
+
for (const f of wanted) {
|
|
1280
|
+
const added = result.added.find((a) => a.prefix === f.prefix);
|
|
1281
|
+
const error = result.errors.find((e) => e.prefix === f.prefix);
|
|
1282
|
+
if (added) say("icons:", `${f.prefix} (${added.count} icons, from ${f.from})`);
|
|
1283
|
+
else say("icons:", `${f.prefix} not added: ${error?.message ?? "unknown error"}`);
|
|
1284
|
+
}
|
|
1285
|
+
if (result.design) stored = result.design;
|
|
1286
|
+
}
|
|
1287
|
+
const iconSets = [
|
|
1288
|
+
...new Set([...(stored?.iconSets ?? []).map((s) => s.prefix), ...BUNDLED_ICON_SETS]),
|
|
1289
|
+
];
|
|
1290
|
+
say("icons:", `${iconSets.join(", ")} available, as <i icon="prefix:name"></i>`);
|
|
1291
|
+
|
|
1292
|
+
const starter = join(process.cwd(), ".mockpit", "starter.html");
|
|
1293
|
+
mkdirSync(dirname(starter), { recursive: true });
|
|
1294
|
+
writeFileSync(starter, renderStarter({ ...design, kit }, iconSets));
|
|
1295
|
+
say("wrote:", ".mockpit/starter.html");
|
|
1296
|
+
if (ignoreMockpitDir()) say("wrote:", ".gitignore (+ .mockpit/)");
|
|
1297
|
+
say("next:", "mockpit guide");
|
|
1298
|
+
if (flags.json) out({ project, design: stored, starter });
|
|
1299
|
+
},
|
|
1300
|
+
|
|
1301
|
+
async publish() {
|
|
1302
|
+
await publishMock();
|
|
1303
|
+
},
|
|
1304
|
+
|
|
1305
|
+
async ask() {
|
|
1306
|
+
const { values: flags, positionals } = parse({
|
|
1307
|
+
allowPositionals: true,
|
|
1308
|
+
options: {
|
|
1309
|
+
mock: { type: "string" },
|
|
1310
|
+
project: { type: "string" },
|
|
1311
|
+
session: { type: "string" },
|
|
1312
|
+
option: { type: "string", multiple: true },
|
|
1313
|
+
scope: { type: "string" },
|
|
1314
|
+
state: { type: "string" },
|
|
1315
|
+
part: { type: "string" },
|
|
1316
|
+
multi: { type: "boolean" },
|
|
1317
|
+
id: { type: "string" },
|
|
1318
|
+
asks: { type: "string" },
|
|
1319
|
+
},
|
|
1320
|
+
});
|
|
1321
|
+
const slug = requireMock(flags, 'mockpit ask --mock writer "Which look?" --option Quiet=quiet');
|
|
1322
|
+
let asks;
|
|
1323
|
+
if (flags.asks !== undefined) {
|
|
1324
|
+
if (positionals.length > 0) {
|
|
1325
|
+
die(
|
|
1326
|
+
"use the question or --asks, not both",
|
|
1327
|
+
`mockpit ask --mock ${slug} --asks <json|file>`,
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
asks = readJsonFlag("asks", flags.asks);
|
|
1331
|
+
if (!Array.isArray(asks)) asks = [asks];
|
|
1332
|
+
} else {
|
|
1333
|
+
const text = positionals.join(" ").trim();
|
|
1334
|
+
if (!text)
|
|
1335
|
+
die(
|
|
1336
|
+
"ask needs a question",
|
|
1337
|
+
`mockpit ask --mock ${slug} "Which look?" --option Quiet=quiet`,
|
|
1338
|
+
);
|
|
1339
|
+
const options = (flags.option ?? []).map(parseOption);
|
|
1340
|
+
if (options.length === 0) {
|
|
1341
|
+
die(
|
|
1342
|
+
"ask needs options",
|
|
1343
|
+
`mockpit ask --mock ${slug} "${text}" --option Quiet=quiet --option Dark=dark`,
|
|
1344
|
+
);
|
|
1345
|
+
}
|
|
1346
|
+
asks = [
|
|
1347
|
+
{
|
|
1348
|
+
text,
|
|
1349
|
+
options,
|
|
1350
|
+
...(flags.id && { id: flags.id }),
|
|
1351
|
+
...(flags.scope && { scope: flags.scope }),
|
|
1352
|
+
...(flags.state && { state: flags.state }),
|
|
1353
|
+
...(flags.part && { part: flags.part }),
|
|
1354
|
+
...(flags.multi && { multi: true }),
|
|
1355
|
+
},
|
|
1356
|
+
];
|
|
1357
|
+
}
|
|
1358
|
+
const session = await resolveSession(flags);
|
|
1359
|
+
const result = await api(
|
|
1360
|
+
`${mockPath(slug)}/asks`,
|
|
1361
|
+
{
|
|
1362
|
+
method: "POST",
|
|
1363
|
+
body: JSON.stringify({
|
|
1364
|
+
project: resolveProject(flags).name,
|
|
1365
|
+
session: session ?? undefined,
|
|
1366
|
+
asks,
|
|
1367
|
+
}),
|
|
1368
|
+
},
|
|
1369
|
+
{ report: "die", fix: `mockpit read ${slug}` },
|
|
1370
|
+
);
|
|
1371
|
+
if (flags.json) return out(result);
|
|
1372
|
+
if (flags.quiet) return;
|
|
1373
|
+
for (const ask of result.asks) {
|
|
1374
|
+
console.log(
|
|
1375
|
+
`asked on ${result.mock}: ${ask.text} [${ask.options.map((o) => o.label).join(" | ")}]`,
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
console.log(`tell the user to look at ${result.url}, then end your turn`);
|
|
1379
|
+
printFeedback(result.feedback);
|
|
1380
|
+
},
|
|
1381
|
+
|
|
1382
|
+
// Metadata by default. Bodies and version rows are opt-in, because a full
|
|
1383
|
+
// variant with its history is the biggest thing an agent can pull into context.
|
|
1384
|
+
async read() {
|
|
1385
|
+
const { values: flags, positionals } = parse({
|
|
1386
|
+
allowPositionals: true,
|
|
1387
|
+
options: {
|
|
1388
|
+
project: { type: "string" },
|
|
1389
|
+
body: { type: "boolean" },
|
|
1390
|
+
history: { type: "boolean" },
|
|
1391
|
+
},
|
|
1392
|
+
});
|
|
1393
|
+
const project = resolveProject(flags).name;
|
|
1394
|
+
if (positionals.length > 1) die("read takes one mock slug", "mockpit read writer");
|
|
1395
|
+
if (positionals.length === 1) {
|
|
1396
|
+
const slug = slugify(positionals[0]);
|
|
1397
|
+
return out(
|
|
1398
|
+
await api(
|
|
1399
|
+
`${mockPath(slug)}${query({ project, body: flags.body, history: flags.history })}`,
|
|
1400
|
+
{},
|
|
1401
|
+
{ report: "die", fix: `mockpit read --project ${project}` },
|
|
1402
|
+
),
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
const list = (await apiSoft(`/api/mocks${query({ project })}`)) ?? {
|
|
1406
|
+
mocks: [],
|
|
1407
|
+
open: 0,
|
|
1408
|
+
pending: [],
|
|
1409
|
+
};
|
|
1410
|
+
if (flags.json) return out(list);
|
|
1411
|
+
if (flags.quiet) return;
|
|
1412
|
+
const mocks = list.mocks ?? [];
|
|
1413
|
+
console.log(
|
|
1414
|
+
`${project} · ${mocks.length} mock${mocks.length === 1 ? "" : "s"}` +
|
|
1415
|
+
(list.open
|
|
1416
|
+
? ` · ${list.open} open ask${list.open === 1 ? "" : "s"}`
|
|
1417
|
+
: " · nothing waiting on the user"),
|
|
1418
|
+
);
|
|
1419
|
+
for (const m of mocks) {
|
|
1420
|
+
const states = m.states.length ? m.states.join(" / ") : "single state";
|
|
1421
|
+
console.log(
|
|
1422
|
+
` ${m.slug} · ${m.kind} · ${states} · ${m.variants} variant${m.variants === 1 ? "" : "s"}${m.open ? ` · ${m.open} open` : ""}`,
|
|
1423
|
+
);
|
|
1424
|
+
}
|
|
1425
|
+
for (const line of pendingLines(list.pending)) console.log(line);
|
|
1426
|
+
},
|
|
1427
|
+
async export() {
|
|
1428
|
+
const { values: flags } = parse({
|
|
1429
|
+
options: {
|
|
1430
|
+
mock: { type: "string" },
|
|
1431
|
+
state: { type: "string" },
|
|
1432
|
+
variant: { type: "string" },
|
|
1433
|
+
project: { type: "string" },
|
|
1434
|
+
out: { type: "string" },
|
|
1435
|
+
},
|
|
1436
|
+
});
|
|
1437
|
+
const slug = requireMock(flags, "mockpit export --mock writer");
|
|
1438
|
+
const project = resolveProject(flags).name;
|
|
1439
|
+
const data = await api(
|
|
1440
|
+
`${mockPath(slug)}/export${query({ project, state: flags.state, variant: flags.variant })}`,
|
|
1441
|
+
{},
|
|
1442
|
+
{ report: "die", fix: `mockpit read ${slug}` },
|
|
1443
|
+
);
|
|
1444
|
+
if (flags.json) return out(data);
|
|
1445
|
+
const root = join(flags.out ? flags.out : join(process.cwd(), ".mockpit", "accepted"), slug);
|
|
1446
|
+
for (const entry of data.states) {
|
|
1447
|
+
const dir = join(root, entry.state === null ? "default" : slugify(entry.state));
|
|
1448
|
+
mkdirSync(dir, { recursive: true });
|
|
1449
|
+
writeFileSync(join(dir, "index.html"), entry.html ?? "");
|
|
1450
|
+
writeFileSync(
|
|
1451
|
+
join(dir, "history.json"),
|
|
1452
|
+
JSON.stringify(
|
|
1453
|
+
{
|
|
1454
|
+
project,
|
|
1455
|
+
mock: slug,
|
|
1456
|
+
state: entry.state,
|
|
1457
|
+
variant: entry.variant,
|
|
1458
|
+
version: entry.version,
|
|
1459
|
+
status: entry.status,
|
|
1460
|
+
history: entry.history,
|
|
1461
|
+
...(entry.screenshotUrl && { screenshotUrl: entry.screenshotUrl }),
|
|
1462
|
+
...(data.reply && { tuned: data.reply.tuned }),
|
|
1463
|
+
},
|
|
1464
|
+
null,
|
|
1465
|
+
2,
|
|
1466
|
+
) + "\n",
|
|
1467
|
+
);
|
|
1468
|
+
if (!flags.quiet) {
|
|
1469
|
+
console.log(
|
|
1470
|
+
`${slug}/${stateLabel(entry.state)}${entry.variant} v${entry.version} → ${dir}`,
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
},
|
|
1475
|
+
|
|
1476
|
+
async run() {
|
|
1477
|
+
const { values: flags, positionals } = parse({
|
|
1478
|
+
allowPositionals: true,
|
|
1479
|
+
options: {
|
|
1480
|
+
session: { type: "string" },
|
|
1481
|
+
project: { type: "string" },
|
|
1482
|
+
agent: { type: "string" },
|
|
1483
|
+
},
|
|
1484
|
+
});
|
|
1485
|
+
if (positionals.length !== 1)
|
|
1486
|
+
die("run needs one script", "mockpit run loop.js (- reads stdin)");
|
|
1487
|
+
const code = readContent(positionals[0]);
|
|
1488
|
+
const session = await resolveSession(flags, { create: true });
|
|
1489
|
+
const result = await api(
|
|
1490
|
+
"/api/run",
|
|
1491
|
+
{
|
|
1492
|
+
method: "POST",
|
|
1493
|
+
body: JSON.stringify({
|
|
1494
|
+
code,
|
|
1495
|
+
session,
|
|
1496
|
+
project: resolveProject(flags).name,
|
|
1497
|
+
agent: agentName(flags),
|
|
1498
|
+
}),
|
|
1499
|
+
},
|
|
1500
|
+
{ report: "die" },
|
|
1501
|
+
);
|
|
1502
|
+
if (flags.json) out(result);
|
|
1503
|
+
else if (!flags.quiet) printRun(result);
|
|
1504
|
+
if (!result.ok) process.exit(2);
|
|
1505
|
+
},
|
|
1506
|
+
|
|
1507
|
+
// Never waits: the server reads from the session's cursor, shared with watch
|
|
1508
|
+
// and piggyback, so each Send is delivered exactly once.
|
|
1509
|
+
async feedback() {
|
|
1510
|
+
const { values: flags } = parse({
|
|
1511
|
+
options: { session: { type: "string" }, project: { type: "string" } },
|
|
1512
|
+
});
|
|
1513
|
+
const session = await resolveSession(flags, { create: true });
|
|
1514
|
+
const result = await api(
|
|
1515
|
+
`/api/feedback${query({ session, project: resolveProject(flags).name })}`,
|
|
1516
|
+
{},
|
|
1517
|
+
{ report: "die" },
|
|
1518
|
+
);
|
|
1519
|
+
if (!flags.quiet) out(result);
|
|
1520
|
+
},
|
|
1521
|
+
async watch() {
|
|
1522
|
+
const { values: flags } = parse({
|
|
1523
|
+
options: { session: { type: "string" }, after: { type: "string" } },
|
|
1524
|
+
});
|
|
1525
|
+
if (flags.after !== undefined && !/^\d+$/.test(flags.after)) {
|
|
1526
|
+
fail(`--after must be a number (got "${flags.after}")`);
|
|
1527
|
+
}
|
|
1528
|
+
// A continuous long-poll that streams each piece of feedback as one line —
|
|
1529
|
+
// one line is one monitor notification. It re-arms forever; a transient
|
|
1530
|
+
// network error backs off and retries rather than exiting.
|
|
1531
|
+
//
|
|
1532
|
+
// After the first poll it carries no client cursor: an author=user read
|
|
1533
|
+
// resumes from the session's server-side cursor and advances it, so
|
|
1534
|
+
// feedback is delivered exactly once across watch, feedback, and piggyback.
|
|
1535
|
+
let firstAfter = flags.after;
|
|
1536
|
+
for (;;) {
|
|
1537
|
+
const session = (await resolveSession(flags)) ?? (await resolveSessionByCwd());
|
|
1538
|
+
if (!session) {
|
|
1539
|
+
await sleep(2000);
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
1542
|
+
let result;
|
|
1543
|
+
try {
|
|
1544
|
+
const res = await fetch(
|
|
1545
|
+
`${BASE}/api/comments${query({ session, author: "user", after: firstAfter, wait: 60 })}`,
|
|
1546
|
+
{ headers: TOKEN ? { authorization: `Bearer ${TOKEN}` } : {} },
|
|
1547
|
+
);
|
|
1548
|
+
if (!res.ok) {
|
|
1549
|
+
await sleep(2000);
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
result = await res.json();
|
|
1553
|
+
} catch {
|
|
1554
|
+
await sleep(2000);
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
firstAfter = undefined;
|
|
1558
|
+
for (const batch of result.feedback ?? []) {
|
|
1559
|
+
for (const line of watchLines(batch)) console.log(line);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
},
|
|
1563
|
+
|
|
1564
|
+
async say() {
|
|
1565
|
+
const { values: flags, positionals } = parse({ allowPositionals: true, options: MOCK_FLAGS });
|
|
1566
|
+
const text = positionals.join(" ").trim();
|
|
1567
|
+
if (!text) die("say needs text", 'mockpit say "…" --mock writer');
|
|
1568
|
+
const slug = requireMock(flags, `mockpit say "${text}" --mock writer`);
|
|
1569
|
+
const session = await resolveSession(flags);
|
|
1570
|
+
const result = await api(
|
|
1571
|
+
`${mockPath(slug)}/say`,
|
|
1572
|
+
{
|
|
1573
|
+
method: "POST",
|
|
1574
|
+
body: JSON.stringify({
|
|
1575
|
+
message: text,
|
|
1576
|
+
state: flags.state,
|
|
1577
|
+
variant: flags.variant,
|
|
1578
|
+
project: resolveProject(flags).name,
|
|
1579
|
+
session: session ?? undefined,
|
|
1580
|
+
}),
|
|
1581
|
+
},
|
|
1582
|
+
{ report: "die", fix: `mockpit read ${slug}` },
|
|
1583
|
+
);
|
|
1584
|
+
if (flags.json) return out(result);
|
|
1585
|
+
if (flags.quiet) return;
|
|
1586
|
+
console.log(`said on ${slug}`);
|
|
1587
|
+
printFeedback(result.feedback);
|
|
1588
|
+
},
|
|
1589
|
+
async demo() {
|
|
1590
|
+
parse();
|
|
1591
|
+
const { DEMO } = await import("./demoData.js");
|
|
1592
|
+
// The demo goes in through the same path an agent uses: one session, one
|
|
1593
|
+
// publish per (state, variant), then the asks.
|
|
1594
|
+
const session = await api("/api/sessions", {
|
|
1595
|
+
method: "POST",
|
|
1596
|
+
body: JSON.stringify({ agent: DEMO.agent, title: DEMO.sessionTitle, project: DEMO.project }),
|
|
1597
|
+
});
|
|
1598
|
+
for (const state of DEMO.states) {
|
|
1599
|
+
for (const variant of DEMO.variants) {
|
|
1600
|
+
await api("/api/mocks", {
|
|
1601
|
+
method: "POST",
|
|
1602
|
+
body: JSON.stringify({
|
|
1603
|
+
session: session.id,
|
|
1604
|
+
project: DEMO.project,
|
|
1605
|
+
mock: DEMO.slug,
|
|
1606
|
+
title: DEMO.title,
|
|
1607
|
+
state: state.label,
|
|
1608
|
+
variant: variant.name,
|
|
1609
|
+
knobs: DEMO.knobs,
|
|
1610
|
+
surfaces: [{ kind: "html", html: DEMO.render(state, variant) }],
|
|
1611
|
+
}),
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
await api(`${mockPath(DEMO.slug)}/asks`, {
|
|
1616
|
+
method: "POST",
|
|
1617
|
+
body: JSON.stringify({ project: DEMO.project, session: session.id, asks: DEMO.asks }),
|
|
1618
|
+
});
|
|
1619
|
+
console.log(
|
|
1620
|
+
`Seeded ${DEMO.project} › ${DEMO.title} (${DEMO.states.length} states × ${DEMO.variants.length} variants) — open ${BASE}/project/${encodeURIComponent(DEMO.project)}/${DEMO.slug}`,
|
|
1621
|
+
);
|
|
1622
|
+
},
|
|
1623
|
+
|
|
1624
|
+
async upload() {
|
|
1625
|
+
const { values: flags, positionals } = parse({
|
|
1626
|
+
allowPositionals: true,
|
|
1627
|
+
options: { session: { type: "string" }, kind: { type: "string" } },
|
|
1628
|
+
});
|
|
1629
|
+
const file = positionals[0];
|
|
1630
|
+
if (!file || file === "-") fail("usage: mockpit upload <file> [--kind k] [--session id]");
|
|
1631
|
+
const session = flags.session ?? (await resolveSession(flags, { create: true }));
|
|
1632
|
+
const asset = await uploadFile(file, { session, kind: flags.kind });
|
|
1633
|
+
out(asset);
|
|
1634
|
+
},
|
|
1635
|
+
|
|
1636
|
+
// Print the URL a file WILL have once uploaded, derived from its content hash
|
|
1637
|
+
// alone — no server call. Lets you write an <img src> (or reference the id)
|
|
1638
|
+
// before, or in parallel with, the upload. Matches the server's hashAssetId.
|
|
1639
|
+
async "asset-url"() {
|
|
1640
|
+
const { positionals } = parse({ allowPositionals: true, options: {} });
|
|
1641
|
+
const file = positionals[0];
|
|
1642
|
+
if (!file || file === "-") fail("usage: mockpit asset-url <file>");
|
|
1643
|
+
const id = createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
1644
|
+
out({ id, url: `${BASE}/a/${id}` });
|
|
1645
|
+
},
|
|
1646
|
+
|
|
1647
|
+
// List the opt-in html kits this workspace offers (id, label, summary, classes).
|
|
1648
|
+
// Pair with `publish --kit <id>` to inject a kit's CSS/JS into an html surface.
|
|
1649
|
+
async icons() {
|
|
1650
|
+
const { values: flags, positionals } = parse({
|
|
1651
|
+
allowPositionals: true,
|
|
1652
|
+
options: { project: { type: "string" } },
|
|
1653
|
+
});
|
|
1654
|
+
const [sub = "list", ...sets] = positionals;
|
|
1655
|
+
const { name: project } = resolveProject(flags);
|
|
1656
|
+
if (sub === "list") {
|
|
1657
|
+
const { sets: rows } = await api(`${projectPath(project)}/icons`);
|
|
1658
|
+
if (flags.json) return out(rows);
|
|
1659
|
+
if (flags.quiet) return;
|
|
1660
|
+
for (const r of rows)
|
|
1661
|
+
console.log(`${r.prefix.padEnd(16)} ${String(r.count).padStart(6)} ${r.source}`);
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
if (sets.length === 0) fail(`usage: mockpit icons ${sub} <set> [<set>...]`);
|
|
1665
|
+
if (sub === "add") {
|
|
1666
|
+
const result = await installIconSets(project, sets);
|
|
1667
|
+
if (result.errors.length) fail(result.errors.map((e) => e.message).join("; "));
|
|
1668
|
+
if (flags.json) return out(result.design);
|
|
1669
|
+
for (const a of result.added) {
|
|
1670
|
+
if (!flags.quiet) console.log(`added ${a.prefix} (${a.count} icons)`);
|
|
1671
|
+
}
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
if (sub === "remove") {
|
|
1675
|
+
const stored = await api(`${projectPath(project)}/design`);
|
|
1676
|
+
const installed = stored?.iconSets ?? [];
|
|
1677
|
+
for (const set of sets) {
|
|
1678
|
+
if (installed.some((s) => s.prefix === set)) continue;
|
|
1679
|
+
fail(
|
|
1680
|
+
BUNDLED_ICON_SETS.includes(set)
|
|
1681
|
+
? `${set} is bundled with the server and cannot be removed`
|
|
1682
|
+
: `${set} is not installed for ${project}`,
|
|
1683
|
+
);
|
|
1684
|
+
}
|
|
1685
|
+
const design = await api(`${projectPath(project)}/design`, {
|
|
1686
|
+
method: "PUT",
|
|
1687
|
+
body: JSON.stringify({
|
|
1688
|
+
...stored,
|
|
1689
|
+
iconSets: installed.filter((s) => !sets.includes(s.prefix)),
|
|
1690
|
+
}),
|
|
1691
|
+
});
|
|
1692
|
+
if (flags.json) return out(design);
|
|
1693
|
+
if (!flags.quiet) console.log(`removed ${sets.join(", ")}`);
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
fail(`unknown icons command "${sub}" — run "mockpit icons --help"`);
|
|
1697
|
+
},
|
|
1698
|
+
|
|
1699
|
+
async kits() {
|
|
1700
|
+
const { values: flags } = parse({ options: { project: { type: "string" } } });
|
|
1701
|
+
const { name: project } = resolveProject(flags);
|
|
1702
|
+
out(await api(`/api/kits?${new URLSearchParams({ project })}`));
|
|
1703
|
+
},
|
|
1704
|
+
|
|
1705
|
+
async kit() {
|
|
1706
|
+
const { values: flags, positionals } = parse({
|
|
1707
|
+
allowPositionals: true,
|
|
1708
|
+
options: {
|
|
1709
|
+
project: { type: "string" },
|
|
1710
|
+
url: { type: "string" },
|
|
1711
|
+
script: { type: "string" },
|
|
1712
|
+
doc: { type: "string" },
|
|
1713
|
+
},
|
|
1714
|
+
});
|
|
1715
|
+
const [sub, id] = positionals;
|
|
1716
|
+
const { name: project } = resolveProject(flags);
|
|
1717
|
+
if ((sub !== "add" && sub !== "remove") || !id)
|
|
1718
|
+
fail("usage: mockpit kit add <id> --url <url> --doc <file|text> | mockpit kit remove <id>");
|
|
1719
|
+
const design =
|
|
1720
|
+
sub === "add"
|
|
1721
|
+
? await putProjectKit(project, id, flags)
|
|
1722
|
+
: await api(`${projectPath(project)}/kits/${encodeURIComponent(id)}`, {
|
|
1723
|
+
method: "DELETE",
|
|
1724
|
+
});
|
|
1725
|
+
if (flags.json) return out(design);
|
|
1726
|
+
if (!flags.quiet) console.log(`${sub === "add" ? "added" : "removed"} kit ${id} (${project})`);
|
|
1727
|
+
},
|
|
1728
|
+
|
|
1729
|
+
async guide() {
|
|
1730
|
+
const { values: flags } = parse({
|
|
1731
|
+
options: { topic: { type: "string" }, project: { type: "string" } },
|
|
1732
|
+
});
|
|
1733
|
+
if (flags.topic !== undefined) return printTopic(flags.topic);
|
|
1734
|
+
return printBrief(flags);
|
|
1735
|
+
},
|
|
1736
|
+
|
|
1737
|
+
async setup() {
|
|
1738
|
+
parse();
|
|
1739
|
+
console.log(await fetchTextWithFallback("/setup", join(ROOT, "guide", "AGENT_SETUP.md")));
|
|
1740
|
+
},
|
|
1741
|
+
|
|
1742
|
+
// Print the running version and check for updates (non-blocking, best-effort).
|
|
1743
|
+
async version() {
|
|
1744
|
+
parse();
|
|
1745
|
+
console.log(`mockpit ${PKG_VERSION}`);
|
|
1746
|
+
try {
|
|
1747
|
+
const cached = readUpdateCache();
|
|
1748
|
+
let latest = cached;
|
|
1749
|
+
if (!latest) {
|
|
1750
|
+
const ctrl = new AbortController();
|
|
1751
|
+
const timer = setTimeout(() => ctrl.abort(), 3000);
|
|
1752
|
+
try {
|
|
1753
|
+
const res = await fetch("https://registry.npmjs.org/mockpit/latest", {
|
|
1754
|
+
signal: ctrl.signal,
|
|
1755
|
+
});
|
|
1756
|
+
clearTimeout(timer);
|
|
1757
|
+
if (res.ok) {
|
|
1758
|
+
const pkg = await res.json();
|
|
1759
|
+
if (typeof pkg.version === "string") {
|
|
1760
|
+
latest = pkg.version;
|
|
1761
|
+
writeUpdateCache(latest);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
} catch {
|
|
1765
|
+
// Offline / timed out — skip silently.
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
if (latest && versionGt(latest, PKG_VERSION)) {
|
|
1769
|
+
console.log(`\nUpdate available: ${PKG_VERSION} → ${latest}`);
|
|
1770
|
+
console.log(`Run: npm install -g mockpit`);
|
|
1771
|
+
}
|
|
1772
|
+
} catch {
|
|
1773
|
+
// Never let the update check fail the command.
|
|
1774
|
+
}
|
|
1775
|
+
},
|
|
1776
|
+
};
|
|
1777
|
+
|
|
1778
|
+
// Topics ship with the CLI, so a typo is caught, and a known topic read, with
|
|
1779
|
+
// no server running.
|
|
1780
|
+
const TOPICS_DIR = join(ROOT, "guide", "topics");
|
|
1781
|
+
|
|
1782
|
+
async function printTopic(topic) {
|
|
1783
|
+
const topics = readdirSync(TOPICS_DIR)
|
|
1784
|
+
.filter((f) => f.endsWith(".md"))
|
|
1785
|
+
.map((f) => f.slice(0, -3))
|
|
1786
|
+
.sort();
|
|
1787
|
+
if (!topics.includes(topic)) {
|
|
1788
|
+
die(
|
|
1789
|
+
`unknown topic "${topic}"; topics: ${topics.join(", ")}`,
|
|
1790
|
+
`mockpit guide --topic ${topics[0]}`,
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
const path = `/agent-howto?${new URLSearchParams({ topic })}`;
|
|
1794
|
+
console.log(await fetchTextWithFallback(path, join(TOPICS_DIR, `${topic}.md`)));
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
// The brief is rendered from the project's stored design settings, so the
|
|
1798
|
+
// server's copy is the real one. With no server it still has to print: an
|
|
1799
|
+
// agent reads it before anything else, so render the generic brief locally
|
|
1800
|
+
// (the same code the server runs) and say where the project-aware one comes from.
|
|
1801
|
+
async function printBrief(flags) {
|
|
1802
|
+
const params = new URLSearchParams({ project: resolveProject(flags).name });
|
|
1803
|
+
try {
|
|
1804
|
+
const res = await fetch(`${BASE}/agent-howto?${params}`);
|
|
1805
|
+
if (res.ok) return console.log(await res.text());
|
|
1806
|
+
} catch {}
|
|
1807
|
+
const { renderBriefGuide } = await import(entrypoint("server", "designGuide.ts"));
|
|
1808
|
+
console.log(renderBriefGuide(null));
|
|
1809
|
+
console.error(
|
|
1810
|
+
`note: no mockpit at ${BASE}; this is the generic brief. Run mockpit serve, then mockpit init for this project's palette, kit and icons.`,
|
|
1811
|
+
);
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
async function fetchTextWithFallback(path, localFile) {
|
|
1815
|
+
try {
|
|
1816
|
+
const res = await fetch(`${BASE}${path}`);
|
|
1817
|
+
if (res.ok) return await res.text();
|
|
1818
|
+
} catch {}
|
|
1819
|
+
return readFileSync(localFile, "utf8");
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
if (cmd === "--version" || cmd === "-V") {
|
|
1823
|
+
await commands.version();
|
|
1824
|
+
} else if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
1825
|
+
console.log(HELP);
|
|
1826
|
+
} else if (commands[cmd]) {
|
|
1827
|
+
await commands[cmd]();
|
|
1828
|
+
} else {
|
|
1829
|
+
fail(`unknown command "${cmd}" — run "mockpit help"`);
|
|
1830
|
+
}
|