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.
- package/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
package/src/utils/git.js
ADDED
|
@@ -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
|
+
}
|