@webjsdev/cli 0.10.57 → 0.10.59

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.
package/lib/create.js CHANGED
@@ -18,24 +18,10 @@ import { existsSync } from 'node:fs';
18
18
  import { createRequire } from 'node:module';
19
19
  import { spawnSync } from 'node:child_process';
20
20
  import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
21
+ import { postgresCompose, postgresCi } from './db-rewrite.js';
21
22
  import { assertValidAppName, toDatabaseName } from './app-name.js';
22
23
  import { isGalleryAppShellFile } from './gallery-shell-files.js';
23
-
24
- /**
25
- * Detect which package manager invoked us. Reads `npm_config_user_agent`,
26
- * which npm / pnpm / yarn / bun all set when running scripts or `npx`.
27
- * Falls back to `npm` when nothing is detected (matches what most users
28
- * actually have installed).
29
- *
30
- * @returns {'npm'|'pnpm'|'yarn'|'bun'}
31
- */
32
- function detectPackageManager() {
33
- const ua = process.env.npm_config_user_agent || '';
34
- if (ua.startsWith('pnpm/')) return 'pnpm';
35
- if (ua.startsWith('yarn/')) return 'yarn';
36
- if (ua.startsWith('bun/')) return 'bun';
37
- return 'npm';
38
- }
24
+ import { detectPackageManager } from './package-manager.js';
39
25
 
