uniweb 0.12.2 → 0.12.4

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.
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Rename Command
3
+ *
4
+ * Renames packages across the workspace transactionally. Currently
5
+ * supports `rename foundation <old> <new>`; sites and extensions can be
6
+ * added with the same scaffolding when needed.
7
+ *
8
+ * What a foundation rename touches (in order):
9
+ * 1. The foundation's own package.json::name.
10
+ * 2. Folder name on disk (when folder leaf matches old package name).
11
+ * 3. Each site's package.json: dep key (old → new) + file: path.
12
+ * 4. Each site's site.yml::foundation.
13
+ * 5. pnpm-workspace.yaml::packages and package.json::workspaces
14
+ * (when the folder rename moved the path).
15
+ * 6. Root scripts (`pnpm --filter <old>` references).
16
+ *
17
+ * Pre-flight checks run before any mutation. If anything would conflict
18
+ * (target name already taken, foundation not found, folder collision)
19
+ * we bail with a clear message and no partial state.
20
+ *
21
+ * Out of scope: registry side. The publish id (`package.json::uniweb.id`)
22
+ * is independent of the workspace name and stays untouched. Users who
23
+ * want to also rename on the registry run `uniweb publish --name <new>`.
24
+ *
25
+ * Usage:
26
+ * uniweb rename foundation <old> <new>
27
+ */
28
+
29
+ import { existsSync } from 'node:fs'
30
+ import { readFile, writeFile, rename as fsRename } from 'node:fs/promises'
31
+ import { join, relative, dirname, basename } from 'node:path'
32
+ import yaml from 'js-yaml'
33
+ import { findWorkspaceRoot } from '../utils/workspace.js'
34
+ import {
35
+ discoverFoundations,
36
+ discoverSites,
37
+ readWorkspaceConfig,
38
+ writeWorkspaceConfig,
39
+ readRootPackageJson,
40
+ writeRootPackageJson,
41
+ updateRootScripts,
42
+ } from '../utils/config.js'
43
+ import { getExistingPackageNames, validatePackageName } from '../utils/names.js'
44
+ import { detectPackageManager, installCmd } from '../utils/pm.js'
45
+ import { getCliPrefix } from '../utils/interactive.js'
46
+
47
+ const colors = {
48
+ reset: '\x1b[0m',
49
+ bright: '\x1b[1m',
50
+ dim: '\x1b[2m',
51
+ cyan: '\x1b[36m',
52
+ green: '\x1b[32m',
53
+ yellow: '\x1b[33m',
54
+ red: '\x1b[31m',
55
+ }
56
+
57
+ const success = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`)
58
+ const error = (msg) => console.error(`${colors.red}✗${colors.reset} ${msg}`)
59
+ const info = (msg) => console.log(`${colors.dim}${msg}${colors.reset}`)
60
+ const log = console.log
61
+
62
+ export async function rename(args = []) {
63
+ const [subcommand, oldName, newName] = args
64
+ const prefix = getCliPrefix()
65
+
66
+ if (!subcommand || subcommand === '--help' || subcommand === '-h') {
67
+ showHelp(prefix)
68
+ return
69
+ }
70
+
71
+ if (subcommand !== 'foundation') {
72
+ error(`Unknown subcommand: ${subcommand}`)
73
+ log(`Supported: rename foundation <old> <new>`)
74
+ process.exit(1)
75
+ }
76
+
77
+ if (!oldName || !newName) {
78
+ error('Missing arguments.')
79
+ log(`Usage: ${prefix} rename foundation <old> <new>`)
80
+ process.exit(1)
81
+ }
82
+
83
+ if (oldName === newName) {
84
+ error('Old and new names are identical — nothing to do.')
85
+ process.exit(1)
86
+ }
87
+
88
+ const rootDir = findWorkspaceRoot()
89
+ if (!rootDir) {
90
+ error('Not in a Uniweb workspace.')
91
+ log(`Run this command from your project root or a site/foundation directory.`)
92
+ process.exit(1)
93
+ }
94
+
95
+ await renameFoundation(rootDir, oldName, newName, prefix)
96
+ }
97
+
98
+ async function renameFoundation(rootDir, oldName, newName, prefix) {
99
+ // ─── Pre-flight ───────────────────────────────────────────────
100
+
101
+ // Validate the new name (format + reserved-name check). `src` is
102
+ // grandfathered in for the same reason add foundation grandfathers
103
+ // it (the convention for "the package that lives in src/").
104
+ if (newName !== 'src') {
105
+ const valid = validatePackageName(newName)
106
+ if (valid !== true) {
107
+ error(valid)
108
+ process.exit(1)
109
+ }
110
+ }
111
+
112
+ // The foundation must exist under its current name.
113
+ const foundations = await discoverFoundations(rootDir)
114
+ const target = foundations.find(f => f.name === oldName)
115
+ if (!target) {
116
+ error(`No foundation named ${colors.bright}${oldName}${colors.reset} in this workspace.`)
117
+ if (foundations.length > 0) {
118
+ log(`Available: ${foundations.map(f => f.name).join(', ')}`)
119
+ }
120
+ process.exit(1)
121
+ }
122
+
123
+ // The new name must be free across the entire workspace.
124
+ const existingNames = await getExistingPackageNames(rootDir)
125
+ if (existingNames.has(newName)) {
126
+ error(`Cannot rename: a package named ${colors.bright}${newName}${colors.reset} already exists in this workspace.`)
127
+ process.exit(1)
128
+ }
129
+
130
+ const oldFoundationPath = target.path // workspace-relative
131
+ const oldFoundationDir = join(rootDir, oldFoundationPath)
132
+ const folderLeaf = basename(oldFoundationPath)
133
+
134
+ // Decide the new folder path. Rule: if the folder leaf matches the
135
+ // old package name, rename the leaf to match the new package name
136
+ // (preserving any parent dirs like `foundations/`). Otherwise leave
137
+ // the folder alone — the user named the folder differently than the
138
+ // package, and we honor that.
139
+ let newFoundationPath = oldFoundationPath
140
+ let newFoundationDir = oldFoundationDir
141
+ const folderWillRename = folderLeaf === oldName
142
+ if (folderWillRename) {
143
+ const parent = dirname(oldFoundationPath)
144
+ newFoundationPath = parent === '.' ? newName : join(parent, newName)
145
+ newFoundationDir = join(rootDir, newFoundationPath)
146
+ if (existsSync(newFoundationDir)) {
147
+ error(`Cannot rename: target folder ${colors.bright}${newFoundationPath}/${colors.reset} already exists.`)
148
+ process.exit(1)
149
+ }
150
+ }
151
+
152
+ // Find every site that depends on the foundation. Two signals must
153
+ // agree: site.yml::foundation === oldName AND package.json has the
154
+ // dep key. If they disagree (which doctor would have flagged) we
155
+ // still rename whichever signals point to the old name — the goal
156
+ // is to leave the workspace consistent under the new name.
157
+ const sites = await discoverSites(rootDir)
158
+ const affectedSites = []
159
+ for (const site of sites) {
160
+ const sitePkgPath = join(rootDir, site.path, 'package.json')
161
+ const siteYmlPath = join(rootDir, site.path, 'site.yml')
162
+ let pkg, ymlText, ymlData
163
+ try {
164
+ pkg = JSON.parse(await readFile(sitePkgPath, 'utf-8'))
165
+ } catch {
166
+ pkg = null
167
+ }
168
+ try {
169
+ ymlText = await readFile(siteYmlPath, 'utf-8')
170
+ ymlData = yaml.load(ymlText) || {}
171
+ } catch {
172
+ ymlText = null
173
+ ymlData = null
174
+ }
175
+ const hasDep = pkg?.dependencies && oldName in pkg.dependencies
176
+ const ymlMatches = ymlData?.foundation === oldName
177
+ if (hasDep || ymlMatches) {
178
+ affectedSites.push({
179
+ path: site.path,
180
+ name: site.name,
181
+ pkg,
182
+ sitePkgPath,
183
+ siteYmlPath,
184
+ ymlData,
185
+ hasDep,
186
+ ymlMatches,
187
+ })
188
+ }
189
+ }
190
+
191
+ // Read workspace config + root package.json once. Both manifests
192
+ // are kept in sync by addWorkspaceGlob; we update both here too
193
+ // (the multi-PM compatibility invariant).
194
+ const wsConfig = await readWorkspaceConfig(rootDir)
195
+ const rootPkg = await readRootPackageJson(rootDir)
196
+
197
+ // ─── Print plan, then execute ────────────────────────────────
198
+
199
+ log('')
200
+ log(`${colors.bright}Rename foundation${colors.reset}: ${colors.yellow}${oldName}${colors.reset} → ${colors.green}${newName}${colors.reset}`)
201
+ log('')
202
+ if (folderWillRename) {
203
+ info(` Folder: ${oldFoundationPath}/ → ${newFoundationPath}/`)
204
+ } else {
205
+ info(` Folder: ${oldFoundationPath}/ (unchanged — leaf doesn't match package name)`)
206
+ }
207
+ info(` package.json::name: "${oldName}" → "${newName}"`)
208
+ if (affectedSites.length === 0) {
209
+ info(` Sites depending on this foundation: none`)
210
+ } else {
211
+ info(` Sites depending on this foundation:`)
212
+ for (const s of affectedSites) {
213
+ info(` • ${s.name} at ${s.path}/`)
214
+ }
215
+ }
216
+ log('')
217
+
218
+ // 1. Rename the folder (if applicable). Do this first because every
219
+ // later write needs paths under the new location.
220
+ if (folderWillRename) {
221
+ await fsRename(oldFoundationDir, newFoundationDir)
222
+ }
223
+
224
+ // 2. Rewrite the foundation's package.json::name.
225
+ const fndPkgPath = join(newFoundationDir, 'package.json')
226
+ const fndPkg = JSON.parse(await readFile(fndPkgPath, 'utf-8'))
227
+ fndPkg.name = newName
228
+ await writeFile(fndPkgPath, JSON.stringify(fndPkg, null, 2) + '\n')
229
+
230
+ // 3 + 4. For each affected site, update package.json deps and site.yml.
231
+ for (const s of affectedSites) {
232
+ if (s.hasDep) {
233
+ // Rename the dep key. Recompute the file: path against the new
234
+ // foundation location (the old one no longer exists if the
235
+ // folder was renamed).
236
+ const newRel = relative(join(rootDir, s.path), newFoundationDir) || '.'
237
+ const oldValue = s.pkg.dependencies[oldName]
238
+ delete s.pkg.dependencies[oldName]
239
+ s.pkg.dependencies[newName] = oldValue.startsWith('file:')
240
+ ? `file:${newRel}`
241
+ : oldValue // npm-pinned, leave it; rename-then-republish would
242
+ // need a separate `pnpm update` step that's out of scope.
243
+ await writeFile(s.sitePkgPath, JSON.stringify(s.pkg, null, 2) + '\n')
244
+ }
245
+ if (s.ymlMatches) {
246
+ const newYmlData = { ...s.ymlData, foundation: newName }
247
+ await writeFile(s.siteYmlPath, yaml.dump(newYmlData, { flowLevel: -1, quotingType: "'" }))
248
+ }
249
+ }
250
+
251
+ // 5. Update workspace manifests. Both pnpm-workspace.yaml and
252
+ // package.json::workspaces (sync invariant) — only the entry
253
+ // matching the old folder path moves; bare globs like
254
+ // `foundations/*` don't change.
255
+ if (folderWillRename) {
256
+ if (wsConfig.packages.includes(oldFoundationPath)) {
257
+ const idx = wsConfig.packages.indexOf(oldFoundationPath)
258
+ wsConfig.packages[idx] = newFoundationPath
259
+ await writeWorkspaceConfig(rootDir, wsConfig)
260
+ }
261
+ if (Array.isArray(rootPkg.workspaces) && rootPkg.workspaces.includes(oldFoundationPath)) {
262
+ const idx = rootPkg.workspaces.indexOf(oldFoundationPath)
263
+ rootPkg.workspaces[idx] = newFoundationPath
264
+ await writeRootPackageJson(rootDir, rootPkg)
265
+ }
266
+ }
267
+
268
+ // 6. Root scripts can reference the foundation by name (e.g.
269
+ // `pnpm --filter <name> build`). updateRootScripts regenerates
270
+ // them from the current discoverSites output, which has fresh
271
+ // names after the writes above.
272
+ const pm = detectPackageManager()
273
+ const freshSites = await discoverSites(rootDir)
274
+ await updateRootScripts(rootDir, freshSites, pm)
275
+
276
+ // ─── Done ─────────────────────────────────────────────────────
277
+
278
+ log('')
279
+ success(`Renamed foundation ${colors.bright}${oldName}${colors.reset} → ${colors.bright}${newName}${colors.reset}`)
280
+ log('')
281
+ log(`Next: ${colors.cyan}${installCmd(pm)}${colors.reset} ${colors.dim}(refresh symlinks under the new name)${colors.reset}`)
282
+ log(` ${colors.cyan}${getCliPrefix()} doctor${colors.reset} ${colors.dim}(verify wiring)${colors.reset}`)
283
+ }
284
+
285
+ function showHelp(prefix) {
286
+ log(`
287
+ ${colors.cyan}${colors.bright}Uniweb Rename${colors.reset}
288
+
289
+ Rename a package across the workspace, keeping all wiring in sync.
290
+
291
+ ${colors.bright}Usage:${colors.reset}
292
+ ${prefix} rename foundation <old-name> <new-name>
293
+
294
+ ${colors.bright}What it does (foundation):${colors.reset}
295
+ • Updates the foundation's package.json::name.
296
+ • Renames the folder if its leaf matched the old package name.
297
+ • Updates every site's package.json dependency key + file: path.
298
+ • Updates every site's site.yml::foundation reference.
299
+ • Updates pnpm-workspace.yaml + package.json::workspaces (kept in sync).
300
+ • Regenerates root scripts.
301
+
302
+ ${colors.bright}What it does NOT do:${colors.reset}
303
+ • Push to the registry. The publish id (package.json::uniweb.id) is
304
+ independent. To rename on the registry too, run \`${prefix} publish --name <new>\`.
305
+
306
+ ${colors.bright}Examples:${colors.reset}
307
+ ${prefix} rename foundation src marketing-src
308
+ ${prefix} rename foundation marketing acme-marketing
309
+ `)
310
+ }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-04-29T13:50:36.314Z",
3
+ "generatedAt": "2026-04-30T00:37:08.969Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.13.1",
6
+ "version": "0.13.3",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -82,7 +82,7 @@
82
82
  "deps": []
