@rwoll/canvas-kit 0.0.0-stage → 0.1.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 (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +482 -2
  3. package/cli/canvas-kit.mjs +148 -0
  4. package/cli/lib.mjs +697 -0
  5. package/cli/release.json +4 -0
  6. package/dist/client.d.ts +72 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +156 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/define.d.ts +249 -0
  11. package/dist/define.d.ts.map +1 -0
  12. package/dist/define.js +197 -0
  13. package/dist/define.js.map +1 -0
  14. package/dist/errors.d.ts +12 -0
  15. package/dist/errors.d.ts.map +1 -0
  16. package/dist/errors.js +39 -0
  17. package/dist/errors.js.map +1 -0
  18. package/dist/extension.d.ts +25 -0
  19. package/dist/extension.d.ts.map +1 -0
  20. package/dist/extension.js +70 -0
  21. package/dist/extension.js.map +1 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +4 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/jobs.d.ts +56 -0
  27. package/dist/jobs.d.ts.map +1 -0
  28. package/dist/jobs.js +33 -0
  29. package/dist/jobs.js.map +1 -0
  30. package/dist/playwright.d.ts +79 -0
  31. package/dist/playwright.d.ts.map +1 -0
  32. package/dist/playwright.js +124 -0
  33. package/dist/playwright.js.map +1 -0
  34. package/dist/react.d.ts +55 -0
  35. package/dist/react.d.ts.map +1 -0
  36. package/dist/react.js +72 -0
  37. package/dist/react.js.map +1 -0
  38. package/dist/server/headless.d.ts +20 -0
  39. package/dist/server/headless.d.ts.map +1 -0
  40. package/dist/server/headless.js +35 -0
  41. package/dist/server/headless.js.map +1 -0
  42. package/dist/server/host.d.ts +146 -0
  43. package/dist/server/host.d.ts.map +1 -0
  44. package/dist/server/host.js +1078 -0
  45. package/dist/server/host.js.map +1 -0
  46. package/dist/server/index.d.ts +9 -0
  47. package/dist/server/index.d.ts.map +1 -0
  48. package/dist/server/index.js +5 -0
  49. package/dist/server/index.js.map +1 -0
  50. package/dist/server/release.d.ts +30 -0
  51. package/dist/server/release.d.ts.map +1 -0
  52. package/dist/server/release.js +71 -0
  53. package/dist/server/release.js.map +1 -0
  54. package/dist/server/storage.d.ts +63 -0
  55. package/dist/server/storage.d.ts.map +1 -0
  56. package/dist/server/storage.js +253 -0
  57. package/dist/server/storage.js.map +1 -0
  58. package/dist/testing.d.ts +49 -0
  59. package/dist/testing.d.ts.map +1 -0
  60. package/dist/testing.js +42 -0
  61. package/dist/testing.js.map +1 -0
  62. package/dist/vite.d.ts +15 -0
  63. package/dist/vite.d.ts.map +1 -0
  64. package/dist/vite.js +213 -0
  65. package/dist/vite.js.map +1 -0
  66. package/package.json +115 -3
  67. package/styles.css +122 -0
  68. package/templates/canvas/README.md +19 -0
  69. package/templates/canvas/e2e/canvas.spec.ts +15 -0
  70. package/templates/canvas/index.html +13 -0
  71. package/templates/canvas/package.json +29 -0
  72. package/templates/canvas/playwright.config.ts +5 -0
  73. package/templates/canvas/server/canvas.test.ts +22 -0
  74. package/templates/canvas/server/canvas.ts +27 -0
  75. package/templates/canvas/src/App.css +21 -0
  76. package/templates/canvas/src/App.tsx +31 -0
  77. package/templates/canvas/src/index.css +3 -0
  78. package/templates/canvas/src/main.tsx +11 -0
  79. package/templates/canvas/tsconfig.app.json +1 -0
  80. package/templates/canvas/tsconfig.json +7 -0
  81. package/templates/canvas/tsconfig.node.json +1 -0
  82. package/templates/canvas/vite.config.ts +7 -0
  83. package/templates/ui/README.md +18 -0
  84. package/templates/ui/package.json +22 -0
  85. package/templates/ui/src/Checklist.tsx +42 -0
  86. package/templates/ui/src/Header.tsx +17 -0
  87. package/templates/ui/src/Markdown.tsx +140 -0
  88. package/templates/ui/src/TextButton.tsx +6 -0
  89. package/templates/ui/src/index.ts +4 -0
  90. package/templates/ui/theme.css +143 -0
  91. package/templates/ui/tsconfig.json +18 -0
  92. package/tsconfig/canvas.json +27 -0
  93. package/tsconfig/node.json +24 -0
package/cli/lib.mjs ADDED
@@ -0,0 +1,697 @@
1
+ // Library behind the `canvas-kit` command and `@rwoll/create-canvas`. Plain ESM so it
2
+ // runs without a build step, including from a workspace checkout of the kit.
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
+ import { access, appendFile, cp, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, extname, join, relative, resolve } from 'node:path';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+
9
+ const kitRoot = fileURLToPath(new URL('..', import.meta.url));
10
+ const templateDir = join(kitRoot, 'templates', 'canvas');
11
+ const uiTemplateDir = join(kitRoot, 'templates', 'ui');
12
+
13
+ /** The repository that hosts the reusable workflow. Update when the repository is renamed. */
14
+ export const KIT_REPOSITORY = 'rwoll/canvas-kit';
15
+ export const CONFIG_FILE = 'marketplace.config.json';
16
+ export const STAMP_FILE = 'release.json';
17
+ const MANIFESTS = ['.plugin/plugin.json', 'plugin.json', '.github/plugin/plugin.json', '.claude-plugin/plugin.json'];
18
+ const SKIPPED = new Set(['node_modules', '.DS_Store']);
19
+ const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
20
+ const REPOSITORY = /^[\w.-]+\/[\w.-]+$/;
21
+
22
+ /**
23
+ * Versions written into new canvases. A test keeps them in step with the kit's
24
+ * own workspace catalog.
25
+ */
26
+ export const VERSIONS = {
27
+ '@playwright/test': '^1.60.0',
28
+ '@types/node': '^24.13.3',
29
+ '@types/react': '^19.2.18',
30
+ '@types/react-dom': '^19.2.7',
31
+ react: '^19.2.8',
32
+ 'react-dom': '^19.2.8',
33
+ typescript: '~6.0.2',
34
+ vite: '^8.3.0',
35
+ vitest: '^5.0.5',
36
+ };
37
+
38
+ export class CliError extends Error {}
39
+
40
+ const exists = path => access(path).then(() => true, () => false);
41
+ const directories = async path =>
42
+ (await exists(path))
43
+ ? (await readdir(path, { withFileTypes: true })).filter(entry => entry.isDirectory()).map(entry => entry.name).sort()
44
+ : [];
45
+
46
+ export async function kitVersion() {
47
+ return JSON.parse(await readFile(join(kitRoot, 'package.json'), 'utf8')).version;
48
+ }
49
+
50
+ /** Written into the npm package at release: the commit it was built from. */
51
+ const RELEASE_FILE = 'cli/release.json';
52
+
53
+ /**
54
+ * The reference new marketplaces use for the kit's reusable workflow: the exact
55
+ * commit this package was released from, with its version as a comment, so a
56
+ * later change to the kit repository never runs in your publish workflow until
57
+ * you update the pin. Unreleased builds use the version tag.
58
+ */
59
+ export async function workflowRef() {
60
+ const version = await kitVersion();
61
+ try {
62
+ const { commit } = JSON.parse(await readFile(join(kitRoot, RELEASE_FILE), 'utf8'));
63
+ if (/^[0-9a-f]{40}$/.test(commit)) return `${commit} # v${version}`;
64
+ } catch {
65
+ // Not a released package.
66
+ }
67
+ return `v${version}`;
68
+ }
69
+
70
+ /**
71
+ * Actions the scaffolded workflows use, pinned to the commits this repository's
72
+ * actions.lock records. Running `gh actions-lock` in a new marketplace moves the
73
+ * pins into its own lockfile; Dependabot keeps them current either way.
74
+ */
75
+ export const ACTIONS = {
76
+ checkout: 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1',
77
+ setupNode: 'actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0',
78
+ pnpm: 'pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0',
79
+ };
80
+
81
+ /** The gh-actions-lock release the scaffolded check verifies lockfiles with: immutable and attested. */
82
+ export const GH_ACTIONS_LOCK = 'v0.4.0';
83
+
84
+ // ---------- configuration ----------
85
+
86
+ /** Read and validate `marketplace.config.json`, filling in defaults. */
87
+ export async function loadConfig(dir) {
88
+ const file = join(dir, CONFIG_FILE);
89
+ if (!(await exists(file))) throw new CliError(`${file} not found; run \`canvas-kit init\` or create it`);
90
+ const raw = JSON.parse(await readFile(file, 'utf8'));
91
+ for (const field of ['name', 'repository']) {
92
+ if (typeof raw[field] !== 'string' || !raw[field]) throw new CliError(`${CONFIG_FILE} needs a "${field}"`);
93
+ }
94
+ if (typeof raw.owner?.name !== 'string' || !raw.owner.name) throw new CliError(`${CONFIG_FILE} needs "owner": { "name": "…" }`);
95
+ if (!REPOSITORY.test(raw.repository)) throw new CliError(`${CONFIG_FILE} "repository" must be owner/name`);
96
+ return {
97
+ publishBranch: 'stable',
98
+ sourceBranch: 'main',
99
+ pluginsDir: 'plugins',
100
+ canvasesDir: 'canvases',
101
+ outDir: 'dist/marketplace',
102
+ checkScript: 'check',
103
+ ...raw,
104
+ };
105
+ }
106
+
107
+ // ---------- canvases ----------
108
+
109
+ const escapers = {
110
+ '.json': value => JSON.stringify(value).slice(1, -1),
111
+ '.ts': value => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"),
112
+ '.tsx': value => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'"),
113
+ '.html': value => value.replace(/[&<>"']/g, char => `&#${char.charCodeAt(0)};`),
114
+ '.md': value => value,
115
+ '.css': value => value,
116
+ };
117
+
118
+ /** Fill the empty dependency ranges of a template package.json. */
119
+ async function fillVersions(pkg, { catalog, kitRange }) {
120
+ for (const field of ['dependencies', 'devDependencies']) {
121
+ for (const dependency of Object.keys(pkg[field] ?? {})) {
122
+ if (pkg[field][dependency]) continue;
123
+ if (dependency === '@rwoll/canvas-kit') pkg[field][dependency] = kitRange ?? (catalog ? 'catalog:' : `^${await kitVersion()}`);
124
+ else if (catalog) pkg[field][dependency] = 'catalog:';
125
+ else if (VERSIONS[dependency]) pkg[field][dependency] = VERSIONS[dependency];
126
+ else throw new CliError(`no version for template dependency ${dependency}`);
127
+ }
128
+ }
129
+ return pkg;
130
+ }
131
+
132
+ /** Copy a template folder, replacing placeholders and filling package.json ranges. */
133
+ async function writeTemplate(from, target, replacements, versions) {
134
+ for (const source of await templateFiles(from)) {
135
+ const escape = escapers[extname(source)];
136
+ if (!escape) throw new CliError(`no escaping rule for template file ${relative(from, source)}`);
137
+ let content = await readFile(source, 'utf8');
138
+ for (const [placeholder, value] of Object.entries(replacements)) content = content.replaceAll(placeholder, escape(value));
139
+ if (relative(from, source) === 'package.json') content = `${JSON.stringify(await fillVersions(JSON.parse(content), versions), null, 2)}\n`;
140
+ const destination = join(target, relative(from, source));
141
+ await mkdir(join(destination, '..'), { recursive: true });
142
+ await writeFile(destination, content);
143
+ }
144
+ }
145
+
146
+ /** Replace text that a template is known to contain, failing loudly if it changed. */
147
+ async function patch(file, from, to) {
148
+ const content = await readFile(file, 'utf8');
149
+ if (!content.includes(from)) throw new CliError(`${file} no longer contains the text canvas-kit expected; update it by hand`);
150
+ await writeFile(file, content.replace(from, to));
151
+ }
152
+
153
+ /**
154
+ * Create the marketplace's shared UI package: React components and a theme that
155
+ * every canvas imports, so canvases look the same and branding changes in one place.
156
+ */
157
+ export async function createUiPackage(marketplaceDir, uiName, options = {}) {
158
+ if (!/^(@[a-z0-9-]+\/)?[a-z0-9-]+$/.test(uiName)) throw new CliError(`"${uiName}" is not a valid package name`);
159
+ const target = join(marketplaceDir, options.folder ?? 'ui');
160
+ if (await exists(target)) throw new CliError(`${relative(marketplaceDir, target) || target} already exists`);
161
+ await writeTemplate(uiTemplateDir, target, { __UI_NAME__: uiName }, { catalog: options.dependencies === 'catalog' });
162
+ return { dir: target, name: uiName };
163
+ }
164
+
165
+ /**
166
+ * Add a shared UI package to an existing marketplace: create it, record it as
167
+ * `ui` in marketplace.config.json, and add it to pnpm-workspace.yaml.
168
+ */
169
+ export async function addUiPackage(marketplaceDir, uiName, options = {}) {
170
+ const config = await loadConfig(marketplaceDir);
171
+ if (config.ui) throw new CliError(`${CONFIG_FILE} already names a shared UI package, ${config.ui}`);
172
+ const folder = options.folder ?? 'ui';
173
+ const ui = await createUiPackage(marketplaceDir, uiName, { ...options, folder });
174
+ const configFile = join(marketplaceDir, CONFIG_FILE);
175
+ await writeFile(configFile, `${JSON.stringify({ ...JSON.parse(await readFile(configFile, 'utf8')), ui: uiName }, null, 2)}\n`);
176
+ const workspace = join(marketplaceDir, 'pnpm-workspace.yaml');
177
+ let workspaceUpdated = false;
178
+ if (await exists(workspace)) {
179
+ const yaml = await readFile(workspace, 'utf8');
180
+ if (/^packages:\s*$/m.test(yaml) && !new RegExp(`^\\s*-\\s*['"]?${folder}['"]?\\s*$`, 'm').test(yaml)) {
181
+ await writeFile(workspace, yaml.replace(/^packages:\s*$/m, `packages:\n - ${folder}`));
182
+ workspaceUpdated = true;
183
+ }
184
+ }
185
+ return { ...ui, workspaceUpdated };
186
+ }
187
+
188
+ async function templateFiles(dir) {
189
+ const files = [];
190
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
191
+ const path = join(dir, entry.name);
192
+ if (entry.isDirectory()) files.push(...(await templateFiles(path)));
193
+ else files.push(path);
194
+ }
195
+ return files;
196
+ }
197
+
198
+ /**
199
+ * Create a canvas package from the template under the marketplace's canvases folder.
200
+ * `dependencies: 'catalog'` writes `catalog:` ranges, for pnpm workspaces with a catalog.
201
+ */
202
+ export async function newCanvas(marketplaceDir, name, options = {}) {
203
+ if (!KEBAB.test(name)) throw new CliError(`"${name}" must be kebab-case, for example release-board`);
204
+ const config = (await exists(join(marketplaceDir, CONFIG_FILE))) ? await loadConfig(marketplaceDir) : undefined;
205
+ const canvasesDir = join(marketplaceDir, config?.canvasesDir ?? 'canvases');
206
+ const id = options.id ?? (options.idPrefix ? `${options.idPrefix}-${name}` : name);
207
+ if (!KEBAB.test(id)) throw new CliError(`canvas id "${id}" must be kebab-case`);
208
+ const title = (options.title ?? name.replace(/-/g, ' ').replace(/^./, char => char.toUpperCase())).trim();
209
+ const description = (options.description ?? `Show the ${title} canvas.`).trim();
210
+ if (!title || !description) throw new CliError('title and description must not be empty');
211
+ if (/[\r\n]/.test(title + description)) throw new CliError('title and description must be a single line');
212
+
213
+ const target = join(canvasesDir, name);
214
+ if (await exists(target)) throw new CliError(`${relative(marketplaceDir, target) || target} already exists`);
215
+ if (config && (await exists(join(marketplaceDir, config.pluginsDir, name)))) {
216
+ throw new CliError(`${config.pluginsDir}/${name} already exists; plugin names must be unique`);
217
+ }
218
+
219
+ const replacements = { __NAME__: name, __CANVAS_ID__: id, __TITLE__: title, __DESCRIPTION__: description };
220
+ await writeTemplate(templateDir, target, replacements, { catalog: options.dependencies === 'catalog', kitRange: options.kitRange });
221
+
222
+ // Use the marketplace's shared components and theme, so every canvas looks the same.
223
+ const ui = options.ui === undefined ? config?.ui : options.ui;
224
+ if (ui) {
225
+ const pkgFile = join(target, 'package.json');
226
+ const pkg = JSON.parse(await readFile(pkgFile, 'utf8'));
227
+ pkg.dependencies = Object.fromEntries(Object.entries({ ...pkg.dependencies, [ui]: 'workspace:*' }).sort(([a], [b]) => a.localeCompare(b)));
228
+ await writeFile(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`);
229
+ await patch(join(target, 'src', 'main.tsx'), "import '@rwoll/canvas-kit/styles.css'\n", `import '@rwoll/canvas-kit/styles.css'\nimport '${ui}/theme.css'\n`);
230
+ const app = join(target, 'src', 'App.tsx');
231
+ await patch(app, "import type canvas from '../server/canvas.ts'\n", `import { Header } from '${ui}'\nimport type canvas from '../server/canvas.ts'\n`);
232
+ await patch(app, '<h1>{TITLE}</h1>', '<Header title={TITLE} />');
233
+ }
234
+ return { dir: target, name, id, title, ...(ui && { ui }) };
235
+ }
236
+
237
+ // ---------- marketplaces ----------
238
+
239
+ /** Create a marketplace repository: config, workspace, publish workflow, and optionally a first canvas. */
240
+ export async function initMarketplace(dir, options) {
241
+ const { name, owner, repository } = options;
242
+ if (!KEBAB.test(name)) throw new CliError(`marketplace name "${name}" must be kebab-case`);
243
+ if (!owner) throw new CliError('owner is required');
244
+ if (!REPOSITORY.test(repository)) throw new CliError('repository must be owner/name');
245
+ if ((await exists(dir)) && (await readdir(dir)).length > 0) throw new CliError(`${dir} is not empty`);
246
+ const version = await kitVersion();
247
+ const ref = await workflowRef();
248
+ const uiName = `@${name}/ui`;
249
+ for (const folder of ['plugins', 'canvases', join('.github', 'workflows')]) await mkdir(join(dir, folder), { recursive: true });
250
+
251
+ const files = {
252
+ [CONFIG_FILE]: { name, owner: { name: owner }, repository, publishBranch: 'stable', sourceBranch: 'main', ui: uiName },
253
+ 'package.json': {
254
+ name,
255
+ private: true,
256
+ type: 'module',
257
+ packageManager: 'pnpm@11.19.0',
258
+ engines: { node: '>=22.12.0' },
259
+ scripts: {
260
+ 'new-canvas': 'canvas-kit new',
261
+ build: 'pnpm -r --filter "./canvases/*" build && canvas-kit build',
262
+ typecheck: 'pnpm -r typecheck',
263
+ test: 'pnpm -r test',
264
+ e2e: 'pnpm -r e2e',
265
+ check: 'pnpm build && pnpm typecheck && pnpm test',
266
+ release: 'canvas-kit release',
267
+ },
268
+ devDependencies: { '@rwoll/canvas-kit': `^${version}` },
269
+ },
270
+ 'pnpm-workspace.yaml': [
271
+ 'packages:',
272
+ ' - ui',
273
+ ' - canvases/*',
274
+ '',
275
+ '# Never run dependency lifecycle scripts, and fail if a dependency needs one.',
276
+ 'ignoreScripts: true',
277
+ 'strictDepBuilds: true',
278
+ '',
279
+ '# Install only versions published at least 7 days ago, including transitive',
280
+ '# dependencies, so a compromised release is usually caught before you get it.',
281
+ '# The canvas-kit release this marketplace was created with is exempt, so it',
282
+ '# installs right away; later versions wait like everything else.',
283
+ 'minimumReleaseAge: 10080',
284
+ 'minimumReleaseAgeIgnoreMissingTime: false',
285
+ 'minimumReleaseAgeExclude:',
286
+ ` - '@rwoll/canvas-kit@${version}'`,
287
+ '',
288
+ ],
289
+ '.gitignore': ['node_modules', 'dist', '*.local', '.DS_Store', 'test-results', 'playwright-report', ''],
290
+ 'plugins/.gitkeep': [''],
291
+ '.github/workflows/publish.yml': [
292
+ '# Publishes the marketplace to the stable branch on every push to main. The',
293
+ '# build runs with a read-only token; only a small push job can write.',
294
+ 'name: Publish marketplace',
295
+ '',
296
+ 'on:',
297
+ ' push:',
298
+ ' branches: [main]',
299
+ ' workflow_dispatch:',
300
+ '',
301
+ 'permissions: {}',
302
+ '',
303
+ 'jobs:',
304
+ ' publish:',
305
+ ' permissions:',
306
+ ' contents: write',
307
+ ` uses: ${KIT_REPOSITORY}/.github/workflows/marketplace.yml@${ref}`,
308
+ ' # To approve each publish, create a `stable` environment with required',
309
+ ' # reviewers, limited to the main branch, and uncomment:',
310
+ ' # with:',
311
+ ' # environment: stable',
312
+ '',
313
+ ],
314
+ '.github/workflows/check.yml': [
315
+ '# Checks pull requests with a read-only token and no secrets.',
316
+ 'name: Check',
317
+ '',
318
+ 'on:',
319
+ ' pull_request:',
320
+ '',
321
+ 'permissions:',
322
+ ' contents: read',
323
+ '',
324
+ 'jobs:',
325
+ ' check:',
326
+ ' runs-on: ubuntu-latest',
327
+ ' timeout-minutes: 30',
328
+ ' steps:',
329
+ ` - uses: ${ACTIONS.checkout}`,
330
+ ' with:',
331
+ ' persist-credentials: false',
332
+ ` - uses: ${ACTIONS.setupNode}`,
333
+ ' with:',
334
+ ' node-version: 24',
335
+ ` - uses: ${ACTIONS.pnpm}`,
336
+ ' - run: pnpm install --frozen-lockfile',
337
+ ' - run: pnpm check',
338
+ '',
339
+ ' # Once you lock your actions with `gh actions-lock`, fail when',
340
+ ' # .github/workflows/actions.lock is out of date or no longer verifies.',
341
+ ' actions-lock:',
342
+ ' runs-on: ubuntu-latest',
343
+ ' timeout-minutes: 10',
344
+ ' env:',
345
+ ' GH_TOKEN: ${{ github.token }}',
346
+ ` GH_ACTIONS_LOCK: ${GH_ACTIONS_LOCK}`,
347
+ ' steps:',
348
+ ` - uses: ${ACTIONS.checkout}`,
349
+ ' with:',
350
+ ' persist-credentials: false',
351
+ ' - name: Verify actions.lock',
352
+ ' run: |',
353
+ ' if [ ! -f .github/workflows/actions.lock ]; then',
354
+ ' echo "::notice::No actions.lock yet. Run gh actions-lock to lock your actions to reviewed commits."',
355
+ ' exit 0',
356
+ ' fi',
357
+ ' gh extension install github/gh-actions-lock --pin "$GH_ACTIONS_LOCK"',
358
+ ' gh attestation verify "${XDG_DATA_HOME:-$HOME/.local/share}/gh/extensions/gh-actions-lock/gh-actions-lock" \\',
359
+ ' --repo github/gh-actions-lock \\',
360
+ ' --signer-workflow github/gh-actions-lock/.github/workflows/release.yml \\',
361
+ ' --source-ref "refs/tags/$GH_ACTIONS_LOCK"',
362
+ ' gh actions-lock --verify',
363
+ '',
364
+ ],
365
+ '.github/dependabot.yml': [
366
+ '# Weekly, grouped updates, with the same 7-day cooldown as pnpm. This also',
367
+ "# updates the pinned canvas-kit workflow and the actions' commit SHAs.",
368
+ 'version: 2',
369
+ 'updates:',
370
+ ' - package-ecosystem: github-actions',
371
+ ' directory: /',
372
+ ' schedule:',
373
+ ' interval: weekly',
374
+ ' cooldown:',
375
+ ' default-days: 7',
376
+ ' groups:',
377
+ ' actions:',
378
+ " patterns: ['*']",
379
+ ' - package-ecosystem: npm',
380
+ ' directory: /',
381
+ ' schedule:',
382
+ ' interval: weekly',
383
+ ' cooldown:',
384
+ ' default-days: 7',
385
+ ' groups:',
386
+ ' dependencies:',
387
+ " patterns: ['*']",
388
+ '',
389
+ ],
390
+ 'README.md': [
391
+ `# ${name}`,
392
+ '',
393
+ `A Copilot plugin marketplace built with [canvas-kit](https://github.com/${KIT_REPOSITORY}).`,
394
+ '',
395
+ '```sh',
396
+ 'pnpm install',
397
+ 'pnpm new-canvas release-board # add a canvas under canvases/',
398
+ 'pnpm --filter release-board dev # preview it in a browser',
399
+ 'pnpm check # build, typecheck, and test',
400
+ '```',
401
+ '',
402
+ `Shared React components and the theme live in \`ui/\` (\`${uiName}\`); every new canvas uses them.`,
403
+ 'Put content-only plugins (skills, agents, MCP servers) under `plugins/<name>/` with a',
404
+ '`plugin.json`. Every push to `main` publishes the marketplace to the `stable` branch.',
405
+ 'Install it with:',
406
+ '',
407
+ '```sh',
408
+ `copilot plugin marketplace add ${repository}#stable`,
409
+ '```',
410
+ '',
411
+ ],
412
+ };
413
+ for (const [path, content] of Object.entries(files)) {
414
+ const text = Array.isArray(content) ? content.join('\n') : `${JSON.stringify(content, null, 2)}\n`;
415
+ await writeFile(join(dir, path), text);
416
+ }
417
+ const ui = await createUiPackage(dir, uiName);
418
+ const canvas = options.canvas ? await newCanvas(dir, options.canvas, { idPrefix: options.idPrefix }) : undefined;
419
+ return { dir, ui, canvas };
420
+ }
421
+
422
+ async function readManifest(pluginDir, name) {
423
+ for (const candidate of MANIFESTS) {
424
+ const file = join(pluginDir, candidate);
425
+ if (!(await exists(file))) continue;
426
+ const manifest = JSON.parse(await readFile(file, 'utf8'));
427
+ for (const field of ['name', 'version', 'description']) {
428
+ if (typeof manifest[field] !== 'string' || !manifest[field]) throw new CliError(`${file} needs a "${field}"`);
429
+ }
430
+ if (manifest.name !== name) throw new CliError(`${file} is named "${manifest.name}" but lives in "${name}"`);
431
+ return manifest;
432
+ }
433
+ throw new CliError(`plugin ${name} has no manifest (${MANIFESTS.join(', ')})`);
434
+ }
435
+
436
+ /**
437
+ * Assemble the installable marketplace: content plugins from `plugins/`, built
438
+ * canvases from `canvases/<name>/dist/plugin`, and a catalog from their manifests.
439
+ */
440
+ export async function buildMarketplace(dir) {
441
+ const config = await loadConfig(dir);
442
+ const outDir = join(dir, config.outDir);
443
+ const sources = new Map();
444
+ for (const name of await directories(join(dir, config.pluginsDir))) sources.set(name, join(dir, config.pluginsDir, name));
445
+ for (const name of await directories(join(dir, config.canvasesDir))) {
446
+ const built = join(dir, config.canvasesDir, name, 'dist', 'plugin');
447
+ if (!(await exists(built))) throw new CliError(`${config.canvasesDir}/${name} is not built; run its build first`);
448
+ if (sources.has(name)) throw new CliError(`"${name}" exists in both ${config.pluginsDir}/ and ${config.canvasesDir}/`);
449
+ sources.set(name, built);
450
+ }
451
+
452
+ await rm(outDir, { recursive: true, force: true });
453
+ await mkdir(join(outDir, '.github', 'plugin'), { recursive: true });
454
+ const plugins = [];
455
+ for (const [name, from] of [...sources].sort(([a], [b]) => a.localeCompare(b))) {
456
+ const target = join(outDir, 'plugins', name);
457
+ await cp(from, target, { recursive: true, filter: source => !SKIPPED.has(source.split(/[\\/]/).at(-1)) });
458
+ if (await exists(join(target, STAMP_FILE))) throw new CliError(`${name}/${STAMP_FILE} is written by \`canvas-kit release\`; remove it from the source`);
459
+ const { version, description } = await readManifest(target, name);
460
+ plugins.push({ name, description, version, source: `./plugins/${name}` });
461
+ }
462
+
463
+ const catalog = { name: config.name, owner: config.owner, ...(config.metadata && { metadata: config.metadata }), plugins };
464
+ await writeFile(join(outDir, '.github', 'plugin', 'marketplace.json'), `${JSON.stringify(catalog, null, 2)}\n`);
465
+ await writeFile(
466
+ join(outDir, 'README.md'),
467
+ [
468
+ `# ${config.name}`,
469
+ '',
470
+ 'Generated plugin marketplace. Do not edit this branch by hand: change the sources on',
471
+ `\`${config.sourceBranch}\` and run \`canvas-kit release\`.`,
472
+ '',
473
+ '```sh',
474
+ `copilot plugin marketplace add ${config.repository}#${config.publishBranch}`,
475
+ '```',
476
+ '',
477
+ ].join('\n'),
478
+ );
479
+ return { catalog, outDir };
480
+ }
481
+
482
+ // ---------- jobs ----------
483
+
484
+ /**
485
+ * Run a canvas's due jobs for its saved documents, with no chat or agent open: every
486
+ * job when `job` is undefined, or one job by name or ID.
487
+ * `plugin` is a built plugin folder: a canvas package's `dist/plugin`, or an installed
488
+ * plugin such as `~/.copilot/installed-plugins/<marketplace>/<plugin>`.
489
+ */
490
+ export async function runJob(dir, job, options = {}) {
491
+ const pluginDir = options.plugin
492
+ ? resolve(dir, options.plugin)
493
+ : (await exists(join(dir, 'dist', 'plugin', 'extensions')))
494
+ ? join(dir, 'dist', 'plugin')
495
+ : dir;
496
+ const extensions = join(pluginDir, 'extensions');
497
+ if (!(await exists(extensions))) {
498
+ throw new CliError(`${pluginDir} is not a built plugin; build the canvas first or pass --plugin <folder>`);
499
+ }
500
+ const all = await directories(extensions);
501
+ const names = [];
502
+ for (const name of all) if (await exists(join(extensions, name, 'canvas.mjs'))) names.push(name);
503
+ const name = options.canvas ?? (names.length === 1 ? names[0] : undefined);
504
+ if (!name) {
505
+ throw new CliError(
506
+ names.length === 0
507
+ ? `${pluginDir} has no canvas.mjs; rebuild it with the current canvas-kit`
508
+ : `${pluginDir} has several canvases (${names.join(', ')}); pass --canvas <name>`,
509
+ );
510
+ }
511
+ if (!names.includes(name)) throw new CliError(`${extensions}/${name}/canvas.mjs not found`);
512
+ const module = await import(pathToFileURL(join(extensions, name, 'canvas.mjs')).href);
513
+ if (typeof module.runJob !== 'function') throw new CliError(`${name}/canvas.mjs has no runJob; rebuild it with the current canvas-kit`);
514
+ try {
515
+ return await module.runJob(job, { key: options.key, force: options.force, dataRoot: options.dataRoot, log: options.log });
516
+ } catch (error) {
517
+ // The canvas bundles its own copy of the kit, so check the error's shape rather than its class.
518
+ if (error && typeof error.code === 'string' && typeof error.message === 'string') throw new CliError(error.message);
519
+ throw error;
520
+ }
521
+ }
522
+
523
+ // ---------- releasing ----------
524
+
525
+ // GitHub Actions command files. The install and check commands run dependency
526
+ // code, which must not set this step's outputs, summary, or later steps' env.
527
+ const ACTIONS_FILES = ['GITHUB_OUTPUT', 'GITHUB_STEP_SUMMARY', 'GITHUB_ENV', 'GITHUB_PATH', 'GITHUB_STATE'];
528
+
529
+ function shell(command, cwd, log) {
530
+ log(`\n$ ${command}`);
531
+ const env = { ...process.env };
532
+ for (const name of ACTIONS_FILES) delete env[name];
533
+ const result = spawnSync(command, { cwd, stdio: 'inherit', shell: true, env });
534
+ if (result.status !== 0) throw new CliError(`${command} failed`);
535
+ }
536
+
537
+ async function packageManager(top) {
538
+ if (await exists(join(top, 'pnpm-lock.yaml'))) return { install: 'pnpm install --frozen-lockfile', run: 'pnpm run' };
539
+ if (await exists(join(top, 'package-lock.json'))) return { install: 'npm ci --ignore-scripts', run: 'npm run' };
540
+ throw new CliError('no pnpm-lock.yaml or package-lock.json at the repository root');
541
+ }
542
+
543
+ async function scriptCommand(marketplaceDir, runner, preferred) {
544
+ const pkg = JSON.parse(await readFile(join(marketplaceDir, 'package.json'), 'utf8').catch(() => '{}'));
545
+ const script = pkg.scripts?.[preferred] ? preferred : 'build';
546
+ if (!pkg.scripts?.[script]) throw new CliError(`${join(marketplaceDir, 'package.json')} needs a "${preferred}" or "build" script`);
547
+ return `${runner} ${script}`;
548
+ }
549
+
550
+ /**
551
+ * Publish the marketplace built from the source branch to the publish branch.
552
+ * Builds in a clean worktree with a frozen-lockfile install, commits only when the
553
+ * output changed, and only moves the publish branch forward. Each plugin gets a
554
+ * `release.json` stamp with the time and source commit of the release that last changed it.
555
+ */
556
+ export async function release(dir, options = {}) {
557
+ const { dryRun = false, skipChecks = false, footer = [], remote = 'origin', log = console.log, bundle, githubOutput = false } = options;
558
+ const config = await loadConfig(dir);
559
+ const top = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8' }).trim();
560
+ const subdir = relative(top, resolve(dir));
561
+ const git = (args, cwd = top) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
562
+ const tryGit = (args, cwd = top) => spawnSync('git', args, { cwd, encoding: 'utf8' });
563
+ const isAncestor = (ancestor, descendant) => tryGit(['merge-base', '--is-ancestor', ancestor, descendant]).status === 0;
564
+ const { publishBranch, sourceBranch } = config;
565
+
566
+ if (git(['status', '--porcelain'])) throw new CliError('commit or stash your changes first so the release matches a commit');
567
+ git(['fetch', '--quiet', remote]);
568
+ const sha = git(['rev-parse', 'HEAD']);
569
+ const tip = git(['rev-parse', `${remote}/${sourceBranch}`]);
570
+ if (sha !== tip) {
571
+ if (process.env.GITHUB_ACTIONS && isAncestor(sha, tip)) {
572
+ log(`${sha.slice(0, 7)} is behind ${remote}/${sourceBranch}; the run for the newer commit publishes it.`);
573
+ return { published: false, reason: 'behind' };
574
+ }
575
+ throw new CliError(
576
+ `only ${remote}/${sourceBranch} is published; check out and pull ${sourceBranch} (HEAD is ${sha.slice(0, 7)}, ${remote}/${sourceBranch} is ${tip.slice(0, 7)})`,
577
+ );
578
+ }
579
+ const subject = git(['log', '-1', '--format=%s', sha]);
580
+
581
+ const remoteExists = tryGit(['ls-remote', '--exit-code', '--heads', remote, publishBranch]).status === 0;
582
+ if (remoteExists) {
583
+ git(['fetch', '--quiet', remote, `${publishBranch}:refs/remotes/${remote}/${publishBranch}`]);
584
+ const previous = git(['log', '-1', '--format=%(trailers:key=Source-Commit,valueonly)', `${remote}/${publishBranch}`]);
585
+ if (previous && !isAncestor(previous, sha)) {
586
+ throw new CliError(
587
+ `${publishBranch} was built from ${previous.slice(0, 7)}, which is not an ancestor of ${sha.slice(0, 7)}; refusing to publish an older or unrelated source`,
588
+ );
589
+ }
590
+ }
591
+
592
+ const source = await mkdtemp(join(tmpdir(), 'canvas-kit-source-'));
593
+ const output = await mkdtemp(join(tmpdir(), 'canvas-kit-publish-'));
594
+ const tempBranch = `canvas-kit-publish-${process.pid}`;
595
+ try {
596
+ // Build from a clean checkout so ignored or stale local files can never be published.
597
+ git(['worktree', 'add', '--quiet', '--detach', source, sha]);
598
+ const marketplace = join(source, subdir);
599
+ const manager = () => packageManager(source);
600
+ shell(config.installCommand ?? (await manager()).install, source, log);
601
+ const build = skipChecks
602
+ ? config.buildCommand ?? (await scriptCommand(marketplace, (await manager()).run, 'build'))
603
+ : config.checkCommand ?? (await scriptCommand(marketplace, (await manager()).run, config.checkScript));
604
+ shell(build, marketplace, log);
605
+
606
+ if (remoteExists) {
607
+ git(['worktree', 'add', '--quiet', '--detach', output, `${remote}/${publishBranch}`]);
608
+ } else {
609
+ git(['worktree', 'add', '--quiet', '--detach', output, sha]);
610
+ git(['switch', '--quiet', '--orphan', tempBranch], output);
611
+ }
612
+ for (const entry of await readdir(output)) {
613
+ if (entry !== '.git') await rm(join(output, entry), { recursive: true, force: true });
614
+ }
615
+ await cp(join(marketplace, config.outDir), output, { recursive: true });
616
+ git(['add', '--all'], output);
617
+ await stampPlugins(output, sha, git, tryGit);
618
+
619
+ if (tryGit(['diff', '--cached', '--quiet'], output).status === 0) {
620
+ log(`\n${publishBranch} already matches ${sha.slice(0, 7)}; nothing to publish.`);
621
+ return { published: false, reason: 'unchanged' };
622
+ }
623
+ const stat = git(['diff', '--cached', '--stat'], output);
624
+ log(`\nChanges for ${publishBranch}:\n${stat}`);
625
+ // Git reads trailers only from the last paragraph, so trailer-style footers join Source-Commit.
626
+ const isTrailer = text => /^[A-Za-z][\w-]*: \S/.test(text);
627
+ const message = [
628
+ `Publish ${sha.slice(0, 7)}: ${subject}`,
629
+ `Built from https://github.com/${config.repository}/commit/${sha}`,
630
+ ...footer.filter(text => !isTrailer(text)),
631
+ [...footer.filter(isTrailer), `Source-Commit: ${sha}`].join('\n'),
632
+ ].join('\n\n');
633
+ if (dryRun) {
634
+ log(`\nDry run: would commit and push to ${publishBranch}:\n\n${message}`);
635
+ return { published: false, reason: 'dry-run', message };
636
+ }
637
+ const commit = spawnSync('git', ['commit', '--quiet', '--file', '-'], { cwd: output, input: message, stdio: ['pipe', 'inherit', 'inherit'] });
638
+ if (commit.status !== 0) throw new CliError('git commit failed');
639
+ if (bundle) {
640
+ const base = remoteExists ? `${remote}/${publishBranch}` : undefined;
641
+ return await writeBundle({ output, file: resolve(bundle), base, branch: publishBranch, sha, stat, git, tryGit, log, githubOutput });
642
+ }
643
+ git(['push', '--quiet', remote, `HEAD:refs/heads/${publishBranch}`], output);
644
+ const published = git(['rev-parse', 'HEAD'], output);
645
+ log(`\nPublished ${published.slice(0, 7)} to ${publishBranch}.`);
646
+ log(`Update installs with:\n copilot plugin marketplace update ${config.name}\n copilot plugin update --all`);
647
+ return { published: true, commit: published };
648
+ } finally {
649
+ for (const worktree of [source, output]) {
650
+ tryGit(['worktree', 'remove', '--force', worktree]);
651
+ await rm(worktree, { recursive: true, force: true });
652
+ }
653
+ if (!remoteExists) tryGit(['branch', '-D', tempBranch]);
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Save the prepared publish commit as a git bundle instead of pushing it, so a
659
+ * separate job that holds the write token can push it without running the build.
660
+ * With `githubOutput`, also writes the step outputs and summary the reusable
661
+ * workflow's push job reads.
662
+ */
663
+ async function writeBundle({ output, file, base, branch, sha, stat, git, tryGit, log, githubOutput }) {
664
+ await mkdir(dirname(file), { recursive: true });
665
+ const created = tryGit(['bundle', 'create', file, 'HEAD', ...(base ? [`^refs/remotes/${base}`] : [])], output);
666
+ if (created.status !== 0) throw new CliError(`git bundle failed: ${created.stderr}`);
667
+ const commit = git(['rev-parse', 'HEAD'], output);
668
+ log(`\nPrepared ${commit.slice(0, 7)} for ${branch} in ${file}.`);
669
+ if (githubOutput && process.env.GITHUB_OUTPUT) await appendFile(process.env.GITHUB_OUTPUT, `commit=${commit}\nbranch=${branch}\n`);
670
+ if (githubOutput && process.env.GITHUB_STEP_SUMMARY) {
671
+ await appendFile(
672
+ process.env.GITHUB_STEP_SUMMARY,
673
+ `### Ready to publish to \`${branch}\`\n\nBuilt from ${sha}. The push job publishes this commit once its environment allows it.\n\n\`\`\`text\n${stat}\n\`\`\`\n`,
674
+ );
675
+ }
676
+ return { published: false, reason: 'bundled', commit, branch, bundle: file };
677
+ }
678
+
679
+ // Restamp plugins whose content changed since the last release; restore the
680
+ // previous stamp for the rest so an unchanged snapshot produces no commit.
681
+ async function stampPlugins(output, sourceCommit, git, tryGit) {
682
+ const hasHead = tryGit(['rev-parse', '--verify', '--quiet', 'HEAD'], output).status === 0;
683
+ const changed = hasHead ? git(['diff', '--cached', '--name-only', 'HEAD'], output).split('\n').filter(Boolean) : [];
684
+ const catalog = JSON.parse(await readFile(join(output, '.github', 'plugin', 'marketplace.json'), 'utf8'));
685
+ const publishedAt = new Date().toISOString();
686
+ for (const { name, version } of catalog.plugins) {
687
+ const stamp = `plugins/${name}/${STAMP_FILE}`;
688
+ const contentChanged = !hasHead || changed.some(path => path.startsWith(`plugins/${name}/`) && path !== stamp);
689
+ const hadStamp = hasHead && tryGit(['cat-file', '-e', `HEAD:${stamp}`], output).status === 0;
690
+ if (contentChanged || !hadStamp) {
691
+ await writeFile(join(output, stamp), `${JSON.stringify({ name, version, publishedAt, sourceCommit }, null, 2)}\n`);
692
+ } else {
693
+ git(['checkout', 'HEAD', '--', stamp], output);
694
+ }
695
+ }
696
+ git(['add', '--all'], output);
697
+ }