uniweb 1.1.0 → 1.2.1

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.
@@ -7,15 +7,17 @@
7
7
  * `uniweb push` runs) — no auth, no backend round-trip.
8
8
  *
9
9
  * `--remote` adds the backend signals (may prompt for login, like `git fetch`):
10
- * - whether the synced draft differs from what's live (publish needed), and
11
- * - whether a newer foundation version is registered than the site pins.
10
+ * - whether the synced draft differs from what's live (publish needed),
11
+ * - whether a newer foundation version is registered than the site pins, and
12
+ * - who has the site open in the app right now, and on what (`describeEditors`).
12
13
  * Those use ASSUMED endpoints (see kb shipping-verbs-and-freshness.md §6.5); until
13
14
  * the backend exposes them, `--remote` degrades silently to the local view.
14
15
  *
15
16
  * Usage:
16
17
  * uniweb status Sync identity + unpushed content + foundation ref (local)
17
18
  * uniweb status --remote Also: whether the backend still holds the site, draft-vs-live,
18
- * and a newer-registered-foundation check
19
+ * a newer-registered-foundation check, and who has it open in
20
+ * the app
19
21
  * uniweb status --json One JSON line (adds a `remote` object under --remote, whose
20
22
  * `site_state` is live / gone / unknown — `gone` only on the
21
23
  * backend's own word that it holds no such site)
@@ -28,7 +30,7 @@ import { join } from 'node:path'
28
30
  import yaml from 'js-yaml'
29
31
 
30
32
  import { resolveSiteDir } from './deploy.js'
31
- import { probeUnpushed } from '../backend/site-sync.js'
33
+ import { probeUnpushed, readItemUuids, unitFilesOf } from '../backend/site-sync.js'
32
34
  import { resolveWorkspace } from '../backend/workspace.js'
33
35
  import {
34
36
  BackendClient,
@@ -75,6 +77,79 @@ function foundationRef(siteYml) {
75
77
  return typeof f === 'string' ? f : f.ref || null
76
78
  }
77
79
 
80
+ // What an item that this copy has not synced is called — by its Section on the site.
81
+ const ITEM_KIND = { pages: 'a page', page_sections: 'a section', layout_sections: 'a layout section' }
82
+
83
+ /**
84
+ * Who has the site open in the app right now, and on what — the status's `editors`
85
+ * (backend, since 2026-10-13), as lines a developer can follow up on. Each tab's item is
86
+ * named by the file it is in this copy, and by its kind and route when this copy has not
87
+ * synced it (`fileByItemUuid`).
88
+ *
89
+ * ⛔ Two ways not to read it. `changed_at` is the item's last change by ANYONE — this
90
+ * author, another, a push — so it says when the item last changed, never what this
91
+ * person did. And it is presence, not a lock: nothing refuses or warns a push over it.
92
+ *
93
+ * @param {object[]} editors - `[{ person: { uuid, handle, brief }, tabs: [{ page, item }] }]`
94
+ * @param {object} [opts]
95
+ * @param {(uuid: string) => string|null} [opts.fileOf] - an item's file in this copy
96
+ * @param {number} [opts.now] - for "3 min ago"
97
+ * @returns {{ person: string, tabs: string[] }[]} a tab with nothing open is left out
98
+ */
99
+ export function describeEditors(editors, { fileOf = () => null, now = Date.now() } = {}) {
100
+ return editors.map((editor) => ({
101
+ person: personLabel(editor?.person),
102
+ tabs: (editor?.tabs || []).map((tab) => tabLabel(tab, fileOf, now)).filter(Boolean)
103
+ }))
104
+ }
105
+
106
+ function personLabel(person) {
107
+ const name = person?.brief?.display_name
108
+ const handle = person?.handle ? `@${person.handle}` : null
109
+ if (name && handle) return `${name} (${handle})`
110
+ return name || handle || `someone (${person?.uuid || 'unnamed'})`
111
+ }
112
+
113
+ function tabLabel(tab, fileOf, now) {
114
+ const item = tab?.item
115
+ if (!item?.uuid) return null
116
+ const route = tab.page?.route || null
117
+ const file = fileOf(item.uuid)
118
+ const what = file
119
+ ? `${file}${route ? ` (${route})` : ''}`
120
+ : `${ITEM_KIND[item.section] || 'an item'} not in this copy${route ? ` (${route})` : ''}`
121
+ const when = sinceLabel(item.changed_at, now)
122
+ return when ? `${what} · changed ${when}` : what
123
+ }
124
+
125
+ function sinceLabel(iso, now) {
126
+ const t = Date.parse(iso)
127
+ if (Number.isNaN(t)) return null
128
+ const min = Math.floor(Math.max(0, now - t) / 60_000)
129
+ if (min < 1) return 'just now'
130
+ if (min < 60) return `${min} min ago`
131
+ const h = Math.floor(min / 60)
132
+ if (h < 24) return `${h} h ago`
133
+ const d = Math.floor(h / 24)
134
+ return `${d} day${d === 1 ? '' : 's'} ago`
135
+ }
136
+
137
+ /**
138
+ * An item's file in this copy, from its uuid: `sync.json` keeps each unit's uuid on
139
+ * `backend` by its path (`items`, banked from every push and pull), and `unitFilesOf`
140
+ * finds the file the author keeps it in (`1-hero.md` for `hero.md`). Null for an item
141
+ * this copy has not synced — one added in the app since its last pull.
142
+ */
143
+ export function fileByItemUuid(siteDir, backend) {
144
+ const unitOf = new Map(
145
+ Object.entries(readItemUuids(siteDir, backend)).map(([unit, uuid]) => [String(uuid).toLowerCase(), unit])
146
+ )
147
+ return (uuid) => {
148
+ const unit = unitOf.get(String(uuid).toLowerCase())
149
+ return unit ? (unitFilesOf(siteDir, unit)[0] ?? unit) : null
150
+ }
151
+ }
152
+
78
153
  // A versioned registry ref `@org/name@1.2.3` → its scoped name `@org/name` and
79
154
  // pinned version `1.2.3`. A bare/local/unversioned ref → nulls.
80
155
  function splitFoundationRef(fnd) {
@@ -263,6 +338,20 @@ export async function status(args = []) {
263
338
  )
264
339
  }
265
340
  }
341
+ // An array is the backend's answer, `[]` included; no key is a backend that does not
342
+ // say, and then neither do we.
343
+ if (Array.isArray(site?.editors)) {
344
+ if (!site.editors.length) {
345
+ say.dim('Nobody has this site open in the app.')
346
+ } else {
347
+ say.info('Open in the app now:')
348
+ const fileOf = fileByItemUuid(siteDir, probeBackend)
349
+ for (const { person, tabs } of describeEditors(site.editors, { fileOf })) {
350
+ console.log(` ${person}${tabs.length ? '' : ', with nothing open'}`)
351
+ for (const tab of tabs) console.log(` ${c.dim}${tab}${c.reset}`)
352
+ }
353
+ }
354
+ }
266
355
  if (
267
356
  fdnLatest?.latest_version &&
268
357
  fndVersion &&
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-13T18:18:01.170Z",
3
+ "generatedAt": "2026-10-14T18:03:31.718Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "1.1.0"
6
+ "version": "1.1.1"
7
7
  },
8
8
  "@uniweb/build": {
9
- "version": "1.1.0"
9
+ "version": "1.1.1"
10
10
  },
11
11
  "@uniweb/content-reader": {
12
12
  "version": "1.2.8"
@@ -18,10 +18,16 @@ import { existsSync, readFileSync } from 'node:fs'
18
18
  import { readFile, writeFile } from 'node:fs/promises'
19
19
  import { join } from 'node:path'
20
20
  import { homedir } from 'node:os'
21
- import yaml from 'js-yaml'
22
21
  import { filterCmd } from './pm.js'
23
22
  import { writeJsonPreservingStyleAsync } from './json-file.js'
24
23
  import { loggedInOriginOf, readSessionFileSync } from './session-file.js'
24
+ import {
25
+ WORKSPACE_FILE,
26
+ readWorkspacePatterns,
27
+ patternsCover,
28
+ editWorkspaceFilePackages,
29
+ renderWorkspaceFile
30
+ } from './workspace-packages.js'
25
31
 
26
32
  // ── Backend origin ─────────────────────────────────────────────
27
33
 
@@ -214,59 +220,88 @@ export const DEFAULT_BACKEND_ORIGIN = 'https://uniweb.app'
214
220
  /**
215
221
  * Read workspace package globs.
216
222
  * Tries pnpm-workspace.yaml first, falls back to package.json workspaces.
223
+ * The folders they name: `workspacePackages` (./workspace-packages.js).
217
224
  * @param {string} rootDir - Workspace root directory
218
225
  * @returns {Promise<{packages: string[]}>}
219
226
  */
220
227
  export async function readWorkspaceConfig(rootDir) {
221
- // Try pnpm-workspace.yaml first
222
- const configPath = join(rootDir, 'pnpm-workspace.yaml')
223
- if (existsSync(configPath)) {
224
- const content = await readFile(configPath, 'utf-8')
225
- const config = yaml.load(content)
226
- return { packages: config?.packages || [] }
227
- }
228
-
229
- // Fall back to package.json workspaces
230
- const pkg = await readRootPackageJson(rootDir)
231
- if (Array.isArray(pkg.workspaces)) {
232
- return { packages: pkg.workspaces }
233
- }
234
-
235
- return { packages: [] }
228
+ return { packages: readWorkspacePatterns(rootDir).patterns }
236
229
  }
237
230
 
238
231
  /**
239
- * Write pnpm-workspace.yaml
232
+ * Make the folder at `relPath` a workspace package.
233
+ *
234
+ * Nothing is written for a folder a pattern already covers — it is a package
235
+ * already. ⛔ Until 2026-10-13 its own path was added beside the pattern, and
236
+ * discovery listed it twice. Otherwise `entry` — the folder itself, unless a
237
+ * pattern is given for it — is added to each manifest that does not cover it:
238
+ *
239
+ * - pnpm-workspace.yaml's `packages:` list, edited in place: comments and every
240
+ * other key stay as they were. ⛔ Until 2026-10-13 the file was rewritten from
241
+ * its `packages` alone, deleting `onlyBuiltDependencies` and `allowBuilds` with
242
+ * the comments that explain them. A workspace without the file (a blank one, at
243
+ * its first `add`) gets it from the workspace template, as `create` writes it.
244
+ * - package.json's `workspaces`, which npm and yarn read.
245
+ *
240
246
  * @param {string} rootDir - Workspace root directory
241
- * @param {{packages: string[]}} config
247
+ * @param {string} relPath - The package's folder, workspace-relative
248
+ * @param {{ entry?: string }} [options] - `entry`: a pattern to list for it instead
242
249
  */
243
- export async function writeWorkspaceConfig(rootDir, config) {
244
- const configPath = join(rootDir, 'pnpm-workspace.yaml')
245
- const content = yaml.dump(config, { flowLevel: -1, quotingType: '"' })
246
- await writeFile(configPath, content)
250
+ export async function registerWorkspacePackage(rootDir, relPath, { entry = relPath } = {}) {
251
+ const { source, patterns } = readWorkspacePatterns(rootDir)
252
+ const ymlPath = join(rootDir, WORKSPACE_FILE)
253
+ if (!patternsCover(patterns, relPath)) {
254
+ if (source === WORKSPACE_FILE) {
255
+ const text = await readFile(ymlPath, 'utf-8')
256
+ await writeFile(ymlPath, editedWorkspaceFile(text, (list) => [...list, entry], entry))
257
+ } else {
258
+ await writeFile(ymlPath, renderWorkspaceFile([...patterns, entry]))
259
+ }
260
+ }
261
+
262
+ const pkg = await readRootPackageJson(rootDir)
263
+ const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : []
264
+ if (!patternsCover(workspaces, relPath)) {
265
+ pkg.workspaces = [...workspaces, entry]
266
+ await writeRootPackageJson(rootDir, pkg)
267
+ }
247
268
  }
248
269
 
249
270
  /**
250
- * Add a glob pattern to pnpm-workspace.yaml if not already present
251
- * @param {string} rootDir - Workspace root directory
252
- * @param {string} glob - Glob pattern to add
271
+ * A package folder moved from `oldPath` to `newPath`: rename its entry in each
272
+ * manifest that lists it by path — a pattern is left as written — then register
273
+ * the new path, in case it has moved out from under the pattern that covered it.
274
+ *
275
+ * @param {string} rootDir
276
+ * @param {string} oldPath
277
+ * @param {string} newPath
253
278
  */
254
- export async function addWorkspaceGlob(rootDir, glob) {
255
- const config = await readWorkspaceConfig(rootDir)
256
- if (!config.packages.includes(glob)) {
257
- config.packages.push(glob)
258
- await writeWorkspaceConfig(rootDir, config)
279
+ export async function moveWorkspacePackage(rootDir, oldPath, newPath) {
280
+ const { source, patterns } = readWorkspacePatterns(rootDir)
281
+ if (source === WORKSPACE_FILE && patterns.includes(oldPath)) {
282
+ const ymlPath = join(rootDir, WORKSPACE_FILE)
283
+ const text = await readFile(ymlPath, 'utf-8')
284
+ const rename = (list) => list.map((p) => (p === oldPath ? newPath : p))
285
+ await writeFile(ymlPath, editedWorkspaceFile(text, rename, newPath))
259
286
  }
260
-
261
- // Sync workspaces array in package.json (for npm compatibility)
262
287
  const pkg = await readRootPackageJson(rootDir)
263
- if (!pkg.workspaces) {
264
- pkg.workspaces = []
265
- }
266
- if (!pkg.workspaces.includes(glob)) {
267
- pkg.workspaces.push(glob)
288
+ if (Array.isArray(pkg.workspaces) && pkg.workspaces.includes(oldPath)) {
289
+ pkg.workspaces = pkg.workspaces.map((p) => (p === oldPath ? newPath : p))
268
290
  await writeRootPackageJson(rootDir, pkg)
269
291
  }
292
+ await registerWorkspacePackage(rootDir, newPath)
293
+ }
294
+
295
+ /** The edited file, or an error that says what to add by hand — the file is never rewritten. */
296
+ function editedWorkspaceFile(text, edit, entry) {
297
+ const next = editWorkspaceFilePackages(text, edit)
298
+ if (next == null) {
299
+ throw new Error(
300
+ `Could not edit the packages: list in ${WORKSPACE_FILE} in place, and it is not rewritten ` +
301
+ `whole. Add "${entry}" to it by hand.`
302
+ )
303
+ }
304
+ return next
270
305
  }
271
306
 
272
307
  /**
@@ -352,66 +387,39 @@ export function computeRootScripts(sites, pm = 'pnpm') {
352
387
  */
353
388
  export async function updateRootScripts(rootDir, sites, pm = 'pnpm') {
354
389
  const pkg = await readRootPackageJson(rootDir)
355
- const newScripts = computeRootScripts(sites, pm)
390
+ const unique = sites.filter((s, i) => sites.findIndex((o) => o.path === s.path) === i)
391
+ const newScripts = computeRootScripts(unique, pm)
392
+ const scripts = { ...(pkg.scripts || {}) }
356
393
 
357
394
  // If we're adding a second site, rename existing dev/preview to dev:{firstName}
358
- if (sites.length === 2 && pkg.scripts?.dev) {
359
- const firstName = sites[0].name
395
+ if (unique.length === 2 && scripts.dev) {
396
+ const firstName = unique[0].name
360
397
  // Only rename if the existing dev script references the first site's name
361
- if (pkg.scripts.dev.includes(firstName)) {
362
- pkg.scripts[`dev:${firstName}`] = pkg.scripts.dev
363
- pkg.scripts[`preview:${firstName}`] = pkg.scripts.preview
364
- }
365
- }
366
-
367
- pkg.scripts = { ...pkg.scripts, ...newScripts }
368
- await writeRootPackageJson(rootDir, pkg)
369
- }
370
-
371
- // Resolve a workspace glob pattern to actual directories
372
- export async function resolveGlob(rootDir, pattern) {
373
- const clean = pattern.replace(/^["']|["']$/g, '')
374
-
375
- if (clean.endsWith('/*')) {
376
- // Pattern like "foundations/*" - list subdirectories
377
- const baseDir = clean.slice(0, -2)
378
- const fullPath = join(rootDir, baseDir)
379
- if (!existsSync(fullPath)) return []
380
- try {
381
- const { readdirSync } = await import('node:fs')
382
- const entries = readdirSync(fullPath, { withFileTypes: true })
383
- return entries
384
- .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
385
- .map((e) => join(baseDir, e.name))
386
- } catch {
387
- return []
398
+ if (scripts.dev.includes(firstName)) {
399
+ scripts[`dev:${firstName}`] = scripts.dev
400
+ scripts[`preview:${firstName}`] = scripts.preview
388
401
  }
389
402
  }
390
403
 
391
- if (clean.startsWith('*/')) {
392
- // Pattern like "*/foundation" - find subdirs with this child
393
- const suffix = clean.slice(2)
394
- const { readdirSync } = await import('node:fs')
395
- try {
396
- const entries = readdirSync(rootDir, { withFileTypes: true })
397
- return entries
398
- .filter(
399
- (e) =>
400
- e.isDirectory() &&
401
- !e.name.startsWith('.') &&
402
- e.name !== 'node_modules'
403
- )
404
- .filter((e) => existsSync(join(rootDir, e.name, suffix)))
405
- .map((e) => join(e.name, suffix))
406
- } catch {
407
- return []
408
- }
404
+ // ⭐ The per-site scripts this CLI writes are regenerated from the sites there are
405
+ // now, and one it wrote for a site that is gone — renamed, moved, removed — goes.
406
+ // A script whose command is not one the CLI writes is the user's, and stays.
407
+ // ⛔ Until 2026-10-13 they were only ever added: after `rename site blog journal`,
408
+ // `dev:blog` still ran `uniweb dev blog`, a site that no longer existed.
409
+ for (const [key, value] of Object.entries(scripts)) {
410
+ if (key in newScripts) continue
411
+ const m = /^(dev|preview):(.+)$/.exec(key)
412
+ if (m && cliSiteScripts(m[1], m[2]).includes(value)) delete scripts[key]
409
413
  }
410
414
 
411
- // Direct path like "foundation" or "site"
412
- if (existsSync(join(rootDir, clean))) {
413
- return [clean]
414
- }
415
+ pkg.scripts = { ...scripts, ...newScripts }
416
+ await writeRootPackageJson(rootDir, pkg)
417
+ }
415
418
 
416
- return []
419
+ /** Every command this CLI has written for the `dev:<site>` / `preview:<site>` script. */
420
+ function cliSiteScripts(kind, site) {
421
+ return kind === 'dev'
422
+ ? [`uniweb dev ${site}`, filterCmd('pnpm', site, 'dev'), filterCmd('npm', site, 'dev')]
423
+ : [filterCmd('pnpm', site, 'preview'), filterCmd('npm', site, 'preview')]
417
424
  }
425
+
@@ -24,7 +24,7 @@ import { existsSync } from 'node:fs'
24
24
  import { readFile } from 'node:fs/promises'
25
25
  import { join } from 'node:path'
26
26
  import { classifyPackage } from '@uniweb/build'
27
- import { readWorkspaceConfig, resolveGlob } from './config.js'
27
+ import { workspacePackages } from './workspace-packages.js'
28
28
 
29
29
  /**
30
30
  * Discover foundations in the workspace.
@@ -45,30 +45,27 @@ export async function discoverSites(rootDir) {
45
45
  }
46
46
 
47
47
  async function discoverByKind(rootDir, kind) {
48
- const { packages } = await readWorkspaceConfig(rootDir)
49
48
  const out = []
50
49
 
51
- for (const pattern of packages) {
52
- const dirs = await resolveGlob(rootDir, pattern)
53
- for (const dir of dirs) {
54
- const fullPath = join(rootDir, dir)
55
- if (classifyPackage(fullPath) !== kind) continue
50
+ // The workspace's packages as pnpm reads its patterns — each folder once.
51
+ for (const dir of workspacePackages(rootDir)) {
52
+ const fullPath = join(rootDir, dir)
53
+ if (classifyPackage(fullPath) !== kind) continue
56
54
 
57
- // Read package.json for the package name. Synthesize one from
58
- // the directory if it's missing or malformed — we still want
59
- // the package to surface in pickers.
60
- const pkgPath = join(fullPath, 'package.json')
61
- let name = dir.split('/').pop()
62
- if (existsSync(pkgPath)) {
63
- try {
64
- const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
65
- if (pkg.name) name = pkg.name
66
- } catch {
67
- // keep directory-derived name
68
- }
55
+ // Read package.json for the package name. Synthesize one from
56
+ // the directory if it's missing or malformed — we still want
57
+ // the package to surface in pickers.
58
+ const pkgPath = join(fullPath, 'package.json')
59
+ let name = dir.split('/').pop()
60
+ if (existsSync(pkgPath)) {
61
+ try {
62
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
63
+ if (pkg.name) name = pkg.name
64
+ } catch {
65
+ // keep directory-derived name
69
66
  }
70
- out.push({ name, path: dir })
71
67
  }
68
+ out.push({ name, path: dir })
72
69
  }
73
70
 
74
71
  return out
@@ -96,6 +96,12 @@ export const EXTENSION_KIND = {
96
96
  * @returns {{ relativePath: string, packageName: string }}
97
97
  */
98
98
  export function resolvePlacement(rootDir, name, opts, kind) {
99
+ // 0. `--project` names the folder itself (rule 4), so a name or a `--path` beside it
100
+ // names a second one: refused, never ignored (`projectConflictLines`).
101
+ if (opts.project && (name || opts.path)) {
102
+ return { projectConflict: true, relativePath: null, packageName: null }
103
+ }
104
+
99
105
  // 1. --path is a PARENT directory. The folder is `<path>/<name>` if a
100
106
  // name was given, or `<path>` itself if not (the path's last segment
101
107
  // is then taken as the package name).
@@ -149,3 +155,52 @@ export function resolvePlacement(rootDir, name, opts, kind) {
149
155
  packageName: kind.defaultPkg
150
156
  }
151
157
  }
158
+
159
+ /**
160
+ * What to say when `--project` came with a name or a `--path` — the first line an
161
+ * error, the rest the commands that do what was meant.
162
+ *
163
+ * `--project <p>` is the co-located shorthand: it places a project's one foundation
164
+ * at `<p>/src/` and its one site at `<p>/site/`, and names nothing else. A name beside
165
+ * it asks for a different folder, which naming the folder says plainly.
166
+ * ⛔ Until 2026-10-13 the name won without a word: `add site fitness --project
167
+ * memberships` created `fitness/` at the workspace root.
168
+ *
169
+ * @param {'foundation'|'site'|'extension'} kind
170
+ * @param {string|null} name - the name given, if any
171
+ * @param {{ project: string, path?: string }} opts
172
+ * @param {string} command - how to run the verb: `uniweb add site`, `uniweb clone <uuid>`
173
+ * @returns {string[]}
174
+ */
175
+ export function projectConflictLines(kind, name, opts, command) {
176
+ const p = opts.project
177
+ if (!name) {
178
+ return [
179
+ `--project and --path both name the folder — use one.`,
180
+ ` ${command} --project ${p} ${kind === 'site' ? `${p}/site/` : `${p}/src/`}`,
181
+ ` ${command} --path ${opts.path}`
182
+ ]
183
+ }
184
+ if (kind === 'extension') {
185
+ return [
186
+ `--project places a project's foundation or site; an extension goes in the folder you name.`,
187
+ ` ${command} ${p}/extensions/${name}`
188
+ ]
189
+ }
190
+ if (kind === 'site') {
191
+ return [
192
+ `--project ${p} places the project's one site, at ${p}/site/, and takes no name.`,
193
+ `For a site named "${name}" in ${p}, name its folder — a project with several sites keeps them in ${p}/sites/:`,
194
+ ` ${command} ${p}/sites/${name}`,
195
+ `Or the project's one site:`,
196
+ ` ${command} --project ${p}`
197
+ ]
198
+ }
199
+ return [
200
+ `--project ${p} places the project's foundation, at ${p}/src/, and takes no name.`,
201
+ `For a foundation named "${name}" in ${p}, name its folder:`,
202
+ ` ${command} ${p}/${name}`,
203
+ `Or the project's foundation:`,
204
+ ` ${command} --project ${p}`
205
+ ]
206
+ }
@@ -37,7 +37,7 @@ export async function scaffoldWorkspace(targetDir, context, options = {}) {
37
37
  registerVersions(getVersionsForTemplates())
38
38
 
39
39
  // Skip pnpm-workspace.yaml for blank workspaces (no packages yet).
40
- // The `add` command creates it on demand via addWorkspaceGlob().
40
+ // The `add` command creates it on demand (registerWorkspacePackage), from this template.
41
41
  // Without this, pnpm fails with ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND
42
42
  // because an empty packages: [] makes pnpm search parent directories.
43
43
  const skip = context.workspaceGlobs?.length ? [] : ['pnpm-workspace.yaml']