40
26
  /**
41
27
  * Run `<pm> install` inside the scaffolded app. Returns true on success.
@@ -292,6 +278,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
292
278
  // points (`webjs create` and `npx create-webjs-app`) explicitly set
293
279
  // `install: true` unless the user passes `--no-install`.
294
280
  const shouldInstall = opts.install === true;
281
+ // `--skip-ci` (#1471, `rails new --skip-ci` parity): omit the GitHub
282
+ // workflow. The local `webjs ci` list in package.json is always emitted, so
283
+ // the app still has its gate; only the cloud half is optional.
284
+ const skipCi = opts.skipCi === true;
295
285
  // Defence in depth. The CLI already validates this, but library
296
286
  // callers (tests, programmatic use) might pass anything.
297
287
  const VALID_TEMPLATES = ['full-stack', 'api'];
@@ -323,7 +313,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
323
313
  // with the explicit flag winning over detection. A bun-flavored app SERVES on
324
314
  // Bun (its dev/start scripts force `--bun`), commits `bun.lock`, sets
325
315
  // `trustedDependencies`, and ships a bun Dockerfile / CI / agent docs.
326
- const runtime = opts.runtime || (detectPackageManager() === 'bun' ? 'bun' : 'node');
316
+ // Runtime detection reads ONLY the invoking tool (no lockfile walk): a
317
+ // global `webjs create` run inside someone else's Bun workspace should not
318
+ // silently flip the new app to serving on Bun.
319
+ const runtime = opts.runtime || (detectPackageManager({ cwd: null, prefer: 'agent' }) === 'bun' ? 'bun' : 'node');
327
320
  const VALID_RUNTIMES = ['node', 'bun'];
328
321
  if (!VALID_RUNTIMES.includes(runtime)) {
329
322
  throw new Error(`Unknown --runtime '${runtime}'. Only ${VALID_RUNTIMES.join(' / ')} are supported.`);
@@ -421,6 +414,10 @@ export async function scaffoldApp(name, cwd, opts = {}) {
421
414
  // the environment-shaped checks (env drift, pin freshness over the
422
415
  // network, the git hook) stay warns and cannot make CI flaky.
423
416
  doctor: 'webjs doctor',
417
+ // Local CI (#1471): every check above plus the test layers, one command,
418
+ // from the step list in the `webjs.ci` block below. Runtime-neutral like
419
+ // the other tooling scripts (it spawns `webjs ...` children).
420
+ ci: 'webjs ci',
424
421
  'db:generate': 'webjs db generate',
425
422
  'db:migrate': 'webjs db migrate',
426
423
  'db:push': 'webjs db push',
@@ -467,7 +464,12 @@ export async function scaffoldApp(name, cwd, opts = {}) {
467
464
  // The Tailwind v4 CLI that css:build runs to compile public/input.css into
468
465
  // the static public/tailwind.css the layout links. UI templates only (the
469
466
  // api template has no CSS). Build tooling, never shipped to the runtime.
470
- ...(isApi ? {} : { '@tailwindcss/cli': '^4.1.0' }),
467
+ // `tailwindcss` itself is declared too (#1493): public/input.css starts
468
+ // with `@import "tailwindcss"`, so the app imports that package directly.
469
+ // Leaving it transitive (via @tailwindcss/cli) breaks under bun's isolated
470
+ // linker and pnpm, which link only declared packages into the app's
471
+ // node_modules, so the compile fails with `Can't resolve 'tailwindcss'`.
472
+ ...(isApi ? {} : { '@tailwindcss/cli': '^4.1.0', tailwindcss: '^4.1.0' }),
471
473
  // tsserver plugin, wired into tsconfig below. Gives the language
472
474
  // INTELLIGENCE (go-to-def, completions, diagnostics, hover inside html``
473
475
  // templates) in any tsserver editor with NO editor plugin installed,
@@ -515,8 +517,18 @@ export async function scaffoldApp(name, cwd, opts = {}) {
515
517
  // `npm audit fix --force` proposes: its @web/test-runner-chrome@1 still
516
518
  // declares puppeteer-core ^24, so the same vulnerable chain resolves and
517
519
  // the audit stays red after a breaking major.
520
+ //
521
+ // basic-ftp (#1492) is the same kind of floor for the same runner: it is
522
+ // reached through puppeteer-core's proxy-agent chain and 6.2.2 is its fixed
523
+ // release (GHSA-c475-qrg2-pj4r), so an install that still resolves an older
524
+ // chain cannot land below it.
525
+ //
526
+ // Overrides are honoured ONLY at a workspace root. When this app is a
527
+ // member of an npm or bun workspace, move this block into the root
528
+ // package.json (`webjs doctor` warns with WORKSPACE_OVERRIDES until then).
518
529
  overrides: {
519
530
  'puppeteer-core': '^25.7.0',
531
+ 'basic-ftp': '^6.2.2',
520
532
  },
521
533
  // Dev + start task orchestration (#550). `webjs dev` / `webjs start` read
522
534
  // `before` and run it in-process, so `npm run dev` / `start` (thin aliases
@@ -551,6 +563,12 @@ export async function scaffoldApp(name, cwd, opts = {}) {
551
563
  }),
552
564
  },
553
565
  start: { before: isApi ? ['webjs db migrate'] : ['webjs db migrate', cssBuildCmd] },
566
+ // No `db` block on purpose (#1468). `webjs db` defaults to drizzle-kit,
567
+ // and Drizzle is the scaffold default by OMISSION: an app that swaps the
568
+ // ORM adds `"db": { "migrate": "prisma migrate deploy", ... }` here and
569
+ // the `webjs db migrate` spelling in `before` (and the Dockerfile, and
570
+ // CI) keeps working. Emitting the Drizzle mapping would only duplicate
571
+ // the default into every app.
554
572
  // Which doctor findings are FATAL is the app's own call (#1257), declared
555
573
  // here rather than in the CI workflow so `npm run doctor` locally and the
556
574
  // workflow step agree about what fails. UNMARKED_ASSET_LINKS starts at
@@ -562,6 +580,56 @@ export async function scaffoldApp(name, cwd, opts = {}) {
562
580
  // everything else keeps its default warn. Add a code with "off" to
563
581
  // silence it, or "error" to make it fatal too.
564
582
  doctor: { gate: { UNMARKED_ASSET_LINKS: 'error' } },
583
+ // The dependency audit's allowlist (#1492), the ONE place an accepted
584
+ // advisory is listed, each with the reason it is safe. `webjs audit`
585
+ // (the CI step below) fails on every other advisory at `level` or above,
586
+ // and prints an entry as stale once the audit stops reporting it, so
587
+ // remove it then. Only accept an advisory with NO patched release that
588
+ // the app's users cannot reach; anything with a fix gets upgraded.
589
+ audit: {
590
+ level: 'high',
591
+ ignore: [
592
+ {
593
+ id: 'GHSA-vfj7-8cjw-p6xm',
594
+ reason: 'braces <=3.0.3 (no patched release) is reached only through dev tooling: the Tailwind '
595
+ + 'CLI file watcher and the test runner globber, on glob patterns this repo writes. Nothing '
596
+ + 'in the served app expands a pattern from request input.',
597
+ },
598
+ ],
599
+ },
600
+ // Local CI (#1471), the Rails `bin/ci` posture. `npm run ci` runs this
601
+ // list on a developer machine and the generated GitHub workflow runs the
602
+ // SAME list through the same command, so the two cannot drift. Bare
603
+ // `webjs ...` commands, like the before-steps above (a Bun app's image
604
+ // has no npm). The `Checks` group runs two steps at a time with each
605
+ // step's output replayed whole; `Tests` inside it stays SEQUENTIAL, one
606
+ // slot, because the server and e2e layers share one SQLite file.
607
+ ci: {
608
+ steps: [
609
+ { title: 'Setup', run: 'webjs db migrate' },
610
+ {
611
+ title: 'Checks',
612
+ parallel: 2,
613
+ steps: [
614
+ { title: 'Conventions', run: 'webjs check' },
615
+ { title: 'Health', run: 'webjs doctor' },
616
+ { title: 'Types', run: 'webjs typecheck' },
617
+ // `webjs audit` (#1492) runs `npm audit` or `bun audit` (by the
618
+ // nearest lockfile) and fails at webjs.audit.level, minus the
619
+ // advisories webjs.audit.ignore accepts below.
620
+ { title: 'Security: dependency audit', run: 'webjs audit' },
621
+ {
622
+ title: 'Tests',
623
+ steps: [
624
+ { title: 'Tests: server', run: 'webjs test --server' },
625
+ { title: 'Tests: browser', run: 'webjs test --browser' },
626
+ { title: 'Tests: e2e', run: 'webjs test --server', env: { WEBJS_E2E: '1' } },
627
+ ],
628
+ },
629
+ ],
630
+ },
631
+ ],
632
+ },
565
633
  },
566
634
  }, null, 2) + '\n');
567
635
 
@@ -657,7 +725,8 @@ export async function scaffoldApp(name, cwd, opts = {}) {
657
725
  // Shipped without a dot (npm strips a published .gitignore) and renamed on copy.
658
726
  'gitignore',
659
727
  '.github/pull_request_template.md',
660
- // CI runs webjs check + the test layers on every PR and push to main.
728
+ // The cloud half of CI: one job that runs `npm run ci`, the same step list
729
+ // the app declares under `webjs.ci` (#1471). Omitted by `--skip-ci`.
661
730
  '.github/workflows/ci.yml',
662
731
  '.editorconfig',
663
732
  '.vscode/settings.json',
@@ -685,7 +754,17 @@ export async function scaffoldApp(name, cwd, opts = {}) {
685
754
  'compose.yaml': bunifyCompose,
686
755
  '.github/workflows/ci.yml': bunifyCi,
687
756
  };
757
+ // Database axis (#1490): the compose + CI templates are the SQLite shape, so
758
+ // a --db postgres app derives its variant (a Postgres service, DATABASE_URL
759
+ // pointed at it) by a pure transform, like the Bun rewrite above. Applied
760
+ // FIRST; the two touch disjoint lines, so they compose. SQLite copies as is.
761
+ const DB_REWRITE = dialect === 'postgres' ? {
762
+ 'compose.yaml': (c) => postgresCompose(c, toDatabaseName(name)),
763
+ '.github/workflows/ci.yml': (c) => postgresCi(c, toDatabaseName(name)),
764
+ } : {};
688
765
  for (const f of templateFiles) {
766
+ // `--skip-ci` drops only the workflow; the PR template still ships.
767
+ if (skipCi && f === '.github/workflows/ci.yml') continue;
689
768
  const src = join(TEMPLATES, f);
690
769
  if (existsSync(src)) {
691
770
  // `gitignore` ships without a dot (npm strips a published `.gitignore`)
@@ -704,6 +783,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
704
783
  const playbook = await readFile(join(TEMPLATES, 'partials', playbookFile), 'utf8');
705
784
  content = content.replace('{{PLAYBOOK}}', () => playbook.trimEnd());
706
785
  }
786
+ if (DB_REWRITE[f]) content = DB_REWRITE[f](content);
707
787
  if (isBun) {
708
788
  if (PROSE_REWRITE.has(f)) content = bunifyProse(content);
709
789
  else if (FILE_REWRITE[f]) content = FILE_REWRITE[f](content);
@@ -1613,8 +1693,11 @@ ThemeToggle.register('theme-toggle');
1613
1693
  // that exercise the scaffold without paying the install cost.
1614
1694
  // In bun mode, install with bun regardless of the invoking PM, so the app
1615
1695
  // commits `bun.lock` (text JSONC, git-diffable) instead of `package-lock.json`
1616
- // (#541). Otherwise honour the invoking PM (npm / pnpm / yarn / bun).
1617
- const pm = isBun ? 'bun' : detectPackageManager();
1696
+ // (#541). Otherwise honour the invoking PM (npm / pnpm / yarn / bun), and when
1697
+ // nothing invoked us through a package manager (a global `webjs` bin), the
1698
+ // lockfile of the enclosing project or workspace (#1494), so an app created
1699
+ // inside a bun workspace does not get a stray package-lock.json.
1700
+ const pm = isBun ? 'bun' : detectPackageManager({ cwd: dirname(appDir), prefer: 'agent' });
1618
1701
  let installed = false;
1619
1702
  let generatedMigration = false;
1620
1703
  if (shouldInstall) {
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Database-dialect rewrites for the deploy files (#1490).
3
+ *
4
+ * The canonical `compose.yaml` and `.github/workflows/ci.yml` templates are the
5
+ * SQLite shape (a `file:` DATABASE_URL, a named volume for the db file). A
6
+ * `--db postgres` app needs a real Postgres in both places, so these pure
7
+ * transforms DERIVE the Postgres variant from the canonical template, the same
8
+ * way `runtime-rewrite.js` derives the Bun variant. There is no parallel
9
+ * Postgres template to drift, and SQLite output stays byte-identical because
10
+ * nothing here runs for it.
11
+ *
12
+ * Order: create.js applies these BEFORE the Bun rewrites. They only touch the
13
+ * DATABASE_URL lines, the volume, and add a database service, none of which
14
+ * the Bun rewrites match (those swap `node -e` healthchecks, `npm` commands and
15
+ * the setup-node block), so the two axes compose in either order. Every anchor
16
+ * is asserted, so a template edit that moves one fails loudly in the scaffold
17
+ * tests instead of shipping a half-rewritten file.
18
+ *
19
+ * The credentials are local-only (a throwaway compose volume, an ephemeral CI
20
+ * service container), never a production value; production points
21
+ * DATABASE_URL at its own managed Postgres.
22
+ *
23
+ * @module db-rewrite
24
+ */
25
+
26
+ /** Local Postgres user + password for compose and CI (never production). */
27
+ export const LOCAL_PG_USER = 'webjs';
28
+ export const LOCAL_PG_PASSWORD = 'webjs';
29
+ /** The Postgres image both files run. */
30
+ export const PG_IMAGE = 'postgres:17-alpine';
31
+
32
+ /**
33
+ * @param {string} s
34
+ * @param {string} from
35
+ * @param {string} to
36
+ * @param {string} file
37
+ */
38
+ function replaceOnce(s, from, to, file) {
39
+ if (!s.includes(from)) {
40
+ throw new Error(`db-rewrite: ${file} template no longer contains the anchor ${JSON.stringify(from.slice(0, 60))}`);
41
+ }
42
+ return s.replace(from, () => to);
43
+ }
44
+
45
+ /**
46
+ * Rewrite compose.yaml for a Postgres app: a sibling `db` service with a
47
+ * `pg_isready` healthcheck and its own named volume, the app's DATABASE_URL
48
+ * pointed at it, and `depends_on` with `service_healthy` so the app's boot-time
49
+ * `webjs db migrate` never races the database's startup.
50
+ *
51
+ * @param {string} s
52
+ * @param {string} dbName fold-stable database name (toDatabaseName(appName))
53
+ * @returns {string}
54
+ */
55
+ export function postgresCompose(s, dbName) {
56
+ const url = `postgres://${LOCAL_PG_USER}:${LOCAL_PG_PASSWORD}@db:5432/${dbName}`;
57
+ let out = s;
58
+ out = replaceOnce(out, 'using the same Dockerfile, one service.',
59
+ 'using the same Dockerfile, plus a Postgres service.', 'compose.yaml');
60
+ out = replaceOnce(out,
61
+ "# In production your host provides DATABASE_URL + AUTH_SECRET. Locally this\n" +
62
+ "# uses the scaffold's SQLite file on a named volume so data survives\n" +
63
+ '# `compose down`.',
64
+ '# In production your host provides DATABASE_URL + AUTH_SECRET. Locally this\n' +
65
+ '# runs Postgres in the `db` service on a named volume so data survives\n' +
66
+ '# `compose down`.',
67
+ 'compose.yaml');
68
+ out = replaceOnce(out,
69
+ ' # SQLite on a volume for local dev. For production, scaffold with\n' +
70
+ ' # --db postgres (or swap db/columns.server.ts + db/connection.server.ts\n' +
71
+ ' # for the pg variant) and point DATABASE_URL at your managed Postgres.\n' +
72
+ ' DATABASE_URL: file:/data/dev.db\n',
73
+ ' # The `db` service below. In production, point DATABASE_URL at your\n' +
74
+ ' # managed Postgres instead.\n' +
75
+ ` DATABASE_URL: ${url}\n`,
76
+ 'compose.yaml');
77
+ // The app no longer owns a db file, so it needs no volume; it waits for a
78
+ // healthy database instead, because `webjs start` migrates before serving.
79
+ out = replaceOnce(out,
80
+ ' volumes:\n - app-data:/data\n',
81
+ ' depends_on:\n db:\n condition: service_healthy\n',
82
+ 'compose.yaml');
83
+ out = replaceOnce(out,
84
+ '\nvolumes:\n app-data:\n',
85
+ '\n' +
86
+ ' db:\n' +
87
+ ` image: ${PG_IMAGE}\n` +
88
+ ' environment:\n' +
89
+ ` POSTGRES_USER: ${LOCAL_PG_USER}\n` +
90
+ ` POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD}\n` +
91
+ ` POSTGRES_DB: ${dbName}\n` +
92
+ ' volumes:\n' +
93
+ ' - db-data:/var/lib/postgresql/data\n' +
94
+ ' healthcheck:\n' +
95
+ ` test: ["CMD-SHELL", "pg_isready -U ${LOCAL_PG_USER} -d ${dbName}"]\n` +
96
+ ' interval: 5s\n' +
97
+ ' timeout: 3s\n' +
98
+ ' retries: 10\n' +
99
+ '\n' +
100
+ 'volumes:\n' +
101
+ ' db-data:\n',
102
+ 'compose.yaml');
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * Rewrite the GitHub Actions CI workflow for a Postgres app: a `postgres`
108
+ * service container with a health check (the job waits until it is healthy
109
+ * before the first step) and DATABASE_URL pointed at it on localhost.
110
+ *
111
+ * @param {string} s
112
+ * @param {string} dbName
113
+ * @returns {string}
114
+ */
115
+ export function postgresCi(s, dbName) {
116
+ return replaceOnce(s,
117
+ ' env:\n DATABASE_URL: file:./ci.db\n',
118
+ ' env:\n' +
119
+ ` DATABASE_URL: postgres://${LOCAL_PG_USER}:${LOCAL_PG_PASSWORD}@localhost:5432/${dbName}\n` +
120
+ ' # The app is scaffolded with --db postgres, so CI runs against a real\n' +
121
+ ' # Postgres. The job waits for the health check before the first step.\n' +
122
+ ' services:\n' +
123
+ ' postgres:\n' +
124
+ ` image: ${PG_IMAGE}\n` +
125
+ ' env:\n' +
126
+ ` POSTGRES_USER: ${LOCAL_PG_USER}\n` +
127
+ ` POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD}\n` +
128
+ ` POSTGRES_DB: ${dbName}\n` +
129
+ ' ports:\n' +
130
+ ' - 5432:5432\n' +
131
+ ' options: >-\n' +
132
+ ` --health-cmd "pg_isready -U ${LOCAL_PG_USER} -d ${dbName}"\n` +
133
+ ' --health-interval 5s\n' +
134
+ ' --health-timeout 5s\n' +
135
+ ' --health-retries 10\n',
136
+ '.github/workflows/ci.yml');
137
+ }
@@ -49,6 +49,7 @@ export const DOCTOR_CODES = {
49
49
  'Component elision (what the browser drops)': 'ELISION_COMPONENTS',
50
50
  'Static build outputs (dev.regenerate freshness)': 'STATIC_ASSET_FRESHNESS',
51
51
  'Asset urls (unmarked stylesheet links)': 'UNMARKED_ASSET_LINKS',
52
+ 'workspace-overrides': 'WORKSPACE_OVERRIDES',
52
53
  };
53
54
 
54
55
  /**
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join, relative, sep, matchesGlob } from 'node:path';
3
+
4
+ /**
5
+ * @typedef {import('../codes.js').DoctorResult} DoctorResult
6
+ */
7
+
8
+ /** @param {string} p */
9
+ function readJson(p) {
10
+ try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; }
11
+ }
12
+
13
+ /**
14
+ * The workspace globs a root package.json declares (npm / bun / yarn), in
15
+ * either the array form or yarn's `{ packages: [...] }` form, else null.
16
+ * @param {any} pkg
17
+ * @returns {string[] | null}
18
+ */
19
+ function workspaceGlobs(pkg) {
20
+ const ws = pkg?.workspaces;
21
+ if (Array.isArray(ws)) return ws;
22
+ if (ws && Array.isArray(ws.packages)) return ws.packages;
23
+ return null;
24
+ }
25
+
26
+ /**
27
+ * Find the workspace root that `appDir` is a MEMBER of: the nearest ancestor
28
+ * whose package.json `workspaces` globs match the app's relative path. A plain
29
+ * parent with a package.json but no matching glob is not a workspace for this
30
+ * app, so it does not count.
31
+ * @param {string} appDir
32
+ * @returns {string | null}
33
+ */
34
+ export function findWorkspaceRoot(appDir) {
35
+ let dir = dirname(appDir);
36
+ for (;;) {
37
+ const pkg = existsSync(join(dir, 'package.json')) ? readJson(join(dir, 'package.json')) : null;
38
+ const globs = workspaceGlobs(pkg);
39
+ if (globs) {
40
+ const rel = relative(dir, appDir).split(sep).join('/');
41
+ const included = globs.filter((g) => !g.startsWith('!')).some((g) => matchesGlob(rel, g.replace(/^\.\//, '').replace(/\/$/, '')));
42
+ const excluded = globs.filter((g) => g.startsWith('!')).some((g) => matchesGlob(rel, g.slice(1).replace(/^\.\//, '')));
43
+ if (included && !excluded) return dir;
44
+ }
45
+ const parent = dirname(dir);
46
+ if (parent === dir) return null;
47
+ dir = parent;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * CHECK (#1492), dependency overrides declared in a workspace MEMBER. npm and
53
+ * bun honour `overrides` (and yarn / bun `resolutions`) only in the workspace
54
+ * ROOT package.json, so the same block in a member is silently ignored and the
55
+ * security floor it encodes (the scaffold's puppeteer-core and basic-ftp
56
+ * floors) never applies. WARN naming the root to move it to; PASS otherwise.
57
+ * @param {string} appDir
58
+ * @returns {DoctorResult}
59
+ */
60
+ export function checkWorkspaceOverrides(appDir) {
61
+ const name = 'workspace-overrides';
62
+ const pkg = readJson(join(appDir, 'package.json'));
63
+ const keys = ['overrides', 'resolutions'].filter((k) => pkg && pkg[k] && typeof pkg[k] === 'object' && Object.keys(pkg[k]).length);
64
+ if (keys.length === 0) {
65
+ return { name, status: 'pass', message: 'No dependency overrides in this package.json.' };
66
+ }
67
+ const root = findWorkspaceRoot(appDir);
68
+ if (!root) {
69
+ return { name, status: 'pass', message: `\`${keys.join('` / `')}\` apply: this app is not a workspace member.` };
70
+ }
71
+ const rel = relative(appDir, join(root, 'package.json')) || 'package.json';
72
+ return {
73
+ name,
74
+ status: 'warn',
75
+ message: `package.json declares \`${keys.join('` / `')}\`, but this app is a member of the workspace at ${rel}, `
76
+ + 'and package managers honour overrides only at the workspace root, so these are ignored.',
77
+ fix: `Move the \`${keys.join('` / `')}\` block into ${rel} (merge it with any the root already has).`,
78
+ };
79
+ }
@@ -11,6 +11,7 @@ import { checkElisionCarriers, checkElisionComponents } from './probes/elision.j
11
11
  import { checkStaticAssetFreshness } from './probes/static-asset-freshness.js';
12
12
  import { checkUnmarkedAssetLinks } from './probes/unmarked-asset-links.js';
13
13
  import { checkFrameworkResolves, checkFrameworkLinks } from './probes/framework-resolves.js';
14
+ import { checkWorkspaceOverrides } from './probes/workspace-overrides.js';
14
15
 
15
16
  /**
16
17
  * @typedef {import('./codes.js').DoctorResult} DoctorResult
@@ -64,6 +65,7 @@ export async function runDoctorChecks(appDir, opts = {}) {
64
65
  checkElisionComponents(elision),
65
66
  checkStaticAssetFreshness(appDir),
66
67
  checkUnmarkedAssetLinks(appDir),
68
+ Promise.resolve(checkWorkspaceOverrides(appDir)),
67
69
  ]);
68
70
  // Attach the stable machine code to every result (#975). Centralized here so
69
71
  // each check function stays free of the code-contract concern.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Package-manager detection for `webjs create` (#1494).
3
+ *
4
+ * KEEP IN SYNC with `packages/ui/src/utils/package-manager.js`, the copy
5
+ * `webjs ui add` uses. The two published
6
+ * packages carry the same small module rather than one importing the other,
7
+ * so a `@webjsdev/cli` release can never fail at import time against an older
8
+ * `@webjsdev/ui` that lacks the export. `packages/cli/test/package-manager.test.mjs`
9
+ * asserts both copies agree on every fixture.
10
+ *
11
+ * Detection order (with `prefer: 'lockfile'`, the default):
12
+ * 1. A lockfile in `cwd` or any ancestor. The walk stops at the first
13
+ * workspace root (a package.json declaring `workspaces`, or a
14
+ * `pnpm-workspace.yaml`) or at the filesystem root, so an app nested in a
15
+ * workspace finds the root's lockfile. Within one directory the order is
16
+ * pnpm, yarn, bun (`bun.lock`, the text lockfile Bun writes since 1.2,
17
+ * and the older binary `bun.lockb`), then npm.
18
+ * 2. `npm_config_user_agent`, which npm, pnpm, yarn and bun set when they
19
+ * run a script or a `dlx` / `bunx` / `npx` binary.
20
+ * 3. `npm`.
21
+ * With `prefer: 'agent'` steps 1 and 2 swap, which suits `webjs create`: the
22
+ * tool that invoked it is the strongest signal for a directory that has no
23
+ * lockfile yet.
24
+ */
25
+ import { existsSync, readFileSync } from 'node:fs';
26
+ import { dirname, join, resolve } from 'node:path';
27
+
28
+ /** @typedef {'npm'|'pnpm'|'yarn'|'bun'} PackageManager */
29
+
30
+ /** Lockfile name to manager, in per-directory precedence order. */
31
+ const LOCKFILES = /** @type {const} */ ([
32
+ ['pnpm-lock.yaml', 'pnpm'],
33
+ ['yarn.lock', 'yarn'],
34
+ ['bun.lock', 'bun'],
35
+ ['bun.lockb', 'bun'],
36
+ ['package-lock.json', 'npm'],
37
+ ['npm-shrinkwrap.json', 'npm'],
38
+ ]);
39
+
40
+ /**
41
+ * Whether `dir` is a workspace root, where the lockfile walk stops.
42
+ * @param {string} dir
43
+ * @returns {boolean}
44
+ */
45
+ function isWorkspaceRoot(dir) {
46
+ if (existsSync(join(dir, 'pnpm-workspace.yaml'))) return true;
47
+ const pkgPath = join(dir, 'package.json');
48
+ if (!existsSync(pkgPath)) return false;
49
+ try {
50
+ return Boolean(JSON.parse(readFileSync(pkgPath, 'utf8')).workspaces);
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Walk up from `cwd` looking for a lockfile.
58
+ * @param {string} cwd
59
+ * @returns {PackageManager | null}
60
+ */
61
+ export function managerFromLockfile(cwd) {
62
+ let dir = resolve(cwd);
63
+ for (;;) {
64
+ for (const [file, manager] of LOCKFILES) {
65
+ if (existsSync(join(dir, file))) return manager;
66
+ }
67
+ if (isWorkspaceRoot(dir)) return null;
68
+ const parent = dirname(dir);
69
+ if (parent === dir) return null;
70
+ dir = parent;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Read the manager from an `npm_config_user_agent` value such as
76
+ * `bun/1.3.14 npm/? node/v24.0.0 linux x64`.
77
+ * @param {string | undefined} ua
78
+ * @returns {PackageManager | null}
79
+ */
80
+ export function managerFromUserAgent(ua) {
81
+ const name = String(ua || '').split('/')[0];
82
+ return name === 'pnpm' || name === 'yarn' || name === 'bun' || name === 'npm' ? name : null;
83
+ }
84
+
85
+ /**
86
+ * @param {{ cwd?: string | null, env?: Record<string, string | undefined>, prefer?: 'lockfile' | 'agent' }} [opts]
87
+ * @returns {PackageManager}
88
+ */
89
+ export function detectPackageManager({ cwd = null, env = process.env, prefer = 'lockfile' } = {}) {
90
+ const fromLock = () => (cwd ? managerFromLockfile(cwd) : null);
91
+ const fromAgent = () => managerFromUserAgent(env.npm_config_user_agent);
92
+ return (prefer === 'agent' ? fromAgent() ?? fromLock() : fromLock() ?? fromAgent()) ?? 'npm';
93
+ }
package/lib/run-tasks.js CHANGED
@@ -12,7 +12,7 @@ import { delimiter, dirname, join } from 'node:path';
12
12
  * @param {string} cwd
13
13
  * @param {NodeJS.ProcessEnv} [env]
14
14
  */
15
- function envWithLocalBin(cwd, env = process.env) {
15
+ export function envWithLocalBin(cwd, env = process.env) {
16
16
  const bins = [];
17
17
  let dir = cwd;
18
18
  // Walk up to the filesystem root, collecting each node_modules/.bin.
@@ -44,7 +44,10 @@ export async function runBeforeSteps(steps, cwd, opts = {}) {
44
44
  if (opts.onStep) opts.onStep(step);
45
45
  const code = await new Promise((res) => {
46
46
  const c = spawn(step, { shell: true, stdio: 'inherit', cwd, env });
47
- c.on('exit', (code) => res(code ?? 0));
47
+ // A child killed by a signal exits with `code` null and `signal` set.
48
+ // That is a failure (an OOM-killed `db migrate` must not boot the
49
+ // server over a half-applied schema), so it maps to 1, never 0.
50
+ c.on('exit', (code, signal) => res(code ?? (signal ? 1 : 0)));
48
51
  c.on('error', () => res(1));
49
52
  });
50
53
  if (code !== 0) return { ok: false, step, code };
@@ -52,6 +55,23 @@ export async function runBeforeSteps(steps, cwd, opts = {}) {
52
55
  return { ok: true };
53
56
  }
54
57
 
58
+ /**
59
+ * Quote one argv entry for a POSIX shell so it survives `shell: true` as ONE
60
+ * word with no expansion. A plain word passes through untouched; anything
61
+ * else is single-quoted, with an embedded single quote spliced as `'\''`.
62
+ * Used by `webjs db <verb> [args]` (#1468) to append the CLI's args to a
63
+ * mapped command string, so `--name "add users"` reaches the ORM as one arg
64
+ * and a `$` / `;` / glob is never expanded, matching what the drizzle-kit
65
+ * default (a real argv, no shell) already guarantees.
66
+ *
67
+ * @param {string} arg
68
+ * @returns {string}
69
+ */
70
+ export function shellQuote(arg) {
71
+ if (/^[A-Za-z0-9_\-.\/=:@,+%]+$/.test(arg)) return arg;
72
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
73
+ }
74
+
55
75
  /**
56
76
  * Spawn the configured dev `parallel` tasks (#550) as long-lived children and
57
77
  * return a killer that tears them ALL down (idempotent), so a watcher cannot
@@ -90,7 +110,7 @@ export function startParallelTasks(commands, cwd, opts = {}) {
90
110
  *
91
111
  * @param {import('node:child_process').ChildProcess} child
92
112
  */
93
- function killChildTree(child) {
113
+ export function killChildTree(child) {
94
114
  try {
95
115
  if (typeof child.pid === 'number') process.kill(-child.pid, 'SIGTERM');
96
116
  else child.kill();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.57",
3
+ "version": "0.10.59",
4
4
  "type": "module",
5
5
  "description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
6
6
  "bin": {
@@ -18,8 +18,8 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@webjsdev/mcp": "^0.1.0",
21
- "@webjsdev/server": "^0.8.0",
22
- "@webjsdev/ui": "^0.3.1"
21
+ "@webjsdev/server": "^0.8.68",
22
+ "@webjsdev/ui": "^0.3.15"
23
23
  },
24
24
  "publishConfig": {
25
25
  "access": "public"
@@ -50,15 +50,21 @@ Read `AGENTS.md` first. Full hosted docs are at https://webjs.dev/docs.
50
50
  2. Browser tests in `test/<feature>/browser/*.test.js` for hydration, DOM, slots,
51
51
  and the client router.
52
52
  3. Documentation stays in sync on the SAME PR as the code, never a follow-up.
53
- 4. `npm run check` must pass (correctness), and so must `npm run doctor`
54
- (project health). CI runs both. Doctor fails on whatever your `package.json`
55
- `webjs.doctor.gate` marks `error`, which starts as the un-versioned
56
- stylesheet link check, plus the two hard toolchain checks that default to
57
- `error` with no gate entry at all: `NODE_VERSION` (the Node floor) and
58
- `TSCONFIG_ERASABLE` (`erasableSyntaxOnly` missing from an existing
59
- tsconfig), either of which would 500 the app at runtime. Everything else it
60
- reports is a warning that cannot fail the build. Widen or narrow the gate in
61
- `package.json` rather than in the workflow.
53
+ 4. `npm run ci` must pass before you push. It runs the step list declared in
54
+ `package.json` under `webjs.ci`, one result line per step: `webjs check`
55
+ (correctness), `webjs doctor` (project health), `webjs typecheck`, a
56
+ dependency audit, then the server, browser, and e2e test layers. The GitHub
57
+ workflow runs the same list on every PR and push, so the two cannot drift;
58
+ `npm run ci -- --only Tests` runs one layer while you iterate, and
59
+ `npm run ci -- --signoff` posts a green commit status (basecamp/gh-signoff)
60
+ a branch-protection rule can require. Doctor fails on whatever your
61
+ `package.json` `webjs.doctor.gate` marks `error`, which starts as the
62
+ un-versioned stylesheet link check, plus the two hard toolchain checks that
63
+ default to `error` with no gate entry at all: `NODE_VERSION` (the Node
64
+ floor) and `TSCONFIG_ERASABLE` (`erasableSyntaxOnly` missing from an
65
+ existing tsconfig), either of which would 500 the app at runtime. Everything
66
+ else it reports is a warning that cannot fail the build. Widen or narrow the
67
+ gate, and the step list, in `package.json` rather than in the workflow.
62
68
 
63
69
  How a PR gets REVIEWED is deliberately not specified here. Use whatever your
64
70
  team already does. WebJs has opinions about the code (the conventions above,