uniweb 1.0.0 → 1.0.2

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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,483 @@
1
+ /**
2
+ * Scaffolding Utilities
3
+ *
4
+ * Shared scaffolding functions used by both `create` and `add` commands.
5
+ * Each function scaffolds a single package from its package template.
6
+ */
7
+
8
+ import fs from 'node:fs/promises'
9
+ import { existsSync, readdirSync } from 'node:fs'
10
+ import { join, dirname } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+ import yaml from 'js-yaml'
13
+ import Handlebars from 'handlebars'
14
+ import {
15
+ copyTemplateDirectory,
16
+ enumerateTemplateOutputs,
17
+ registerVersions
18
+ } from '../templates/processor.js'
19
+ import { getVersionsForTemplates, getCliVersion } from '../versions.js'
20
+ import { writeJsonPreservingStyleAsync } from './json-file.js'
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url))
23
+ const TEMPLATES_DIR = join(__dirname, '..', '..', 'templates')
24
+ const STARTER_DIR = join(__dirname, '..', '..', 'starter')
25
+
26
+ /**
27
+ * Scaffold a workspace root from the workspace package template
28
+ *
29
+ * @param {string} targetDir - Target directory
30
+ * @param {Object} context - Template context
31
+ * @param {string} context.projectName - Workspace/project name
32
+ * @param {string[]} context.workspaceGlobs - Workspace glob patterns
33
+ * @param {Object} context.scripts - Root package.json scripts
34
+ * @param {Object} [options] - Processing options
35
+ */
36
+ export async function scaffoldWorkspace(targetDir, context, options = {}) {
37
+ registerVersions(getVersionsForTemplates())
38
+
39
+ // Skip pnpm-workspace.yaml for blank workspaces (no packages yet).
40
+ // The `add` command creates it on demand via addWorkspaceGlob().
41
+ // Without this, pnpm fails with ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND
42
+ // because an empty packages: [] makes pnpm search parent directories.
43
+ const skip = context.workspaceGlobs?.length ? [] : ['pnpm-workspace.yaml']
44
+
45
+ // Inject CLI version for AGENTS.md stamp
46
+ context = { ...context, cliVersion: getCliVersion() }
47
+
48
+ const templatePath = join(TEMPLATES_DIR, 'workspace')
49
+ await copyTemplateDirectory(templatePath, targetDir, context, {
50
+ onProgress: options.onProgress,
51
+ onWarning: options.onWarning,
52
+ skip
53
+ })
54
+ }
55
+
56
+ /**
57
+ * Return the relative paths the workspace template would write into the
58
+ * project root, given the same skip rules `scaffoldWorkspace` applies.
59
+ * Used by the in-place create flow (`uniweb create .`) to detect conflicts
60
+ * before any I/O begins.
61
+ *
62
+ * Only the workspace template's outputs are enumerated. The foundation,
63
+ * site, and starter stages write into newly-created subdirectories
64
+ * (`src/`, `site/`) that don't pre-exist in a target like a fresh GitHub
65
+ * clone, so they can't conflict.
66
+ *
67
+ * @param {Object} [options]
68
+ * @param {boolean} [options.blank] - True when scaffolding a blank workspace
69
+ * (no packages yet) — skips `pnpm-workspace.yaml`, mirroring scaffoldWorkspace.
70
+ * @returns {Promise<string[]>}
71
+ */
72
+ export async function getWorkspaceTemplateOutputs({ blank = false } = {}) {
73
+ const skip = blank ? ['pnpm-workspace.yaml'] : []
74
+ const templatePath = join(TEMPLATES_DIR, 'workspace')
75
+ return enumerateTemplateOutputs(templatePath, { skip })
76
+ }
77
+
78
+ /**
79
+ * Scaffold a foundation from the foundation package template.
80
+ *
81
+ * @param {string} targetDir
82
+ * @param {Object} context - Template context
83
+ * @param {string} context.name - Package name — workspace plumbing (`src`, `docs-src`)
84
+ * @param {string} [context.registryName] - The foundation's own name, written to
85
+ * main.js's `name`: what it registers as (`@org/<name>`). Omitted when none can be
86
+ * made, and `uniweb register` asks for one
87
+ * @param {string} context.projectName - Workspace name
88
+ * @param {boolean} [context.isExtension]
89
+ */
90
+ export async function scaffoldFoundation(targetDir, context, options = {}) {
91
+ registerVersions(getVersionsForTemplates())
92
+
93
+ const templatePath = join(TEMPLATES_DIR, 'foundation')
94
+ await copyTemplateDirectory(templatePath, targetDir, context, {
95
+ onProgress: options.onProgress,
96
+ onWarning: options.onWarning
97
+ })
98
+ }
99
+
100
+ /**
101
+ * Scaffold a site from the site package template
102
+ *
103
+ * Two shapes, by whether a local foundation is wired:
104
+ * - Local foundation (the default for `create`/`add site`): pass
105
+ * `foundationName` + `foundationPath` (a `file:` spec) and the site's
106
+ * package.json gets that dependency.
107
+ * - Referenced foundation (no local sibling — what `uniweb clone` uses):
108
+ * OMIT `foundationName`/`foundationPath` and set `foundationRef` to a
109
+ * registry ref (`@ns/name@ver`) or URL. No `file:` dependency is written;
110
+ * `site.yml::foundation` carries the ref and the runtime loads it as a
111
+ * federated module (runtime mode). Deps still pin to the CLI version
112
+ * matrix via the template's `{{version}}` helper.
113
+ *
114
+ * @param {string} targetDir - Target directory for the site
115
+ * @param {Object} context - Template context
116
+ * @param {string} context.name - Package name
117
+ * @param {string} context.projectName - Workspace name
118
+ * @param {string} [context.foundationName] - Local foundation package name (omit for a referenced foundation)
119
+ * @param {string} [context.foundationPath] - Relative file: path to a local foundation (omit for a referenced foundation)
120
+ * @param {string} [context.foundationRef] - Foundation ref written to site.yml (a local package name, a registry ref, or a URL)
121
+ * @param {Object} [options] - Processing options
122
+ */
123
+ export async function scaffoldSite(targetDir, context, options = {}) {
124
+ registerVersions(getVersionsForTemplates())
125
+
126
+ const templatePath = join(TEMPLATES_DIR, 'site')
127
+ // A ref is written as a YAML scalar, not pasted as text: a scoped ref
128
+ // (`@acme/base@1.0.0`, `@acme/marketing`) starts with `@`, which plain YAML
129
+ // reserves, so the file would not parse — and every reader that swallows
130
+ // the parse error then reports the site as having no `$uuid`. js-yaml quotes
131
+ // only when needed, so `src` stays `src`. Rendered with `{{{ }}}`, because
132
+ // Handlebars' HTML escaping would also rewrite a URL's `&` and `=`.
133
+ const siteContext = context.foundationRef
134
+ ? { ...context, foundationRefYaml: yaml.dump(context.foundationRef, { lineWidth: -1 }).trim() }
135
+ : context
136
+ await copyTemplateDirectory(templatePath, targetDir, siteContext, {
137
+ onProgress: options.onProgress,
138
+ onWarning: options.onWarning
139
+ })
140
+ }
141
+
142
+ /**
143
+ * Apply content overlay from a content directory onto a target
144
+ *
145
+ * Content files overwrite scaffolded defaults. Structural files
146
+ * (package.json, vite.config.js, entry.js, index.html) are NOT overwritten.
147
+ *
148
+ * Note: a foundation's `main.js` (the user-authored declarations file) is
149
+ * NOT structural — templates legitimately provide their own `main.js` to
150
+ * override the empty scaffold default. The site's `entry.js` (formerly
151
+ * `main.js`) IS structural — the boilerplate `start({...})` is identical
152
+ * across all sites and shouldn't be overwritten.
153
+ *
154
+ * @param {string} contentDir - Source content directory (e.g., starter/foundation/)
155
+ * @param {string} targetDir - Target directory to overlay onto
156
+ * @param {Object} context - Handlebars context for .hbs files
157
+ * @param {Object} [options] - Processing options
158
+ */
159
+ export async function applyContent(
160
+ contentDir,
161
+ targetDir,
162
+ context,
163
+ options = {}
164
+ ) {
165
+ if (!existsSync(contentDir)) return
166
+
167
+ registerVersions(getVersionsForTemplates())
168
+
169
+ // Structural files that content should never overwrite
170
+ const STRUCTURAL_FILES = new Set([
171
+ 'package.json',
172
+ 'vite.config.js',
173
+ 'entry.js',
174
+ 'index.html',
175
+ '.gitignore'
176
+ ])
177
+
178
+ // Config files that should be merged, not overwritten: for each listed
179
+ // key, when the scaffolded version's value is used.
180
+ //
181
+ // foundation ALWAYS — the CLI resolved it for this project, and a
182
+ // content template cannot know it.
183
+ // name ONLY WHEN THE TEMPLATE SETS NONE. A template's own name is
184
+ // the default name of a site made from it — the same thing
185
+ // cloning a template in an app gives — so a literal
186
+ // `name: Product Launch` survives, and the project name fills
187
+ // in for a template with no name, or only the
188
+ // `{{projectName}}` placeholder.
189
+ const MERGE_FILES = {
190
+ 'site.yml': [
191
+ ['name', 'when-template-has-none'],
192
+ ['foundation', 'always']
193
+ ]
194
+ }
195
+
196
+ await copyContentRecursive(
197
+ contentDir,
198
+ targetDir,
199
+ context,
200
+ STRUCTURAL_FILES,
201
+ MERGE_FILES,
202
+ options,
203
+ 0
204
+ )
205
+ }
206
+
207
+ /**
208
+ * What a project records about where IT synced and deployed — a site's identity on
209
+ * each backend, its deploy targets and history, and the local caches of both. A new
210
+ * project made from a template owns none of it: carried over, its first push would
211
+ * update the template's own site. The same line `uniweb forget --all` draws for a
212
+ * copied project. Matched at the top of a content directory, where a site keeps them.
213
+ */
214
+ const INHERITED_STATE = new Set(['sync.json', 'deploy.yml', '.uniweb'])
215
+
216
+ /**
217
+ * Recursively copy content files, skipping structural files.
218
+ *
219
+ * `depth` is tracked so the project-state names (INHERITED_STATE) are
220
+ * skipped only at the top of the content directory.
221
+ */
222
+ async function copyContentRecursive(
223
+ sourceDir,
224
+ targetDir,
225
+ context,
226
+ structuralFiles,
227
+ mergeFiles,
228
+ options,
229
+ depth = 0
230
+ ) {
231
+ await fs.mkdir(targetDir, { recursive: true })
232
+
233
+ const entries = readdirSync(sourceDir, { withFileTypes: true })
234
+
235
+ for (const entry of entries) {
236
+ const sourcePath = join(sourceDir, entry.name)
237
+
238
+ if (entry.isDirectory()) {
239
+ if (depth === 0 && INHERITED_STATE.has(entry.name)) continue
240
+ const targetSubDir = join(targetDir, entry.name)
241
+ await copyContentRecursive(
242
+ sourcePath,
243
+ targetSubDir,
244
+ context,
245
+ structuralFiles,
246
+ mergeFiles,
247
+ options,
248
+ depth + 1
249
+ )
250
+ } else {
251
+ // Determine the output filename (strip .hbs extension)
252
+ const outputName = entry.name.endsWith('.hbs')
253
+ ? entry.name.slice(0, -4)
254
+ : entry.name
255
+
256
+ // Skip structural files
257
+ if (structuralFiles.has(outputName)) continue
258
+ if (depth === 0 && INHERITED_STATE.has(outputName)) continue
259
+
260
+ const targetPath = join(targetDir, outputName)
261
+
262
+ // Get new content (process .hbs or read as-is)
263
+ let newContent
264
+ if (entry.name.endsWith('.hbs')) {
265
+ const Handlebars = (await import('handlebars')).default
266
+ const raw = await fs.readFile(sourcePath, 'utf-8')
267
+ const template = Handlebars.compile(raw)
268
+ newContent = template(context)
269
+ }
270
+
271
+ // Merge config files instead of overwriting.
272
+ //
273
+ // The "merge" here is narrow: take the content template's output
274
+ // as the source of truth (so comments, formatting, and the full
275
+ // educational structure of the content template survive) and
276
+ // override only the specific top-level keys listed in
277
+ // preserveKeys with the values from the already-scaffolded base
278
+ // file (so the resolved foundation ref is never replaced by whatever
279
+ // the content template hardcoded, and a project name fills in for a
280
+ // template that names nothing).
281
+ //
282
+ // Earlier versions of this code parsed both files through
283
+ // js-yaml, merged the objects, and re-emitted the result via
284
+ // yaml.dump() — which stripped every comment and blank line on
285
+ // the way through. For templates like `cv/site/site.yml.hbs`
286
+ // whose comments are the template's educational payload, that
287
+ // was devastating. The line-level override below preserves the
288
+ // content template's text verbatim except for the preserved keys.
289
+ const preserveKeys = mergeFiles[outputName]
290
+ if (preserveKeys && existsSync(targetPath)) {
291
+ const existingContent = await fs.readFile(targetPath, 'utf-8')
292
+ const existing = yaml.load(existingContent) || {}
293
+ let merged = newContent ?? (await fs.readFile(sourcePath, 'utf-8'))
294
+
295
+ for (const [key, when] of preserveKeys) {
296
+ if (existing[key] === undefined) continue
297
+ const baseLine = matchTopLevelLine(existingContent, key)
298
+ if (!baseLine) continue
299
+ // If the new content carries the key, replace its line with
300
+ // the scaffolded value — unless the key only fills in, and the
301
+ // template set a value of its own. Otherwise insert the line —
302
+ // older content templates (notably `docs/site/site.yml.hbs`) omit
303
+ // `foundation:` entirely, and dropping it leaves the site without a
304
+ // foundation ref so the entry's `import '#foundation/styles'` fails
305
+ // at build time.
306
+ if (matchTopLevelLine(merged, key)) {
307
+ if (when === 'when-template-has-none' && hasOwnValue(merged, key)) {
308
+ continue
309
+ }
310
+ merged = replaceTopLevelLine(merged, key, baseLine)
311
+ } else {
312
+ merged = insertTopLevelLine(merged, baseLine)
313
+ }
314
+ }
315
+
316
+ await fs.writeFile(targetPath, merged)
317
+ } else if (newContent !== undefined) {
318
+ await fs.writeFile(targetPath, newContent)
319
+ } else {
320
+ // Copy as-is
321
+ await fs.copyFile(sourcePath, targetPath)
322
+ }
323
+
324
+ if (options.onProgress) {
325
+ options.onProgress(`Creating ${outputName}`)
326
+ }
327
+ }
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Apply the built-in starter content
333
+ *
334
+ * The starter ships its foundation content under `cli/starter/foundation/`
335
+ * (a description of the *kind* of content), and is applied into whatever
336
+ * folder the workspace uses for the foundation package — which is `src/`
337
+ * in the current single-foundation layout, not `foundation/`. Callers
338
+ * can override via options when scaffolding multi-foundation workspaces.
339
+ *
340
+ * @param {string} projectDir - Root project directory
341
+ * @param {Object} context - Template context
342
+ * @param {Object} [options] - Processing options
343
+ * @param {string} [options.foundationDir] - Foundation directory name (default: 'src')
344
+ * @param {string} [options.siteDir] - Site directory name (default: 'site')
345
+ */
346
+ export async function applyStarter(projectDir, context, options = {}) {
347
+ const foundationDir = options.foundationDir || 'src'
348
+ const siteDir = options.siteDir || 'site'
349
+
350
+ // Apply foundation starter content
351
+ const foundationContentDir = join(STARTER_DIR, 'foundation')
352
+ const foundationTargetDir = join(projectDir, foundationDir)
353
+ await applyContent(
354
+ foundationContentDir,
355
+ foundationTargetDir,
356
+ context,
357
+ options
358
+ )
359
+
360
+ // Apply site starter content
361
+ const siteContentDir = join(STARTER_DIR, 'site')
362
+ const siteTargetDir = join(projectDir, siteDir)
363
+ await applyContent(siteContentDir, siteTargetDir, context, options)
364
+ }
365
+
366
+ /**
367
+ * Escape a string for safe inclusion in a RegExp literal. Used by the
368
+ * site.yml line-level merge path.
369
+ */
370
+ function escapeRegex(s) {
371
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
372
+ }
373
+
374
+ /**
375
+ * Whether a content template sets `key` to a value of its own: a non-empty
376
+ * string that is not an unrendered `{{placeholder}}` (a plain `site.yml`, not
377
+ * `.hbs`, is copied without rendering). Content that does not parse counts as
378
+ * setting nothing, so the scaffolded value fills in, as it always did.
379
+ */
380
+ function hasOwnValue(content, key) {
381
+ let value
382
+ try {
383
+ value = (yaml.load(content) || {})[key]
384
+ } catch {
385
+ return false
386
+ }
387
+ return typeof value === 'string' && value.trim() !== '' && !value.includes('{{')
388
+ }
389
+
390
+ /**
391
+ * Find the verbatim text of a single-line top-level YAML entry like
392
+ * `name: foo bar` or `foundation: my-foundation`. Returns the matched
393
+ * line (including any inline comment) or null if the key isn't present.
394
+ * Multi-line values (block scalars, nested maps) are deliberately
395
+ * unsupported — the merge path only uses this for simple scalar keys.
396
+ */
397
+ function matchTopLevelLine(content, key) {
398
+ const match = content.match(new RegExp(`^${escapeRegex(key)}:.*$`, 'm'))
399
+ return match ? match[0] : null
400
+ }
401
+
402
+ /**
403
+ * Replace the verbatim text of a single-line top-level YAML entry.
404
+ * Returns the content unchanged if the key isn't present. See
405
+ * matchTopLevelLine for scope.
406
+ */
407
+ function replaceTopLevelLine(content, key, replacement) {
408
+ return content.replace(
409
+ new RegExp(`^${escapeRegex(key)}:.*$`, 'm'),
410
+ () => replacement
411
+ )
412
+ }
413
+
414
+ /**
415
+ * Insert a top-level YAML line into a content string.
416
+ *
417
+ * Used by the merge path when the content template omits a key that
418
+ * the scaffolded base file declared (e.g. an older `site.yml.hbs` with
419
+ * no `foundation:` line). Inserts immediately after the `name:` line
420
+ * if one exists — that's the conventional position for site
421
+ * configuration — and otherwise prepends to the file. The original
422
+ * trailing newline (or lack thereof) of the file is preserved.
423
+ */
424
+ function insertTopLevelLine(content, line) {
425
+ const nameMatch = content.match(/^name:.*$/m)
426
+ if (nameMatch) {
427
+ const idx = nameMatch.index + nameMatch[0].length
428
+ return content.slice(0, idx) + '\n' + line + content.slice(idx)
429
+ }
430
+ return line + '\n' + content
431
+ }
432
+
433
+ /**
434
+ * Resolve a dependency version string from a template.json entry.
435
+ *
436
+ * Template authors can either hardcode a concrete spec (`"^0.2.1"`) or
437
+ * use the same `{{version}}` Handlebars helper that `package.json.hbs`
438
+ * uses (`"{{version \"@uniweb/press\"}}"`). The helper is populated
439
+ * earlier in the scaffold flow via `registerVersions()`, so by the time
440
+ * this runs `versionData` already holds the current on-disk versions
441
+ * of every `@uniweb/*` package.
442
+ *
443
+ * Plain strings without `{{…}}` pass through untouched. Strings that
444
+ * fail to compile (rare — e.g. malformed mustache) fall back to the
445
+ * original literal so a bad template.json doesn't break scaffolding.
446
+ */
447
+ function resolveDependencyVersion(rawValue) {
448
+ if (typeof rawValue !== 'string' || !rawValue.includes('{{')) {
449
+ return rawValue
450
+ }
451
+ try {
452
+ return Handlebars.compile(rawValue)({})
453
+ } catch {
454
+ return rawValue
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Merge additional dependencies from a content template into a scaffolded package.json
460
+ *
461
+ * Reads the package.json at the given path, adds any deps not already present
462
+ * (in either dependencies or devDependencies), and writes it back.
463
+ *
464
+ * Each version string in `deps` is first processed through the shared
465
+ * Handlebars pipeline so template.json entries can reference live
466
+ * workspace versions via `{{version "@uniweb/press"}}` instead of
467
+ * hardcoding a spec that goes stale on the next publish.
468
+ *
469
+ * @param {string} packageJsonPath - Absolute path to package.json
470
+ * @param {Object} deps - Dependencies to merge (name → version spec)
471
+ */
472
+ export async function mergeTemplateDependencies(packageJsonPath, deps) {
473
+ if (!deps || Object.keys(deps).length === 0) return
474
+ const src = await fs.readFile(packageJsonPath, 'utf-8')
475
+ const pkg = JSON.parse(src)
476
+ if (!pkg.dependencies) pkg.dependencies = {}
477
+ for (const [name, version] of Object.entries(deps)) {
478
+ if (!pkg.dependencies[name] && !pkg.devDependencies?.[name]) {
479
+ pkg.dependencies[name] = resolveDependencyVersion(version)
480
+ }
481
+ }
482
+ await writeJsonPreservingStyleAsync(packageJsonPath, pkg, src)
483
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Report queries publishing without a data schema.
3
+ *
4
+ * ⭐ This is a PRODUCT decision the author is making, usually without knowing:
5
+ * entities or static files. It is reported at warn level for that reason — the
6
+ * producer deliberately emits a structured entry rather than a prose warning so
7
+ * this can say what the trade actually is.
8
+ *
9
+ * The old line said `"… — not synced"`, dim, among everything else. The data IS
10
+ * delivered (as static files), so that read as "my data did not upload" to
11
+ * anyone who did not skim past it. Neither reading tells an author what they
12
+ * gave up or how to get it back.
13
+ *
14
+ * @param {Array<{name: string, model?: string}>} schemaless
15
+ * @param {{ warn: (m: string) => void, dim: (m: string) => void }} out
16
+ */
17
+ export function reportSchemalessQueries(schemaless, out) {
18
+ if (!schemaless?.length) return
19
+ const names = schemaless.map((c) => c.name)
20
+ const label = names.length === 1 ? 'query' : 'queries'
21
+ out.warn(
22
+ `${names.length} ${label} shipping as STATIC FILES, not entities: ${names.join(', ')}`
23
+ )
24
+ out.dim('No queries, no editor, no detail projection — and a republish to change the data.')
25
+ out.dim(
26
+ `Declare a data schema to get entities. Each resolves its schema by subfolder name (${schemaless
27
+ .map((c) => `${c.name} → ${c.model || c.name}`)
28
+ .join(', ')}), or set \`schema:\` on the query.`
29
+ )
30
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * SemVer 2.0.0 precedence — how a registry orders a foundation's versions.
3
+ *
4
+ * A pre-release sorts below its release (`1.0.0-rc.1` < `1.0.0`), and build
5
+ * metadata carries no order (`1.2.0+b` equals `1.2.0`).
6
+ *
7
+ * ⚖️ Not `compareSemver` in `dep-survey.js`: that one reads dependency specs
8
+ * (`^1.2.3`) for `uniweb update` and compares major.minor.patch only. This one
9
+ * is strict — anything that is not a SemVer version is `null`, never a guess.
10
+ */
11
+
12
+ // The regex published with the SemVer 2.0.0 specification (semver.org, §FAQ).
13
+ const SEMVER =
14
+ /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
15
+
16
+ /**
17
+ * @param {unknown} version
18
+ * @returns {{ major: number, minor: number, patch: number, pre: string[] }|null}
19
+ */
20
+ export function parseSemver(version) {
21
+ if (typeof version !== 'string') return null
22
+ const m = SEMVER.exec(version)
23
+ if (!m) return null
24
+ return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ? m[4].split('.') : [] }
25
+ }
26
+
27
+ /**
28
+ * The next version above `version` on its own line — what `--bump` releases under
29
+ * when the registry already holds the local version with other code.
30
+ *
31
+ * A release gets its next patch (`1.4.2` → `1.4.3`); a pre-release its next
32
+ * pre-release (`2.0.0-beta.3` → `2.0.0-beta.4`, `1.0.0-rc` → `1.0.0-rc.0`), so a bump
33
+ * nobody chose never promotes a beta line to a release. Build metadata is dropped: it
34
+ * carries no order, so it could not make a version newer.
35
+ *
36
+ * @param {string} version
37
+ * @returns {string|null} null when `version` is not SemVer
38
+ */
39
+ export function nextVersionAbove(version) {
40
+ const v = parseSemver(version)
41
+ if (!v) return null
42
+ if (!v.pre.length) return `${v.major}.${v.minor}.${v.patch + 1}`
43
+ const pre = [...v.pre]
44
+ const last = pre.length - 1
45
+ if (/^\d+$/.test(pre[last])) pre[last] = String(BigInt(pre[last]) + 1n)
46
+ else pre.push('0')
47
+ return `${v.major}.${v.minor}.${v.patch}-${pre.join('.')}`
48
+ }
49
+
50
+ /**
51
+ * @param {string} a
52
+ * @param {string} b
53
+ * @returns {-1|0|1|null} how `a` sorts against `b`; null when either is not SemVer
54
+ */
55
+ export function compareSemverPrecedence(a, b) {
56
+ const x = parseSemver(a)
57
+ const y = parseSemver(b)
58
+ if (!x || !y) return null
59
+ for (const part of ['major', 'minor', 'patch']) {
60
+ if (x[part] !== y[part]) return x[part] > y[part] ? 1 : -1
61
+ }
62
+ // A version with a pre-release sorts below the same version without one.
63
+ if (!x.pre.length || !y.pre.length) {
64
+ return x.pre.length === y.pre.length ? 0 : x.pre.length ? -1 : 1
65
+ }
66
+ for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
67
+ const p = x.pre[i]
68
+ const q = y.pre[i]
69
+ if (p === undefined) return -1
70
+ if (q === undefined) return 1
71
+ if (p === q) continue
72
+ const pNum = /^\d+$/.test(p)
73
+ const qNum = /^\d+$/.test(q)
74
+ // Numeric identifiers compare numerically and sort below alphanumeric ones.
75
+ if (pNum && qNum) return BigInt(p) > BigInt(q) ? 1 : -1
76
+ if (pNum !== qNum) return pNum ? -1 : 1
77
+ return p > q ? 1 : -1
78
+ }
79
+ return 0
80
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `~/.uniweb/registry-auth.json` — the ONE reader of its shape.
3
+ *
4
+ * { version: 2, current: "<origin>", sessions: { "<origin>": { token, … } } }
5
+ *
6
+ * ⭐ ONE session since 2026-09-21 — a login replaces the file, a logout deletes it
7
+ * (registry-auth.js). The map shape stays so this reader also understands a file from
8
+ * 2026-09-20→21, when the store kept one session PER backend.
9
+ *
10
+ * ⛔ **There were two readers until 2026-09-21, and the copy is how "the backend you are
11
+ * logged in to" stopped meaning anything.** `registry-auth.js` writes the file and read
12
+ * it; the origin ladder in `config.js` kept a sync copy that read a top-level `origin`.
13
+ * When the file became one session per backend (`fb4907e`), the writer moved to the shape
14
+ * above and the copy kept reading `origin` — a field the new shape does not have — so
15
+ * every command that falls back to the logged-in backend quietly fell through to the
16
+ * default one. Both now read through here, so the shape has one reader to change.
17
+ *
18
+ * ⛔ Dependency-free, by requirement: `config.js` imports this, and `registry-auth.js`
19
+ * imports `config.js`, so this module can import neither.
20
+ */
21
+
22
+ import { existsSync, readFileSync } from 'node:fs'
23
+ import { homedir } from 'node:os'
24
+ import { join } from 'node:path'
25
+
26
+ export const SESSION_FILE_VERSION = 2
27
+
28
+ export function sessionFilePath() {
29
+ return join(homedir(), '.uniweb', 'registry-auth.json')
30
+ }
31
+
32
+ const originOf = (value) => {
33
+ try {
34
+ // http(s) only — `localhost:8080` parses as a scheme with the origin "null".
35
+ const u = new URL(value)
36
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Any parsed value → `{ version: 2, current, sessions }`. Never throws.
44
+ *
45
+ * A v1 flat record is ONE session, keyed by its own `origin` stamp — or by
46
+ * `defaultOrigin` when it carries none — and that session is current: it was the only
47
+ * login there was.
48
+ *
49
+ * @param {*} raw - the parsed file, or anything
50
+ * @param {string} defaultOrigin
51
+ */
52
+ export function normalizeSessionFile(raw, defaultOrigin) {
53
+ const empty = { version: SESSION_FILE_VERSION, current: null, sessions: {} }
54
+ if (!raw || typeof raw !== 'object') return empty
55
+ if (raw.sessions && typeof raw.sessions === 'object' && !Array.isArray(raw.sessions)) {
56
+ return { version: SESSION_FILE_VERSION, current: originOf(raw.current), sessions: raw.sessions }
57
+ }
58
+ if (typeof raw.token === 'string') {
59
+ const { origin, ...rest } = raw
60
+ const key = originOf(origin) || defaultOrigin
61
+ return { version: SESSION_FILE_VERSION, current: key, sessions: { [key]: rest } }
62
+ }
63
+ return empty
64
+ }
65
+
66
+ /** The file, normalized; synchronous, for the origin ladder. Never throws. */
67
+ export function readSessionFileSync(defaultOrigin) {
68
+ const p = sessionFilePath()
69
+ if (!existsSync(p)) return normalizeSessionFile(null, defaultOrigin)
70
+ try {
71
+ return normalizeSessionFile(JSON.parse(readFileSync(p, 'utf8')), defaultOrigin)
72
+ } catch {
73
+ return normalizeSessionFile(null, defaultOrigin)
74
+ }
75
+ }
76
+
77
+ /**
78
+ * **The backend the user is logged in to** — the one session's backend.
79
+ *
80
+ * A file written since sessions became single holds one session, marked `current`. The
81
+ * other branches read what came before: a per-backend file names its last login
82
+ * `current`; one with a single unmarked session answers with it; several and none
83
+ * marked is no answer, and saying so beats guessing. An expired session still answers —
84
+ * the user logged in there, and the next request asks them to log in there again.
85
+ *
86
+ * @param {{ current?: string|null, sessions?: object }} file - normalized
87
+ * @returns {string|null}
88
+ */
89
+ export function loggedInOriginOf(file) {
90
+ const sessions = file?.sessions || {}
91
+ const has = (o) => !!(o && sessions[o] && typeof sessions[o].token === 'string')
92
+ if (has(file?.current)) return file.current
93
+ const all = Object.keys(sessions).filter(has)
94
+ return all.length === 1 ? all[0] : null
95
+ }