83
83
  },
84
84
  "@uniweb/templates": {
85
- "version": "0.7.36",
85
+ "version": "0.7.38",
86
86
  "path": "framework/templates",
87
87
  "deps": []
88
88
  },
@@ -92,7 +92,7 @@
92
92
  "deps": []
93
93
  },
94
94
  "@uniweb/unipress": {
95
- "version": "0.4.1",
95
+ "version": "0.4.2",
96
96
  "path": "framework/unipress",
97
97
  "deps": [
98
98
  "@uniweb/build",
package/src/index.js CHANGED
@@ -495,8 +495,8 @@ async function main() {
495
495
  // Handle doctor command (dynamic import — depends on @uniweb/build)
496
496
  if (command === 'doctor') {
497
497
  const { doctor } = await importProjectCommand('./commands/doctor.js')
498
- await doctor(args.slice(1))
499
- return
498
+ const result = await doctor(args.slice(1))
499
+ process.exit(result?.errors > 0 ? 1 : 0)
500
500
  }
501
501
 
502
502
  // Handle update command
@@ -518,6 +518,13 @@ async function main() {
518
518
  return
519
519
  }
520
520
 
521
+ // Handle rename command (dynamic import — depends on @uniweb/build via deps)
522
+ if (command === 'rename') {
523
+ const { rename } = await importProjectCommand('./commands/rename.js')
524
+ await rename(args.slice(1))
525
+ return
526
+ }
527
+
521
528
  // Handle publish command (dynamic import — depends on @uniweb/build)
522
529
  if (command === 'publish') {
523
530
  const { publish } = await importProjectCommand('./commands/publish.js')
@@ -815,6 +822,7 @@ ${colors.bright}Usage:${colors.reset}
815
822
  ${colors.bright}Commands:${colors.reset}
816
823
  create [name] Create a new project
817
824
  add <type> [name] Add a foundation, site, or extension to a project
825
+ rename <type> Rename a workspace package (foundation today)
818
826
  build Build the current project
819
827
  deploy Deploy a site to Uniweb hosting
820
828
  publish Publish a foundation to the Uniweb Registry
@@ -876,11 +884,12 @@ ${colors.bright}Deploy Options:${colors.reset}
876
884
 
877
885
  ${colors.bright}Build Options:${colors.reset}
878
886
  --target <type> Build target (foundation, site) - auto-detected if not specified
879
- --prerender Force pre-rendering (overrides site.yml)
880
- --no-prerender Skip pre-rendering (overrides site.yml)
887
+ --link Site: data-only pipeline (Uniweb-edge hosting)
888
+ --bundle Site: vite-built static-host artifact (default)
889
+ --prerender Force pre-rendering (bundle mode only; overrides site.yml)
890
+ --no-prerender Skip pre-rendering (bundle mode only; overrides site.yml)
881
891
  --foundation-dir Path to foundation directory (for prerendering)
882
892
  --platform <name> Deployment platform (e.g., vercel) for platform-specific output
883
- --shell Build site without embedded content (for dynamic backend serving)
884
893
 
885
894
  At workspace root, builds all foundations first, then all sites.
886
895
  Pre-rendering is enabled by default when build.prerender: true in site.yml
@@ -0,0 +1,91 @@
1
+ /**
2
+ * `dist/publish.json` receipt — shared shape used by `publish` and `deploy`.
3
+ *
4
+ * The receipt is a per-checkout cache of the last publish; it lets the
5
+ * deploy verb decide whether a workspace-local foundation needs republishing
6
+ * without a network round-trip on the happy path. It's gitignored, so it
7
+ * never travels with the source — fresh clones, CI runs, and teammates all
8
+ * start with no cache. Both verbs refill the cache lazily by reading the
9
+ * registry's index when the local file is missing.
10
+ *
11
+ * See `kb/framework/build/workspace-ergonomics.md` for the full rationale.
12
+ */
13
+
14
+ /**
15
+ * Compose the canonical six-field receipt body. All callers MUST go through
16
+ * this helper so the runbook's pp-10 schema check stays meaningful.
17
+ *
18
+ * @param {Object} params
19
+ * @param {string|null} params.gitSha
20
+ * @param {boolean} params.gitDirty
21
+ * @param {string} params.url
22
+ * @param {string} params.publishedAt
23
+ * @param {string} params.classification 'propagate' | 'silent'
24
+ * @returns {Object}
25
+ */
26
+ export function composeReceipt({ gitSha, gitDirty, url, publishedAt, classification }) {
27
+ return {
28
+ schemaVersion: 1,
29
+ publishedFromGitSha: gitSha,
30
+ publishedFromGitDirty: gitDirty,
31
+ url,
32
+ publishedAt,
33
+ classification,
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Resolve the canonical receipt URL given (in priority order):
39
+ * 1. A `publishResult.url` from a fresh upload — server-rendered, handles
40
+ * empty-scope rewrites the CLI can't synthesize.
41
+ * 2. An `existingEntry.url` recorded by a previous publish (refill path).
42
+ * 3. A synthesized canonical form — `file://` for local registries,
43
+ * `<apiUrl>/foundations/<name>@<version>/foundation.js` for remote.
44
+ * The remote form mirrors the path the worker returns in `publishResult.url`,
45
+ * which keeps the receipt's URL parseable by the regex in
46
+ * `deploy.js::deriveLocalFoundationRef` even when the registry's index
47
+ * entry doesn't carry an explicit `url` field. (Unicloud's index entries
48
+ * don't; uniweb-edge's index entries don't either — both rely on the
49
+ * response shape, not the index shape.)
50
+ *
51
+ * Path-shaped candidates (e.g. `/foundations/...`) are joined with the
52
+ * registry's `apiUrl` so the receipt always carries an absolute URL.
53
+ */
54
+ export function deriveReceiptUrl({ publishResult, existingEntry, registry, name, version, isLocal }) {
55
+ const candidate = publishResult?.url || existingEntry?.url
56
+ if (candidate) {
57
+ if (candidate.startsWith('http') || candidate.startsWith('file://')) return candidate
58
+ if (registry?.apiUrl) return new URL(candidate, registry.apiUrl).toString()
59
+ }
60
+ if (isLocal) return `file://${registry.getPackagePath(name, version)}/`
61
+ return `${registry.apiUrl.replace(/\/$/, '')}/foundations/${name}@${version}/foundation.js`
62
+ }
63
+
64
+ /**
65
+ * Build a receipt from an existing registry version entry, used to refill a
66
+ * missing `dist/publish.json` from server-of-record state. Returns null if
67
+ * the entry doesn't carry enough provenance to make the receipt useful for
68
+ * staleness checks (the only field that strictly must be present is
69
+ * `publishedFromGitSha` — without it, the deploy verb can't compare against
70
+ * HEAD, and refilling would just re-trigger the auto-publish next run).
71
+ */
72
+ export function receiptFromRegistryEntry({ existingEntry, registry, name, version, isLocal, isPropagateDefault }) {
73
+ if (!existingEntry || !existingEntry.publishedFromGitSha) return null
74
+ return composeReceipt({
75
+ gitSha: existingEntry.publishedFromGitSha,
76
+ gitDirty: existingEntry.publishedFromGitDirty ?? false,
77
+ url: deriveReceiptUrl({ existingEntry, registry, name, version, isLocal }),
78
+ publishedAt: existingEntry.publishedAt || new Date().toISOString(),
79
+ classification: existingEntry.classification || (isPropagateDefault ? 'propagate' : 'silent'),
80
+ })
81
+ }
82
+
83
+ /**
84
+ * Split `@ns/name@ver`, `~user/name@ver`, or `name@ver` into name + version.
85
+ * Returns null on any shape we don't recognize.
86
+ */
87
+ export function splitRegistryRef(ref) {
88
+ if (typeof ref !== 'string') return null
89
+ const m = /^(@[^/]+\/[^@]+|~[^/]+\/[^@]+|[^@]+)@(.+)$/.exec(ref)
90
+ return m ? { name: m[1], version: m[2] } : null
91
+ }
@@ -142,6 +142,21 @@ export class LocalRegistry {
142
142
  return index[name]?.versions || []
143
143
  }
144
144
 
145
+ /**
146
+ * Get the full version entry for `name@version`, or null if absent.
147
+ * Used by `publish` and `deploy` to refill `dist/publish.json` from
148
+ * server-of-record state when the local cache is missing — see
149
+ * `kb/framework/build/workspace-ergonomics.md` (receipt-as-cache).
150
+ *
151
+ * @param {string} name
152
+ * @param {string} version
153
+ * @returns {Promise<Object|null>}
154
+ */
155
+ async getVersionEntry(name, version) {
156
+ const versions = await this.getVersions(name)
157
+ return versions.find(v => v.version === version) || null
158
+ }
159
+
145
160
  /**
146
161
  * Publish a foundation to the local registry.
147
162
  * Copies the dist directory and updates the index.
@@ -253,6 +268,24 @@ export class RemoteRegistry {
253
268
  return index[name]?.versions || []
254
269
  }
255
270
 
271
+ /**
272
+ * Get the full version entry for `name@version`, or null if absent.
273
+ * Used by `publish` and `deploy` to refill `dist/publish.json` from
274
+ * server-of-record state when the local cache is missing.
275
+ *
276
+ * @param {string} name
277
+ * @param {string} version
278
+ * @returns {Promise<Object|null>}
279
+ */
280
+ async getVersionEntry(name, version) {
281
+ try {
282
+ const versions = await this.getVersions(name)
283
+ return versions.find(v => v.version === version) || null
284
+ } catch {
285
+ return null
286
+ }
287
+ }
288
+
256
289
  /**
257
290
  * Publish a foundation to the remote registry.
258
291
  * Reads files from distDir, encodes as base64, and POSTs to the server.
@@ -8,13 +8,11 @@
8
8
  "{{@key}}": "{{this}}"{{#unless @last}},{{/unless}}
9
9
  {{/each}}
10
10
  },
11
- {{#if workspaceGlobs.length}}
12
- "workspaces": [
11
+ "workspaces": [{{#if workspaceGlobs.length}}
13
12
  {{#each workspaceGlobs}}
14
13
  "{{this}}"{{#unless @last}},{{/unless}}
15
14
  {{/each}}
16
- ],
17
- {{/if}}
15
+ {{/if}}],
18
16
  "devDependencies": {
19
17
  "@types/node": "^22.0.0",
20
18
  "uniweb": "{{version "uniweb"}}"