From 6d539f8557e2bc09b830aac19c5d2422b4fa1856 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 22 Jul 2026 00:25:07 +0000 Subject: [PATCH] feat: add `pnpm run play` playground picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/play.ts, an autocomplete prompt (via `prompts`) that discovers every runnable playground under packages/*, plugins/*, examples/*, storybook, and docs — mirroring the workspace glob list in verify-typecheck-coverage.ts — and launches its dev/storybook/docs/start script in place. New playgrounds are picked up automatically with no change to this script. --- package.json | 3 ++ pnpm-lock.yaml | 53 +++++++++++++++++++---- pnpm-workspace.yaml | 2 + scripts/play.ts | 100 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 scripts/play.ts diff --git a/package.json b/package.json index 97c2bb78..2b59d008 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "scripts": { "build": "turbo run build --concurrency=3", "watch": "pnpm -r run watch", + "play": "tsx scripts/play.ts", "docs": "pnpm -C docs run docs", "docs:build": "pnpm -C docs run docs:build", "docs:serve": "pnpm -C docs run docs:serve", @@ -37,10 +38,12 @@ "@antfu/utils": "catalog:inlined", "@playwright/test": "catalog:testing", "@types/node": "catalog:types", + "@types/prompts": "catalog:types", "@types/ws": "catalog:types", "bumpp": "catalog:tooling", "eslint": "catalog:tooling", "nano-staged": "catalog:tooling", + "prompts": "catalog:tooling", "simple-git-hooks": "catalog:tooling", "skills-npm": "catalog:tooling", "tsdown": "catalog:build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bb1adb5..e9e5f943 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -296,6 +296,9 @@ catalogs: nano-staged: specifier: ^1.0.2 version: 1.0.2 + prompts: + specifier: ^2.4.2 + version: 2.4.2 simple-git-hooks: specifier: ^2.13.1 version: 2.13.1 @@ -309,6 +312,9 @@ catalogs: '@types/node': specifier: ^26.1.1 version: 26.1.1 + '@types/prompts': + specifier: ^2.4.9 + version: 2.4.9 '@types/react': specifier: ^19.2.17 version: 19.2.17 @@ -344,6 +350,9 @@ importers: '@types/node': specifier: catalog:types version: 26.1.1 + '@types/prompts': + specifier: catalog:types + version: 2.4.9 '@types/ws': specifier: catalog:types version: 8.18.1 @@ -356,6 +365,9 @@ importers: nano-staged: specifier: catalog:tooling version: 1.0.2 + prompts: + specifier: catalog:tooling + version: 2.4.2 simple-git-hooks: specifier: catalog:tooling version: 2.13.1 @@ -4431,6 +4443,9 @@ packages: '@types/node@26.1.1': resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + '@types/prompts@2.4.9': + resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -6958,6 +6973,10 @@ packages: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -7989,6 +8008,10 @@ packages: promise@7.3.1: resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} @@ -9717,14 +9740,14 @@ snapshots: '@babel/core@7.29.0(supports-color@10.2.2)': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.29.2 '@babel/parser': 7.29.7 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0(supports-color@10.2.2) + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 @@ -9772,7 +9795,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 + browserslist: 4.28.6 lru-cache: 5.1.1 semver: 7.8.5 @@ -9806,7 +9829,7 @@ snapshots: '@babel/helper-module-imports@7.28.6(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.0(supports-color@10.2.2) + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -9816,7 +9839,7 @@ snapshots: '@babel/core': 7.29.0(supports-color@10.2.2) '@babel/helper-module-imports': 7.28.6(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.0(supports-color@10.2.2) + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -9856,7 +9879,7 @@ snapshots: '@babel/helpers@7.29.2': dependencies: - '@babel/template': 7.28.6 + '@babel/template': 7.29.7 '@babel/types': 7.29.7 '@babel/parser@7.29.7': @@ -9870,7 +9893,7 @@ snapshots: '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.0(supports-color@10.2.2) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.0(supports-color@10.2.2))': dependencies: @@ -12474,6 +12497,11 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/prompts@2.4.9': + dependencies: + '@types/node': 26.1.1 + kleur: 3.0.3 + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 @@ -15229,6 +15257,8 @@ snapshots: kind-of@6.0.3: {} + kleur@3.0.3: {} + kleur@4.1.5: {} klona@2.0.6: {} @@ -16741,6 +16771,11 @@ snapshots: dependencies: asap: 2.0.6 + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + proper-lockfile@4.1.2: dependencies: graceful-fs: 4.2.11 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f54d812..ad54cf53 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -136,11 +136,13 @@ catalogs: bumpp: ^11.1.0 eslint: ^10.7.0 nano-staged: ^1.0.2 + prompts: ^2.4.2 simple-git-hooks: ^2.13.1 skills-npm: ^1.2.0 typescript: ^6.0.3 types: '@types/node': ^26.1.1 + '@types/prompts': ^2.4.9 '@types/react': ^19.2.17 '@types/react-dom': ^19.2.3 '@types/ws': ^8.18.1 diff --git a/scripts/play.ts b/scripts/play.ts new file mode 100644 index 00000000..515a8a28 --- /dev/null +++ b/scripts/play.ts @@ -0,0 +1,100 @@ +import type { Choice } from 'prompts' +import { spawnSync } from 'node:child_process' +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import prompts from 'prompts' + +/** + * Workspace globs, mirrored from `pnpm-workspace.yaml`'s `packages:` list + * (same mirroring rationale as `scripts/verify-typecheck-coverage.ts`): a + * new example, plugin, or other playground shows up here for free, with no + * change to this script. + */ +const WORKSPACE_PATTERNS = ['packages/*', 'plugins/*', 'examples/*', 'storybook', 'docs'] + +/** + * Script names that make a workspace package runnable as a "play" — the + * first one present in a package's `scripts` wins. + */ +const RUN_SCRIPTS = ['dev', 'storybook', 'docs', 'start'] + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +interface Play { + dir: string + pkgName: string + script: string +} + +function expandPattern(pattern: string): string[] { + if (!pattern.endsWith('/*')) + return [pattern] + const base = pattern.slice(0, -2) + const baseDir = join(rootDir, base) + if (!existsSync(baseDir)) + return [] + return readdirSync(baseDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => `${base}/${entry.name}`) +} + +function findPlay(dir: string): Play | undefined { + const pkgPath = join(rootDir, dir, 'package.json') + if (!existsSync(pkgPath)) + return undefined + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) + const script = RUN_SCRIPTS.find(name => pkg.scripts?.[name]) + if (!script) + return undefined + return { dir, pkgName: pkg.name, script } +} + +function suggest(input: string, choices: Choice[]): Promise { + const needle = input.toLowerCase() + return Promise.resolve(choices.filter(choice => choice.title.toLowerCase().includes(needle))) +} + +async function main(): Promise { + const plays = WORKSPACE_PATTERNS + .flatMap(expandPattern) + .map(findPlay) + .filter((play): play is Play => play !== undefined) + .sort((a, b) => a.dir.localeCompare(b.dir)) + + if (plays.length === 0) { + console.error(`No playgrounds found — none of ${WORKSPACE_PATTERNS.join(', ')} has a package.json with a ${RUN_SCRIPTS.join('/')} script.`) + process.exitCode = 1 + return + } + + const { play } = await prompts({ + type: 'autocomplete', + name: 'play', + message: 'Select a playground to run', + choices: plays.map(p => ({ + title: p.dir, + description: `${p.pkgName} · pnpm run ${p.script}`, + value: p, + })), + suggest, + }) as { play?: Play } + + if (!play) { + console.log('No playground selected.') + return + } + + console.log(`\n▶ pnpm run ${play.script} (${play.dir})\n`) + + const result = spawnSync('pnpm', ['run', play.script], { + cwd: join(rootDir, play.dir), + stdio: 'inherit', + shell: false, + }) + + process.exitCode = result.status ?? 1 +} + +main()