diff --git a/CHANGELOG.md b/CHANGELOG.md index 0559d91..cde80b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ User-visible changes are recorded here. Versions follow semantic versioning; pre-1.0 releases may change the design format or public APIs. Release notes must describe migrations when an existing document or integration is affected. +## Unreleased + +- The first `uidx dev` sets up the workspace itself when the install script did + not run: recent npm versions block a package's install scripts until they are + approved, which left a fresh install with no `.uidx/`, no `uidx` scripts and + no skills. The setup is the same one the install script performs, and the + command says what it added. + ## 0.1.5 — 2026-09-20 - The prebuilt viewer is served by `@uidx/server`'s own static server instead diff --git a/README.md b/README.md index b642462..36da22d 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,13 @@ doesn't exist. Existing scripts, configuration, and designs are preserved. The CLI, server, viewer, WASM, and bundled fonts live in your project's `node_modules`; a global UIDX installation is not required. -If npm install scripts are disabled, initialize once with -`npx --no-install uidx init`. If your project already uses a script named `uidx`, -choose another with `npx --no-install uidx init --script design`, then run -`npm run design`. +Recent npm versions do not run a package's install script until you approve +it, and warn with `install scripts not yet covered by allowScripts`. Nothing is +lost: the first `npx uidx dev` performs the same setup itself and says so, or +approve the script with `npm install-scripts approve @uidxkit/uidx` and +reinstall. To set up explicitly, run `npx --no-install uidx init`. If your +project already uses a script named `uidx`, choose another with +`npx --no-install uidx init --script design`, then run `npm run design`. ## Your design workspace diff --git a/packages/cli/README.md b/packages/cli/README.md index d96aff7..d69b142 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -21,10 +21,12 @@ merges `.mcp.json`, and installs portable skills. Existing designs, configuratio MCP servers and skills are preserved. The CLI, server, built viewer, WASM and fonts live in your project's `node_modules`; no global installation is needed. -Use `-D` / `--save-dev` to save `@uidxkit/uidx` as a development dependency. If install -scripts are disabled or `UIDX_SKIP_INIT=1` is set, run `npx --no-install uidx init` -after installation. When a script name is already taken, use -`npx --no-install uidx init --script design` and then `npm run design`. +Use `-D` / `--save-dev` to save `@uidxkit/uidx` as a development dependency. Recent +npm versions do not run install scripts until they are approved, so the first +`npx uidx dev` performs the same setup itself; alternatively approve the script with +`npm install-scripts approve @uidxkit/uidx`, or run `npx --no-install uidx init` after +installation (also the path when `UIDX_SKIP_INIT=1` is set). When a script name is +already taken, use `npx --no-install uidx init --script design` and then `npm run design`. ## Configuration and designs diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7ed5c79..8db3422 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -216,7 +216,7 @@ async function runOpen(argv: string[], io: Io): Promise { } try { - const { server, url } = await open(file, { + const { server, url, initialized } = await open(file, { port, root: parsed.values.root, viewerDev: parsed.values['viewer-dev'], @@ -224,6 +224,12 @@ async function runOpen(argv: string[], io: Io): Promise { verbose: parsed.values.verbose, cwd: io.cwd, }) + if (initialized) { + io.out( + `No .uidx workspace was found, so uidx set one up in ${initialized} — the setup npm's install script does when it is allowed to run.\n` + + 'Added the uidx and uidx:mcp scripts to package.json, an entry to .mcp.json, and the skills.\n', + ) + } io.out(url ? `uidx serving ${file} at ${url}\n` : `uidx watching ${file} (no viewer root)\n`) await waitForInterrupt(server) return 0 diff --git a/packages/cli/src/commands/open.ts b/packages/cli/src/commands/open.ts index 45e7ea8..0076999 100644 --- a/packages/cli/src/commands/open.ts +++ b/packages/cli/src/commands/open.ts @@ -17,7 +17,8 @@ import { type LoadedDocument, } from '@uidx/server/document' -import { exists, projectContentRoot } from '../project.js' +import { exists, findProjectRoot, PROJECT_DIR, projectContentRoot } from '../project.js' +import { initProject } from './init.js' export interface OpenOptions { port?: number @@ -37,6 +38,13 @@ export interface OpenOptions { } export interface OpenResult { + /** + * The project root whose workspace this run created, or null when one + * already existed. npm now blocks install scripts unless they are approved, + * so the postinstall that used to do this setup cannot be relied on; the + * first `uidx dev` does it instead, and says so. + */ + initialized: string | null server: UidxServer url: string | null /** Null only when `requireDocument` is false. */ @@ -60,7 +68,7 @@ export async function open(file = '.', options: OpenOptions = {}): Promise null))?.isDirectory() ?? false - const path = await resolveEntry(target) + const { path, initialized } = await resolveEntry(target) let source: string try { @@ -117,7 +125,7 @@ export async function open(file = '.', options: OpenOptions = {}): Promise { +async function resolveEntry(target: string): Promise<{ path: string; initialized: string | null }> { const info = await stat(target).catch(() => null) - if (!info?.isDirectory()) return target - const content = (await exists(resolve(target, '.uidx', MANIFEST_NAME))) + if (!info?.isDirectory()) return { path: target, initialized: null } + let content = (await exists(resolve(target, '.uidx', MANIFEST_NAME))) ? resolve(target, '.uidx') : (await exists(resolve(target, MANIFEST_NAME))) ? target : await projectContentRoot(target) + let initialized: string | null = null if (!content) { - throw new BootError(['No uidx workspace found. Run uidx init inside your project first.']) + // An npm project with no workspace yet: the install script that would + // have set it up did not run — npm blocks install scripts unless they + // are approved — so the first run does the same setup itself. + const root = await findProjectRoot(target) + if (!root) { + throw new BootError(['No uidx workspace found. Run uidx init inside your project first.']) + } + try { + initialized = await initProject(target) + } catch (error) { + throw new BootError([ + `No uidx workspace found, and setting one up failed: ${(error as Error).message}`, + 'Run uidx init inside your project, with --script if the default script name is taken.', + ]) + } + content = resolve(initialized, PROJECT_DIR) } const path = resolve(content, MANIFEST_NAME) const found = { path, dir: content, manifest: await readManifest(path) } @@ -190,9 +214,9 @@ async function resolveEntry(target: string): Promise { for (const member of members) { const file = resolve(content, member) const result = parse(await readFile(file, 'utf8')) - if (!result.doc || result.doc.tree.element !== 'Tokens') return file + if (!result.doc || result.doc.tree.element !== 'Tokens') return { path: file, initialized } } - if (members[0]) return resolve(content, members[0]) + if (members[0]) return { path: resolve(content, members[0]), initialized } throw new BootError([`No .uidx pages are declared in ${path}. Add a page inside ${content}.`]) } diff --git a/packages/cli/test/init.test.ts b/packages/cli/test/init.test.ts index 0f3067a..de5d99c 100644 --- a/packages/cli/test/init.test.ts +++ b/packages/cli/test/init.test.ts @@ -200,15 +200,17 @@ describe('project setup', () => { }) it('reports missing setup and invalid ports clearly', async () => { - expect(await invoke(['dev', '--no-open'])).toMatchObject({ - code: 1, - out: expect.stringContaining('uidx init'), - }) expect(await invoke(['dev', '--port', '70000'])).toMatchObject({ code: 1, err: expect.stringContaining('65535'), }) + // Inside an npm project a missing workspace is set up by `dev` itself + // (see open.test.ts); outside one there is nothing to set up, so it says how. await rm(join(dir, 'package.json')) + expect(await invoke(['dev', '--no-open'])).toMatchObject({ + code: 1, + out: expect.stringContaining('uidx init'), + }) expect(await invoke(['init'])).toMatchObject({ code: 1, err: expect.stringContaining('package.json'), diff --git a/packages/cli/test/open.test.ts b/packages/cli/test/open.test.ts index f6fff0f..24b6560 100644 --- a/packages/cli/test/open.test.ts +++ b/packages/cli/test/open.test.ts @@ -76,6 +76,42 @@ describe('uidx open', () => { expect(await readFile(file, 'utf8')).toBe(VALID) }) + /** + * npm blocks install scripts unless they are approved, so the postinstall + * that set the workspace up is no longer something an install can count on. + * The first run has to do the same setup itself, or the documented path — + * install, then `npm run uidx` — dead-ends with "no workspace". + */ + it('sets the workspace up on the first run when the install script did not', async () => { + const app = join(dir, 'app') + await mkdir(app) + await writeFile(join(app, 'package.json'), '{"name":"fresh-app"}') + const result = await open('.', { cwd: app, launchBrowser: false }) + server = result.server + expect(result.initialized).toBe(app) + expect(result.document?.dir).toBe(join(app, '.uidx')) + expect(result.document?.pages.map((page) => page.file)).toEqual(['welcome.uidx']) + expect(JSON.parse(await readFile(join(app, 'package.json'), 'utf8')).scripts).toMatchObject({ + uidx: 'uidx dev', + 'uidx:mcp': 'uidx mcp', + }) + expect(result.url).toBe(server.url) + }) + + it('still refuses a directory that is not an npm project', async () => { + const loose = join(dir, 'loose') + await mkdir(loose) + await expect(open('.', { cwd: loose, launchBrowser: false })).rejects.toBeInstanceOf(BootError) + }) + + it('reports an existing workspace as not newly set up', async () => { + await writeFile(join(dir, 'package.json'), '{"name":"app"}') + await initProject(dir) + const result = await open('.', { cwd: dir, launchBrowser: false }) + server = result.server + expect(result.initialized).toBeNull() + }) + it('parses once and starts watching', async () => { const result = await open(file, { launchBrowser: false }) server = result.server