uniweb 0.12.15 → 0.12.17

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/README.md CHANGED
@@ -40,12 +40,18 @@ Edit files in `site/pages/` and `src/sections/` to see changes instantly.
40
40
 
41
41
  Use `--blank` for an empty workspace (no packages) — grow with `uniweb add`.
42
42
 
43
- Or skip the interactive prompt:
43
+ **Two starting points.** Either let the CLI create a new directory:
44
44
 
45
45
  ```bash
46
46
  pnpm create uniweb my-site --template docs
47
47
  ```
48
48
 
49
+ …or scaffold inside an existing directory (e.g., a freshly-cloned GitHub repo):
50
+
51
+ ```bash
52
+ pnpm create uniweb . --template docs
53
+ ```
54
+
49
55
  ### Local Scripts
50
56
 
51
57
  Run these from the **project root**:
@@ -243,7 +249,7 @@ The parser extracts semantic elements from markdown—`title` from the first hea
243
249
 
244
250
  ## Foundations Are Portable
245
251
 
246
- The `src/` folder (your project's foundation) ships with your project as a convenience, but a foundation is a dynamically linked module (DML) with no dependency on any specific site. Sites reference foundations by configuration, not by folder proximity.
252
+ The `src/` folder (your project's foundation) ships with your project as a convenience, but a foundation is dynamically loaded sites reference it by configuration, not by folder proximity.
247
253
 
248
254
  **Two ways to use a foundation:**
249
255
 
@@ -308,8 +314,7 @@ You (or your dev team) write the markdown. Deploy site + foundation together.
308
314
  The shortest path to a live site is free, on GitHub Pages, with a custom domain:
309
315
 
310
316
  ```bash
311
- npm create uniweb my-site
312
- cd my-site
317
+ uniweb create . # from within a freshly-cloned GitHub repo
313
318
  uniweb add ci --host=github-pages
314
319
  # Commit, push to GitHub, enable Pages in repo settings → live site
315
320
  ```
@@ -346,6 +351,7 @@ A future version will let markdown in a git repo and content in the Uniweb apps
346
351
  | `uniweb export` | Produce a self-contained `dist/` for any static host. You upload it yourself. `--host=<adapter>` adds host-specific helper files. |
347
352
  | `uniweb publish @org/name` | Publish a foundation to the registry (path 2). |
348
353
  | `uniweb build` | Inspect a build locally. For shipping, use `deploy` or `export`. |
354
+ | `uniweb update` | Reconcile workspace state with the CLI: self-update the global install, align `@uniweb/*` deps in every `package.json` to the CLI's matrix, refresh `AGENTS.md`. Refuses to outpace declared deps with a stale doc. |
349
355
 
350
356
  `--host=<adapter>` is the same option across `deploy`, `export`, and `add ci`. Built-in adapters: `cloudflare-pages`, `netlify`, `github-pages`, `vercel`, `s3-cloudfront`, `generic-static`. Each adapter implements only the operations it supports — `add ci` is currently `github-pages`-only because it's the only one that needs a workflow file in the repo.
351
357
 
@@ -373,7 +379,7 @@ Full documentation is available at **[github.com/uniweb/docs](https://github.com
373
379
  | Content Structure | [How markdown becomes component props](https://github.com/uniweb/docs/blob/main/reference/content-structure.md) |
374
380
  | Component Metadata | [The meta.js schema](https://github.com/uniweb/docs/blob/main/reference/component-metadata.md) |
375
381
  | Site Configuration | [site.yml reference](https://github.com/uniweb/docs/blob/main/reference/site-configuration.md) |
376
- | CLI Commands | [create, add, build, docs, doctor, i18n](https://github.com/uniweb/docs/blob/main/reference/cli-commands.md) |
382
+ | CLI Commands | [All CLI commands and flags](https://github.com/uniweb/docs/blob/main/reference/cli-commands.md) |
377
383
  | Templates | [Built-in, official, and external templates](https://github.com/uniweb/docs/blob/main/getting-started/templates.md) |
378
384
  | Deployment | [Two artifacts, two verbs — bundled, linked, and per-host recipes](https://github.com/uniweb/docs/blob/main/development/deploying.md) |
379
385
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.15",
3
+ "version": "0.12.17",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,8 +46,8 @@
46
46
  "@uniweb/kit": "0.9.11"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/content-reader": "1.1.10",
50
49
  "@uniweb/build": "0.14.3",
50
+ "@uniweb/content-reader": "1.1.10",
51
51
  "@uniweb/semantic-parser": "1.1.17"
52
52
  },
53
53
  "peerDependenciesMeta": {
@@ -150,6 +150,9 @@ uniweb deploy --dry-run # Resolve foundation/runtime + print summary;
150
150
  uniweb export # Build dist/ for any static host (no Uniweb account)
151
151
  uniweb publish # Publish a foundation to the Uniweb registry
152
152
  uniweb doctor # Diagnose project configuration issues (--fix to auto-repair)
153
+ uniweb update # Reconcile workspace state with the CLI: align @uniweb/*
154
+ # deps in every package.json + refresh this AGENTS.md.
155
+ # Use --dry-run to preview, --deps-only to skip the doc.
153
156
 
154
157
  # Help
155
158
  uniweb --help # Top-level help
@@ -158,6 +161,8 @@ uniweb <command> --help # Per-command help (no side effects)
158
161
 
159
162
  `uniweb deploy` auto-publishes a workspace-local foundation as part of the deploy under a site-scoped slot — no separate `uniweb publish` step needed for site-bound foundations.
160
163
 
164
+ If this AGENTS.md was stamped against an older CLI than the workspace's installed `@uniweb/*` packages, run `uniweb update --dry-run` to see the gap. The verb refuses to refresh the doc while declared deps lag the CLI — a stale doc that documents features the installed code doesn't have is worse than no refresh.
165
+
161
166
  ---
162
167
 
163
168
  ## `package.json` `uniweb` configuration
@@ -1,35 +1,32 @@
1
1
  /**
2
- * uniweb update — Update the CLI itself, and (in a Uniweb project) the
3
- * project's AGENTS.md.
2
+ * uniweb update — Reconcile a Uniweb workspace's state with the running
3
+ * CLI's expectations. Three convergence steps, in order:
4
4
  *
5
- * Two responsibilities, in priority order:
5
+ * 1. Self-update the global CLI install (npm / pnpm / yarn auto-detected).
6
+ * 2. Align workspace `@uniweb/*` + `uniweb` deps to the CLI's bundled
7
+ * version matrix (`getResolvedVersions`), then run `<pm> install`.
8
+ * 3. Refresh AGENTS.md from the CLI's bundled partial.
6
9
  *
7
- * 1. **Self-update the global install.** Most users running `uniweb update`
8
- * expect the verb to update the CLI binary itself (this is what `npm
9
- * update -g`, `gh update`, `claude update`, etc. all do). The CLI
10
- * detects the relevant package manager and runs the global-install
11
- * command for it (`npm i -g uniweb@latest`, `pnpm add -g uniweb@latest`,
12
- * `yarn global add uniweb@latest`). In TTY, prompts before executing.
13
- * In non-interactive mode, prints the command and exits — never runs an
14
- * unconfirmed self-update from a script.
15
- *
16
- * 2. **Refresh AGENTS.md** (only when the cwd resolves to a *Uniweb*
17
- * project — checked via `package.json::devDependencies::uniweb` or
18
- * `dependencies::uniweb` at the workspace root). The previous
19
- * implementation walked up looking for ANY pnpm-workspace.yaml or
20
- * `package.json::workspaces` root, which falsely identified unrelated
21
- * monorepos as Uniweb projects and wrote AGENTS.md into them.
10
+ * Why steps 2 and 3 belong together: AGENTS.md is regenerated from the
11
+ * CLI's *current* partials and stamped with `cliVersion`. Refreshing it
12
+ * while declared deps in `package.json` lag the CLI silently produces a
13
+ * doc that documents features the installed code doesn't have. The
14
+ * verb's drift gate refuses that combination unless `--allow-mismatch`
15
+ * is explicit.
22
16
  *
23
17
  * Flags:
24
- * --agents-only Skip self-update; only refresh AGENTS.md.
25
- * --no-agents Skip AGENTS.md; only self-update.
26
- * --yes Skip the confirmation prompt before self-update.
27
- * --non-interactive Auto-detected; never runs unconfirmed self-update.
18
+ * --agents-only Skip self-update + deps; only refresh AGENTS.md.
19
+ * --deps-only Skip self-update + AGENTS.md; only align deps.
20
+ * --no-agents Skip the AGENTS.md step.
21
+ * --no-deps Skip the deps-alignment step.
22
+ * --dry-run Print survey + would-be writes; no mutations.
23
+ * --allow-mismatch Permit AGENTS.md refresh when declared deps lag.
24
+ * --yes Skip confirmation prompts (still respects gates).
25
+ * --non-interactive Auto-detected; never auto-installs from a script.
28
26
  *
29
27
  * Project-local case (CLI lives in node_modules, not global): self-update
30
- * isn't possible that's a project decision (bump the dep in
31
- * package.json). The verb prints that explanation and proceeds with the
32
- * AGENTS.md refresh path only.
28
+ * is a no-op (the version is pinned by package.json). Deps + AGENTS.md
29
+ * paths still run.
33
30
  */
34
31
 
35
32
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
@@ -37,10 +34,11 @@ import { join } from 'node:path'
37
34
  import { spawn } from 'node:child_process'
38
35
  import prompts from 'prompts'
39
36
 
40
- import { findWorkspaceRoot } from '../utils/workspace.js'
37
+ import { findWorkspaceRoot, getWorkspacePackages } from '../utils/workspace.js'
41
38
  import { readAgentsVersion, generateAgentsContent } from '../utils/agents-stamp.js'
42
- import { getCliVersion } from '../versions.js'
39
+ import { getCliVersion, getResolvedVersions, updatePackageVersions } from '../versions.js'
43
40
  import { isNonInteractive } from '../utils/interactive.js'
41
+ import { detectWorkspacePm, installCmd } from '../utils/pm.js'
44
42
 
45
43
  const colors = {
46
44
  reset: '\x1b[0m',
@@ -55,6 +53,7 @@ const colors = {
55
53
  const success = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`)
56
54
  const warn = (msg) => console.log(`${colors.yellow}⚠${colors.reset} ${msg}`)
57
55
  const error = (msg) => console.log(`${colors.red}✗${colors.reset} ${msg}`)
56
+ const info = (msg) => console.log(`${colors.cyan}ℹ${colors.reset} ${msg}`)
58
57
  const log = console.log
59
58
 
60
59
  /**
@@ -72,8 +71,8 @@ function isGlobalInstall() {
72
71
  /**
73
72
  * Find a *Uniweb* workspace root from cwd. Stricter than findWorkspaceRoot
74
73
  * — also requires that the workspace's root package.json declares uniweb
75
- * as a dep or devDep. Otherwise the previous behavior (walking up to any
76
- * pnpm-workspace.yaml) writes AGENTS.md into unrelated monorepos.
74
+ * as a dep or devDep. Otherwise we'd write AGENTS.md and edit package.json
75
+ * files in unrelated monorepos.
77
76
  */
78
77
  function findUniwebWorkspace(cwd) {
79
78
  const workspaceDir = findWorkspaceRoot(cwd)
@@ -90,9 +89,9 @@ function findUniwebWorkspace(cwd) {
90
89
  }
91
90
 
92
91
  /**
93
- * Detect the package manager that owns the global install. Heuristic
94
- * based on the CLI's filesystem path — pnpm and yarn berry use distinctive
95
- * directory layouts; npm is the fallback.
92
+ * Detect the package manager that owns the *global* CLI install.
93
+ * Path-based (different signal than detectWorkspacePm, which reads
94
+ * lockfiles in the workspace).
96
95
  *
97
96
  * @returns {'pnpm'|'yarn'|'npm'}
98
97
  */
@@ -127,11 +126,21 @@ async function fetchLatestVersion() {
127
126
  }
128
127
 
129
128
  /**
130
- * Compare two semver strings: 1 if a>b, -1 if a<b, 0 if equal.
129
+ * Strip a leading semver range operator (^, ~, >=, <, etc.) so two specs
130
+ * can be compared by their underlying version. Range expressions like
131
+ * ">=0.5 <0.7" aren't fully parsed — we take the first version-shaped
132
+ * token. Sufficient for `@uniweb/*` deps which use `^x.y.z` consistently.
133
+ */
134
+ function stripRange(spec) {
135
+ return (spec || '').replace(/^[\^~>=<\s]+/, '').trim().split(/\s+/)[0] || ''
136
+ }
137
+
138
+ /**
139
+ * Compare two version specs (range prefix tolerated). Returns 1/-1/0.
131
140
  */
132
141
  function compareSemver(a, b) {
133
- const pa = a.split('.').map(Number)
134
- const pb = b.split('.').map(Number)
142
+ const pa = stripRange(a).split('.').map(Number)
143
+ const pb = stripRange(b).split('.').map(Number)
135
144
  for (let i = 0; i < 3; i++) {
136
145
  if ((pa[i] || 0) > (pb[i] || 0)) return 1
137
146
  if ((pa[i] || 0) < (pb[i] || 0)) return -1
@@ -142,28 +151,177 @@ function compareSemver(a, b) {
142
151
  /**
143
152
  * Run a shell command, inheriting stdio. Resolves with the exit code.
144
153
  */
145
- function runCommand(cmd) {
154
+ function runCommand(cmd, cwd) {
146
155
  return new Promise((resolve) => {
147
156
  const [bin, ...rest] = cmd.split(' ')
148
- const child = spawn(bin, rest, { stdio: 'inherit' })
157
+ const child = spawn(bin, rest, { stdio: 'inherit', cwd })
149
158
  child.on('close', code => resolve(code ?? 0))
150
159
  child.on('error', () => resolve(1))
151
160
  })
152
161
  }
153
162
 
163
+ /**
164
+ * Survey workspace `@uniweb/*` and `uniweb` deps against the CLI's
165
+ * bundled version matrix. Returns a structured report with one row per
166
+ * (package directory, dep section, dep name).
167
+ *
168
+ * Comparison is on *declared* versions (package.json), not installed
169
+ * (node_modules) — that's what the user committed and what they'll
170
+ * `git diff` after `applyDepUpdates`.
171
+ */
172
+ async function surveyVersions(workspaceDir) {
173
+ const targets = getResolvedVersions()
174
+ const packages = await getWorkspacePackages(workspaceDir)
175
+ const dirs = ['', ...packages]
176
+ const rows = []
177
+ let anyDrift = false
178
+ let anyAhead = false
179
+
180
+ for (const relDir of dirs) {
181
+ const pkgDir = relDir ? join(workspaceDir, relDir) : workspaceDir
182
+ const pkgPath = join(pkgDir, 'package.json')
183
+ if (!existsSync(pkgPath)) continue
184
+ let pkg
185
+ try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch { continue }
186
+
187
+ for (const sectionName of ['dependencies', 'devDependencies', 'peerDependencies']) {
188
+ const section = pkg[sectionName]
189
+ if (!section) continue
190
+ for (const [name, current] of Object.entries(section)) {
191
+ if (!(name.startsWith('@uniweb/') || name === 'uniweb')) continue
192
+ const target = targets[name]
193
+ if (!target) continue
194
+ const cmp = compareSemver(target, current)
195
+ let status
196
+ if (cmp > 0) { status = 'behind'; anyDrift = true }
197
+ else if (cmp < 0) { status = 'ahead'; anyAhead = true }
198
+ else { status = 'aligned' }
199
+ rows.push({
200
+ relDir: relDir || '(root)',
201
+ section: sectionName,
202
+ name,
203
+ current,
204
+ target,
205
+ status,
206
+ })
207
+ }
208
+ }
209
+ }
210
+
211
+ return { targets, rows, anyDrift, anyAhead }
212
+ }
213
+
214
+ /**
215
+ * Print the survey report grouped by package directory.
216
+ */
217
+ function printSurvey(report, cliVersion, agentsVersion) {
218
+ log('')
219
+ log(`${colors.bright}uniweb CLI:${colors.reset} v${cliVersion}`)
220
+ log(`${colors.bright}AGENTS.md stamp:${colors.reset} ${agentsVersion ? 'v' + agentsVersion : colors.dim + '(none)' + colors.reset}`)
221
+ log('')
222
+
223
+ if (report.rows.length === 0) {
224
+ log(`${colors.dim}No @uniweb/* deps found in workspace package.json files.${colors.reset}`)
225
+ log('')
226
+ return
227
+ }
228
+
229
+ const byDir = {}
230
+ for (const row of report.rows) {
231
+ if (!byDir[row.relDir]) byDir[row.relDir] = []
232
+ byDir[row.relDir].push(row)
233
+ }
234
+
235
+ log(`${colors.bright}Workspace deps (declared):${colors.reset}`)
236
+ for (const [dir, dirRows] of Object.entries(byDir)) {
237
+ log(` ${colors.dim}${dir}/${colors.reset}`)
238
+ const maxName = Math.max(...dirRows.map(r => r.name.length))
239
+ for (const row of dirRows) {
240
+ const padding = ' '.repeat(maxName - row.name.length)
241
+ let icon, statusText
242
+ if (row.status === 'aligned') {
243
+ icon = `${colors.green}✓${colors.reset}`
244
+ statusText = `${colors.dim}aligned${colors.reset}`
245
+ } else if (row.status === 'behind') {
246
+ icon = `${colors.yellow}✗${colors.reset}`
247
+ statusText = `${colors.yellow}behind${colors.reset}`
248
+ } else {
249
+ icon = `${colors.cyan}↑${colors.reset}`
250
+ statusText = `${colors.cyan}ahead of CLI${colors.reset}`
251
+ }
252
+ log(` ${icon} ${row.name}${padding} ${row.current.padEnd(10)} → ${row.target.padEnd(10)} ${statusText}`)
253
+ }
254
+ }
255
+ log('')
256
+ }
257
+
258
+ /**
259
+ * Apply the CLI's bundled matrix to every workspace package.json.
260
+ * `updatePackageVersions` only touches `@uniweb/*` + `uniweb` keys, so
261
+ * unrelated deps (`react`, `vite`, `file:../foundation`, etc.) are left
262
+ * untouched. Returns the list of paths that actually changed.
263
+ */
264
+ async function applyDepUpdates(workspaceDir, dryRun) {
265
+ const packages = await getWorkspacePackages(workspaceDir)
266
+ const dirs = ['', ...packages]
267
+ const edited = []
268
+
269
+ for (const relDir of dirs) {
270
+ const pkgDir = relDir ? join(workspaceDir, relDir) : workspaceDir
271
+ const pkgPath = join(pkgDir, 'package.json')
272
+ if (!existsSync(pkgPath)) continue
273
+ const original = readFileSync(pkgPath, 'utf8')
274
+ let pkg
275
+ try { pkg = JSON.parse(original) } catch { continue }
276
+
277
+ const updated = updatePackageVersions(pkg)
278
+ const newContent = JSON.stringify(updated, null, 2) + (original.endsWith('\n') ? '\n' : '')
279
+
280
+ if (newContent !== original) {
281
+ edited.push(pkgPath)
282
+ if (!dryRun) writeFileSync(pkgPath, newContent)
283
+ }
284
+ }
285
+
286
+ return edited
287
+ }
288
+
289
+ function relativize(path, root) {
290
+ return path.startsWith(root) ? path.slice(root.length + 1) : path
291
+ }
292
+
154
293
  export async function update(args = []) {
155
294
  const agentsOnly = args.includes('--agents-only')
156
- const skipAgents = args.includes('--no-agents')
157
- const skipPrompt = args.includes('--yes') || isNonInteractive(args)
295
+ const depsOnly = args.includes('--deps-only')
296
+ const skipAgents = args.includes('--no-agents') || depsOnly
297
+ const skipDeps = args.includes('--no-deps') || agentsOnly
298
+ const dryRun = args.includes('--dry-run')
299
+ const allowMismatch = args.includes('--allow-mismatch')
300
+ const nonInteractive = isNonInteractive(args)
301
+ const skipPrompt = args.includes('--yes') || nonInteractive || dryRun
158
302
  const isGlobal = isGlobalInstall()
159
303
  const workspaceDir = findUniwebWorkspace(process.cwd())
160
304
  const inProject = !!workspaceDir
161
305
  const cliVersion = getCliVersion()
162
306
 
163
- // ─── Step 1: Self-update path ─────────────────────────────────
164
- if (!agentsOnly) {
307
+ if ((agentsOnly || depsOnly) && !inProject) {
308
+ error(`${agentsOnly ? '--agents-only' : '--deps-only'} requires a Uniweb project (no \`uniweb\` dep in the workspace root).`)
309
+ log(`${colors.dim}Run this command from inside a project created by${colors.reset} ${colors.cyan}uniweb create${colors.reset}${colors.dim}.${colors.reset}`)
310
+ process.exit(1)
311
+ }
312
+
313
+ // ── Survey first (always, when in a Uniweb project) ──────────────
314
+ let survey = null
315
+ let agentsVersion = null
316
+ if (inProject) {
317
+ survey = await surveyVersions(workspaceDir)
318
+ agentsVersion = readAgentsVersion(join(workspaceDir, 'AGENTS.md'))
319
+ printSurvey(survey, cliVersion, agentsVersion)
320
+ }
321
+
322
+ // ── Step 1: Self-update path ─────────────────────────────────────
323
+ if (!agentsOnly && !depsOnly) {
165
324
  if (!isGlobal) {
166
- // Project-local: can't self-update meaningfully.
167
325
  log(`${colors.dim}Running the project-local CLI (v${cliVersion}). This copy is pinned by your${colors.reset}`)
168
326
  log(`${colors.dim}project's package.json. To update it, bump${colors.reset} ${colors.cyan}uniweb${colors.reset}${colors.dim} in${colors.reset} ${colors.cyan}package.json${colors.reset}${colors.dim} and re-install.${colors.reset}`)
169
327
  log('')
@@ -184,7 +342,10 @@ export async function update(args = []) {
184
342
  log(`${colors.dim}Will run:${colors.reset} ${colors.cyan}${cmd}${colors.reset}`)
185
343
  log('')
186
344
 
187
- if (skipPrompt) {
345
+ if (dryRun) {
346
+ info(`${colors.dim}--dry-run: would run \`${cmd}\`.${colors.reset}`)
347
+ log('')
348
+ } else if (skipPrompt) {
188
349
  log(`${colors.dim}Non-interactive — skipping self-update. Run the command above to update.${colors.reset}`)
189
350
  log('')
190
351
  } else {
@@ -211,15 +372,148 @@ export async function update(args = []) {
211
372
  }
212
373
  }
213
374
 
214
- // ─── Step 2: AGENTS.md refresh path ───────────────────────────
375
+ // ── Step 2: Deps alignment ───────────────────────────────────────
376
+ if (!skipDeps && inProject && survey) {
377
+ if (!survey.anyDrift) {
378
+ success('Workspace deps are aligned with the CLI.')
379
+ if (survey.anyAhead) {
380
+ log(`${colors.dim}(Some deps are ahead of the CLI's bundled matrix — left untouched.)${colors.reset}`)
381
+ }
382
+ log('')
383
+ } else {
384
+ log(`${colors.yellow}⚠${colors.reset} Some workspace deps lag the CLI's bundled matrix.`)
385
+ log('')
386
+
387
+ let proceed
388
+ if (dryRun) {
389
+ proceed = false
390
+ } else if (skipPrompt) {
391
+ proceed = !nonInteractive || args.includes('--yes')
392
+ // In CI without --yes, refuse to mutate. The survey above is the report.
393
+ if (!proceed) {
394
+ info(`${colors.dim}Non-interactive — printing the alignment plan; not editing files.${colors.reset}`)
395
+ log(`${colors.dim}To apply, re-run with${colors.reset} ${colors.cyan}--yes${colors.reset}${colors.dim}, or align manually:${colors.reset}`)
396
+ log(` ${colors.cyan}pnpm update "@uniweb/*" uniweb -r${colors.reset}`)
397
+ log('')
398
+ }
399
+ } else {
400
+ const { go } = await prompts({
401
+ type: 'confirm',
402
+ name: 'go',
403
+ message: `Edit workspace package.json files to align with v${cliVersion}?`,
404
+ initial: true,
405
+ })
406
+ proceed = !!go
407
+ }
408
+
409
+ if (dryRun) {
410
+ const wouldEdit = await applyDepUpdates(workspaceDir, true)
411
+ if (wouldEdit.length > 0) {
412
+ info('Dry-run: would update package.json in:')
413
+ for (const path of wouldEdit) log(` ${colors.dim}- ${relativize(path, workspaceDir)}${colors.reset}`)
414
+ const pm = detectWorkspacePm(workspaceDir)
415
+ if (pm) {
416
+ log(`${colors.dim}Then would run:${colors.reset} ${colors.cyan}${installCmd(pm)}${colors.reset}`)
417
+ } else {
418
+ log(`${colors.dim}Then would prompt for an install command (no lockfile detected).${colors.reset}`)
419
+ }
420
+ log('')
421
+ }
422
+ } else if (proceed) {
423
+ const edited = await applyDepUpdates(workspaceDir, false)
424
+ if (edited.length === 0) {
425
+ info('No package.json files needed changes.')
426
+ log('')
427
+ } else {
428
+ for (const path of edited) {
429
+ success(`Updated ${relativize(path, workspaceDir)}`)
430
+ }
431
+ log('')
432
+
433
+ // Resolve the workspace PM (lockfile-driven). If absent, ask.
434
+ let pm = detectWorkspacePm(workspaceDir)
435
+ if (!pm) {
436
+ if (nonInteractive) {
437
+ warn('No lockfile in workspace root — cannot pick an install command for you.')
438
+ log(`${colors.dim}Run one of:${colors.reset} ${colors.cyan}pnpm install${colors.reset} ${colors.dim}/${colors.reset} ${colors.cyan}yarn install${colors.reset} ${colors.dim}/${colors.reset} ${colors.cyan}npm install${colors.reset}`)
439
+ log('')
440
+ } else {
441
+ const { picked } = await prompts({
442
+ type: 'select',
443
+ name: 'picked',
444
+ message: 'No lockfile found. Which package manager does this workspace use?',
445
+ choices: [
446
+ { title: 'pnpm', value: 'pnpm' },
447
+ { title: 'yarn', value: 'yarn' },
448
+ { title: 'npm', value: 'npm' },
449
+ { title: 'skip — I\'ll install manually', value: null },
450
+ ],
451
+ })
452
+ pm = picked || null
453
+ }
454
+ }
455
+
456
+ if (pm) {
457
+ const cmd = installCmd(pm)
458
+ let runInstall
459
+ if (nonInteractive) {
460
+ runInstall = false
461
+ info(`${colors.dim}Non-interactive — printing install command:${colors.reset}`)
462
+ log(` ${colors.cyan}${cmd}${colors.reset}`)
463
+ log('')
464
+ } else if (skipPrompt) {
465
+ runInstall = true
466
+ } else {
467
+ const { go } = await prompts({
468
+ type: 'confirm',
469
+ name: 'go',
470
+ message: `Run \`${cmd}\` now?`,
471
+ initial: true,
472
+ })
473
+ runInstall = !!go
474
+ }
475
+
476
+ if (runInstall) {
477
+ const code = await runCommand(cmd, workspaceDir)
478
+ if (code === 0) {
479
+ success('Install complete.')
480
+ log('')
481
+ } else {
482
+ error(`Install failed (exit ${code}). package.json edits are intact.`)
483
+ const editedRel = edited.map(p => relativize(p, workspaceDir)).join(' ')
484
+ log(`${colors.dim}To revert:${colors.reset} ${colors.cyan}git checkout -- ${editedRel}${colors.reset}`)
485
+ log(`${colors.dim}To retry: ${colors.reset} ${colors.cyan}${cmd}${colors.reset}`)
486
+ log('')
487
+ process.exit(code)
488
+ }
489
+ } else {
490
+ log(`${colors.dim}Skipped install. Edits saved; run${colors.reset} ${colors.cyan}${cmd}${colors.reset} ${colors.dim}to apply.${colors.reset}`)
491
+ log('')
492
+ }
493
+ }
494
+ }
495
+ } else {
496
+ log(`${colors.dim}Skipped deps alignment.${colors.reset}`)
497
+ log('')
498
+ }
499
+ }
500
+ }
501
+
502
+ // ── Step 3: AGENTS.md ────────────────────────────────────────────
215
503
  if (skipAgents) return
216
504
  if (!inProject) {
217
- if (agentsOnly) {
218
- error('Not in a Uniweb project (no `uniweb` dep in the workspace root package.json).')
219
- log(`${colors.dim}Run this command from inside a project created by${colors.reset} ${colors.cyan}uniweb create${colors.reset}${colors.dim}.${colors.reset}`)
220
- process.exit(1)
221
- }
222
- // Self-update-only path. Quietly skip AGENTS.md.
505
+ // Self-update-only invocation outside a Uniweb project: quietly skip.
506
+ return
507
+ }
508
+
509
+ // Re-survey: deps may have just been edited, which clears the gate.
510
+ const finalSurvey = await surveyVersions(workspaceDir)
511
+ if (finalSurvey.anyDrift && !allowMismatch) {
512
+ warn('AGENTS.md refresh skipped: workspace deps still lag the CLI.')
513
+ log(`${colors.dim}AGENTS.md from v${cliVersion} would document features not in your installed packages.${colors.reset}`)
514
+ log(`${colors.dim}Re-run without ${colors.reset}${colors.cyan}--no-deps${colors.reset}${colors.dim}, or pass ${colors.reset}${colors.cyan}--allow-mismatch${colors.reset}${colors.dim} to override.${colors.reset}`)
515
+ log('')
516
+ if (agentsOnly) process.exit(1)
223
517
  return
224
518
  }
225
519
 
@@ -230,11 +524,15 @@ export async function update(args = []) {
230
524
  return
231
525
  }
232
526
 
233
- // Prompt before writing in TTY, unless --yes / non-interactive (in which
234
- // case we err on the side of doing the right thing — refresh — since the
235
- // user explicitly invoked `uniweb update` from a Uniweb project).
527
+ if (dryRun) {
528
+ info(`Dry-run: would ${currentAgentsVersion ? `update AGENTS.md (v${currentAgentsVersion} v${cliVersion})` : `create AGENTS.md (v${cliVersion})`}.`)
529
+ return
530
+ }
531
+
236
532
  if (!skipPrompt && !agentsOnly) {
237
- const action = currentAgentsVersion ? `Update AGENTS.md (v${currentAgentsVersion} → v${cliVersion})?` : `Create AGENTS.md (v${cliVersion})?`
533
+ const action = currentAgentsVersion
534
+ ? `Update AGENTS.md (v${currentAgentsVersion} → v${cliVersion})?`
535
+ : `Create AGENTS.md (v${cliVersion})?`
238
536
  const { yes } = await prompts({ type: 'confirm', name: 'yes', message: action, initial: true })
239
537
  if (!yes) {
240
538
  log(`${colors.dim}Skipped AGENTS.md.${colors.reset}`)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-05-06T16:32:45.366Z",
3
+ "generatedAt": "2026-05-06T17:01:18.376Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
6
  "version": "0.14.3",
@@ -92,7 +92,7 @@
92
92
  "deps": []
93
93
  },
94
94
  "@uniweb/unipress": {
95
- "version": "0.4.7",
95
+ "version": "0.4.8",
96
96
  "path": "framework/unipress",
97
97
  "deps": [
98
98
  "@uniweb/build",
package/src/index.js CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  import { existsSync, readFileSync } from 'node:fs'
25
25
  import { execSync, spawn as spawnChild } from 'node:child_process'
26
- import { resolve, join, relative, dirname } from 'node:path'
26
+ import { resolve, join, relative, dirname, basename } from 'node:path'
27
27
  import { fileURLToPath } from 'node:url'
28
28
  import prompts from 'prompts'
29
29
  // `doctor`, `add`, `publish`, and `deploy` are loaded lazily via
@@ -43,7 +43,7 @@ import {
43
43
  parseTemplateId,
44
44
  } from './templates/index.js'
45
45
  import { validateTemplate } from './templates/validator.js'
46
- import { scaffoldWorkspace, scaffoldFoundation, scaffoldSite, applyContent, applyStarter, mergeTemplateDependencies } from './utils/scaffold.js'
46
+ import { scaffoldWorkspace, scaffoldFoundation, scaffoldSite, applyContent, applyStarter, mergeTemplateDependencies, getWorkspaceTemplateOutputs } from './utils/scaffold.js'
47
47
  import { detectPackageManager, filterCmd, installCmd, runCmd } from './utils/pm.js'
48
48
  import { isNonInteractive, getCliPrefix, stripNonInteractiveFlag, formatOptions } from './utils/interactive.js'
49
49
  import { findWorkspaceRoot } from './utils/workspace.js'
@@ -73,6 +73,30 @@ const TEMPLATE_CHOICES = [
73
73
  { title: 'Blank workspace', value: 'blank', description: 'Empty workspace — grow with uniweb add' },
74
74
  ]
75
75
 
76
+ // Files that may pre-exist in the target dir during `uniweb create .` and
77
+ // will be silently overwritten by the scaffold. Anything else colliding
78
+ // causes the verb to abort. README and .gitignore are the only two files
79
+ // the workspace template writes that overlap with what `gh repo create`
80
+ // puts in a fresh repo, and the scaffold's versions are more useful in
81
+ // this context (Vite/Node-aware .gitignore, project-shaped README).
82
+ const IN_PLACE_OVERWRITE_ALLOWED = new Set(['README.md', '.gitignore'])
83
+
84
+ /**
85
+ * Slugify a directory name into a valid project slug — lowercase,
86
+ * `[a-z0-9-]+`, no leading/trailing/duplicated hyphens. Matches the
87
+ * validation regex used for the interactive name prompt.
88
+ *
89
+ * @param {string} name
90
+ * @returns {string} Slugified name; empty if no valid characters remain.
91
+ */
92
+ function slugifyName(name) {
93
+ return String(name)
94
+ .toLowerCase()
95
+ .replace(/[^a-z0-9-]+/g, '-')
96
+ .replace(/^-+|-+$/g, '')
97
+ .replace(/-{2,}/g, '-')
98
+ }
99
+
76
100
  function log(message) {
77
101
  console.log(message)
78
102
  }
@@ -669,6 +693,18 @@ async function main() {
669
693
  let projectName = args[1]
670
694
  let templateType = null // null = use new package template flow
671
695
 
696
+ // In-place mode: `uniweb create .` scaffolds into the current working
697
+ // directory instead of creating a new one. Pairs with the GitHub-first
698
+ // workflow where the user already cloned an empty repo (README.md and
699
+ // optionally .gitignore present) and wants to scaffold inside it.
700
+ const inPlace = projectName === '.'
701
+ if (inPlace) {
702
+ // Clear the positional so downstream logic (template prompt, name
703
+ // prompt, etc.) doesn't see `.` as a literal name. The actual project
704
+ // name is derived below from the cwd basename or `--name`.
705
+ projectName = null
706
+ }
707
+
672
708
  // Check for --template flag
673
709
  const templateIndex = args.indexOf('--template')
674
710
  if (templateIndex !== -1 && args[templateIndex + 1]) {
@@ -682,11 +718,14 @@ async function main() {
682
718
  }
683
719
  }
684
720
 
685
- // Check for --name flag (used for project display name)
721
+ // Check for --name flag. Accepts both `--name foo` and `--name=foo`.
686
722
  let displayName = null
687
723
  const nameIndex = args.indexOf('--name')
688
724
  if (nameIndex !== -1 && args[nameIndex + 1]) {
689
725
  displayName = args[nameIndex + 1]
726
+ } else {
727
+ const nameEq = args.find(a => a.startsWith('--name='))
728
+ if (nameEq) displayName = nameEq.slice('--name='.length)
690
729
  }
691
730
 
692
731
  // Check for --blank flag
@@ -708,6 +747,28 @@ async function main() {
708
747
 
709
748
  const prefix = getCliPrefix()
710
749
 
750
+ // In-place: derive the project name from the cwd basename (slugified),
751
+ // or use --name when provided. Skip the interactive name prompt below.
752
+ if (inPlace) {
753
+ if (displayName) {
754
+ projectName = displayName
755
+ } else {
756
+ const dirName = basename(process.cwd())
757
+ const slug = slugifyName(dirName)
758
+ if (!slug) {
759
+ error(`Could not derive a valid project name from the current directory ("${dirName}").`)
760
+ log(`Re-run with ${colors.cyan}--name=<your-name>${colors.reset}.`)
761
+ process.exit(1)
762
+ }
763
+ projectName = slug
764
+ if (slug !== dirName) {
765
+ log(`${colors.dim}Project name:${colors.reset} ${slug} ${colors.dim}(slugified from "${dirName}")${colors.reset}`)
766
+ } else {
767
+ log(`${colors.dim}Project name:${colors.reset} ${slug}`)
768
+ }
769
+ }
770
+ }
771
+
711
772
  // Non-interactive: fail with actionable message instead of prompting
712
773
  if (nonInteractive && !projectName) {
713
774
  error(`Missing project name.\n`)
@@ -720,7 +781,7 @@ async function main() {
720
781
  templateType = 'starter'
721
782
  }
722
783
 
723
- // Interactive prompts
784
+ // Interactive prompts (skipped in in-place mode — name was derived above)
724
785
  const response = await prompts([
725
786
  {
726
787
  type: projectName ? null : 'text',
@@ -773,14 +834,40 @@ async function main() {
773
834
 
774
835
  const effectiveName = displayName || projectName
775
836
 
776
- // Create project directory
777
- const projectDir = resolve(process.cwd(), projectName)
837
+ // Resolve target directory. In-place mode scaffolds into the cwd;
838
+ // otherwise create `./<projectName>`.
839
+ const projectDir = inPlace ? process.cwd() : resolve(process.cwd(), projectName)
778
840
 
779
- if (existsSync(projectDir)) {
841
+ if (!inPlace && existsSync(projectDir)) {
780
842
  error(`Directory already exists: ${projectName}`)
781
843
  process.exit(1)
782
844
  }
783
845
 
846
+ if (inPlace) {
847
+ // Conflict check: enumerate the workspace template's would-write paths
848
+ // (the only stage that touches the project root) and bail if any
849
+ // collide with existing files outside the small allowlist.
850
+ //
851
+ // Allowlist: README.md and .gitignore overwrite cleanly. README is
852
+ // typically GitHub-generated boilerplate; .gitignore should be ours
853
+ // since the scaffold ships sensible Vite/Node ignores.
854
+ const outputs = await getWorkspaceTemplateOutputs({ blank: isBlank })
855
+ const conflicts = []
856
+ for (const rel of outputs) {
857
+ const full = resolve(projectDir, rel)
858
+ if (existsSync(full) && !IN_PLACE_OVERWRITE_ALLOWED.has(rel)) {
859
+ conflicts.push(rel)
860
+ }
861
+ }
862
+ if (conflicts.length > 0) {
863
+ error(`Cannot scaffold in place — these files would be overwritten:`)
864
+ for (const c of conflicts) log(` ${colors.yellow}${c}${colors.reset}`)
865
+ log('')
866
+ log(`Move or remove them, then re-run ${colors.cyan}uniweb create .${colors.reset}.`)
867
+ process.exit(1)
868
+ }
869
+ }
870
+
784
871
  // Template routing logic
785
872
  const progressCb = (msg) => log(` ${colors.dim}${msg}${colors.reset}`)
786
873
  const warningCb = (msg) => log(` ${colors.yellow}Warning: ${msg}${colors.reset}`)
@@ -879,13 +966,13 @@ async function main() {
879
966
 
880
967
  if (isBlank) {
881
968
  log(`Next steps:\n`)
882
- log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
969
+ if (!inPlace) log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
883
970
  log(` ${colors.cyan}${prefix} add project${colors.reset}`)
884
971
  log(` ${colors.cyan}${installCmd(pm)}${colors.reset}`)
885
972
  log(` ${colors.cyan}${prefix} dev${colors.reset} ${colors.dim}# Start dev server${colors.reset}`)
886
973
  } else {
887
974
  log(`Next steps:\n`)
888
- log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
975
+ if (!inPlace) log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
889
976
  log(` ${colors.cyan}${installCmd(pm)}${colors.reset}`)
890
977
  log(` ${colors.cyan}${prefix} dev${colors.reset} ${colors.dim}# Start dev server${colors.reset}`)
891
978
  }
@@ -979,6 +1066,7 @@ ${colors.cyan}${colors.bright}uniweb create${colors.reset} ${colors.dim}— Crea
979
1066
 
980
1067
  ${colors.bright}Usage:${colors.reset}
981
1068
  uniweb create [name] [options]
1069
+ uniweb create . Scaffold into the current directory
982
1070
 
983
1071
  ${colors.bright}Options:${colors.reset}
984
1072
  --template <type> Project template (default: starter)
@@ -987,13 +1075,24 @@ ${colors.bright}Options:${colors.reset}
987
1075
  npm: @scope/template-name
988
1076
  GitHub: github:user/repo or https://github.com/user/repo
989
1077
  --blank Create an empty workspace (grow with \`uniweb add\`)
990
- --name <name> Project display name
1078
+ --name <name> Project name (overrides slugified basename when used with \`.\`)
991
1079
  --no-git Skip git repository initialization
992
1080
 
1081
+ ${colors.bright}In-place mode (\`uniweb create .\`):${colors.reset}
1082
+ Pairs with the GitHub-first workflow — clone an empty repo locally
1083
+ (README, optional .gitignore), then scaffold inside it. Project name
1084
+ is the cwd basename, slugified to a valid npm name. Pass \`--name\` to
1085
+ override. Pre-existing \`README.md\` and \`.gitignore\` are overwritten;
1086
+ any other collision aborts with the list of conflicting files. Skips
1087
+ \`git init\` when a \`.git/\` directory already exists.
1088
+
993
1089
  ${colors.bright}Examples:${colors.reset}
994
1090
  uniweb create my-project # Foundation + site + starter content
995
1091
  uniweb create my-project --template marketing # Official template
996
1092
  uniweb create my-project --blank # Empty workspace
1093
+ uniweb create . # Scaffold into current dir
1094
+ uniweb create . --template docs # In place + a content template
1095
+ uniweb create . --name=my-app # In place, explicit slug
997
1096
  `,
998
1097
  dev: `
999
1098
  ${colors.cyan}${colors.bright}uniweb dev${colors.reset} ${colors.dim}— Start a dev server for a site${colors.reset}
@@ -1185,28 +1284,39 @@ Prints the parsed content shape of a markdown file or folder — the
1185
1284
  Useful for debugging "why isn't my section getting X?".
1186
1285
  `,
1187
1286
  update: `
1188
- ${colors.cyan}${colors.bright}uniweb update${colors.reset} ${colors.dim}— Update the CLI itself, plus AGENTS.md when in a project${colors.reset}
1287
+ ${colors.cyan}${colors.bright}uniweb update${colors.reset} ${colors.dim}— Reconcile workspace state with the running CLI${colors.reset}
1189
1288
 
1190
1289
  ${colors.bright}Usage:${colors.reset}
1191
- uniweb update Self-update + (in project) refresh AGENTS.md
1192
- uniweb update --agents-only Only refresh AGENTS.md (skip self-update)
1193
- uniweb update --no-agents Only self-update (skip AGENTS.md)
1194
- uniweb update --yes Skip the confirmation prompts
1290
+ uniweb update Self-update + align deps + refresh AGENTS.md
1291
+ uniweb update --deps-only Only align workspace @uniweb/* deps
1292
+ uniweb update --agents-only Only refresh AGENTS.md
1293
+ uniweb update --no-deps Skip the deps-alignment step
1294
+ uniweb update --no-agents Skip the AGENTS.md step
1295
+ uniweb update --dry-run Print survey + would-be writes; no mutations
1296
+ uniweb update --allow-mismatch Refresh AGENTS.md even if declared deps lag
1297
+ uniweb update --yes Skip confirmation prompts
1195
1298
 
1196
1299
  ${colors.bright}What it does:${colors.reset}
1197
- 1. Self-update the global install via npm / pnpm / yarn (auto-detected).
1198
- In TTY, prompts before running. In CI / non-interactive, prints the
1199
- command and exits without running it.
1200
- 2. If the cwd resolves to a Uniweb project (root package.json declares
1201
- \`uniweb\` as a dep), refreshes AGENTS.md from the CLI's bundled
1202
- version. Outside a Uniweb project, this step is skipped — the
1203
- command will not write AGENTS.md into unrelated directories.
1300
+ Prints a version survey first (CLI version, AGENTS.md stamp, every
1301
+ @uniweb/* + uniweb dep declared in workspace package.json files,
1302
+ marked aligned / behind / ahead). Then three steps:
1303
+
1304
+ 1. ${colors.bright}Self-update${colors.reset} the global install via npm / pnpm / yarn
1305
+ (auto-detected). TTY prompts; non-interactive prints the command.
1306
+ 2. ${colors.bright}Align workspace deps${colors.reset} to the CLI's bundled version matrix —
1307
+ edits every workspace package.json (only @uniweb/* + uniweb keys),
1308
+ then offers to run the workspace's package manager (lockfile-detected:
1309
+ pnpm-lock.yaml → pnpm, yarn.lock → yarn, package-lock.json → npm).
1310
+ If the install fails, package.json edits are kept and a revert
1311
+ command is printed.
1312
+ 3. ${colors.bright}Refresh AGENTS.md${colors.reset} from the CLI's bundled partial. Refuses to
1313
+ run while declared deps still lag the CLI (would document features
1314
+ not in your installed packages); pass --allow-mismatch to override.
1204
1315
 
1205
1316
  ${colors.bright}Project-local installs:${colors.reset}
1206
1317
  When the running CLI is project-local (lives in node_modules), self-
1207
1318
  update is a no-op — the version is pinned by your project's
1208
- package.json. The verb prints that explanation and proceeds with the
1209
- AGENTS.md refresh path only.
1319
+ package.json. The deps + AGENTS.md steps still run.
1210
1320
  `,
1211
1321
  }
1212
1322
 
@@ -224,6 +224,53 @@ export async function copyTemplateDirectory(sourcePath, targetPath, data, option
224
224
  }
225
225
  }
226
226
 
227
+ /**
228
+ * Enumerate the output paths a template directory would write, without
229
+ * touching disk. Mirrors `copyTemplateDirectory`'s naming rules:
230
+ * - `.hbs` extension is stripped
231
+ * - `_dir` is renamed to `.dir` (but `__dir` is preserved as `_dir` would be)
232
+ * - `template.json` is excluded
233
+ * - `skip` filenames are excluded by their post-rename name
234
+ *
235
+ * Returns relative paths (POSIX-style separators on POSIX, native on Windows
236
+ * — `path.join` semantics). Used by the in-place create flow to detect
237
+ * conflicts before any I/O begins.
238
+ *
239
+ * @param {string} sourcePath - Source template directory
240
+ * @param {Object} [options]
241
+ * @param {string[]} [options.skip] - Output filenames to exclude
242
+ * @returns {Promise<string[]>}
243
+ */
244
+ export async function enumerateTemplateOutputs(sourcePath, options = {}) {
245
+ const { skip = [] } = options
246
+ const outputs = []
247
+ await enumerateInto(sourcePath, '', outputs, skip)
248
+ return outputs
249
+ }
250
+
251
+ async function enumerateInto(sourcePath, relPath, outputs, skip) {
252
+ const entries = await fs.readdir(sourcePath, { withFileTypes: true })
253
+ for (const entry of entries) {
254
+ const sourceName = entry.name
255
+ if (entry.isDirectory()) {
256
+ const targetName = sourceName.startsWith('_') && !sourceName.startsWith('__')
257
+ ? `.${sourceName.slice(1)}`
258
+ : sourceName
259
+ await enumerateInto(
260
+ path.join(sourcePath, sourceName),
261
+ relPath ? path.join(relPath, targetName) : targetName,
262
+ outputs,
263
+ skip,
264
+ )
265
+ } else {
266
+ if (sourceName === 'template.json') continue
267
+ const outputName = sourceName.endsWith('.hbs') ? sourceName.slice(0, -4) : sourceName
268
+ if (skip.includes(outputName)) continue
269
+ outputs.push(relPath ? path.join(relPath, outputName) : outputName)
270
+ }
271
+ }
272
+ }
273
+
227
274
  /**
228
275
  * Clear the template cache
229
276
  */
package/src/utils/pm.js CHANGED
@@ -5,9 +5,17 @@
5
5
  * and generate PM-appropriate commands for output messages.
6
6
  */
7
7
 
8
+ import { existsSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+
8
11
  /**
9
12
  * Detect which package manager invoked the CLI.
10
13
  * Uses the standard npm_config_user_agent env var (same technique as create-vite, create-next-app).
14
+ *
15
+ * Note: this returns the *invoker* PM, which is empty when the user runs
16
+ * a global CLI binary directly from a shell. For the workspace's own PM
17
+ * (driven by lockfile presence), use `detectWorkspacePm`.
18
+ *
11
19
  * @returns {'pnpm' | 'npm'}
12
20
  */
13
21
  export function detectPackageManager() {
@@ -16,6 +24,22 @@ export function detectPackageManager() {
16
24
  return 'npm'
17
25
  }
18
26
 
27
+ /**
28
+ * Detect the workspace's package manager by inspecting lockfiles at the
29
+ * workspace root. This is the right signal for "what PM should I use to
30
+ * install in this workspace" — independent of how the CLI was invoked.
31
+ *
32
+ * @param {string} workspaceRoot - Absolute path to workspace root
33
+ * @returns {'pnpm' | 'yarn' | 'npm' | null} - null when no lockfile is present
34
+ */
35
+ export function detectWorkspacePm(workspaceRoot) {
36
+ if (!workspaceRoot) return null
37
+ if (existsSync(join(workspaceRoot, 'pnpm-lock.yaml'))) return 'pnpm'
38
+ if (existsSync(join(workspaceRoot, 'yarn.lock'))) return 'yarn'
39
+ if (existsSync(join(workspaceRoot, 'package-lock.json'))) return 'npm'
40
+ return null
41
+ }
42
+
19
43
  /**
20
44
  * Generate a workspace-filtered command.
21
45
  * pnpm: "pnpm --filter site dev"
@@ -33,11 +57,13 @@ export function filterCmd(pm, pkg, cmd) {
33
57
 
34
58
  /**
35
59
  * Generate an install command.
36
- * @param {'pnpm' | 'npm'} pm
60
+ * @param {'pnpm' | 'yarn' | 'npm'} pm
37
61
  * @returns {string}
38
62
  */
39
63
  export function installCmd(pm) {
40
- return pm === 'pnpm' ? 'pnpm install' : 'npm install'
64
+ if (pm === 'pnpm') return 'pnpm install'
65
+ if (pm === 'yarn') return 'yarn install'
66
+ return 'npm install'
41
67
  }
42
68
 
43
69
  /**
@@ -11,7 +11,7 @@ import { join, dirname } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import yaml from 'js-yaml'
13
13
  import Handlebars from 'handlebars'
14
- import { copyTemplateDirectory, registerVersions } from '../templates/processor.js'
14
+ import { copyTemplateDirectory, enumerateTemplateOutputs, registerVersions } from '../templates/processor.js'
15
15
  import { getVersionsForTemplates, getCliVersion } from '../versions.js'
16
16
 
17
17
  const __dirname = dirname(fileURLToPath(import.meta.url))
@@ -48,6 +48,28 @@ export async function scaffoldWorkspace(targetDir, context, options = {}) {
48
48
  })
49
49
  }
50
50
 
51
+ /**
52
+ * Return the relative paths the workspace template would write into the
53
+ * project root, given the same skip rules `scaffoldWorkspace` applies.
54
+ * Used by the in-place create flow (`uniweb create .`) to detect conflicts
55
+ * before any I/O begins.
56
+ *
57
+ * Only the workspace template's outputs are enumerated. The foundation,
58
+ * site, and starter stages write into newly-created subdirectories
59
+ * (`src/`, `site/`) that don't pre-exist in a target like a fresh GitHub
60
+ * clone, so they can't conflict.
61
+ *
62
+ * @param {Object} [options]
63
+ * @param {boolean} [options.blank] - True when scaffolding a blank workspace
64
+ * (no packages yet) — skips `pnpm-workspace.yaml`, mirroring scaffoldWorkspace.
65
+ * @returns {Promise<string[]>}
66
+ */
67
+ export async function getWorkspaceTemplateOutputs({ blank = false } = {}) {
68
+ const skip = blank ? ['pnpm-workspace.yaml'] : []
69
+ const templatePath = join(TEMPLATES_DIR, 'workspace')
70
+ return enumerateTemplateOutputs(templatePath, { skip })
71
+ }
72
+
51
73
  /**
52
74
  * Scaffold a foundation from the foundation package template
53
75
  *