skilld-harness 3.2.0 → 3.4.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/README.md +81 -5
- package/dist/_chunks/skills.mjs +35 -18
- package/dist/_chunks/skills.mjs.map +1 -1
- package/dist/index.d.mts +56 -6
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +226 -57
- package/dist/index.mjs.map +1 -1
- package/dist/sandbox-local.d.mts +11 -3
- package/dist/sandbox-local.d.mts.map +1 -1
- package/dist/sandbox-local.mjs +50 -7
- package/dist/sandbox-local.mjs.map +1 -1
- package/dist/skills/generate-package-skill/SKILL.md +132 -63
- package/dist/skills/generate-package-skill/assets/harness-request.md +3 -0
- package/dist/skills/generate-package-skill/scripts/serve-fixture.mjs +233 -0
- package/dist/skills/generate-project-skill/SKILL.md +21 -0
- package/dist/skills/review-skill/SKILL.md +4 -2
- package/dist/skills/skilld/SKILL.md +107 -2
- package/dist/skills/skilld/references/declared-skills.md +79 -0
- package/dist/skills.d.mts +8 -3
- package/dist/skills.d.mts.map +1 -1
- package/package.json +3 -3
package/dist/sandbox-local.mjs
CHANGED
|
@@ -4,6 +4,45 @@ import { Readable } from "node:stream";
|
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { createServer } from "node:net";
|
|
6
6
|
import { constants, tmpdir } from "node:os";
|
|
7
|
+
const inheritedVariables = [
|
|
8
|
+
"PATH",
|
|
9
|
+
"LANG",
|
|
10
|
+
"LC_ALL",
|
|
11
|
+
"LC_CTYPE",
|
|
12
|
+
"TZ",
|
|
13
|
+
"TERM",
|
|
14
|
+
"USER",
|
|
15
|
+
"LOGNAME",
|
|
16
|
+
"SHELL",
|
|
17
|
+
"HTTP_PROXY",
|
|
18
|
+
"HTTPS_PROXY",
|
|
19
|
+
"NO_PROXY",
|
|
20
|
+
"http_proxy",
|
|
21
|
+
"https_proxy",
|
|
22
|
+
"no_proxy",
|
|
23
|
+
"NODE_EXTRA_CA_CERTS",
|
|
24
|
+
"SSL_CERT_FILE",
|
|
25
|
+
"SSL_CERT_DIR"
|
|
26
|
+
];
|
|
27
|
+
function sessionDirectories(root) {
|
|
28
|
+
return {
|
|
29
|
+
home: join(root, ".home"),
|
|
30
|
+
tmp: join(root, ".tmp")
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function sessionEnvironment(directories, caller, extra) {
|
|
34
|
+
const { home, tmp } = directories;
|
|
35
|
+
return {
|
|
36
|
+
...Object.fromEntries(inheritedVariables.flatMap((name) => caller[name] === void 0 ? [] : [[name, caller[name]]])),
|
|
37
|
+
HOME: home,
|
|
38
|
+
XDG_CONFIG_HOME: join(home, ".config"),
|
|
39
|
+
XDG_DATA_HOME: join(home, ".local/share"),
|
|
40
|
+
XDG_CACHE_HOME: join(home, ".cache"),
|
|
41
|
+
XDG_STATE_HOME: join(home, ".local/state"),
|
|
42
|
+
TMPDIR: tmp,
|
|
43
|
+
...extra
|
|
44
|
+
};
|
|
45
|
+
}
|
|
7
46
|
const { signals } = constants;
|
|
8
47
|
function killProcessGroup(pid) {
|
|
9
48
|
try {
|
|
@@ -61,11 +100,11 @@ function watchAbort(child, abortSignal) {
|
|
|
61
100
|
child.once("close", () => abortSignal.removeEventListener("abort", onAbort));
|
|
62
101
|
});
|
|
63
102
|
}
|
|
64
|
-
function startProcess(options, root, processes) {
|
|
103
|
+
function startProcess(options, root, environment, processes) {
|
|
65
104
|
const child = spawn("/bin/sh", ["-c", options.command], {
|
|
66
105
|
cwd: options.workingDirectory ?? root,
|
|
67
106
|
env: {
|
|
68
|
-
...
|
|
107
|
+
...environment,
|
|
69
108
|
...options.env
|
|
70
109
|
},
|
|
71
110
|
detached: true
|
|
@@ -95,10 +134,10 @@ function startProcess(options, root, processes) {
|
|
|
95
134
|
}
|
|
96
135
|
};
|
|
97
136
|
}
|
|
98
|
-
function createSandboxSession(root, processes) {
|
|
137
|
+
function createSandboxSession(root, environment, processes) {
|
|
99
138
|
const readBinaryFile = async ({ path }) => readFile(path).then((value) => Uint8Array.from(value), (cause) => errorCode(cause) === "ENOENT" ? null : Promise.reject(cause));
|
|
100
139
|
return {
|
|
101
|
-
description: `Local sandbox rooted at ${root}. POSIX sh, real processes, no isolation.`,
|
|
140
|
+
description: `Local sandbox rooted at ${root}. POSIX sh, real processes, a session home directory, no process isolation.`,
|
|
102
141
|
readBinaryFile,
|
|
103
142
|
async readFile({ path }) {
|
|
104
143
|
const content = await readBinaryFile({ path });
|
|
@@ -127,10 +166,10 @@ function createSandboxSession(root, processes) {
|
|
|
127
166
|
await writeFile(path, content, { encoding });
|
|
128
167
|
},
|
|
129
168
|
async spawn(options) {
|
|
130
|
-
return startProcess(options, root, processes).handle;
|
|
169
|
+
return startProcess(options, root, environment, processes).handle;
|
|
131
170
|
},
|
|
132
171
|
async run(options) {
|
|
133
|
-
const { handle, exited } = startProcess(options, root, processes);
|
|
172
|
+
const { handle, exited } = startProcess(options, root, environment, processes);
|
|
134
173
|
const [stdout, stderr, exit] = await Promise.all([
|
|
135
174
|
readStream(handle.stdout),
|
|
136
175
|
readStream(handle.stderr),
|
|
@@ -151,11 +190,15 @@ function createLocalSandbox(options = {}) {
|
|
|
151
190
|
async createSession() {
|
|
152
191
|
const root = options.root ?? await mkdtemp(join(tmpdir(), "skilld-local-sandbox-"));
|
|
153
192
|
await mkdir(root, { recursive: true });
|
|
193
|
+
const directories = sessionDirectories(root);
|
|
194
|
+
await mkdir(directories.home, { recursive: true });
|
|
195
|
+
await mkdir(directories.tmp, { recursive: true });
|
|
196
|
+
const environment = sessionEnvironment(directories, process.env, options.env ?? {});
|
|
154
197
|
const processes = {
|
|
155
198
|
live: /* @__PURE__ */ new Set(),
|
|
156
199
|
groups: /* @__PURE__ */ new Set()
|
|
157
200
|
};
|
|
158
|
-
const session = createSandboxSession(root, processes);
|
|
201
|
+
const session = createSandboxSession(root, environment, processes);
|
|
159
202
|
let ports = [options.port ?? await freePort()];
|
|
160
203
|
const stop = async () => {
|
|
161
204
|
for (const pid of processes.groups) killProcessGroup(pid);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sandbox-local.mjs","names":["spawnChildProcess"],"sources":["../src/sandbox-local.ts"],"sourcesContent":["import type { HarnessV1NetworkSandboxSession, HarnessV1PortEndpoint, HarnessV1SandboxProvider } from '@ai-sdk/harness'\nimport type { ChildProcessWithoutNullStreams } from 'node:child_process'\nimport { spawn as spawnChildProcess } from 'node:child_process'\nimport { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'\nimport { createServer } from 'node:net'\nimport { constants, tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { Readable } from 'node:stream'\n\n/**\n * The local sandbox runs real processes on this computer.\n * It is a working directory and a port, not a security boundary.\n * Use a hosted sandbox provider when the Harness must contain what it runs.\n */\nexport interface CreateLocalSandboxOptions {\n /** Session root directory. The default is a new temporary directory. */\n readonly root?: string\n /** Bridge port. The default asks the operating system for a free port. */\n readonly port?: number\n /** Keep the session root after `destroy`. The default removes it. */\n readonly keepRoot?: boolean\n}\n\n/** The file and process surface the Harness hands to Skill code. */\ntype SandboxSession = ReturnType<HarnessV1NetworkSandboxSession['restricted']>\ntype SandboxProcessOptions = Parameters<SandboxSession['run']>[0]\ntype SandboxProcess = Awaited<ReturnType<SandboxSession['spawn']>>\n\nconst { signals } = constants\n\n/** The processes one session started, so that `stop` can reach all of them. */\ninterface SessionProcesses {\n /** Processes that have not closed yet. */\n readonly live: Set<ChildProcessWithoutNullStreams>\n /**\n * Group identifiers, kept after the leader exits. A command like\n * `agent &` leaves the shell dead and the agent alive in the same group.\n */\n readonly groups: Set<number>\n}\n\n/**\n * Terminate every process in a group.\n * A detached child leads a process group that shares its identifier.\n * The kernel holds the identifier while the group has a member, so it stays\n * ours to signal even after the leader exits.\n */\nfunction killProcessGroup(pid: number): void {\n try {\n process.kill(-pid, 'SIGTERM')\n }\n catch (cause) {\n // ESRCH means the group already exited. Anything else is unexpected.\n if (errorCode(cause) !== 'ESRCH')\n throw cause\n }\n}\n\n/** Terminate a child's group, and the child alone if it leads no group. */\nfunction killChild(child: ChildProcessWithoutNullStreams): void {\n if (child.pid === undefined)\n return\n try {\n killProcessGroup(child.pid)\n }\n catch {\n if (child.exitCode === null)\n child.kill('SIGTERM')\n }\n}\n\nfunction errorCode(cause: unknown): string | undefined {\n return (cause as NodeJS.ErrnoException | undefined)?.code\n}\n\nasync function freePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const server = createServer()\n server.once('error', reject)\n server.listen(0, '127.0.0.1', () => {\n const address = server.address()\n if (address === null || typeof address === 'string') {\n server.close(() => reject(new Error('The operating system did not report a free port.')))\n return\n }\n server.close(() => resolve(address.port))\n })\n })\n}\n\nasync function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {\n const reader = stream.getReader()\n const chunks: Uint8Array[] = []\n while (true) {\n const result = await reader.read()\n if (result.done)\n break\n chunks.push(result.value)\n }\n return Buffer.concat(chunks).toString('utf8')\n}\n\ninterface StartedProcess {\n readonly handle: SandboxProcess\n /** Resolves with the exit code even when the caller aborted the process. */\n readonly exited: Promise<{ exitCode: number }>\n}\n\n/**\n * Kill the process when the caller aborts, and reject the returned promise.\n * The listener is removed on close so that a shared signal does not collect\n * one listener for every process the session runs.\n */\nfunction watchAbort(child: ChildProcessWithoutNullStreams, abortSignal: AbortSignal): Promise<never> {\n return new Promise<never>((_, reject) => {\n const onAbort = (): void => {\n killChild(child)\n reject(abortSignal.reason ?? new Error('The sandbox process was aborted.'))\n }\n if (abortSignal.aborted) {\n onAbort()\n return\n }\n abortSignal.addEventListener('abort', onAbort, { once: true })\n child.once('close', () => abortSignal.removeEventListener('abort', onAbort))\n })\n}\n\nfunction startProcess(\n options: SandboxProcessOptions,\n root: string,\n processes: SessionProcesses,\n): StartedProcess {\n // The process leads its own group so that killing it also kills what it\n // started. The OpenCode bridge starts OpenCode, which starts more processes.\n const child = spawnChildProcess('/bin/sh', ['-c', options.command], {\n cwd: options.workingDirectory ?? root,\n env: { ...process.env, ...options.env },\n detached: true,\n })\n processes.live.add(child)\n if (child.pid !== undefined)\n processes.groups.add(child.pid)\n\n const exited = new Promise<{ exitCode: number }>((resolve) => {\n child.once('close', (code, signal) => {\n processes.live.delete(child)\n resolve({ exitCode: code ?? (signal === null ? 1 : 128 + (signals[signal] ?? 0)) })\n })\n })\n\n const kill = async (): Promise<void> => {\n killChild(child)\n await exited\n }\n\n const aborted = options.abortSignal === undefined\n ? undefined\n : watchAbort(child, options.abortSignal)\n // The abort rejection is reported through `wait`. Nothing else observes it.\n aborted?.catch(() => {})\n\n return {\n exited,\n handle: {\n pid: child.pid,\n stdout: Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,\n stderr: Readable.toWeb(child.stderr) as ReadableStream<Uint8Array>,\n wait: () => aborted === undefined ? exited : Promise.race([exited, aborted]),\n kill,\n },\n }\n}\n\nfunction createSandboxSession(root: string, processes: SessionProcesses): SandboxSession {\n const readBinaryFile = async ({ path }: { path: string }): Promise<Uint8Array | null> =>\n readFile(path).then(\n value => Uint8Array.from(value),\n cause => errorCode(cause) === 'ENOENT' ? null : Promise.reject(cause),\n )\n\n return {\n description: `Local sandbox rooted at ${root}. POSIX sh, real processes, no isolation.`,\n readBinaryFile,\n\n async readFile({ path }) {\n const content = await readBinaryFile({ path })\n if (content === null)\n return null\n return new ReadableStream<Uint8Array>({\n start(controller) {\n controller.enqueue(content)\n controller.close()\n },\n })\n },\n\n async readTextFile({ path, encoding = 'utf8', startLine, endLine }) {\n const content = await readFile(path, { encoding: encoding as BufferEncoding })\n .catch(cause => errorCode(cause) === 'ENOENT' ? null : Promise.reject(cause))\n if (content === null || (startLine === undefined && endLine === undefined))\n return content\n const lines = content.split('\\n')\n return lines.slice((startLine ?? 1) - 1, endLine ?? lines.length).join('\\n')\n },\n\n async writeFile({ path, content }) {\n await mkdir(dirname(path), { recursive: true })\n await writeFile(path, Buffer.from(await readStream(content), 'utf8'))\n },\n\n async writeBinaryFile({ path, content }) {\n await mkdir(dirname(path), { recursive: true })\n await writeFile(path, content)\n },\n\n async writeTextFile({ path, content, encoding = 'utf8' }) {\n await mkdir(dirname(path), { recursive: true })\n await writeFile(path, content, { encoding: encoding as BufferEncoding })\n },\n\n async spawn(options) {\n return startProcess(options, root, processes).handle\n },\n\n async run(options) {\n // An aborted `run` reports the exit code of the killed process.\n // Only `spawn` rejects its wait, because its caller holds the handle.\n const { handle, exited } = startProcess(options, root, processes)\n const [stdout, stderr, exit] = await Promise.all([\n readStream(handle.stdout),\n readStream(handle.stderr),\n exited,\n ])\n return { exitCode: exit.exitCode, stdout, stderr }\n },\n }\n}\n\n/**\n * Create a sandbox provider that runs Harness sessions on this computer.\n *\n * The session needs POSIX `sh` at `/bin/sh` and GNU `find`, because the Harness\n * inventories output with `find -printf`. It runs on Linux, and not on macOS or\n * Windows. It exposes one port on `127.0.0.1` for bridge-backed Harness\n * adapters. It applies no isolation:\n * every process reaches the whole computer and the caller's environment.\n */\nexport function createLocalSandbox(options: CreateLocalSandboxOptions = {}): HarnessV1SandboxProvider {\n return {\n specificationVersion: 'harness-sandbox-v1',\n providerId: 'skilld-local',\n\n async createSession(): Promise<HarnessV1NetworkSandboxSession> {\n const root = options.root ?? await mkdtemp(join(tmpdir(), 'skilld-local-sandbox-'))\n await mkdir(root, { recursive: true })\n const processes: SessionProcesses = { live: new Set(), groups: new Set() }\n const session = createSandboxSession(root, processes)\n let ports: ReadonlyArray<number> = [options.port ?? await freePort()]\n\n const stop = async (): Promise<void> => {\n // Groups, not live children: a backgrounded process outlives its shell.\n for (const pid of processes.groups)\n killProcessGroup(pid)\n processes.groups.clear()\n processes.live.clear()\n }\n\n const endpoint = ({ port, protocol = 'http' }: { port: number, protocol?: 'http' | 'https' | 'ws' }): HarnessV1PortEndpoint =>\n ({ url: `${protocol}://127.0.0.1:${port}` })\n\n return {\n ...session,\n id: root,\n defaultWorkingDirectory: root,\n get ports() {\n return ports\n },\n async getPortEndpoint(request) {\n return endpoint(request)\n },\n async getPortUrl(request) {\n return endpoint(request).url\n },\n async setPorts(next) {\n ports = [...next]\n },\n stop,\n async destroy() {\n await stop()\n if (options.keepRoot !== true)\n await rm(root, { recursive: true, force: true })\n },\n restricted: () => session,\n }\n },\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"sandbox-local.mjs","names":["spawnChildProcess"],"sources":["../src/sandbox-local.ts"],"sourcesContent":["import type { HarnessV1NetworkSandboxSession, HarnessV1PortEndpoint, HarnessV1SandboxProvider } from '@ai-sdk/harness'\nimport type { ChildProcessWithoutNullStreams } from 'node:child_process'\nimport { spawn as spawnChildProcess } from 'node:child_process'\nimport { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'\nimport { createServer } from 'node:net'\nimport { constants, tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { Readable } from 'node:stream'\n\n/**\n * The local sandbox runs real processes on this computer.\n * It gives each session its own home directory and a minimal environment.\n * It is not a security boundary: every process can still read the whole computer.\n * Use a hosted sandbox provider when the Harness must contain what it runs.\n */\nexport interface CreateLocalSandboxOptions {\n /** Session root directory. The default is a new temporary directory. */\n readonly root?: string\n /** Bridge port. The default asks the operating system for a free port. */\n readonly port?: number\n /** Keep the session root after `destroy`. The default removes it. */\n readonly keepRoot?: boolean\n /**\n * More environment variables for every process in the session, such as a\n * registry URL or proxy settings. Harness adapters pass their own credentials,\n * so the session does not need the caller's API keys.\n */\n readonly env?: Readonly<Record<string, string>>\n}\n\n/**\n * Variables the session copies from the caller. Everything else stays out, so\n * that a process cannot read the caller's secrets or agent configuration.\n */\nconst inheritedVariables = [\n 'PATH',\n 'LANG',\n 'LC_ALL',\n 'LC_CTYPE',\n 'TZ',\n 'TERM',\n 'USER',\n 'LOGNAME',\n 'SHELL',\n 'HTTP_PROXY',\n 'HTTPS_PROXY',\n 'NO_PROXY',\n 'http_proxy',\n 'https_proxy',\n 'no_proxy',\n 'NODE_EXTRA_CA_CERTS',\n 'SSL_CERT_FILE',\n 'SSL_CERT_DIR',\n] as const\n\ninterface SessionDirectories {\n /** HOME for every process. Harness state and installed Skills land here. */\n readonly home: string\n /** TMPDIR for every process. */\n readonly tmp: string\n}\n\nfunction sessionDirectories(root: string): SessionDirectories {\n return { home: join(root, '.home'), tmp: join(root, '.tmp') }\n}\n\n/**\n * The environment every process in a session starts from.\n * Home, XDG, and temporary directories point inside the session root, so that\n * Harness state, installed Skills, and agent configuration never reach the\n * caller's home directory, and `destroy` removes all of them.\n */\nfunction sessionEnvironment(\n directories: SessionDirectories,\n caller: NodeJS.ProcessEnv,\n extra: Readonly<Record<string, string>>,\n): Record<string, string> {\n const { home, tmp } = directories\n const inherited = Object.fromEntries(\n inheritedVariables.flatMap(name => caller[name] === undefined ? [] : [[name, caller[name]]]),\n )\n return {\n ...inherited,\n HOME: home,\n XDG_CONFIG_HOME: join(home, '.config'),\n XDG_DATA_HOME: join(home, '.local/share'),\n XDG_CACHE_HOME: join(home, '.cache'),\n XDG_STATE_HOME: join(home, '.local/state'),\n TMPDIR: tmp,\n ...extra,\n }\n}\n\n/** The file and process surface the Harness hands to Skill code. */\ntype SandboxSession = ReturnType<HarnessV1NetworkSandboxSession['restricted']>\ntype SandboxProcessOptions = Parameters<SandboxSession['run']>[0]\ntype SandboxProcess = Awaited<ReturnType<SandboxSession['spawn']>>\n\nconst { signals } = constants\n\n/** The processes one session started, so that `stop` can reach all of them. */\ninterface SessionProcesses {\n /** Processes that have not closed yet. */\n readonly live: Set<ChildProcessWithoutNullStreams>\n /**\n * Group identifiers, kept after the leader exits. A command like\n * `agent &` leaves the shell dead and the agent alive in the same group.\n */\n readonly groups: Set<number>\n}\n\n/**\n * Terminate every process in a group.\n * A detached child leads a process group that shares its identifier.\n * The kernel holds the identifier while the group has a member, so it stays\n * ours to signal even after the leader exits.\n */\nfunction killProcessGroup(pid: number): void {\n try {\n process.kill(-pid, 'SIGTERM')\n }\n catch (cause) {\n // ESRCH means the group already exited. Anything else is unexpected.\n if (errorCode(cause) !== 'ESRCH')\n throw cause\n }\n}\n\n/** Terminate a child's group, and the child alone if it leads no group. */\nfunction killChild(child: ChildProcessWithoutNullStreams): void {\n if (child.pid === undefined)\n return\n try {\n killProcessGroup(child.pid)\n }\n catch {\n if (child.exitCode === null)\n child.kill('SIGTERM')\n }\n}\n\nfunction errorCode(cause: unknown): string | undefined {\n return (cause as NodeJS.ErrnoException | undefined)?.code\n}\n\nasync function freePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const server = createServer()\n server.once('error', reject)\n server.listen(0, '127.0.0.1', () => {\n const address = server.address()\n if (address === null || typeof address === 'string') {\n server.close(() => reject(new Error('The operating system did not report a free port.')))\n return\n }\n server.close(() => resolve(address.port))\n })\n })\n}\n\nasync function readStream(stream: ReadableStream<Uint8Array>): Promise<string> {\n const reader = stream.getReader()\n const chunks: Uint8Array[] = []\n while (true) {\n const result = await reader.read()\n if (result.done)\n break\n chunks.push(result.value)\n }\n return Buffer.concat(chunks).toString('utf8')\n}\n\ninterface StartedProcess {\n readonly handle: SandboxProcess\n /** Resolves with the exit code even when the caller aborted the process. */\n readonly exited: Promise<{ exitCode: number }>\n}\n\n/**\n * Kill the process when the caller aborts, and reject the returned promise.\n * The listener is removed on close so that a shared signal does not collect\n * one listener for every process the session runs.\n */\nfunction watchAbort(child: ChildProcessWithoutNullStreams, abortSignal: AbortSignal): Promise<never> {\n return new Promise<never>((_, reject) => {\n const onAbort = (): void => {\n killChild(child)\n reject(abortSignal.reason ?? new Error('The sandbox process was aborted.'))\n }\n if (abortSignal.aborted) {\n onAbort()\n return\n }\n abortSignal.addEventListener('abort', onAbort, { once: true })\n child.once('close', () => abortSignal.removeEventListener('abort', onAbort))\n })\n}\n\nfunction startProcess(\n options: SandboxProcessOptions,\n root: string,\n environment: Readonly<Record<string, string>>,\n processes: SessionProcesses,\n): StartedProcess {\n // The process leads its own group so that killing it also kills what it\n // started. The OpenCode bridge starts OpenCode, which starts more processes.\n const child = spawnChildProcess('/bin/sh', ['-c', options.command], {\n cwd: options.workingDirectory ?? root,\n env: { ...environment, ...options.env },\n detached: true,\n })\n processes.live.add(child)\n if (child.pid !== undefined)\n processes.groups.add(child.pid)\n\n const exited = new Promise<{ exitCode: number }>((resolve) => {\n child.once('close', (code, signal) => {\n processes.live.delete(child)\n resolve({ exitCode: code ?? (signal === null ? 1 : 128 + (signals[signal] ?? 0)) })\n })\n })\n\n const kill = async (): Promise<void> => {\n killChild(child)\n await exited\n }\n\n const aborted = options.abortSignal === undefined\n ? undefined\n : watchAbort(child, options.abortSignal)\n // The abort rejection is reported through `wait`. Nothing else observes it.\n aborted?.catch(() => {})\n\n return {\n exited,\n handle: {\n pid: child.pid,\n stdout: Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,\n stderr: Readable.toWeb(child.stderr) as ReadableStream<Uint8Array>,\n wait: () => aborted === undefined ? exited : Promise.race([exited, aborted]),\n kill,\n },\n }\n}\n\nfunction createSandboxSession(root: string, environment: Readonly<Record<string, string>>, processes: SessionProcesses): SandboxSession {\n const readBinaryFile = async ({ path }: { path: string }): Promise<Uint8Array | null> =>\n readFile(path).then(\n value => Uint8Array.from(value),\n cause => errorCode(cause) === 'ENOENT' ? null : Promise.reject(cause),\n )\n\n return {\n description: `Local sandbox rooted at ${root}. POSIX sh, real processes, a session home directory, no process isolation.`,\n readBinaryFile,\n\n async readFile({ path }) {\n const content = await readBinaryFile({ path })\n if (content === null)\n return null\n return new ReadableStream<Uint8Array>({\n start(controller) {\n controller.enqueue(content)\n controller.close()\n },\n })\n },\n\n async readTextFile({ path, encoding = 'utf8', startLine, endLine }) {\n const content = await readFile(path, { encoding: encoding as BufferEncoding })\n .catch(cause => errorCode(cause) === 'ENOENT' ? null : Promise.reject(cause))\n if (content === null || (startLine === undefined && endLine === undefined))\n return content\n const lines = content.split('\\n')\n return lines.slice((startLine ?? 1) - 1, endLine ?? lines.length).join('\\n')\n },\n\n async writeFile({ path, content }) {\n await mkdir(dirname(path), { recursive: true })\n await writeFile(path, Buffer.from(await readStream(content), 'utf8'))\n },\n\n async writeBinaryFile({ path, content }) {\n await mkdir(dirname(path), { recursive: true })\n await writeFile(path, content)\n },\n\n async writeTextFile({ path, content, encoding = 'utf8' }) {\n await mkdir(dirname(path), { recursive: true })\n await writeFile(path, content, { encoding: encoding as BufferEncoding })\n },\n\n async spawn(options) {\n return startProcess(options, root, environment, processes).handle\n },\n\n async run(options) {\n // An aborted `run` reports the exit code of the killed process.\n // Only `spawn` rejects its wait, because its caller holds the handle.\n const { handle, exited } = startProcess(options, root, environment, processes)\n const [stdout, stderr, exit] = await Promise.all([\n readStream(handle.stdout),\n readStream(handle.stderr),\n exited,\n ])\n return { exitCode: exit.exitCode, stdout, stderr }\n },\n }\n}\n\n/**\n * Create a sandbox provider that runs Harness sessions on this computer.\n *\n * The session needs POSIX `sh` at `/bin/sh` and GNU `find`, because the Harness\n * inventories output with `find -printf`. It runs on Linux, and not on macOS or\n * Windows. It exposes one port on `127.0.0.1` for bridge-backed Harness\n * adapters. Each session gets its own home directory under the session root and\n * a minimal environment, and `destroy` removes both. It applies no process\n * isolation: every process can still read and write the whole computer.\n */\nexport function createLocalSandbox(options: CreateLocalSandboxOptions = {}): HarnessV1SandboxProvider {\n return {\n specificationVersion: 'harness-sandbox-v1',\n providerId: 'skilld-local',\n\n async createSession(): Promise<HarnessV1NetworkSandboxSession> {\n const root = options.root ?? await mkdtemp(join(tmpdir(), 'skilld-local-sandbox-'))\n await mkdir(root, { recursive: true })\n const directories = sessionDirectories(root)\n await mkdir(directories.home, { recursive: true })\n await mkdir(directories.tmp, { recursive: true })\n const environment = sessionEnvironment(directories, process.env, options.env ?? {})\n const processes: SessionProcesses = { live: new Set(), groups: new Set() }\n const session = createSandboxSession(root, environment, processes)\n let ports: ReadonlyArray<number> = [options.port ?? await freePort()]\n\n const stop = async (): Promise<void> => {\n // Groups, not live children: a backgrounded process outlives its shell.\n for (const pid of processes.groups)\n killProcessGroup(pid)\n processes.groups.clear()\n processes.live.clear()\n }\n\n const endpoint = ({ port, protocol = 'http' }: { port: number, protocol?: 'http' | 'https' | 'ws' }): HarnessV1PortEndpoint =>\n ({ url: `${protocol}://127.0.0.1:${port}` })\n\n return {\n ...session,\n id: root,\n defaultWorkingDirectory: root,\n get ports() {\n return ports\n },\n async getPortEndpoint(request) {\n return endpoint(request)\n },\n async getPortUrl(request) {\n return endpoint(request).url\n },\n async setPorts(next) {\n ports = [...next]\n },\n stop,\n async destroy() {\n await stop()\n if (options.keepRoot !== true)\n await rm(root, { recursive: true, force: true })\n },\n restricted: () => session,\n }\n },\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,75 +1,144 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: generate-package-skill
|
|
3
|
-
description: Generate or update an Agent Skill
|
|
3
|
+
description: Generate or update an Agent Skill that teaches consumers one npm or local package the user maintains, including framework modules and wrappers. Tests each example against the installed version. Use when a maintainer asks for a package Skill, a SKILL.md for their library, or a Skill update after a release.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Generate a package Skill
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
Write a short Skill that stops an Agent from misusing one package version.
|
|
9
|
+
The reader knows the language, the framework, and the domain. Write only what it would get wrong.
|
|
10
10
|
|
|
11
11
|
## Inputs
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
1.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
The
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
13
|
+
Take a package name, directory, or prepared source. Ask for the destination only when it is missing.
|
|
14
|
+
In a monorepo, put the Skill in the published package directory.
|
|
15
|
+
Write one Skill per installed package, unless a second package has distinct users.
|
|
16
|
+
`assets/` holds the Harness request. A direct run ignores it.
|
|
17
|
+
|
|
18
|
+
## 1. Research
|
|
19
|
+
|
|
20
|
+
1. Record the exact package version.
|
|
21
|
+
2. Read the manifest, every exported entry point, and the public types.
|
|
22
|
+
3. For a framework module, read what setup registers: auto-imports, components, the config key and defaults, hooks, server routes.
|
|
23
|
+
4. If the package wraps, re-exports, or peers on another package, read the types or tagged source of the version the consumer gets, never a default branch.
|
|
24
|
+
5. Read the current official docs and examples, and release notes only for breaking changes and removed APIs.
|
|
25
|
+
|
|
26
|
+
Test an existing Skill's claims; do not copy its layout.
|
|
27
|
+
|
|
28
|
+
## 2. Test the examples
|
|
29
|
+
|
|
30
|
+
Observed behaviour beats documentation.
|
|
31
|
+
|
|
32
|
+
1. Create a minimal consumer fixture outside the package source. Install the recorded version, or link the local build.
|
|
33
|
+
Before packing, build the package and its native code (NAPI, WASM); `prepack` can need them.
|
|
34
|
+
Pack with the repository's package manager: `npm pack` leaves pnpm `catalog:` versions.
|
|
35
|
+
Install only the package and documented peers. A resolution error is a finding.
|
|
36
|
+
2. Use consumer defaults. The package repository's config and fixtures can turn them off.
|
|
37
|
+
In Nuxt, keep test modules out of `modules/`; Nuxt registers every module there.
|
|
38
|
+
One fixture page can exercise many examples.
|
|
39
|
+
3. Run each example you include. Compare output with the claim: HTML, return values, type errors, build logs, exit codes, report files.
|
|
40
|
+
Test each mode: framework modes, export conditions (`node`, `workerd`, `edge-light`, `browser`, CDN or IIFE build), and each CLI binary with a config file and with flags.
|
|
41
|
+
Wrap every run in `timeout 120`.
|
|
42
|
+
Grep the package for agent and CI detection, such as `CLAUDECODE` or `CI`; unset each variable it reads with `env -u VAR`.
|
|
43
|
+
Read dev warnings in the dev log, prerender results in build output, and runtime results from the production server.
|
|
44
|
+
If a trap says nothing happens, run it and confirm the silence.
|
|
45
|
+
To fetch from a server, run [scripts/serve-fixture.mjs](scripts/serve-fixture.mjs) in the fixture: `node SKILL_DIR/scripts/serve-fixture.mjs --fetch / -- node .output/server/index.mjs`.
|
|
46
|
+
For a binary, use `--fetch-raw PATH --out DIR`. `DIR/responses.json` lists each status and content type.
|
|
47
|
+
Without `--fetch`, it holds the server until SIGTERM. Background it with your tool's option; `&` and `nohup` die with the shell call.
|
|
48
|
+
Never kill by port or with `pkill -f`: another Agent can own that process.
|
|
49
|
+
In a browser, set a desktop user agent; a package can treat `HeadlessChrome` as a bot.
|
|
50
|
+
4. If documentation and behaviour disagree, write the behaviour and report the mismatch.
|
|
51
|
+
5. Keep an unrunnable example only when the types prove it; report it untested.
|
|
52
|
+
|
|
53
|
+
Do not fix the package or its docs in the Skill change.
|
|
54
|
+
|
|
55
|
+
## 3. Write
|
|
56
|
+
|
|
57
|
+
Include:
|
|
58
|
+
|
|
59
|
+
- Setup that differs from the framework default, such as a required config key.
|
|
60
|
+
- What the package does automatically, and how to turn it off.
|
|
61
|
+
- Traps: silent failures, plausible wrong calls, documentation mismatches, version limits.
|
|
62
|
+
- One small example per common task the types do not make obvious.
|
|
63
|
+
- Breaking changes an Agent trained on an older version would repeat: old call, new call.
|
|
64
|
+
- A deploy section, such as CI cache or Cloudflare, when most traps live there.
|
|
65
|
+
|
|
66
|
+
Cut:
|
|
67
|
+
|
|
68
|
+
- Self-explanatory config options; link the config reference. Keep a table when values are the API, such as priorities.
|
|
69
|
+
- Internals the consumer cannot act on, such as tree shaking.
|
|
70
|
+
- Changelog paraphrase, fixed bugs, release history.
|
|
71
|
+
- Generic debug advice, such as "view source" or public validators.
|
|
72
|
+
- `path:line` citations. Put evidence in the report.
|
|
73
|
+
- Any second copy of content, such as a list repeating inline reference links.
|
|
74
|
+
|
|
75
|
+
Shape:
|
|
76
|
+
|
|
77
|
+
- Name the package and tested version in the first paragraph, not the frontmatter.
|
|
78
|
+
- Order, dropping empty sections: setup, automatic behaviour, common tasks, integrations, traps, version limits, config, debug.
|
|
79
|
+
- A config example that is a common task goes there; the config section only lists options.
|
|
80
|
+
- Write each code block as a complete module with imports. ESLint can lint fenced code.
|
|
81
|
+
- A trap detailed in a reference gets one linking line in traps.
|
|
82
|
+
- Aim for 150 lines and about 2,000 tokens in `SKILL.md`. Never exceed 500 lines.
|
|
83
|
+
- Keep one file. Move a topic to `references/<topic>.md` only past about 40 lines and when under a third of tasks need it.
|
|
84
|
+
- Link each reference from `SKILL.md`, one level deep. A reference over 100 lines starts with contents.
|
|
85
|
+
- Write at most eight reference files. Add `scripts/` only when running code beats reading it.
|
|
86
|
+
- Never include credentials, caches, build output, or dependency directories.
|
|
87
|
+
|
|
88
|
+
The frontmatter contains only `name` and `description`.
|
|
89
|
+
The name uses lowercase letters, numbers, and single hyphens, at most 64 characters, and matches the directory.
|
|
90
|
+
For a scoped package, drop the `@` and replace `/` with a hyphen: `@nuxtjs/seo` becomes `nuxtjs-seo`.
|
|
91
|
+
The description, at most 1024 characters in third person, says what the Skill does, then when to use it, in the words a user types: package name, main exports, config key, error symptoms.
|
|
92
|
+
Good: `Add and debug Schema.org JSON-LD in Nuxt with nuxt-schema-org. Use when a task mentions structured data, rich results, useSchemaOrg, defineArticle, or the schemaOrg config key.`
|
|
93
|
+
|
|
94
|
+
## 4. Check and report
|
|
95
|
+
|
|
96
|
+
### Cross-Agent portability
|
|
97
|
+
|
|
98
|
+
Keep one source Skill for compatible Agents. Keep their discovery paths outside the generated Skill.
|
|
99
|
+
Use capability descriptions instead of provider-specific tool names.
|
|
100
|
+
Ask for inputs explicitly; do not depend on `$ARGUMENTS`, hooks, or dynamic context injection.
|
|
101
|
+
Resolve bundled files from the Skill directory and consumer commands from the consumer project root.
|
|
102
|
+
Name script runtimes, binaries, network access, and credential requirements beside their use.
|
|
103
|
+
If a required capability is unavailable, report it and stop that dependent step.
|
|
104
|
+
Never invent evidence or silently skip a required check.
|
|
105
|
+
|
|
106
|
+
Check a matching task, an unrelated task, and a missing-input task.
|
|
107
|
+
When available, use a fresh session in each Agent the user asks to support.
|
|
108
|
+
Record its version, model, task, output, and required permissions.
|
|
109
|
+
Distinguish format validation, discovery, activation, and task completion.
|
|
110
|
+
Report unavailable Agent paths untested. Package example checks do not prove cross-Agent execution.
|
|
111
|
+
|
|
112
|
+
Before finishing, confirm:
|
|
113
|
+
|
|
114
|
+
- Each example ran against the recorded version, or is reported untested.
|
|
115
|
+
- Each reference is linked. Delete stale files from an earlier Skill.
|
|
116
|
+
- The frontmatter follows the rules above.
|
|
117
|
+
|
|
118
|
+
Report to the user:
|
|
119
|
+
|
|
120
|
+
- The files and the tested version.
|
|
121
|
+
- Each untested example, and each mismatch: example, documented result, observed result. These are package bugs.
|
|
122
|
+
- The source path or documentation URL behind each version-specific rule.
|
|
123
|
+
- If the package ships the Skill, add its directory to `files` in `package.json`. After one build, list packed files with `npm pack --dry-run --ignore-scripts`; `pnpm pack` rejects that flag.
|
|
124
|
+
- Replace any README or docs `skilld add <package>` tip in place with the tip below. Add the badge after the others.
|
|
125
|
+
Replace `OWNER`, `REPOSITORY`, and `PACKAGE`. If the Repository has several Skills, add `/SKILL` to the page and badge paths. The skilld.dev indexer skips `SKILL.md` under test and fixture folders. The README omits the run command; the page shows it.
|
|
126
|
+
|
|
127
|
+
```html
|
|
128
|
+
<a href="https://skilld.dev/gh/OWNER/REPOSITORY">
|
|
129
|
+
<picture>
|
|
130
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://skilld.dev/b/OWNER/REPOSITORY?theme=dark">
|
|
131
|
+
<source media="(prefers-color-scheme: light)" srcset="https://skilld.dev/b/OWNER/REPOSITORY?theme=light">
|
|
132
|
+
<img alt="Skill repository on skilld.dev" src="https://skilld.dev/b/OWNER/REPOSITORY?theme=light">
|
|
133
|
+
</picture>
|
|
134
|
+
</a>
|
|
135
|
+
```
|
|
49
136
|
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
description: Clear trigger conditions and the result this Skill provides.
|
|
54
|
-
---
|
|
137
|
+
```md
|
|
138
|
+
> [!TIP]
|
|
139
|
+
> Using an AI agent? Get the PACKAGE Skill on [skilld.dev/gh/OWNER/REPOSITORY](https://skilld.dev/gh/OWNER/REPOSITORY).
|
|
55
140
|
```
|
|
56
141
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
## Quality checks
|
|
62
|
-
|
|
63
|
-
- Keep instructions specific to the package.
|
|
64
|
-
- Use current APIs and package vocabulary.
|
|
65
|
-
- Include small examples for common tasks.
|
|
66
|
-
- State environment or version limits.
|
|
67
|
-
- Cite public APIs, version limits, and migration advice.
|
|
68
|
-
- Link each reference from `SKILL.md`.
|
|
69
|
-
- Remove generated filler and repeated prose.
|
|
70
|
-
- Do not copy large documentation sections.
|
|
71
|
-
- Do not include credentials, caches, build output, or dependency directories.
|
|
72
|
-
|
|
73
|
-
For a direct run, show the generated files for user review.
|
|
74
|
-
Replace an existing Skill only after the user approves the files.
|
|
75
|
-
Do not claim that the Skill passed Harness checks.
|
|
142
|
+
For a direct run, show the files for review, or open a pull request if asked.
|
|
143
|
+
Replace an existing Skill only with user approval.
|
|
144
|
+
A direct run has no Harness checks; never claim it passed them.
|
|
@@ -7,5 +7,8 @@ The Skill directory name must be `{{SKILL_NAME}}`.
|
|
|
7
7
|
|
|
8
8
|
Read this Skill fully before writing files.
|
|
9
9
|
Use only the visible prepared source and cited official documentation.
|
|
10
|
+
Pin external documentation to the dependency versions in the prepared manifest.
|
|
11
|
+
If the session cannot run an example, list it as untested in your final message.
|
|
12
|
+
List each documentation and behaviour mismatch in your final message.
|
|
10
13
|
Write no files outside `{{OUTPUT_PATH}}`.
|
|
11
14
|
Finish only after checking every output rule in this Skill.
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Start a fixture server on a free port, fetch paths, then stop its whole process group.
|
|
3
|
+
//
|
|
4
|
+
// Usage: node serve-fixture.mjs [--fetch PATH]... [--fetch-raw PATH]... [--out DIR] [--hold SECONDS] [--timeout SECONDS] -- COMMAND [ARG]...
|
|
5
|
+
//
|
|
6
|
+
// The script replaces `{port}` in each argument and sets PORT, NITRO_PORT, and NUXT_PORT.
|
|
7
|
+
// It prints `ready http://localhost:PORT` on stderr when the server answers.
|
|
8
|
+
// `--fetch` asks for HTML. `--fetch-raw` asks for any type, such as an image, and needs `--out`.
|
|
9
|
+
// Each fetch prints `GET PATH STATUS CONTENT-TYPE BYTES` on stderr. The body goes to stdout, or to DIR when `--out` is set.
|
|
10
|
+
// With `--out`, DIR/responses.json lists each path with its file, status, content type, and byte count.
|
|
11
|
+
// Without `--fetch`, or with `--hold`, the server stays up until the hold time ends, the script gets SIGTERM, or its parent exits.
|
|
12
|
+
// The script never kills by port or by name. It stops only the process group it started. POSIX only.
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process'
|
|
15
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { createServer } from 'node:net'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
import process from 'node:process'
|
|
19
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
20
|
+
|
|
21
|
+
function usage(message) {
|
|
22
|
+
process.stderr.write(`${message}\nUsage: node serve-fixture.mjs [--fetch PATH]... [--fetch-raw PATH]... [--out DIR] [--hold SECONDS] [--timeout SECONDS] -- COMMAND [ARG]...\n`)
|
|
23
|
+
process.exit(2)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseSeconds(flag, value) {
|
|
27
|
+
const seconds = Number(value)
|
|
28
|
+
if (!Number.isFinite(seconds) || seconds < 0)
|
|
29
|
+
usage(`${flag} needs a number of seconds.`)
|
|
30
|
+
return seconds
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv) {
|
|
34
|
+
const options = { fetch: [], out: undefined, hold: undefined, timeout: 120, command: [] }
|
|
35
|
+
for (let index = 0; index < argv.length; index++) {
|
|
36
|
+
const flag = argv[index]
|
|
37
|
+
if (flag === '--') {
|
|
38
|
+
options.command = argv.slice(index + 1)
|
|
39
|
+
break
|
|
40
|
+
}
|
|
41
|
+
const value = argv[++index]
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
usage(`${flag} needs a value.`)
|
|
44
|
+
if (flag === '--fetch' || flag === '--fetch-raw')
|
|
45
|
+
options.fetch.push({ path: value.startsWith('/') ? value : `/${value}`, raw: flag === '--fetch-raw' })
|
|
46
|
+
else if (flag === '--out')
|
|
47
|
+
options.out = value
|
|
48
|
+
else if (flag === '--hold')
|
|
49
|
+
options.hold = parseSeconds(flag, value)
|
|
50
|
+
else if (flag === '--timeout')
|
|
51
|
+
options.timeout = parseSeconds(flag, value)
|
|
52
|
+
else
|
|
53
|
+
usage(`Unknown option: ${flag}`)
|
|
54
|
+
}
|
|
55
|
+
if (options.command.length === 0)
|
|
56
|
+
usage('Give the server command after --.')
|
|
57
|
+
if (options.fetch.some(request => request.raw) && options.out === undefined)
|
|
58
|
+
usage('--fetch-raw needs --out, because a binary body cannot go to stdout.')
|
|
59
|
+
return options
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function freePort() {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const server = createServer()
|
|
65
|
+
server.once('error', reject)
|
|
66
|
+
server.listen(0, '127.0.0.1', () => {
|
|
67
|
+
const { port } = server.address()
|
|
68
|
+
server.close(() => resolve(port))
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const NOT_EXTENSION = /[^a-z0-9]+/g
|
|
74
|
+
|
|
75
|
+
function extension(contentType) {
|
|
76
|
+
// `image/svg+xml; charset=utf-8` becomes `svg`. An unknown type becomes `bin`.
|
|
77
|
+
const subtype = contentType?.split(';')[0].split('/')[1]?.split('+')[0].toLowerCase().replace(NOT_EXTENSION, '')
|
|
78
|
+
return subtype || 'bin'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// encodeURIComponent maps each path to one name, so `/a/b` and `/a_b` never share a file.
|
|
82
|
+
// It always escapes `#`, so the `#N` suffix for a repeated name never matches a path.
|
|
83
|
+
const usedNames = new Set()
|
|
84
|
+
function fileName(path, contentType) {
|
|
85
|
+
const base = `${encodeURIComponent(path)}.${extension(contentType)}`
|
|
86
|
+
let name = base
|
|
87
|
+
for (let copy = 2; usedNames.has(name); copy++)
|
|
88
|
+
name = `${base.slice(0, base.lastIndexOf('.'))}#${copy}.${extension(contentType)}`
|
|
89
|
+
usedNames.add(name)
|
|
90
|
+
return name
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const options = parseArgs(process.argv.slice(2))
|
|
94
|
+
const port = await freePort()
|
|
95
|
+
const origin = `http://localhost:${port}`
|
|
96
|
+
const [command, ...args] = options.command.map(part => part.replaceAll('{port}', String(port)))
|
|
97
|
+
|
|
98
|
+
// `detached` makes the child a process group leader, so one signal reaches a package manager wrapper and its server.
|
|
99
|
+
const child = spawn(command, args, {
|
|
100
|
+
detached: true,
|
|
101
|
+
stdio: ['ignore', 2, 2],
|
|
102
|
+
env: { ...process.env, PORT: String(port), NITRO_PORT: String(port), NUXT_PORT: String(port) },
|
|
103
|
+
})
|
|
104
|
+
let exited = false
|
|
105
|
+
const exit = new Promise((resolve) => {
|
|
106
|
+
child.once('exit', (code, signal) => {
|
|
107
|
+
exited = true
|
|
108
|
+
resolve({ code, signal })
|
|
109
|
+
})
|
|
110
|
+
child.once('error', (error) => {
|
|
111
|
+
exited = true
|
|
112
|
+
process.stderr.write(`Could not start ${command}: ${error.message}\n`)
|
|
113
|
+
resolve({ code: 127, signal: null })
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
function signalGroup(signal) {
|
|
118
|
+
try {
|
|
119
|
+
process.kill(-child.pid, signal)
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
// ESRCH: the group has already exited, which is the goal.
|
|
123
|
+
if (error.code !== 'ESRCH')
|
|
124
|
+
throw error
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function kill() {
|
|
129
|
+
if (child.pid !== undefined)
|
|
130
|
+
signalGroup('SIGKILL')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let stopping
|
|
134
|
+
function stop() {
|
|
135
|
+
stopping ??= (async () => {
|
|
136
|
+
if (child.pid === undefined)
|
|
137
|
+
return
|
|
138
|
+
signalGroup('SIGTERM')
|
|
139
|
+
await Promise.race([exit, delay(5000)])
|
|
140
|
+
// Kill any group member that ignored SIGTERM or outlived the leader.
|
|
141
|
+
kill()
|
|
142
|
+
})()
|
|
143
|
+
return stopping
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Keep the handlers after the first signal. Otherwise a second signal ends this script before the final kill.
|
|
147
|
+
let signalled = false
|
|
148
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
149
|
+
process.on(signal, () => {
|
|
150
|
+
if (signalled) {
|
|
151
|
+
kill()
|
|
152
|
+
process.exit(130)
|
|
153
|
+
}
|
|
154
|
+
signalled = true
|
|
155
|
+
stop().then(() => process.exit(130))
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A `node` shim, such as the pnpm one, can die on a signal and leave this script orphaned. Stop the server then too.
|
|
160
|
+
const parent = process.ppid
|
|
161
|
+
setInterval(() => {
|
|
162
|
+
if (process.ppid !== parent)
|
|
163
|
+
stop().then(() => process.exit(130))
|
|
164
|
+
}, 500).unref()
|
|
165
|
+
|
|
166
|
+
async function waitUntilReady({ path, raw }) {
|
|
167
|
+
const deadline = Date.now() + options.timeout * 1000
|
|
168
|
+
while (Date.now() < deadline) {
|
|
169
|
+
if (exited)
|
|
170
|
+
return { _tag: 'Exited' }
|
|
171
|
+
const response = await fetch(`${origin}${path}`, { headers: { accept: raw ? '*/*' : 'text/html' }, signal: AbortSignal.timeout(Math.max(deadline - Date.now(), 1)) })
|
|
172
|
+
.catch(() => undefined) // Connection refused while the server starts. Retry until the deadline.
|
|
173
|
+
if (response && ![502, 503, 504].includes(response.status)) {
|
|
174
|
+
await response.body?.cancel()
|
|
175
|
+
return { _tag: 'Ready' }
|
|
176
|
+
}
|
|
177
|
+
await response?.body?.cancel()
|
|
178
|
+
await delay(500)
|
|
179
|
+
}
|
|
180
|
+
return { _tag: 'TimedOut' }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const responses = []
|
|
184
|
+
|
|
185
|
+
async function fetchPath({ path, raw }) {
|
|
186
|
+
const response = await fetch(`${origin}${path}`, { headers: { accept: raw ? '*/*' : 'text/html' }, signal: AbortSignal.timeout(options.timeout * 1000) })
|
|
187
|
+
const body = Buffer.from(await response.arrayBuffer())
|
|
188
|
+
const contentType = response.headers.get('content-type')
|
|
189
|
+
process.stderr.write(`GET ${path} ${response.status} ${contentType ?? '-'} ${body.byteLength}\n`)
|
|
190
|
+
if (options.out) {
|
|
191
|
+
const file = fileName(path, contentType)
|
|
192
|
+
await mkdir(options.out, { recursive: true })
|
|
193
|
+
await writeFile(join(options.out, file), body)
|
|
194
|
+
responses.push({ path, file, status: response.status, contentType, bytes: body.byteLength })
|
|
195
|
+
await writeFile(join(options.out, 'responses.json'), `${JSON.stringify(responses, null, 2)}\n`)
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
process.stdout.write(`${body}\n`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function run() {
|
|
203
|
+
const ready = await waitUntilReady(options.fetch[0] ?? { path: '/', raw: false })
|
|
204
|
+
if (ready._tag === 'Exited') {
|
|
205
|
+
const { code, signal } = await exit
|
|
206
|
+
process.stderr.write(`The server exited before it answered: code ${code}, signal ${signal}.\n`)
|
|
207
|
+
return 1
|
|
208
|
+
}
|
|
209
|
+
if (ready._tag === 'TimedOut') {
|
|
210
|
+
process.stderr.write(`The server did not answer within ${options.timeout} seconds.\n`)
|
|
211
|
+
return 1
|
|
212
|
+
}
|
|
213
|
+
process.stderr.write(`ready ${origin}\n`)
|
|
214
|
+
let status = 0
|
|
215
|
+
for (const request of options.fetch) {
|
|
216
|
+
// A crashed route or an unwritable DIR fails one path. Report it and keep going.
|
|
217
|
+
await fetchPath(request).catch((error) => {
|
|
218
|
+
process.stderr.write(`GET ${request.path} failed: ${error.cause?.message ?? error.message}\n`)
|
|
219
|
+
status = 1
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
if (options.fetch.length === 0 || options.hold !== undefined)
|
|
223
|
+
await Promise.race([exit, options.hold === undefined ? new Promise(() => {}) : delay(options.hold * 1000)])
|
|
224
|
+
return status
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Stop the group on any failure, so an error never orphans the server.
|
|
228
|
+
const status = await run().catch((error) => {
|
|
229
|
+
process.stderr.write(`${error.stack ?? error}\n`)
|
|
230
|
+
return 1
|
|
231
|
+
})
|
|
232
|
+
await stop()
|
|
233
|
+
process.exit(status)
|