mcplane 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +108 -18
  3. package/dist/agent-plugins.d.ts +6 -0
  4. package/dist/agent-plugins.js +76 -0
  5. package/dist/cdp.d.ts +49 -0
  6. package/dist/cdp.js +253 -0
  7. package/dist/checks/ard.d.ts +67 -0
  8. package/dist/checks/ard.js +215 -0
  9. package/dist/checks/auth.js +2 -1
  10. package/dist/checks/chain.d.ts +45 -0
  11. package/dist/checks/chain.js +171 -0
  12. package/dist/checks/listing.js +23 -14
  13. package/dist/checks/oauth.d.ts +45 -0
  14. package/dist/checks/oauth.js +64 -0
  15. package/dist/checks/readiness.d.ts +58 -0
  16. package/dist/checks/readiness.js +230 -0
  17. package/dist/checks/tools.js +38 -7
  18. package/dist/checks/vercel.d.ts +27 -0
  19. package/dist/checks/vercel.js +153 -0
  20. package/dist/cli.js +54 -6
  21. package/dist/core.d.ts +1 -0
  22. package/dist/core.js +20 -4
  23. package/dist/demo.d.ts +60 -0
  24. package/dist/demo.js +573 -0
  25. package/dist/drift.js +10 -3
  26. package/dist/evals.d.ts +34 -0
  27. package/dist/evals.js +75 -0
  28. package/dist/extras.js +1 -1
  29. package/dist/images.d.ts +21 -0
  30. package/dist/images.js +99 -0
  31. package/dist/listings.d.ts +14 -0
  32. package/dist/listings.js +39 -0
  33. package/dist/mcp-client.d.ts +1 -5
  34. package/dist/mcp-client.js +8 -1
  35. package/dist/mcp-server.js +21 -11
  36. package/dist/packs.d.ts +48 -18
  37. package/dist/packs.js +380 -90
  38. package/dist/report.js +30 -13
  39. package/dist/skills.d.ts +21 -0
  40. package/dist/skills.js +180 -0
  41. package/dist/submissions.js +2 -1
  42. package/dist/types.d.ts +37 -4
  43. package/dist/types.js +1 -0
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/dist/video.d.ts +58 -0
  47. package/dist/video.js +190 -0
  48. package/dist/zip.d.ts +16 -2
  49. package/dist/zip.js +65 -5
  50. package/package.json +4 -1
  51. package/schema.json +86 -12
  52. package/schemas/agent-plugins-1.0.0/LICENSE +202 -0
  53. package/schemas/agent-plugins-1.0.0/mcp.schema.json +120 -0
  54. package/schemas/agent-plugins-1.0.0/plugin.schema.json +65 -0
package/dist/report.js CHANGED
@@ -1,25 +1,41 @@
1
1
  import { STORE_NAMES } from './types.js';
2
2
  const tty = process.stdout.isTTY;
