Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 6 additions & 4 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,20 @@ async function runOpen(argv: string[], io: Io): Promise<number> {
}

try {
const { server, url } = await open(file, {
const { server, url, initialized } = await open(file, {
port,
root: parsed.values.root,
viewerDev: parsed.values['viewer-dev'],
launchBrowser: !parsed.values['no-open'],
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
Expand Down
42 changes: 33 additions & 9 deletions packages/cli/src/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand All @@ -60,7 +68,7 @@ export async function open(file = '.', options: OpenOptions = {}): Promise<OpenR
const cwd = options.cwd ?? process.cwd()
const target = resolve(cwd, file)
const browse = (await stat(target).catch(() => null))?.isDirectory() ?? false
const path = await resolveEntry(target)
const { path, initialized } = await resolveEntry(target)

let source: string
try {
Expand Down Expand Up @@ -117,7 +125,7 @@ export async function open(file = '.', options: OpenOptions = {}): Promise<OpenR
url = pageUrl.toString()
}
if (options.launchBrowser && url) launch(url)
return { server, url, document }
return { server, url, document, initialized }
}

/** Fail before boot when a page cannot resolve its document-wide symbols. */
Expand Down Expand Up @@ -171,16 +179,32 @@ function resolveViewerRoot(): { root: string; dist: string } | undefined {
}

/** Resolve a project or document directory without depending on its first filename. */
async function resolveEntry(target: string): Promise<string> {
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 <name> 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) }
Expand All @@ -190,9 +214,9 @@ async function resolveEntry(target: string): Promise<string> {
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}.`])
}

Expand Down
10 changes: 6 additions & 4 deletions packages/cli/test/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/test/open.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading