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,342 @@
1
+ /**
2
+ * Git awareness for the sync verbs.
3
+ *
4
+ * The CLI's file-based workflow is for people who keep their content in git, so
5
+ * git is the right place to look before doing something destructive to the working
6
+ * tree — rather than building a merge engine to solve a problem the user's VCS
7
+ * already solves better.
8
+ *
9
+ * DETECTED, NEVER REQUIRED. A project may legitimately have no repo, and — more
10
+ * commonly — may live *inside* a larger one: `uniweb create` already skips
11
+ * `git init` when it finds itself inside a work tree ("common for
12
+ * monorepos/workspaces"), and whole scopes of real projects are versioned by a
13
+ * parent repo rather than being repo roots themselves. So the question is never
14
+ * "is this directory a git root" but "is this path tracked by some repo", which is
15
+ * the question git itself answers. Callers degrade to an explicit confirmation
16
+ * when the answer is no; sync already requires an account, and a second hard
17
+ * dependency for a safety net we can provide anyway is a bad trade.
18
+ */
19
+
20
+ import { execFileSync } from 'node:child_process'
21
+ import { createHash } from 'node:crypto'
22
+ import { readFileSync, realpathSync } from 'node:fs'
23
+ import { join, relative } from 'node:path'
24
+ import yaml from 'js-yaml'
25
+
26
+ /**
27
+ * The files and directories `uniweb pull` writes into.
28
+ *
29
+ * ONE definition, shared by the guard that blocks a pull and the message that
30
+ * tells someone how to recover from a refused push — a second copy would drift,
31
+ * and a stale list here means either a guard that misses or advice that is wrong.
32
+ * `paths:` can relocate the content roots, so the local site.yml is consulted
33
+ * rather than assuming the defaults.
34
+ */
35
+ export function siteContentRoots(siteDir) {
36
+ // ⚠️ `queries.yml` IS AT THE SITE ROOT, and that is why it must be named here.
37
+ // Its predecessor lived at `collections/collections.yml`, so the `collections`
38
+ // root added below already covered it and the bare `'collections.yml'` entry
39
+ // that used to sit in this list resolved to a path no site ever had. A
40
+ // root-level file has no directory entry standing in for it.
41
+ //
42
+ // The records folder's `folder.yml` is the reverse case: it lives in the records
43
+ // directory, which is a root below, so it needs no entry. (It was `records.yml` at
44
+ // the site root until 2026-09-21, and was listed here then.)
45
+ const roots = new Set([
46
+ 'site.yml',
47
+ 'theme.yml',
48
+ 'head.html',
49
+ 'queries.yml',
50
+ 'locales'
51
+ ])
52
+ let paths = {}
53
+ try {
54
+ paths =
55
+ yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))?.paths || {}
56
+ } catch {
57
+ /* no or unreadable site.yml — the defaults are right */
58
+ }
59
+ roots.add(paths.pages || 'pages')
60
+ roots.add(paths.layout || 'layout')
61
+ roots.add(paths.records || 'records')
62
+ return [...roots]
63
+ }
64
+
65
+ /** Is there uncommitted work where a pull would write? False outside a repo. */
66
+ export function hasUncommittedContent(siteDir) {
67
+ const dirty = uncommittedUnder(siteDir, siteContentRoots(siteDir))
68
+ return Array.isArray(dirty) && dirty.length > 0
69
+ }
70
+
71
+ function git(args, cwd) {
72
+ return execFileSync('git', args, {
73
+ cwd,
74
+ encoding: 'utf8',
75
+ stdio: ['ignore', 'pipe', 'ignore']
76
+ })
77
+ }
78
+
79
+ /**
80
+ * Is `dir` inside a git work tree? False when git is absent, when the path isn't
81
+ * tracked, or on any error — the caller treats all three the same way.
82
+ */
83
+ export function isGitRepo(dir) {
84
+ try {
85
+ return git(['rev-parse', '--is-inside-work-tree'], dir).trim() === 'true'
86
+ } catch {
87
+ return false
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Uncommitted work under `relPaths`, relative to `dir`.
93
+ *
94
+ * Counts modified, staged, deleted AND untracked files. Untracked matters as much
95
+ * as modified here: a section file that exists only locally is not on the backend,
96
+ * so a pruning pull deletes it — losing work that was never committed anywhere.
97
+ *
98
+ * ⛔ **Returned relative to `dir`, which is NOT what git prints.** `--porcelain`
99
+ * paths are always relative to the REPOSITORY ROOT, whatever directory it runs
100
+ * in, so they only coincide with `dir`-relative when the site *is* the repo root.
101
+ * Both callers do `join(siteDir, rel)` with the result, so the un-normalized form
102
+ * was wrong for every nested layout — a site at `myproject/site/` inside a repo.
103
+ *
104
+ * What that cost, measured 2026-08-12: pull exempts its own previous output from
105
+ * the uncommitted-work guard by looking each dirty path up in a record keyed
106
+ * `dir`-relative (`utils/pull-written.js`). Given `myproject/site/site.yml` the
107
+ * lookup missed, so **pull refused on files pull itself had written** — the exact
108
+ * false alarm that record exists to prevent, and the one its own comment warns
109
+ * teaches people to reach for `--force`. `captureLocalWork` had the same defect
110
+ * one door along: it reads each path back with `join(siteDir, rel)`.
111
+ *
112
+ * The earlier `@returns` said "repo-relative-ish paths as git reports them" while
113
+ * the summary above said "relative to `dir`" — the ambiguity was noticed and left,
114
+ * and both callers had picked the other reading.
115
+ *
116
+ * @returns {string[]|null} paths relative to `dir`, or `null` when this isn't a
117
+ * git work tree (distinct from `[]`, which means "clean").
118
+ */
119
+ export function uncommittedUnder(dir, relPaths) {
120
+ if (!isGitRepo(dir)) return null
121
+ // ⚠️ Both sides must be REAL paths before `relative` can compare them.
122
+ // `rev-parse --show-toplevel` resolves symlinks; the caller's `dir` usually has
123
+ // not. On macOS that alone breaks it — `/var` is a symlink to `/private/var`,
124
+ // so a repo under the temp dir yields a root of `/private/var/…` against a dir
125
+ // of `/var/…`, and `relative` walks all the way up and back down. The result is
126
+ // a valid-looking path that matches nothing, i.e. the same silent miss this
127
+ // normalization exists to fix. Any symlinked checkout does it, not just tmp.
128
+ let root
129
+ let base
130
+ try {
131
+ root = git(['rev-parse', '--show-toplevel'], dir).trim()
132
+ base = realpathSync(dir)
133
+ } catch {
134
+ return null
135
+ }
136
+ try {
137
+ const out = git(
138
+ ['status', '--porcelain', '--untracked-files=all', '--', ...relPaths],
139
+ dir
140
+ )
141
+ return (
142
+ out
143
+ .split('\n')
144
+ .filter(Boolean)
145
+ // porcelain v1: XY<space>path, and a rename is "orig -> new".
146
+ .map((line) => line.slice(3).trim())
147
+ .map((p) => (p.includes(' -> ') ? p.split(' -> ')[1] : p))
148
+ // Porcelain paths are repo-root-relative; callers want them relative to
149
+ // `dir`. Quoted paths (git quotes names with spaces or non-ASCII when
150
+ // `core.quotePath` is on) are left alone rather than half-decoded — a
151
+ // wrong path is worse than an unmatched one here, since an unmatched
152
+ // path merely stays "dirty" and the guard errs toward refusing.
153
+ .map((p) => (p.startsWith('"') ? p : relative(base, join(root, p))))
154
+ .filter(Boolean)
155
+ )
156
+ } catch {
157
+ // A path git doesn't know (e.g. none of the roots exist yet) is not an error
158
+ // worth failing a pull over.
159
+ return []
160
+ }
161
+ }
162
+
163
+ /**
164
+ * A file's committed content — the COMMON ANCESTOR for a three-way merge.
165
+ *
166
+ * This is the input a merge needs and the backend cannot supply: it keeps no
167
+ * per-version item content, and retaining some so it could feed a merge running on
168
+ * the client would put a permanent second write on its hottest table. It doesn't
169
+ * have to. `uniweb pull` writes the backend's content into these files, so the
170
+ * committed version IS the state both sides diverged from — the ancestor was always
171
+ * on this side, in the tool that already knows how to merge.
172
+ *
173
+ * @returns {Buffer|null} null when the path isn't in HEAD (a file added locally and
174
+ * never committed has no ancestor, so there is nothing to merge against).
175
+ */
176
+ export function showAtHead(dir, relPath) {
177
+ return showAt(dir, 'HEAD', relPath)
178
+ }
179
+
180
+ /**
181
+ * A file's content at a revision, by a path relative to `dir`.
182
+ *
183
+ * ⛔ THE `./` IS WHAT MAKES THE PATH RELATIVE TO `dir`. In `<rev>:<path>` a bare path
184
+ * is read from the REPOSITORY ROOT, whatever the cwd. With the repo at the project
185
+ * root and the site in `site/` — the default layout — `HEAD:pages/home/hero.md`
186
+ * named nothing, every lookup failed, and `pull --merge` "kept yours" for every file:
187
+ * the backend's changes never merged in, while the pull recorded them as taken
188
+ * (2026-09-23). Every merge test ran with the repo AT the site directory, the one
189
+ * layout where a bare path happens to work.
190
+ *
191
+ * @returns {Buffer|null} null when the path isn't in that revision
192
+ */
193
+ export function showAt(dir, rev, relPath) {
194
+ try {
195
+ return execFileSync('git', ['show', `${rev}:./${relPath}`], {
196
+ cwd: dir,
197
+ stdio: ['ignore', 'pipe', 'ignore'],
198
+ maxBuffer: 64 * 1024 * 1024
199
+ })
200
+ } catch {
201
+ return null
202
+ }
203
+ }
204
+
205
+ /**
206
+ * The most recent committed version of a file whose content hashes to `sha256` —
207
+ * how the version a pull wrote is found again, as the ancestor of a merge.
208
+ *
209
+ * HEAD is that version only until the user commits an edit on top of it. After that
210
+ * HEAD is THEIR edit, and a merge against it reads their change as the starting
211
+ * point: the backend's version wins whole and the committed edit is reverted in the
212
+ * working tree. The pull recorded the hash of what it wrote (`pull-written.json`),
213
+ * so the version is recoverable from history by content, wherever it was committed.
214
+ *
215
+ * @param {number} [limit] - how many commits touching the file to search
216
+ * @returns {Buffer|null} null when no committed version matches
217
+ */
218
+ export function findCommitted(dir, relPath, sha256, { limit = 50 } = {}) {
219
+ let revs
220
+ try {
221
+ revs = git(['log', `-n${limit}`, '--format=%H', '--', `./${relPath}`], dir)
222
+ .split('\n')
223
+ .filter(Boolean)
224
+ } catch {
225
+ return null
226
+ }
227
+ for (const rev of revs) {
228
+ const buf = showAt(dir, rev, relPath)
229
+ if (buf && createHash('sha256').update(buf).digest('hex') === sha256) return buf
230
+ }
231
+ return null
232
+ }
233
+
234
+ /**
235
+ * Three-way merge, in place: `minePath` is rewritten with the merged result.
236
+ *
237
+ * Delegates to `git merge-file`, which is the same machinery git uses for a merge —
238
+ * text that only one side touched is taken silently, and only genuine overlaps get
239
+ * conflict markers. Building our own would be a worse version of a solved problem.
240
+ *
241
+ * @returns {{ merged: boolean, conflicted: boolean }} `merged:false` means the merge
242
+ * could not be attempted at all, which is different from attempting it and finding
243
+ * conflicts — the caller must not conflate them.
244
+ */
245
+ export function mergeFile(dir, minePath, basePath, theirsPath, labels = {}) {
246
+ try {
247
+ execFileSync(
248
+ 'git',
249
+ [
250
+ 'merge-file',
251
+ '-L',
252
+ labels.mine || 'yours (local)',
253
+ '-L',
254
+ labels.base || 'common ancestor',
255
+ '-L',
256
+ labels.theirs || 'theirs (backend)',
257
+ minePath,
258
+ basePath,
259
+ theirsPath
260
+ ],
261
+ { cwd: dir, stdio: ['ignore', 'ignore', 'ignore'] }
262
+ )
263
+ return { merged: true, conflicted: false }
264
+ } catch (err) {
265
+ // A positive status is the CONFLICT COUNT and the file still holds a valid
266
+ // merge with markers. Anything else (git missing, unreadable input) means no
267
+ // merge happened and the caller must fall back rather than trust the file.
268
+ if (typeof err?.status === 'number' && err.status > 0 && err.status < 128) {
269
+ return { merged: true, conflicted: true }
270
+ }
271
+ return { merged: false, conflicted: false }
272
+ }
273
+ }
274
+
275
+ /** Does this repo have a remote configured to pull from? */
276
+ export function hasRemote(dir) {
277
+ try {
278
+ return git(['remote'], dir).trim().length > 0
279
+ } catch {
280
+ return false
281
+ }
282
+ }
283
+
284
+ /**
285
+ * `git pull --ff-only` — bring in teammates' commits.
286
+ *
287
+ * Fast-forward only, deliberately. A refresh should never silently create a merge
288
+ * commit or drop the user into a rebase they didn't ask for; if the branches have
289
+ * genuinely diverged, that is a git problem the user should handle in git, with
290
+ * git's own vocabulary. We report and step back.
291
+ *
292
+ * @returns {{ ok: boolean, changed: boolean, message: string }}
293
+ */
294
+ export function pullRemote(dir) {
295
+ const before = (() => {
296
+ try {
297
+ return git(['rev-parse', 'HEAD'], dir).trim()
298
+ } catch {
299
+ return null
300
+ }
301
+ })()
302
+ try {
303
+ const out = execFileSync('git', ['pull', '--ff-only'], {
304
+ cwd: dir,
305
+ encoding: 'utf8',
306
+ stdio: ['ignore', 'pipe', 'pipe']
307
+ })
308
+ const after = (() => {
309
+ try {
310
+ return git(['rev-parse', 'HEAD'], dir).trim()
311
+ } catch {
312
+ return null
313
+ }
314
+ })()
315
+ return {
316
+ ok: true,
317
+ changed: Boolean(before && after && before !== after),
318
+ message: out.trim()
319
+ }
320
+ } catch (err) {
321
+ const msg = [err?.stderr, err?.stdout]
322
+ .map((b) => (b ? String(b) : ''))
323
+ .join('\n')
324
+ .trim()
325
+ return { ok: false, changed: false, message: msg || 'git pull failed' }
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Provenance for a deploy record: `{ sha, dirty }`, or null outside a repo.
331
+ * Answers "what is actually live?" later, which a version number cannot.
332
+ */
333
+ export function headProvenance(dir) {
334
+ if (!isGitRepo(dir)) return null
335
+ try {
336
+ const sha = git(['rev-parse', 'HEAD'], dir).trim()
337
+ const dirty = git(['status', '--porcelain'], dir).trim().length > 0
338
+ return { sha, dirty }
339
+ } catch {
340
+ return null // a repo with no commits yet
341
+ }
342
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Interactive host adapter selection
3
+ *
4
+ * Prompts the user to pick a host adapter from the registry. Used when
5
+ * `--host` is passed without a value to `uniweb deploy / build / export`.
6
+ *
7
+ * Non-interactive contexts (CI, piped input, --non-interactive) get a
8
+ * structured error instead of a prompt — never silently default to
9
+ * something the user didn't pick.
10
+ */
11
+
12
+ import { promptSelect } from './workspace.js'
13
+ import { isNonInteractive } from './interactive.js'
14
+
15
+ /**
16
+ * Pick a host adapter, optionally with a pre-selection.
17
+ *
18
+ * @param {object} opts
19
+ * @param {string[]} opts.args — Argv, used only to gate non-interactive mode.
20
+ * @param {string|null} [opts.preselect] — Suggested adapter name; the prompt
21
+ * highlights this so Enter accepts it without arrow-key navigation.
22
+ * @returns {Promise<string>} The chosen adapter name.
23
+ * @throws {Error} When non-interactive (with the registry list in the message),
24
+ * or when the user aborts the prompt.
25
+ */
26
+ export async function promptForHost({ args, preselect = null } = {}) {
27
+ // Lazy-load so this module doesn't pull @uniweb/build at import time
28
+ // for callers that never reach the prompt path.
29
+ const { listAdapters } = await import('@uniweb/build/hosts')
30
+ const adapters = listAdapters()
31
+
32
+ if (isNonInteractive(args || [])) {
33
+ const list = adapters.join(', ')
34
+ throw new Error(
35
+ `--host requires a value when running non-interactively. Known adapters: ${list}.`
36
+ )
37
+ }
38
+
39
+ // promptSelect doesn't expose initial-index, so move the preselect to
40
+ // the top of the list — the menu still highlights index 0 by default.
41
+ const ordered =
42
+ preselect && adapters.includes(preselect)
43
+ ? [preselect, ...adapters.filter((a) => a !== preselect)]
44
+ : adapters
45
+
46
+ const choice = await promptSelect('Pick a host adapter:', ordered)
47
+ if (!choice) {
48
+ throw new Error('Host selection cancelled.')
49
+ }
50
+ return choice
51
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Local development: is the bundler reading the foundation source you are editing?
3
+ *
4
+ * SCOPE — this is a check about CLI scaffolding, and only about one modality.
5
+ * A site's `package.json`, its `file:` dependency on the foundation, and its
6
+ * `node_modules` are not part of the site as an artifact; they are the project
7
+ * shape the CLI hangs tooling on (see the three-ingredient model,
8
+ * the site/foundation/runtime model). Treating
9
+ * that scaffolding as part of the site is where most of the confusion in this
10
+ * area comes from, so: nothing here says anything about what a foundation *is*,
11
+ * how it is distributed, or how it reaches a host.
12
+ *
13
+ * The one modality it covers is bundled dev mode against a workspace-local
14
+ * foundation — `uniweb dev` with `foundation:` naming a sibling package. There,
15
+ * the site imports `#foundation`, which `build/src/site/config.js` aliases to
16
+ * the foundation's PACKAGE NAME, so Vite resolves it through node_modules. A
17
+ * linked site (registry ref or URL) never takes that path, and neither does the
18
+ * desktop app, unipress, or the edge — they load a built `dist/entry.js`, or
19
+ * read a content folder directly. The check stays silent for all of them
20
+ * because there is no node_modules entry to look at.
21
+ *
22
+ * What goes wrong in that one modality: a `file:` dependency can be satisfied
23
+ * by a link to the workspace source or by a materialized copy, and a copy is a
24
+ * snapshot carrying its own nested node_modules — so it keeps resolving
25
+ * whatever `@uniweb/*` versions were current when it was taken.
26
+ *
27
+ * It hides well. `uniweb build` resolves the foundation by PATH and reads the
28
+ * workspace source, so the build is green and the tests pass while the dev
29
+ * server serves code from weeks ago. Cheap enough (a few lstats and JSON reads)
30
+ * to run on every `uniweb dev` rather than waiting for someone to suspect it.
31
+ *
32
+ * Reports; never prints. Callers present findings in their own voice.
33
+ */
34
+
35
+ import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs'
36
+ import { join, relative, resolve } from 'node:path'
37
+ import yaml from 'js-yaml'
38
+ import { stripVersionRange } from './dep-survey.js'
39
+
40
+ /**
41
+ * The foundation a site declares, or null. Small enough to live beside the one
42
+ * check that needs it rather than becoming another shared module.
43
+ *
44
+ * @param {string} sitePath
45
+ * @returns {string|null}
46
+ */
47
+ export function readDeclaredFoundation(sitePath) {
48
+ for (const name of ['site.yml', 'site.yaml']) {
49
+ const path = join(sitePath, name)
50
+ if (!existsSync(path)) continue
51
+ try {
52
+ return yaml.load(readFileSync(path, 'utf8'))?.foundation ?? null
53
+ } catch {
54
+ return null
55
+ }
56
+ }
57
+ return null
58
+ }
59
+
60
+ function readJson(path) {
61
+ try {
62
+ return JSON.parse(readFileSync(path, 'utf8'))
63
+ } catch {
64
+ return null
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Where a package name resolves from a starting directory, walking up through
70
+ * node_modules the way Node does. Returns the directory, or null.
71
+ */
72
+ function resolvePackageDir(fromDir, packageName) {
73
+ let dir = resolve(fromDir)
74
+
75
+ while (true) {
76
+ const candidate = join(dir, 'node_modules', packageName)
77
+ if (existsSync(join(candidate, 'package.json'))) return candidate
78
+
79
+ const parent = resolve(dir, '..')
80
+ if (parent === dir) return null
81
+ dir = parent
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Check one site against the foundation it declares.
87
+ *
88
+ * @param {Object} site - { name, path }
89
+ * @param {Object} foundation - { name, path } — the workspace source
90
+ * @param {string} workspaceDir - For readable paths in findings
91
+ * @returns {Array<Object>} findings, each { id, severity, site, message, detail, remedy }
92
+ */
93
+ export function checkSiteInstall(site, foundation, workspaceDir) {
94
+ const findings = []
95
+ const rel = (p) => relative(workspaceDir, p) || '.'
96
+
97
+ const linkPath = join(site.path, 'node_modules', foundation.name)
98
+
99
+ // Nothing installed at all — not this check's business to complain about,
100
+ // and every other check will already be shouting.
101
+ if (!existsSync(linkPath)) {
102
+ return findings
103
+ }
104
+
105
+ // A symlink points at one source of truth; a real directory is a snapshot.
106
+ const stat = lstatSync(linkPath)
107
+ const isLink = stat.isSymbolicLink()
108
+
109
+ if (!isLink) {
110
+ findings.push({
111
+ id: 'dev-foundation-source-stale',
112
+ severity: 'error',
113
+ site: site.name,
114
+ message: `In dev, this site builds against a copy of "${foundation.name}", not your workspace source.`,
115
+ detail:
116
+ ` ${rel(linkPath)} is a directory rather than a link, so it is a snapshot of\n` +
117
+ ` ${rel(foundation.path)} taken when it was installed. Edits you make will not\n` +
118
+ ` reach the dev server. \`uniweb build\` resolves the foundation by path and reads\n` +
119
+ ` your source, so builds stay correct — which is why this is easy to miss.`,
120
+ remedy:
121
+ 'Reinstall so it links to the workspace source (e.g. pnpm install)'
122
+ })
123
+ } else {
124
+ // Linked, but possibly at the wrong source.
125
+ const target = realpathSync(linkPath)
126
+ const expected = realpathSync(foundation.path)
127
+ if (target !== expected) {
128
+ findings.push({
129
+ id: 'dev-foundation-source-elsewhere',
130
+ severity: 'error',
131
+ site: site.name,
132
+ message: `In dev, this site resolves "${foundation.name}" somewhere other than your workspace source.`,
133
+ detail: ` resolves to: ${rel(target)}\n workspace source: ${rel(expected)}`,
134
+ remedy: "Reinstall, and check the site's package.json file: reference"
135
+ })
136
+ }
137
+ }
138
+
139
+ // What the site actually reaches, versus what the foundation asks for. These
140
+ // agree through a link and drift through a copy — the drift is what runs.
141
+ const foundationPkg = readJson(join(foundation.path, 'package.json'))
142
+ const declared = {
143
+ ...foundationPkg?.dependencies,
144
+ ...foundationPkg?.peerDependencies
145
+ }
146
+
147
+ for (const [name, spec] of Object.entries(declared)) {
148
+ if (!name.startsWith('@uniweb/')) continue
149
+ // A range says "anything compatible" — only an exact pin is a promise.
150
+ if (stripVersionRange(spec) !== spec) continue
151
+
152
+ const reachedDir = resolvePackageDir(linkPath, name)
153
+ if (!reachedDir) continue
154
+
155
+ const reached = readJson(join(reachedDir, 'package.json'))?.version
156
+ if (reached && reached !== spec) {
157
+ findings.push({
158
+ id: 'dev-foundation-dep-skew',
159
+ severity: 'error',
160
+ site: site.name,
161
+ message: `In dev, "${foundation.name}" compiles against ${name} ${reached}, but declares ${spec}.`,
162
+ detail:
163
+ ` declared in ${rel(join(foundation.path, 'package.json'))}: ${spec}\n` +
164
+ ` resolved from ${rel(linkPath)}: ${reached}\n` +
165
+ ` ${name} is bundled into the foundation, so the dev server runs the version it\n` +
166
+ ` reaches here. A build reads the workspace tree and may compile a different one.`,
167
+ remedy: 'Reinstall so the resolved tree matches what is declared'
168
+ })
169
+ }
170
+ }
171
+
172
+ return findings
173
+ }
174
+
175
+ /**
176
+ * Check every site in the workspace.
177
+ *
178
+ * @param {Array<Object>} sites - [{ name, path, foundation }]
179
+ * @param {Array<Object>} foundations - [{ name, path }]
180
+ * @param {string} workspaceDir
181
+ * @returns {Array<Object>} findings
182
+ */
183
+ export function checkWorkspaceInstall(sites, foundations, workspaceDir) {
184
+ const findings = []
185
+
186
+ for (const site of sites) {
187
+ const foundation = foundations.find((f) => f.name === site.foundation)
188
+ // An unresolvable foundation name is a different check's finding.
189
+ if (!foundation) continue
190
+ findings.push(...checkSiteInstall(site, foundation, workspaceDir))
191
+ }
192
+
193
+ return findings
194
+ }
195
+
196
+ /**
197
+ * One-line summary for callers that only want to know whether to speak up.
198
+ * @param {Array<Object>} findings
199
+ * @returns {string|null}
200
+ */
201
+ export function summarizeInstallFindings(findings) {
202
+ if (!findings.length) return null
203
+
204
+ const sites = [...new Set(findings.map((f) => f.site))]
205
+ const what = findings.some((f) => f.id === 'foundation-dep-skew')
206
+ ? 'the installed packages do not match what is declared'
207
+ : 'the foundation is not linked to its workspace source'
208
+
209
+ return `Install is out of date in ${sites.join(', ')} — ${what}. Run your package manager's install, or \`uniweb doctor\` for detail.`
210
+ }