3
3
  const c = (code, s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
4
- const MARK = { pass: c('32', '✓'), warn: c('33', '!'), fail: c('31', '✗'), skip: c('90', '-') };
5
- export function printPreflight(r, opts = {}) {
6
- const fails = r.checks.filter((x) => x.level === 'fail');
7
- const warns = r.checks.filter((x) => x.level === 'warn');
8
- console.log(c('1', `Preflight for ${r.stores.map((s) => STORE_NAMES[s]).join(', ')}`));
9
- console.log('');
10
- for (const x of r.checks) {
11
- if (x.level === 'pass' && !opts.verbose)
4
+ const MARK = { pass: c('32', '✓'), warn: c('33', '!'), fail: c('31', '✗'), skip: c('90', '-'), info: c('36', 'i') };
5
+ /** Readiness for the next MCP revision gets its own section, so it never reads as a store requirement. */
6
+ const isReadiness = (x) => x.id.startsWith('readiness.');
7
+ function printChecks(checks, verbose, indent = '') {
8
+ for (const x of checks) {
9
+ if (x.level === 'pass' && !verbose)
12
10
  continue;
13
11
  const where = x.stores?.length ? c('90', ` [${x.stores.map((s) => STORE_NAMES[s]).join(', ')}]`) : '';
14
- console.log(`${MARK[x.level]} ${x.title}${where}`);
12
+ console.log(`${indent}${MARK[x.level]} ${x.title}${where}`);
15
13
  if (x.detail)
16
- console.log(c('90', ` ${x.detail}`));
14
+ console.log(c('90', `${indent} ${x.detail}`));
17
15
  if (x.fix && x.level !== 'pass')
18
- console.log(` ${x.fix}`);
16
+ console.log(`${indent} ${x.fix}`);
19
17
  }
20
- const passed = r.checks.length - fails.length - warns.length;
18
+ }
19
+ export function printPreflight(r, opts = {}) {
20
+ const fails = r.checks.filter((x) => x.level === 'fail');
21
+ const warns = r.checks.filter((x) => x.level === 'warn');
22
+ const notes = r.checks.filter((x) => x.level === 'info');
23
+ const main = r.checks.filter((x) => !isReadiness(x));
24
+ const ready = r.checks.filter(isReadiness);
25
+ console.log(c('1', `Preflight for ${r.stores.map((s) => STORE_NAMES[s]).join(', ')}`));
26
+ console.log('');
27
+ printChecks(main, opts.verbose);
28
+ const passed = main.filter((x) => x.level === 'pass').length;
21
29
  if (!opts.verbose && passed)
22
30
  console.log(c('32', `✓ ${passed} checks passed`) + c('90', ' (--verbose to list them)'));
31
+ if (ready.length) {
32
+ console.log('');
33
+ console.log(c('1', 'Readiness for MCP 2026-07-28') + c('90', ' (never blocking)'));
34
+ printChecks(ready, opts.verbose, ' ');
35
+ const ok = ready.filter((x) => x.level === 'pass').length;
36
+ if (!opts.verbose && ok)
37
+ console.log(` ${c('32', `✓ ${ok} ready`)}`);
38
+ }
23
39
  if (r.reminders.length) {
24
40
  console.log('');
25
41
  console.log(c('1', 'Check these yourself'));
@@ -27,5 +43,6 @@ export function printPreflight(r, opts = {}) {
27
43
  console.log(` • ${t}`);
28
44
  }
29
45
  console.log('');
30
- console.log(fails.length ? c('31', `${fails.length} blocking, ${warns.length} to review.`) : warns.length ? c('33', `No blockers, ${warns.length} to review.`) : c('32', 'Ready to submit.'));
46
+ const noteLine = notes.length ? `, ${notes.length} note${notes.length === 1 ? '' : 's'}` : '';
47
+ console.log(fails.length ? c('31', `${fails.length} blocking, ${warns.length} to review${noteLine}.`) : warns.length ? c('33', `No blockers, ${warns.length} to review${noteLine}.`) : c('32', `Ready to submit${noteLine ? ` (${notes.length} note${notes.length === 1 ? '' : 's'})` : ''}.`));
31
48
  }
@@ -0,0 +1,21 @@
1
+ import type { ZipEntry } from './zip.js';
2
+ export type FrontMatter = {
3
+ data: Record<string, unknown>;
4
+ body: string;
5
+ } | {
6
+ error: string;
7
+ };
8
+ /**
9
+ * Reads SKILL.md front matter: the YAML a skill loader sees, for the subset skills use (plain,
10
+ * quoted and block scalars, nested maps and lists). A plain value holding ": " is an error, as in
11
+ * YAML itself, because strict loaders refuse the whole skill over it.
12
+ */
13
+ export declare function frontMatter(raw: string): FrontMatter;
14
+ export interface SkillBundle {
15
+ files: ZipEntry[];
16
+ names: string[];
17
+ problems: string[];
18
+ todo: string[];
19
+ }
20
+ /** Reads, checks and lays out skills for skills/<folder>/ in a plugin package. */
21
+ export declare function bundleSkills(spec: string | string[] | undefined, dir: string, pluginName: string): Promise<SkillBundle>;
package/dist/skills.js ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Agent Skills for plugin packages: each skill is a folder with a SKILL.md, bundled under
3
+ * skills/<folder>/ with its scripts and references, executable bits kept. Checked against the
4
+ * Agent Skills spec (agentskills.io) and the skill rules in OpenAI's submission errors.
5
+ */
6
+ import { lstat, readFile, readdir } from 'node:fs/promises';
7
+ import { basename, join, relative, resolve, sep } from 'node:path';
8
+ /** Front matter fields the Agent Skills spec defines. */
9
+ const SPEC_FIELDS = new Set(['name', 'description', 'license', 'compatibility', 'metadata', 'allowed-tools']);
10
+ const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
11
+ const DESCRIPTION_MAX = 1024;
12
+ /** Noise and bulk that never belongs in a package. */
13
+ const SKIPPED = new Set(['.DS_Store', 'Thumbs.db', 'node_modules', '.git']);
14
+ function unquoteDouble(s) {
15
+ try {
16
+ return JSON.parse(s.replace(/\\'/g, "'").replace(/\\\n/g, ''));
17
+ }
18
+ catch {
19
+ return s.slice(1, -1);
20
+ }
21
+ }
22
+ /**
23
+ * Reads SKILL.md front matter: the YAML a skill loader sees, for the subset skills use (plain,
24
+ * quoted and block scalars, nested maps and lists). A plain value holding ": " is an error, as in
25
+ * YAML itself, because strict loaders refuse the whole skill over it.
26
+ */
27
+ export function frontMatter(raw) {
28
+ const lines = raw.replace(/^/, '').replace(/\r\n/g, '\n').split('\n');
29
+ if (lines[0].trimEnd() !== '---')
30
+ return { error: 'SKILL.md must start with front matter between --- lines' };
31
+ const close = lines.findIndex((l, i) => i > 0 && /^(---|\.\.\.)\s*$/.test(l));
32
+ if (close < 0)
33
+ return { error: 'the front matter has no closing --- line' };
34
+ const fm = lines.slice(1, close);
35
+ const data = {};
36
+ for (let i = 0; i < fm.length; i++) {
37
+ const line = fm[i];
38
+ if (!line.trim() || /^\s*#/.test(line))
39
+ continue;
40
+ const m = /^([A-Za-z0-9_-]+)\s*:(?:[ \t]+(.*))?$/.exec(line);
41
+ if (!m)
42
+ return { error: `front matter line ${i + 2} isn't "key: value": ${line.trim().slice(0, 60)}` };
43
+ const key = m[1];
44
+ const value = (m[2] ?? '').trim();
45
+ const more = [];
46
+ while (i + 1 < fm.length && (fm[i + 1].trim() === '' || /^\s/.test(fm[i + 1])))
47
+ more.push(fm[++i]);
48
+ while (more.length && !more[more.length - 1].trim())
49
+ more.pop();
50
+ if (/^[|>][+-]?\d*(\s+#.*)?$/.test(value)) {
51
+ const indent = Math.min(...more.filter((l) => l.trim()).map((l) => l.length - l.trimStart().length));
52
+ const body = more.map((l) => l.slice(Number.isFinite(indent) ? indent : 0));
53
+ data[key] = value.startsWith('|') ? body.join('\n') : body.reduce((s, l) => (!l.trim() ? `${s}\n` : s && !s.endsWith('\n') ? `${s} ${l}` : `${s}${l}`), '');
54
+ }
55
+ else if (!value && more.length) {
56
+ const items = more.filter((l) => l.trim());
57
+ data[key] = items.every((l) => /^\s*-\s/.test(l))
58
+ ? items.map((l) => l.replace(/^\s*-\s+/, '').trim())
59
+ : Object.fromEntries(items.map((l) => /^\s*([^:]+):\s*(.*)$/.exec(l)).filter(Boolean).map((r) => [r[1].trim(), r[2].trim().replace(/^(["'])(.*)\1$/, '$2')]));
60
+ }
61
+ else if (value.startsWith('"')) {
62
+ data[key] = unquoteDouble([value, ...more.map((l) => l.trim())].join(' '));
63
+ }
64
+ else if (value.startsWith("'")) {
65
+ data[key] = [value, ...more.map((l) => l.trim())].join(' ').slice(1, -1).replace(/''/g, "'");
66
+ }
67
+ else {
68
+ const text = [value, ...more.map((l) => l.trim())].join(' ').replace(/\s+#.*$/, '');
69
+ if (/:\s/.test(text))
70
+ return { error: `"${key}" holds ": " without quotes, which YAML reads as a nested key; quote the value` };
71
+ data[key] = text;
72
+ }
73
+ }
74
+ return { data, body: lines.slice(close + 1).join('\n') };
75
+ }
76
+ async function walk(root, problems, skipped, at = root) {
77
+ const out = [];
78
+ for (const name of (await readdir(at)).sort()) {
79
+ const abs = join(at, name);
80
+ const inner = relative(root, abs).split(sep).join('/');
81
+ if (SKIPPED.has(name))
82
+ continue;
83
+ if (name.startsWith('.')) {
84
+ skipped.push(inner);
85
+ continue;
86
+ }
87
+ const st = await lstat(abs);
88
+ if (st.isSymbolicLink())
89
+ problems.push(`${basename(root)}/${inner} is a symbolic link; packages can't hold one (OpenAI ignores the skill). Copy the file in instead`);
90
+ else if (st.isDirectory())
91
+ out.push(...(await walk(root, problems, skipped, abs)));
92
+ else if (st.isFile())
93
+ out.push({ inner, abs, mode: st.mode & 0o111 ? 0o755 : 0o644 });
94
+ }
95
+ return out;
96
+ }
97
+ /** Skill folders from the manifest setting: one folder of skills, or a list of skill folders. Null when the folder isn't there. */
98
+ async function skillDirs(spec, dir) {
99
+ if (Array.isArray(spec))
100
+ return spec.map((p) => resolve(dir, p));
101
+ const parent = resolve(dir, spec);
102
+ const names = await readdir(parent).catch(() => null);
103
+ if (!names)
104
+ return null;
105
+ const dirs = [];
106
+ for (const n of names.sort())
107
+ if (!n.startsWith('.') && !SKIPPED.has(n) && (await lstat(join(parent, n))).isDirectory())
108
+ dirs.push(join(parent, n));
109
+ return dirs;
110
+ }
111
+ /** Reads, checks and lays out skills for skills/<folder>/ in a plugin package. */
112
+ export async function bundleSkills(spec, dir, pluginName) {
113
+ const out = { files: [], names: [], problems: [], todo: [] };
114
+ if (spec === undefined)
115
+ return out;
116
+ const dirs = await skillDirs(spec, dir);
117
+ if (!dirs) {
118
+ out.problems.push(`chatgpt.skills: ${spec} isn't a folder (paths are relative to mcplane.json)`);
119
+ return out;
120
+ }
121
+ if (!dirs.length && typeof spec === 'string')
122
+ out.todo.push(`chatgpt.skills: ${spec} holds no skill folders, so the package has no skills.`);
123
+ const seen = new Map();
124
+ for (const root of dirs) {
125
+ const folder = basename(root);
126
+ const where = `skills/${folder}`;
127
+ const st = await lstat(root).catch(() => null);
128
+ if (!st?.isDirectory()) {
129
+ out.problems.push(`${where}: ${relative(dir, root) || root} isn't a folder`);
130
+ continue;
131
+ }
132
+ const skipped = [];
133
+ const found = await walk(root, out.problems, skipped);
134
+ if (skipped.length)
135
+ out.todo.push(`Left out of ${where}: ${skipped.join(', ')} (hidden files never go in the package).`);
136
+ const md = found.find((f) => f.inner === 'SKILL.md');
137
+ if (!md) {
138
+ out.problems.push(`${where} has no SKILL.md`);
139
+ continue;
140
+ }
141
+ const raw = await readFile(md.abs, 'utf8');
142
+ const fm = frontMatter(raw);
143
+ if ('error' in fm) {
144
+ out.problems.push(`${where}/SKILL.md: ${fm.error}`);
145
+ continue;
146
+ }
147
+ const { name, description } = fm.data;
148
+ if (typeof name !== 'string' || !name.trim())
149
+ out.problems.push(`${where}/SKILL.md: "name" is required`);
150
+ else {
151
+ if (!NAME.test(name) || name.length > 64)
152
+ out.problems.push(`${where}/SKILL.md: name "${name}" must be lowercase letters, digits and single hyphens, at most 64 characters`);
153
+ if (name !== folder)
154
+ out.problems.push(`${where}/SKILL.md: name "${name}" doesn't match its folder "${folder}"; the Agent Skills spec requires a match, and strict clients skip the skill`);
155
+ if (`${pluginName}:${name}`.length > 64)
156
+ out.problems.push(`${where}: "${pluginName}:${name}" is over 64 characters, OpenAI's limit for a skill's full name`);
157
+ if (seen.has(name))
158
+ out.problems.push(`${where}: the name "${name}" is also used by ${seen.get(name)}`);
159
+ seen.set(name, where);
160
+ out.names.push(name);
161
+ }
162
+ if (typeof description !== 'string' || !description.trim())
163
+ out.problems.push(`${where}/SKILL.md: "description" is required`);
164
+ else if (description.trim().length > DESCRIPTION_MAX)
165
+ out.problems.push(`${where}/SKILL.md: the description is ${description.trim().length} characters; the limit is ${DESCRIPTION_MAX}`);
166
+ if (!fm.body.trim())
167
+ out.problems.push(`${where}/SKILL.md has no instructions after the front matter`);
168
+ const extra = Object.keys(fm.data).filter((k) => !SPEC_FIELDS.has(k));
169
+ if (extra.length)
170
+ out.todo.push(`${where}/SKILL.md: ${extra.map((k) => `"${k}"`).join(', ')} ${extra.length === 1 ? "isn't" : "aren't"} in the Agent Skills spec. OpenAI ignores unknown fields, but strict clients may refuse the skill.`);
171
+ const own = new Set(found.map((f) => f.inner));
172
+ for (const ref of new Set([...raw.matchAll(/\bscripts\/[A-Za-z0-9._/-]*[A-Za-z0-9_-]/g)].map((r) => r[0]))) {
173
+ if (!own.has(ref))
174
+ out.problems.push(`${where}/SKILL.md refers to ${ref}, which isn't in the skill's folder. Installed on its own, the skill can't run it`);
175
+ }
176
+ for (const f of found)
177
+ out.files.push({ path: `${where}/${f.inner}`, data: await readFile(f.abs), mode: f.mode });
178
+ }
179
+ return out;
180
+ }
@@ -17,6 +17,7 @@ const RT_SLUG = {
17
17
  'claude-plugins': 'claude-plugins',
18
18
  cursor: 'cursor',
19
19
  muse: 'muse',
20
+ 'vercel-connect': 'vercel-connect',
20
21
  };
21
22
  const TRACKED_FROM_GITHUB = ['grok', 'docker'];
22
23
  export async function load(dir = process.cwd()) {
@@ -36,7 +37,7 @@ const today = () => new Date().toISOString().slice(0, 10);
36
37
  function listingRef(m, store, appId) {
37
38
  if (store === 'chatgpt')
38
39
  return appId;
39
- if (store === 'claude-connectors' || store === 'muse')
40
+ if (store === 'claude-connectors' || store === 'muse' || store === 'vercel-connect')
40
41
  return m.server.url;
41
42
  if (store === 'claude-plugins' || store === 'cursor')
42
43
  return m.repository;
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- export type Level = 'pass' | 'warn' | 'fail' | 'skip';
1
+ /** info: optional or advisory. It never blocks and isn't counted as something to review. */
2
+ export type Level = 'pass' | 'warn' | 'fail' | 'skip' | 'info';
2
3
  export interface Check {
3
4
  id: string;
4
5
  level: Level;
@@ -10,7 +11,7 @@ export interface Check {
10
11
  /** The stores this matters for; empty means all. */
11
12
  stores?: StoreId[];
12
13
  }
13
- export type StoreId = 'mcp-registry' | 'chatgpt' | 'claude-connectors' | 'claude-plugins' | 'cursor' | 'grok' | 'docker' | 'muse' | 'smithery' | 'glama' | 'cline' | 'lobehub' | 'awesome-mcp-servers' | 'awesome-remote-mcp-servers';
14
+ export type StoreId = 'mcp-registry' | 'chatgpt' | 'claude-connectors' | 'claude-plugins' | 'cursor' | 'grok' | 'docker' | 'muse' | 'smithery' | 'glama' | 'cline' | 'lobehub' | 'awesome-mcp-servers' | 'awesome-remote-mcp-servers' | 'vercel-connect';
14
15
  export declare const STORE_NAMES: Record<StoreId, string>;
15
16
  export interface Manifest {
16
17
  $schema?: string;
@@ -23,6 +24,11 @@ export interface Manifest {
23
24
  server: {
24
25
  url: string;
25
26
  auth?: 'none' | 'oauth';
27
+ /**
28
+ * Sign-in pages and identity-provider URLs your consent screen sends people to (e.g. https://accounts.example.com/sign-in).
29
+ * Preflight GETs them with the authorization endpoint to find bot challenges and 403s reviewers would hit.
30
+ */
31
+ signIn?: string[];
26
32
  };
27
33
  repository?: string;
28
34
  author?: {
@@ -36,6 +42,7 @@ export interface Manifest {
36
42
  terms?: string;
37
43
  docs?: string;
38
44
  };
45
+ /** A direct image URL. The ChatGPT package also takes a local path, relative to mcplane.json. */
39
46
  icon?: string;
40
47
  prompts?: string[];
41
48
  stores?: StoreId[];
@@ -54,7 +61,10 @@ export interface Manifest {
54
61
  prompt: string;
55
62
  }[];
56
63
  };
57
- /** Hand-written ChatGPT hint justifications, per tool; they replace mcplane's drafts. */
64
+ /**
65
+ * @deprecated OpenAI no longer asks for hint justifications. Still accepted: the ChatGPT pack lists them
66
+ * as optional notes for an appeal if review flags a hint.
67
+ */
58
68
  justifications?: Record<string, {
59
69
  readOnly?: string;
60
70
  openWorld?: string;
@@ -77,9 +87,15 @@ export interface Manifest {
77
87
  /** #RRGGBB with 2:1 contrast against white; the dark one against #212121. */
78
88
  brandColor?: string;
79
89
  brandColorDark?: string;
80
- /** Direct image URLs; default to "icon". */
90
+ /** Image URLs or local paths (relative to mcplane.json), bundled into the package. logo and composerIcon default to "icon". */
91
+ logo?: string;
81
92
  composerIcon?: string;
82
93
  logoDark?: string;
94
+ /**
95
+ * Agent Skills to bundle under skills/ in the package: a folder whose subfolders each hold a SKILL.md,
96
+ * or a list of skill folders. Paths are relative to mcplane.json.
97
+ */
98
+ skills?: string | string[];
83
99
  /** Reviewer-accessible video URL showing the main use cases (required for review). */
84
100
  demoVideo?: string;
85
101
  commerce?: boolean;
@@ -100,6 +116,23 @@ export interface Manifest {
100
116
  keywords?: string[];
101
117
  domains?: string[];
102
118
  };
119
+ /** Vercel Connect's Submit a Service form fields with no shared equivalent. */
120
+ vercelConnect?: {
121
+ /** Base URL of a REST API your users also call, listed as a second target. */
122
+ apiBase?: string;
123
+ /** Default scopes for the OAuth method. Defaults to the discovery document's scopes_supported. */
124
+ scopes?: string[];
125
+ /** Offer an API key connection method: where users create a key, and the header it's sent in. */
126
+ apiKey?: {
127
+ docs?: string;
128
+ header?: string;
129
+ };
130
+ };
131
+ /** The Claude Code plugin that ships this server, for "mcplane pack claude-eval". Read from the plugin repo when left out. */
132
+ claudePlugin?: {
133
+ name?: string;
134
+ server?: string;
135
+ };
103
136
  /** Named workflows, run with "mcplane <lane>": each step is a command line without "mcplane". */
104
137
  lanes?: Record<string, string[]>;
105
138
  /** How reviewers get in, for servers behind sign-in. Never put the password here; say where it lives. */
package/dist/types.js CHANGED
@@ -13,4 +13,5 @@ export const STORE_NAMES = {
13
13
  lobehub: 'LobeHub Market',
14
14
  'awesome-mcp-servers': 'awesome-mcp-servers',
15
15
  'awesome-remote-mcp-servers': 'awesome-remote-mcp-servers',
16
+ 'vercel-connect': 'Vercel Connect',
16
17
  };
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.4.1";
1
+ export declare const VERSION = "0.5.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.4.1';
1
+ export const VERSION = '0.5.0';
@@ -0,0 +1,58 @@
1
+ /** One screencast frame: a JPEG relative to the work folder, and when it was shown (seconds). */
2
+ export interface Frame {
3
+ file: string;
4
+ ts: number;
5
+ }
6
+ export interface Scene {
7
+ caption: string;
8
+ frames: Frame[];
9
+ /** When recording stopped; the last frame is held until then. */
10
+ end: number;
11
+ }
12
+ /** ffmpeg's concat-demuxer list: each frame held until the next one, the last until `end`. */
13
+ export declare function concatList(frames: Frame[], end: number): string;
14
+ /** How long a scene runs once assembled. */
15
+ export declare function sceneSeconds(s: Scene): number;
16
+ /**
17
+ * Escapes a value for a filter option inside an ffmpeg filtergraph. Two levels:
18
+ * the option value (\ ' :) and then the filtergraph itself (\ ' [ ] , ;).
19
+ */
20
+ export declare function ffEscape(value: string): string;
21
+ /**
22
+ * A caption as at most `maxLines` lines of about `width` characters. Control
23
+ * characters become spaces (drawtext would draw them as boxes); anything that
24
+ * doesn't fit ends in an ellipsis.
25
+ */
26
+ export declare function wrapCaption(text: string, width?: number, maxLines?: number): string[];
27
+ /** Width and height from a JPEG's start-of-frame marker. */
28
+ export declare function jpegSize(b: Uint8Array): {
29
+ width: number;
30
+ height: number;
31
+ } | null;
32
+ /** Even dimensions (H.264 needs them) within the maximum, keeping the aspect ratio. */
33
+ export declare function outputSize(width: number, height: number, maxWidth?: number, maxHeight?: number): {
34
+ width: number;
35
+ height: number;
36
+ };
37
+ export declare function findFfmpeg(path?: string): string | null;
38
+ /** Text colours that read on a background, given as 0xRRGGBB: light on dark, dark on light. */
39
+ export declare function inkFor(bg: string): {
40
+ text: string;
41
+ muted: string;
42
+ };
43
+ /**
44
+ * Builds the video in `work` (where the frames are) and writes it to `out`.
45
+ * Every part is encoded the same way so they join without re-encoding.
46
+ */
47
+ export declare function buildVideo(o: {
48
+ scenes: Scene[];
49
+ title: string;
50
+ subtitle?: string;
51
+ out: string;
52
+ work: string;
53
+ ffmpeg: string;
54
+ size: {
55
+ width: number;
56
+ height: number;
57
+ };
58
+ }): Promise<number>;
package/dist/video.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Recorded scenes to one mp4 with ffmpeg: a title card, then each scene at real
3
+ * speed with its caption burned in. A DevTools screencast only sends a frame when
4
+ * something on screen changes, so each frame is held until the next one arrives
5
+ * (ffmpeg's concat demuxer with a duration per frame).
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ import { existsSync } from 'node:fs';
9
+ import { writeFile } from 'node:fs/promises';
10
+ import { delimiter, join, resolve } from 'node:path';
11
+ const FPS = 30;
12
+ const DARK = '0x16181d';
13
+ const TITLE_SECONDS = 3;
14
+ const FONTS = ['/System/Library/Fonts/SFNS.ttf', '/System/Library/Fonts/Helvetica.ttc', '/Library/Fonts/Arial Unicode.ttf'];
15
+ /** ffmpeg's concat-demuxer list: each frame held until the next one, the last until `end`. */
16
+ export function concatList(frames, end) {
17
+ if (!frames.length)
18
+ throw new Error('No frames were recorded for this scene.');
19
+ const sorted = [...frames].sort((a, b) => a.ts - b.ts);
20
+ const entry = (f) => `file '${f.file.replace(/'/g, "'\\''")}'`;
21
+ const lines = [];
22
+ sorted.forEach((f, i) => {
23
+ const until = i + 1 < sorted.length ? sorted[i + 1].ts : Math.max(end, f.ts + 1 / FPS);
24
+ lines.push(entry(f), `duration ${Math.max(0.001, until - f.ts).toFixed(3)}`);
25
+ });
26
+ // The demuxer ignores the last entry's duration, so the last frame is listed once more.
27
+ lines.push(entry(sorted[sorted.length - 1]));
28
+ return `${lines.join('\n')}\n`;
29
+ }
30
+ /** How long a scene runs once assembled. */
31
+ export function sceneSeconds(s) {
32
+ if (!s.frames.length)
33
+ return 0;
34
+ return Math.max(s.end, Math.max(...s.frames.map((f) => f.ts))) - Math.min(...s.frames.map((f) => f.ts));
35
+ }
36
+ /**
37
+ * Escapes a value for a filter option inside an ffmpeg filtergraph. Two levels:
38
+ * the option value (\ ' :) and then the filtergraph itself (\ ' [ ] , ;).
39
+ */
40
+ export function ffEscape(value) {
41
+ const option = value.replace(/[\\':]/g, (c) => `\\${c}`);
42
+ return option.replace(/[\\'[\],;]/g, (c) => `\\${c}`);
43
+ }
44
+ /**
45
+ * A caption as at most `maxLines` lines of about `width` characters. Control
46
+ * characters become spaces (drawtext would draw them as boxes); anything that
47
+ * doesn't fit ends in an ellipsis.
48
+ */
49
+ export function wrapCaption(text, width = 64, maxLines = 2) {
50
+ const words = text.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().split(/\s+/).filter(Boolean);
51
+ const lines = [];
52
+ for (const w of words) {
53
+ const last = lines[lines.length - 1];
54
+ if (last !== undefined && `${last} ${w}`.length <= width)
55
+ lines[lines.length - 1] = `${last} ${w}`;
56
+ else
57
+ lines.push(w);
58
+ }
59
+ if (lines.length <= maxLines)
60
+ return lines;
61
+ const kept = lines.slice(0, maxLines);
62
+ let tail = kept[maxLines - 1];
63
+ while (tail.length > width - 1 && tail.includes(' '))
64
+ tail = tail.slice(0, tail.lastIndexOf(' '));
65
+ kept[maxLines - 1] = `${tail.replace(/[\s,;:.]+$/, '')}…`;
66
+ return kept;
67
+ }
68
+ /** Width and height from a JPEG's start-of-frame marker. */
69
+ export function jpegSize(b) {
70
+ if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8)
71
+ return null;
72
+ let i = 2;
73
+ while (i + 8 < b.length) {
74
+ if (b[i] !== 0xff) {
75
+ i++;
76
+ continue;
77
+ }
78
+ const marker = b[i + 1];
79
+ if (marker === 0xff) {
80
+ i++;
81
+ continue;
82
+ }
83
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
84
+ i += 2;
85
+ continue;
86
+ }
87
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
88
+ return { height: (b[i + 5] << 8) | b[i + 6], width: (b[i + 7] << 8) | b[i + 8] };
89
+ }
90
+ i += 2 + ((b[i + 2] << 8) | b[i + 3]);
91
+ }
92
+ return null;
93
+ }
94
+ /** Even dimensions (H.264 needs them) within the maximum, keeping the aspect ratio. */
95
+ export function outputSize(width, height, maxWidth = 1920, maxHeight = 1200) {
96
+ const s = Math.min(1, maxWidth / width, maxHeight / height);
97
+ const even = (n) => Math.max(2, Math.floor((n * s) / 2) * 2);
98
+ return { width: even(width), height: even(height) };
99
+ }
100
+ export function findFfmpeg(path = process.env.PATH ?? '') {
101
+ for (const dir of [...path.split(delimiter), '/opt/homebrew/bin', '/usr/local/bin']) {
102
+ if (dir && existsSync(join(dir, 'ffmpeg')))
103
+ return join(dir, 'ffmpeg');
104
+ }
105
+ return null;
106
+ }
107
+ /** A drawtext filter reading its text from a file, so no caption needs filtergraph escaping. */
108
+ function drawtext(o) {
109
+ return [`drawtext=fontfile=${ffEscape(o.font)}`, `textfile=${ffEscape(o.textfile)}`, 'expansion=none', `fontsize=${o.size}`, `fontcolor=${o.color}`, 'x=(w-tw)/2', `y=${o.y}`].join(':');
110
+ }
111
+ /** Text colours that read on a background, given as 0xRRGGBB: light on dark, dark on light. */
112
+ export function inkFor(bg) {
113
+ const n = parseInt(bg.replace(/^(0x|#)/, ''), 16);
114
+ const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
115
+ const light = 0.2126 * r + 0.7152 * g + 0.0722 * b > 140;
116
+ return light ? { text: '0x1d1d1f', muted: '0x5f6673' } : { text: 'white', muted: '0x9aa3b2' };
117
+ }
118
+ /** The app's background colour, from a frame's bottom-left corner, so the borders and caption band blend in. */
119
+ function sampleBackground(ffmpeg, file, cwd) {
120
+ return new Promise((done) => {
121
+ const p = spawn(ffmpeg, ['-v', 'error', '-i', file, '-vf', 'format=rgb24,crop=1:1:4:ih-5', '-frames:v', '1', '-f', 'rawvideo', 'pipe:1'], { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
122
+ const chunks = [];
123
+ p.stdout.on('data', (d) => chunks.push(d));
124
+ p.on('error', () => done(null));
125
+ p.on('close', (code) => {
126
+ const px = Buffer.concat(chunks);
127
+ done(code === 0 && px.length >= 3 ? `0x${[px[0], px[1], px[2]].map((v) => v.toString(16).padStart(2, '0')).join('')}` : null);
128
+ });
129
+ });
130
+ }
131
+ function run(cmd, args, cwd) {
132
+ return new Promise((done, fail) => {
133
+ const p = spawn(cmd, args, { cwd, stdio: ['ignore', 'ignore', 'pipe'] });
134
+ let err = '';
135
+ p.stderr.on('data', (d) => (err += d));
136
+ p.on('error', fail);
137
+ p.on('close', (code) => (code === 0 ? done() : fail(new Error(`ffmpeg failed: ${err.trim().split('\n').slice(-3).join(' ')}`))));
138
+ });
139
+ }
140
+ /**
141
+ * Builds the video in `work` (where the frames are) and writes it to `out`.
142
+ * Every part is encoded the same way so they join without re-encoding.
143
+ */
144
+ export async function buildVideo(o) {
145
+ const { width: W, height: H } = o.size;
146
+ const k = H / 1200;
147
+ const font = FONTS.find((f) => existsSync(f));
148
+ if (!font)
149
+ throw new Error(`No font for captions; looked for ${FONTS.join(', ')}`);
150
+ const enc = ['-an', '-c:v', 'libx264', '-preset', 'medium', '-crf', '18', '-pix_fmt', 'yuv420p', '-r', String(FPS), '-video_track_timescale', '15360'];
151
+ const parts = [];
152
+ const first = o.scenes.find((s) => s.frames.length)?.frames[0];
153
+ const bg = (first && (await sampleBackground(o.ffmpeg, first.file, o.work))) ?? DARK;
154
+ const ink = inkFor(bg);
155
+ // Title card: the name large, one line of what it does underneath.
156
+ await writeFile(join(o.work, 'title.txt'), o.title);
157
+ const sub = o.subtitle ? wrapCaption(o.subtitle, 70) : [];
158
+ for (const [i, line] of sub.entries())
159
+ await writeFile(join(o.work, `title-${i}.txt`), line);
160
+ const titleSize = Math.round(76 * k);
161
+ const subSize = Math.round(38 * k);
162
+ const titleY = sub.length ? `(h/2)-th-${Math.round(24 * k)}` : '(h-th)/2';
163
+ const titleFilters = [
164
+ drawtext({ textfile: 'title.txt', font, size: titleSize, color: ink.text, y: titleY }),
165
+ ...sub.map((_, i) => drawtext({ textfile: `title-${i}.txt`, font, size: subSize, color: ink.muted, y: `(h/2)+${Math.round((24 + i * 54) * k)}` })),
166
+ 'format=yuv420p',
167
+ ];
168
+ await run(o.ffmpeg, ['-y', '-v', 'error', '-f', 'lavfi', '-i', `color=c=${bg}:s=${W}x${H}:r=${FPS}:d=${TITLE_SECONDS}`, '-vf', titleFilters.join(','), ...enc, 'part-title.mp4'], o.work);
169
+ parts.push('part-title.mp4');
170
+ // Scenes: the window at real speed above a band that holds the caption, so it never covers the app.
171
+ const band = Math.round((136 * k) / 2) * 2;
172
+ const capSize = Math.round(38 * k);
173
+ const lineH = Math.round(50 * k);
174
+ for (const [i, s] of o.scenes.entries()) {
175
+ await writeFile(join(o.work, `scene-${i}.txt`), concatList(s.frames, s.end));
176
+ const lines = wrapCaption(s.caption, 80);
177
+ const filters = [`scale=${W}:${H - band}:force_original_aspect_ratio=decrease:flags=lanczos`, `pad=${W}:${H}:(ow-iw)/2:0:color=${bg}`, `fps=${FPS}`];
178
+ const top = H - band + Math.round((band - lines.length * lineH) / 2);
179
+ for (const [j, line] of lines.entries()) {
180
+ await writeFile(join(o.work, `caption-${i}-${j}.txt`), line);
181
+ filters.push(drawtext({ textfile: `caption-${i}-${j}.txt`, font, size: capSize, color: ink.text, y: `${top + j * lineH}+(${lineH}-th)/2` }));
182
+ }
183
+ filters.push('format=yuv420p');
184
+ await run(o.ffmpeg, ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', `scene-${i}.txt`, '-vf', filters.join(','), ...enc, `part-${i}.mp4`], o.work);
185
+ parts.push(`part-${i}.mp4`);
186
+ }
187
+ await writeFile(join(o.work, 'parts.txt'), parts.map((p) => `file '${p}'`).join('\n') + '\n');
188
+ await run(o.ffmpeg, ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', 'parts.txt', '-c', 'copy', '-movflags', '+faststart', resolve(o.out)], o.work);
189
+ return TITLE_SECONDS + o.scenes.reduce((n, s) => n + sceneSeconds(s), 0);
190
+ }
package/dist/zip.d.ts CHANGED
@@ -1,4 +1,18 @@
1
- export declare function zip(files: {
1
+ /** 1980-01-01 00:00 in DOS format: (year - 1980) << 9 | month << 5 | day. */
2
+ export declare const DOS_DATE: number;
3
+ export interface ZipEntry {
2
4
  path: string;
3
5
  data: Uint8Array | string;
4
- }[]): Uint8Array;
6
+ /** Unix permission bits; 0o644 unless given. Use 0o755 for scripts. */
7
+ mode?: number;
8
+ }
9
+ export declare function zip(files: ZipEntry[]): Uint8Array;
10
+ export interface UnzippedEntry {
11
+ path: string;
12
+ data: Uint8Array;
13
+ /** Unix permission bits, when the archive was made on Unix. */
14
+ mode: number | null;
15
+ dosDate: number;
16
+ }
17
+ /** Reads every entry from the central directory, inflating and checking each CRC. */
18
+ export declare function unzip(buf: Uint8Array): UnzippedEntry[];