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
|
@@ -0,0 +1,692 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bring-the-foundation-along — the freshness loop `uniweb publish` AND `uniweb
|
|
3
|
+
* push` run before handing a site to a backend (shipping-model.md §4).
|
|
4
|
+
*
|
|
5
|
+
* A publish must never ship a site pointing at stale or missing foundation code
|
|
6
|
+
* (the footgun: a site goes live referencing a version the catalog doesn't
|
|
7
|
+
* have). So when the site references a LOCAL foundation, the verb fingerprints it
|
|
8
|
+
* and reconciles with the catalog (§4):
|
|
9
|
+
*
|
|
10
|
+
* | version not yet registered | release it |
|
|
11
|
+
* | registered, code unchanged | skip the release (digest match) |
|
|
12
|
+
* | registered, code CHANGED | release it under the next version |
|
|
13
|
+
* | a NEWER version is registered | stop — `--bump` releases above it |
|
|
14
|
+
*
|
|
15
|
+
* ⭐ **Changed code is released by default** *[Diego, 2026-09-24]*. A site whose project
|
|
16
|
+
* holds its own foundation is a content+code bundle — *"a developer is used to code
|
|
17
|
+
* going with the site, so they don't think of bumping the code of a site"* — and the
|
|
18
|
+
* version there decides nothing: the only site reading it is the one being pushed.
|
|
19
|
+
* Since a registered version is immutable, the change goes under the next version
|
|
20
|
+
* above the registered one, written into the package's `package.json` first. ⛔ This
|
|
21
|
+
* reverses the version-bump release gate of 2026-06-23, whose reasoning — a deliberate,
|
|
22
|
+
* npm-style release — fits a foundation other sites use as a product; those sites pin
|
|
23
|
+
* a catalog ref and never reach this code. Its author's demo site, which names it by
|
|
24
|
+
* path, does — and gets the same default, by ruling [Diego, 2026-09-24]. The release is
|
|
25
|
+
* always the next patch, so a change that is not backwards compatible needs a higher
|
|
26
|
+
* version set in `package.json` by hand.
|
|
27
|
+
*
|
|
28
|
+
* ⛔ **A NEWER registered version still stops it.** It was released from another copy
|
|
29
|
+
* of the project, whose code this one may not have, and releasing above it would make
|
|
30
|
+
* the older code the newest — the code twin of push's content staleness gate. `--bump`
|
|
31
|
+
* releases above it anyway; `--no-release` ships the content bound to it.
|
|
32
|
+
*
|
|
33
|
+
* The freshness signal is the backend-stored, framework-computed digest (§4.1):
|
|
34
|
+
* no local state, multi-machine-safe. When the site references a published
|
|
35
|
+
* registry ref or a URL there's nothing to bring along. When the backend
|
|
36
|
+
* doesn't expose the stored digest yet, the compare DEGRADES to "ask" (same
|
|
37
|
+
* posture as `status --remote` on a 404).
|
|
38
|
+
*
|
|
39
|
+
* "Release" here is literally `uniweb register` run in the foundation directory
|
|
40
|
+
* — same build-if-stale → schema submit → code upload → digest the standalone
|
|
41
|
+
* verb does, so there is exactly one foundation-release path.
|
|
42
|
+
*
|
|
43
|
+
* ⭐ **`push` runs it too, and for a reason publish's framing does not cover.**
|
|
44
|
+
* [Diego, 2026-08-19] — *"A published site can only reference a registered
|
|
45
|
+
* foundation … In fact, not even a push can, because we can't preview the site in
|
|
46
|
+
* the frontend in that case."* A push is the collaboration verb: a teammate opens
|
|
47
|
+
* the site in the visual app straight after, and the app can only render it against
|
|
48
|
+
* foundation code the backend can serve. So an unregistered ref is not merely a
|
|
49
|
+
* publish-time problem — it is a broken preview, which is where a teammate actually
|
|
50
|
+
* meets it. `verb` names the caller in the messages so the fix the user is told to
|
|
51
|
+
* run is the command they ran.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { readFileSync } from 'node:fs'
|
|
55
|
+
import { join } from 'node:path'
|
|
56
|
+
import { execFileSync } from 'node:child_process'
|
|
57
|
+
|
|
58
|
+
import {
|
|
59
|
+
detectFoundationType,
|
|
60
|
+
isExtensionUrl,
|
|
61
|
+
readFoundationName,
|
|
62
|
+
checkFoundationName
|
|
63
|
+
} from '@uniweb/build'
|
|
64
|
+
import { computeFoundationDigest } from '../utils/code-upload.js'
|
|
65
|
+
import { isNonInteractive } from '../utils/interactive.js'
|
|
66
|
+
import { readOrgFlag } from '../utils/args.js'
|
|
67
|
+
import { belongsToScope } from '../utils/registry-orgs.js'
|
|
68
|
+
import { writeJsonPreservingStyle } from '../utils/json-file.js'
|
|
69
|
+
import {
|
|
70
|
+
compareSemverPrecedence,
|
|
71
|
+
nextVersionAbove
|
|
72
|
+
} from '../utils/semver-precedence.js'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the site's LOCAL foundation — the one publish should bring along — or
|
|
76
|
+
* null when the site references a published registry ref / URL (the catalog
|
|
77
|
+
* already has it; nothing to do). Uses the SAME resolver the build uses
|
|
78
|
+
* (`detectFoundationType`), so "which foundation" never drifts between them.
|
|
79
|
+
*
|
|
80
|
+
* Its catalog name is `foundationScopedName(dir)` — a separate, async read, because
|
|
81
|
+
* the name lives in `main.js`, which is loaded rather than parsed.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} siteDir
|
|
84
|
+
* @param {object} siteYml - parsed site.yml
|
|
85
|
+
* @returns {{ dir: string, version: string|null }|null}
|
|
86
|
+
*/
|
|
87
|
+
export function resolveLocalFoundation(siteDir, siteYml) {
|
|
88
|
+
const decl = siteYml?.foundation
|
|
89
|
+
if (!decl) return null
|
|
90
|
+
let info
|
|
91
|
+
try {
|
|
92
|
+
info = detectFoundationType(decl, siteDir)
|
|
93
|
+
} catch {
|
|
94
|
+
// Unresolved declaration — the site build will surface the canonical
|
|
95
|
+
// error; bring-along simply has nothing local to act on.
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
if (!info || info.type !== 'local' || !info.path) return null
|
|
99
|
+
return { dir: info.path, version: readPkgField(info.path, 'version') }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The site's LOCAL extensions — the ones publish must bring along. An extension IS
|
|
104
|
+
* a foundation (same build, same output), so it is declared and resolved the same
|
|
105
|
+
* way and goes through the SAME resolver, for the same reason `resolveLocalFoundation`
|
|
106
|
+
* does: "which code" must never drift between the build and the publish.
|
|
107
|
+
*
|
|
108
|
+
* A declaration that resolves to a URL or a catalog ref yields nothing local — the
|
|
109
|
+
* host already serves that code. Only workspace-local extensions need releasing.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} siteDir
|
|
112
|
+
* @param {object} siteYml - parsed site.yml
|
|
113
|
+
* @returns {Array<{ decl: string, dir: string, version: string|null }>}
|
|
114
|
+
* `decl` is the authored declaration, which is the wire entry's `$id` — the key
|
|
115
|
+
* publish stamps the pinned ref back onto.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveLocalExtensions(siteDir, siteYml) {
|
|
118
|
+
const list = siteYml?.extensions
|
|
119
|
+
if (!Array.isArray(list)) return []
|
|
120
|
+
const out = []
|
|
121
|
+
for (const entry of list) {
|
|
122
|
+
// Only the name/ref form can be local; an explicit `url` never is.
|
|
123
|
+
const decl =
|
|
124
|
+
entry && typeof entry === 'object'
|
|
125
|
+
? entry.ref || entry.name || null
|
|
126
|
+
: typeof entry === 'string'
|
|
127
|
+
? entry
|
|
128
|
+
: null
|
|
129
|
+
if (!decl || isExtensionUrl(decl)) continue
|
|
130
|
+
let info
|
|
131
|
+
try {
|
|
132
|
+
info = detectFoundationType(decl, siteDir)
|
|
133
|
+
} catch {
|
|
134
|
+
// Unresolved — the site build surfaces the canonical error; nothing local.
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
if (!info || info.type !== 'local' || !info.path) continue
|
|
138
|
+
out.push({ decl, dir: info.path, version: readPkgField(info.path, 'version') })
|
|
139
|
+
}
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The foundation's scoped catalog name (`@org/name`): its name — `main.js`'s
|
|
145
|
+
* `name`, else package.json's, the one rule the build reads for the schema
|
|
146
|
+
* `register` submits (`readFoundationName`). ⭐ The scope is part of that name
|
|
147
|
+
* (2026-09-22), so a scoped name IS the catalog name. Null otherwise; the caller
|
|
148
|
+
* then treats the foundation as unreleased, and `register` — which it runs to
|
|
149
|
+
* release — is where a missing scope is chosen and written into the name, and a
|
|
150
|
+
* missing name is asked for.
|
|
151
|
+
*
|
|
152
|
+
* ⛔ A NAME THAT CANNOT REGISTER IS NULL, not looked up. `src` and `foundation`
|
|
153
|
+
* name a folder (`checkFoundationName`); looking one up would find some other
|
|
154
|
+
* project's `@org/src` — the very collision refusing them prevents.
|
|
155
|
+
*
|
|
156
|
+
* ⛔ A BARE NAME IS NULL too: it has not registered, since `register` writes the scope
|
|
157
|
+
* it registers under into the name. Until 2026-09-22 this joined a bare name with
|
|
158
|
+
* `package.json::uniweb.scope`; that key is refused now (`readFoundationName` throws,
|
|
159
|
+
* so a leftover reads as unreleased here and `register` says what to write instead).
|
|
160
|
+
* *(This read package.json alone until 2026-09-21 — `uniweb.id`, else `name` — and
|
|
161
|
+
* so named a foundation differently from the build whenever `main.js` named it.)*
|
|
162
|
+
*
|
|
163
|
+
* @param {string} dir - the foundation package
|
|
164
|
+
* @returns {Promise<string|null>}
|
|
165
|
+
*/
|
|
166
|
+
export async function foundationScopedName(dir) {
|
|
167
|
+
let name
|
|
168
|
+
try {
|
|
169
|
+
;({ name } = await readFoundationName(dir))
|
|
170
|
+
} catch {
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
if (checkFoundationName(name)) return null
|
|
174
|
+
return name.startsWith('@') ? name : null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function readPkgField(dir, field) {
|
|
178
|
+
try {
|
|
179
|
+
return (
|
|
180
|
+
JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))?.[field] ||
|
|
181
|
+
null
|
|
182
|
+
)
|
|
183
|
+
} catch {
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Set the package's version, changing nothing else in the file — its key order,
|
|
189
|
+
// indentation and trailing newline stay as they were, so the diff is one line.
|
|
190
|
+
function writePkgVersion(dir, version) {
|
|
191
|
+
const path = join(dir, 'package.json')
|
|
192
|
+
const src = readFileSync(path, 'utf8')
|
|
193
|
+
writeJsonPreservingStyle(path, { ...JSON.parse(src), version }, src)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// What travels to the spawned `uniweb register` on the command line: --non-interactive,
|
|
197
|
+
// and the WORKSPACE this command names (`--org` / `--personal`). The BACKEND travels in
|
|
198
|
+
// the child's environment (releaseFoundation), and the SESSION is the shared session
|
|
199
|
+
// file — or UNIWEB_TOKEN, which the child inherits. The backend commands take no
|
|
200
|
+
// backend flag (`--server` is the login's) and no `--token`.
|
|
201
|
+
//
|
|
202
|
+
// ⭐ The workspace travels because a bare name registers under the workspace the command
|
|
203
|
+
// works in *[Diego, 2026-10-06]*, and a workspace named on this command is this command's
|
|
204
|
+
// alone: the child cannot read it from the session. One chosen at the login, or in
|
|
205
|
+
// UNIWEB_WORKSPACE, reaches the child on its own.
|
|
206
|
+
export function forwardedFlags(args) {
|
|
207
|
+
const out = []
|
|
208
|
+
if (isNonInteractive(args)) out.push('--non-interactive')
|
|
209
|
+
const org = readOrgFlag(args)
|
|
210
|
+
if (org) out.push('--org', org)
|
|
211
|
+
else if (args.includes('--personal')) out.push('--personal')
|
|
212
|
+
return out
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Decide + (maybe) act on the site's local foundation before go-live.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} o
|
|
219
|
+
* @param {import('./client.js').BackendClient} o.client
|
|
220
|
+
* @param {string} o.siteDir
|
|
221
|
+
* @param {object} o.siteYml
|
|
222
|
+
* @param {string[]} o.args
|
|
223
|
+
* @param {object} o.say - { ok, info, warn, err, dim } reporters
|
|
224
|
+
* @param {(q: string, def?: boolean) => Promise<boolean>} o.confirm
|
|
225
|
+
* @param {string} o.cliBin - the CLI entry (process.argv[1]) to re-spawn
|
|
226
|
+
* @param {boolean} [o.dryRun]
|
|
227
|
+
* @returns {Promise<{ released: boolean, proceed: boolean, ref: string|null }>}
|
|
228
|
+
* proceed:false → the caller should abort the publish (user declined). `ref`
|
|
229
|
+
* is the pinned `@scope/name@version` to stamp on the pushed site — read AFTER
|
|
230
|
+
* any release, so it reflects the released version + the scope register
|
|
231
|
+
* derived. Delivery is version-pinned end-to-end (the gateway serves a
|
|
232
|
+
* foundation only by a concrete version, no latest-resolution at serve time —
|
|
233
|
+
* collab framework↔backend), so an unversioned local ref MUST be pinned
|
|
234
|
+
* on the wire or the live site points at code the gateway can't serve. null
|
|
235
|
+
* when the site already references a registry ref / URL (no override needed)
|
|
236
|
+
* or no scoped ref can be formed.
|
|
237
|
+
*/
|
|
238
|
+
export async function bringFoundationAlong({
|
|
239
|
+
client,
|
|
240
|
+
siteDir,
|
|
241
|
+
siteYml,
|
|
242
|
+
args,
|
|
243
|
+
say,
|
|
244
|
+
confirm,
|
|
245
|
+
cliBin,
|
|
246
|
+
dryRun = false,
|
|
247
|
+
verb = 'publish'
|
|
248
|
+
}) {
|
|
249
|
+
const local = resolveLocalFoundation(siteDir, siteYml)
|
|
250
|
+
if (!local) {
|
|
251
|
+
// Published registry ref / URL — the catalog (or the URL host) already
|
|
252
|
+
// serves the code, and site.yml already pins the version. Nothing to bring
|
|
253
|
+
// along, and no ref override (forward the site.yml ref verbatim).
|
|
254
|
+
return { released: false, proceed: true, ref: null }
|
|
255
|
+
}
|
|
256
|
+
return bringLocalCodeAlong({
|
|
257
|
+
client,
|
|
258
|
+
local,
|
|
259
|
+
kind: 'foundation',
|
|
260
|
+
args,
|
|
261
|
+
say,
|
|
262
|
+
confirm,
|
|
263
|
+
cliBin,
|
|
264
|
+
dryRun,
|
|
265
|
+
verb
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Bring ONE piece of local code along — the primary foundation or one extension.
|
|
271
|
+
* Identical logic for both because an extension is a foundation; `kind` only names
|
|
272
|
+
* it in the messages.
|
|
273
|
+
*
|
|
274
|
+
* @param {object} o
|
|
275
|
+
* @param {{dir: string, version: string|null}} o.local
|
|
276
|
+
* @param {'foundation'|'extension'} o.kind
|
|
277
|
+
* @returns {Promise<{ released: boolean, proceed: boolean, ref: string|null, bumped?: string }>}
|
|
278
|
+
* `bumped` is the version written into package.json when changed code was released
|
|
279
|
+
* under the next version.
|
|
280
|
+
*/
|
|
281
|
+
async function bringLocalCodeAlong({
|
|
282
|
+
client,
|
|
283
|
+
local,
|
|
284
|
+
kind,
|
|
285
|
+
args,
|
|
286
|
+
say,
|
|
287
|
+
confirm,
|
|
288
|
+
cliBin,
|
|
289
|
+
dryRun = false,
|
|
290
|
+
verb = 'publish'
|
|
291
|
+
}) {
|
|
292
|
+
const Kind = kind === 'extension' ? 'Extension ' : 'Foundation'
|
|
293
|
+
const scopedName = await foundationScopedName(local.dir)
|
|
294
|
+
// A name with no scope yet (it has never registered) is still a name: say it. ⛔ Until
|
|
295
|
+
// 2026-10-06 the label fell back to the KIND, and the line read "Releasing the foundation
|
|
296
|
+
// foundation@0.1.0".
|
|
297
|
+
const bareName = scopedName
|
|
298
|
+
? null
|
|
299
|
+
: await readFoundationName(local.dir).then((r) => (r?.name && !checkFoundationName(r.name) ? r.name : null), () => null)
|
|
300
|
+
const shown = scopedName || bareName
|
|
301
|
+
const label =
|
|
302
|
+
shown || local.version
|
|
303
|
+
? `${shown || kind}${local.version ? `@${local.version}` : ''}`
|
|
304
|
+
: `the local ${kind}`
|
|
305
|
+
const skipPrompts =
|
|
306
|
+
args.includes('--yes') ||
|
|
307
|
+
args.includes('--force') ||
|
|
308
|
+
args.includes('--no-verify')
|
|
309
|
+
|
|
310
|
+
// `--no-release`: ship the site against the code that is ALREADY released, and do
|
|
311
|
+
// not release the local changes.
|
|
312
|
+
//
|
|
313
|
+
// The intent is ordinary and had no name until 2026-08-19 — a developer edits
|
|
314
|
+
// content and a component in one sitting, and wants the copy fix live without
|
|
315
|
+
// shipping a half-finished component. Until now the way to get it was `--yes`,
|
|
316
|
+
// which means "do not ask me": the behaviour was reachable only as a side effect of
|
|
317
|
+
// a confirmation-skipper, which is discovery by accident.
|
|
318
|
+
//
|
|
319
|
+
// ⛔ It skips the RELEASE, never the REGISTRATION REQUIREMENT. A site referencing a
|
|
320
|
+
// foundation no deployment can resolve cannot be opened in the app at all, so where
|
|
321
|
+
// this flag cannot be honoured it REFUSES rather than doing the opposite of what it
|
|
322
|
+
// says (see the `!reg` branch).
|
|
323
|
+
const noRelease = args.includes('--no-release')
|
|
324
|
+
|
|
325
|
+
// `--bump`: when the registry holds a version NEWER than the local one, release the
|
|
326
|
+
// local code above it instead of stopping. That is all it does — changed code under
|
|
327
|
+
// the registered version is released without it, and unchanged code never is — so a
|
|
328
|
+
// script can pass it on every run.
|
|
329
|
+
const bump = args.includes('--bump')
|
|
330
|
+
|
|
331
|
+
if (bump && noRelease) {
|
|
332
|
+
say.err(
|
|
333
|
+
'`--bump` releases your local changes and `--no-release` ships without them — pass one of the two.'
|
|
334
|
+
)
|
|
335
|
+
return { released: false, proceed: false, refused: true, ref: null }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// The pinned ref to stamp on the pushed site — read at RETURN time (after any
|
|
339
|
+
// release), so it reflects the released version, the scope register derived and
|
|
340
|
+
// the name it asked for. null when no scoped ref can be formed (then the site.yml
|
|
341
|
+
// ref is forwarded).
|
|
342
|
+
const pinnedRef = async () => {
|
|
343
|
+
const s = await foundationScopedName(local.dir)
|
|
344
|
+
const v = readPkgField(local.dir, 'version')
|
|
345
|
+
return s && v ? `${s}@${v}` : null
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// The ref for a run that releases NOTHING — the version the catalog actually holds,
|
|
349
|
+
// which is not always the local one. ⚠️ `pinnedRef()` reads the LOCAL package.json,
|
|
350
|
+
// so on a bumped-but-unreleased foundation it names a version nobody can serve. Any
|
|
351
|
+
// branch that skips a release must bind to this instead.
|
|
352
|
+
const registeredRef = (reg) =>
|
|
353
|
+
scopedName && reg?.latest_version
|
|
354
|
+
? `${scopedName}@${reg.latest_version}`
|
|
355
|
+
: null
|
|
356
|
+
|
|
357
|
+
// Dry-run reports the intent WITHOUT touching the network — it must not force
|
|
358
|
+
// a login (the digest read is auth-gated). The real run does the compare.
|
|
359
|
+
if (dryRun) {
|
|
360
|
+
say.dim(
|
|
361
|
+
`${Kind} : ${label} — local; would release if changed or not yet registered`
|
|
362
|
+
)
|
|
363
|
+
// The ref still comes back where one can be formed: it is read from the
|
|
364
|
+
// foundation's own files, so it costs no network, and an offline preview that
|
|
365
|
+
// omitted it would emit a document the real run would not — the one thing `-o`
|
|
366
|
+
// exists to avoid.
|
|
367
|
+
//
|
|
368
|
+
// ⚠️ It is null for a foundation that has NEVER been registered and carries no
|
|
369
|
+
// scope (a fresh scaffold), because the scope is what `register` writes into its
|
|
370
|
+
// name (`settleFoundationScope`) — and for one with no name of its own (`src`),
|
|
371
|
+
// which `register` asks for. So the preview shows the authored value there, and the
|
|
372
|
+
// first real push — which releases, and so acquires both — sends the pinned ref
|
|
373
|
+
// instead. That gap is unavoidable offline: before the first release there is
|
|
374
|
+
// no registered name to name.
|
|
375
|
+
return { released: false, proceed: true, ref: await pinnedRef() }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Ask the catalog what it has. Null → not registered (or the backend can't
|
|
379
|
+
// answer / no scoped name to look up) → release.
|
|
380
|
+
const reg = scopedName ? await client.readFoundationLatest(scopedName) : null
|
|
381
|
+
|
|
382
|
+
// Every release below goes through here, so a refusal for a scope you are not a member
|
|
383
|
+
// of is said as that, with the ways on (`explainReleaseFailure`).
|
|
384
|
+
const release = async () => {
|
|
385
|
+
try {
|
|
386
|
+
return releaseFoundation(local, args, cliBin, say, client?.origin)
|
|
387
|
+
} catch (err) {
|
|
388
|
+
throw await explainReleaseFailure(err, { client, local, reg, verb })
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (!reg) {
|
|
393
|
+
// ⛔ Nothing to bind to. Releasing anyway would be the opposite of what was asked,
|
|
394
|
+
// and shipping anyway would leave a site the app cannot open — so stop and say so.
|
|
395
|
+
if (noRelease) {
|
|
396
|
+
say.err(
|
|
397
|
+
`--no-release, but ${label} has never been released — there is no registered version to bind to.`
|
|
398
|
+
)
|
|
399
|
+
say.dim(
|
|
400
|
+
`A site referencing an unreleased ${kind} cannot be opened in the app, so this cannot be skipped.`
|
|
401
|
+
)
|
|
402
|
+
say.dim(`Drop \`--no-release\` to release it now.`)
|
|
403
|
+
return { released: false, proceed: false, refused: true, ref: null }
|
|
404
|
+
}
|
|
405
|
+
say.info(`Releasing the ${kind} ${label} (not yet registered)…`)
|
|
406
|
+
return {
|
|
407
|
+
released: await release(),
|
|
408
|
+
proceed: true,
|
|
409
|
+
ref: await pinnedRef()
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// The next version above the registered one — what changed code is released under
|
|
414
|
+
// when the local version is taken. Null when the registered version is not SemVer,
|
|
415
|
+
// and then there is nothing to count up from.
|
|
416
|
+
const bumpTo = nextVersionAbove(reg.latest_version)
|
|
417
|
+
|
|
418
|
+
// Write it into package.json, then release. `register` rebuilds on its own when the
|
|
419
|
+
// built schema's version no longer matches package.json, so the release carries it.
|
|
420
|
+
// `optOut` names `--no-release` for a release nobody asked for by flag.
|
|
421
|
+
//
|
|
422
|
+
// ⚖️ A failed release leaves the new version in the file, and that is the useful
|
|
423
|
+
// state: the next run finds a local version above the registered one and releases
|
|
424
|
+
// it, with nothing to redo.
|
|
425
|
+
const releaseAsNext = async (why, { optOut = false } = {}) => {
|
|
426
|
+
if (!bumpTo) {
|
|
427
|
+
say.err(
|
|
428
|
+
`Cannot release ${label} under a new version: the registered ${reg.latest_version} is not a SemVer version.`
|
|
429
|
+
)
|
|
430
|
+
say.dim(
|
|
431
|
+
`Nothing was sent. Set a version above it in the ${kind}'s package.json, then re-run \`uniweb ${verb}\`; or \`uniweb ${verb} --no-release\` sends content bound to the released ${reg.latest_version}.`
|
|
432
|
+
)
|
|
433
|
+
return { released: false, proceed: false, refused: true, ref: null }
|
|
434
|
+
}
|
|
435
|
+
writePkgVersion(local.dir, bumpTo)
|
|
436
|
+
say.info(`Releasing the ${kind} ${scopedName || kind} as ${bumpTo} — ${why}…`)
|
|
437
|
+
const released = await release()
|
|
438
|
+
say.dim(`The ${kind}'s package.json now says ${bumpTo} — commit it.`)
|
|
439
|
+
if (optOut) say.dim('To send content without releasing code, pass `--no-release`.')
|
|
440
|
+
return { released, proceed: true, bumped: bumpTo, ref: await pinnedRef() }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Registered — fingerprint the local build and compare. Build first so the
|
|
444
|
+
// digest reflects current source (idempotent: a no-op when already fresh).
|
|
445
|
+
buildFoundation(local, cliBin)
|
|
446
|
+
const localDigest = computeFoundationDigest(join(local.dir, 'dist'))
|
|
447
|
+
|
|
448
|
+
if (reg.digest && localDigest && reg.digest === localDigest) {
|
|
449
|
+
say.dim(
|
|
450
|
+
`${Kind} : ${label} — unchanged since release (digest matches); nothing to release.`
|
|
451
|
+
)
|
|
452
|
+
return { released: false, proceed: true, ref: await pinnedRef() }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// A different version locally.
|
|
456
|
+
if (local.version && local.version !== reg.latest_version) {
|
|
457
|
+
const order = compareSemverPrecedence(local.version, reg.latest_version)
|
|
458
|
+
// Equal in precedence (build metadata apart) is the registered version itself, and
|
|
459
|
+
// is treated as it, below.
|
|
460
|
+
if (order !== 0) {
|
|
461
|
+
if (noRelease) {
|
|
462
|
+
// ⚠️ Bind to the REGISTERED version, not the local one. `pinnedRef()` would
|
|
463
|
+
// return the bumped-but-unreleased `local.version` here — a ref no deployment
|
|
464
|
+
// can serve, which is the very failure this flag must not create.
|
|
465
|
+
say.info(
|
|
466
|
+
`Keeping the released ${kind} ${reg.latest_version} — local ${local.version} not released (\`--no-release\`).`
|
|
467
|
+
)
|
|
468
|
+
return { released: false, proceed: true, ref: registeredRef(reg) }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ⛔ OLDER than the registered latest: that version was released from another
|
|
472
|
+
// copy of the project, and this one may not have its code. Releasing above it
|
|
473
|
+
// would make this code the newest and bind the site to it — a teammate's release
|
|
474
|
+
// undone by a push that never saw it. So it stops, and names the ways on.
|
|
475
|
+
//
|
|
476
|
+
// Until 2026-09-24 it was submitted, the registry deciding: it took a version it
|
|
477
|
+
// already held with this very code (a resume), and refused anything else. Only
|
|
478
|
+
// the latest version's digest is readable, and the digest folds in the version,
|
|
479
|
+
// so "this code, under an older version" cannot be told apart from here.
|
|
480
|
+
if (order === -1) {
|
|
481
|
+
if (bump) {
|
|
482
|
+
say.warn(
|
|
483
|
+
`The registry holds ${reg.latest_version}, newer than your ${label} — releasing your code above it (\`--bump\`). If ${reg.latest_version} is someone else's release, its changes are not in yours.`
|
|
484
|
+
)
|
|
485
|
+
return releaseAsNext(`above the registered ${reg.latest_version}`)
|
|
486
|
+
}
|
|
487
|
+
say.err(
|
|
488
|
+
`The registry holds ${kind} ${scopedName || kind} ${reg.latest_version}, newer than your ${local.version} — a release this copy does not have.`
|
|
489
|
+
)
|
|
490
|
+
say.dim(
|
|
491
|
+
`Nothing was sent. Pull the change that released it, then re-run \`uniweb ${verb}\`; or:`
|
|
492
|
+
)
|
|
493
|
+
if (bumpTo) {
|
|
494
|
+
say.dim(` • \`uniweb ${verb} --bump\` — releases your code as ${bumpTo}, above it`)
|
|
495
|
+
}
|
|
496
|
+
say.dim(
|
|
497
|
+
` • \`uniweb ${verb} --no-release\` — sends content bound to the released ${reg.latest_version}`
|
|
498
|
+
)
|
|
499
|
+
return { released: false, proceed: false, refused: true, ref: null }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Newer than anything registered — a new version, released as it stands. Not
|
|
503
|
+
// SemVer (`order === null`) — nothing to compare, so the same, and the registry
|
|
504
|
+
// decides; `register` prints its answer if it is a no.
|
|
505
|
+
say.info(
|
|
506
|
+
order === 1
|
|
507
|
+
? `Releasing the ${kind} ${label} (new version; registered latest is ${reg.latest_version})…`
|
|
508
|
+
: `Releasing the ${kind} ${label} (registered latest is ${reg.latest_version})…`
|
|
509
|
+
)
|
|
510
|
+
return {
|
|
511
|
+
released: await release(),
|
|
512
|
+
proceed: true,
|
|
513
|
+
ref: await pinnedRef()
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// The registered version — but the digest differs, or the backend can't confirm it.
|
|
519
|
+
if (!reg.digest) {
|
|
520
|
+
// Degrade: the backend doesn't return the stored digest yet, so we can't
|
|
521
|
+
// be sure the registered version matches local. Offer to re-deliver.
|
|
522
|
+
if (noRelease) {
|
|
523
|
+
say.info(
|
|
524
|
+
`Keeping the released ${kind} ${reg.latest_version} — nothing released (\`--no-release\`).`
|
|
525
|
+
)
|
|
526
|
+
return { released: false, proceed: true, ref: registeredRef(reg) }
|
|
527
|
+
}
|
|
528
|
+
say.warn(
|
|
529
|
+
`Can't verify the registered ${label} matches your local copy (backend returned no digest).`
|
|
530
|
+
)
|
|
531
|
+
if (skipPrompts || isNonInteractive(args)) {
|
|
532
|
+
// ⚖️ Not released: nothing says the code changed, and releasing on no evidence
|
|
533
|
+
// would mint a version on every push against such a backend.
|
|
534
|
+
say.dim(
|
|
535
|
+
`Proceeding without re-releasing — to release a change, set a higher version in the ${kind}'s package.json.`
|
|
536
|
+
)
|
|
537
|
+
return { released: false, proceed: true, ref: await pinnedRef() }
|
|
538
|
+
}
|
|
539
|
+
const reRelease = await confirm(
|
|
540
|
+
`Re-release ${label} to be sure its code is current?`,
|
|
541
|
+
false
|
|
542
|
+
)
|
|
543
|
+
if (reRelease)
|
|
544
|
+
return {
|
|
545
|
+
released: await release(),
|
|
546
|
+
proceed: true,
|
|
547
|
+
ref: await pinnedRef()
|
|
548
|
+
}
|
|
549
|
+
return { released: false, proceed: true, ref: await pinnedRef() }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// The code changed and the version did not. A registered version is immutable, so
|
|
553
|
+
// the change is released under the next one.
|
|
554
|
+
if (noRelease) {
|
|
555
|
+
say.info(
|
|
556
|
+
`Keeping the released ${kind} ${reg.latest_version} — your local changes are not released (\`--no-release\`).`
|
|
557
|
+
)
|
|
558
|
+
return { released: false, proceed: true, ref: registeredRef(reg) }
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ⭐ RELEASED BY DEFAULT — for a person and an agent alike, with no question.
|
|
562
|
+
//
|
|
563
|
+
// Until 2026-09-24 this stopped: a terminal was asked, a run without one refused, and
|
|
564
|
+
// `--yes` shipped the REGISTERED code. That guarded against a site bound to code that
|
|
565
|
+
// was not the working tree's — which is exactly what releasing prevents: the site is
|
|
566
|
+
// bound to the code just released, as it is to the content pushed with it. `--yes`
|
|
567
|
+
// has nothing left to consent to here, and a question whose answer is nearly always
|
|
568
|
+
// yes teaches people to stop reading it.
|
|
569
|
+
return releaseAsNext(`its code changed since ${reg.latest_version} was registered`, {
|
|
570
|
+
optOut: true
|
|
571
|
+
})
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Build the foundation so its dist/ can be fingerprinted. Idempotent — the
|
|
575
|
+
// foundation build no-ops when already fresh.
|
|
576
|
+
function buildFoundation(local, cliBin) {
|
|
577
|
+
execFileSync('node', [cliBin, 'build', '--target', 'foundation'], {
|
|
578
|
+
cwd: local.dir,
|
|
579
|
+
stdio: 'inherit',
|
|
580
|
+
// A step of this command, so the build skips the next-step hints meant for
|
|
581
|
+
// someone who ran `uniweb build` themselves.
|
|
582
|
+
env: { ...process.env, UNIWEB_BUILD_STEP: '1' }
|
|
583
|
+
})
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Release = `uniweb register` in the foundation directory (the one foundation
|
|
587
|
+
// release path). Returns true on success; throws to the caller on failure so
|
|
588
|
+
// publish stops before going live with missing code.
|
|
589
|
+
function releaseFoundation(local, args, cliBin, say, origin) {
|
|
590
|
+
console.log('')
|
|
591
|
+
// ⭐ Pinned to the parent's backend through the environment, so the release lands
|
|
592
|
+
// where the publish goes by construction — not because two processes happen to read
|
|
593
|
+
// the same session file the same way.
|
|
594
|
+
execFileSync('node', [cliBin, 'register', ...forwardedFlags(args)], {
|
|
595
|
+
cwd: local.dir,
|
|
596
|
+
stdio: 'inherit',
|
|
597
|
+
env: origin ? { ...process.env, UNIWEB_SERVER: origin } : process.env
|
|
598
|
+
})
|
|
599
|
+
console.log('')
|
|
600
|
+
return true
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* A failed release, explained when the cause is a scope you are not a member of
|
|
605
|
+
* *[Diego, 2026-10-06]* — or the error as it came.
|
|
606
|
+
*
|
|
607
|
+
* The scope is the one in the foundation's name: `register` writes a bare name's scope
|
|
608
|
+
* before it submits, so a refused release leaves the name scoped. When the orgs read
|
|
609
|
+
* says the account is neither that scope's owner nor a member of its org, the error
|
|
610
|
+
* says so (`notMember`) and carries the ways on (`ways`): `--no-release`, where a
|
|
611
|
+
* released version exists to send the content against, and a member releasing it.
|
|
612
|
+
* Its message is what the refusal means for the push — nothing was sent — since the
|
|
613
|
+
* `register` it ran has just printed the refusal itself, membership and the registry's
|
|
614
|
+
* own sentence included (measured against a local backend, 2026-10-06: HTTP 400, no
|
|
615
|
+
* typed reason).
|
|
616
|
+
*
|
|
617
|
+
* ⛔ The registry decides membership; this only names it after a refusal, and passes
|
|
618
|
+
* the error through whenever it cannot tell.
|
|
619
|
+
*
|
|
620
|
+
* @param {Error} err - the release's failure
|
|
621
|
+
* @param {object} o
|
|
622
|
+
* @param {import('./client.js').BackendClient} o.client
|
|
623
|
+
* @param {{ dir: string }} o.local
|
|
624
|
+
* @param {{ latest_version?: string }|null} o.reg - the catalog's answer for this name
|
|
625
|
+
* @param {string} o.verb - `push` or `publish`, for the ways on
|
|
626
|
+
* @returns {Promise<Error>}
|
|
627
|
+
*/
|
|
628
|
+
export async function explainReleaseFailure(err, { client, local, reg, verb }) {
|
|
629
|
+
const name = await foundationScopedName(local.dir).catch(() => null)
|
|
630
|
+
const scope = name ? name.split('/')[0] : null
|
|
631
|
+
if (!scope || typeof client?.fetchOrgs !== 'function') return err
|
|
632
|
+
const member = belongsToScope(scope, await client.fetchOrgs().catch(() => null))
|
|
633
|
+
if (member !== false) return err
|
|
634
|
+
const explained = new Error(`${name} was not released, so nothing was sent.`)
|
|
635
|
+
explained.notMember = true
|
|
636
|
+
explained.ways = reg?.latest_version
|
|
637
|
+
? [
|
|
638
|
+
`Send the content against the released ${reg.latest_version}: \`uniweb ${verb} --no-release\`.`,
|
|
639
|
+
`Or ask a member of ${scope} to release it.`
|
|
640
|
+
]
|
|
641
|
+
: [`Ask a member of ${scope} to release it.`]
|
|
642
|
+
return explained
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Bring the site's LOCAL extensions along — the exact parallel of
|
|
647
|
+
* `bringFoundationAlong`, run for each workspace-local extension.
|
|
648
|
+
*
|
|
649
|
+
* An extension is a foundation, so it gets a foundation's freshness guarantee:
|
|
650
|
+
* released when unregistered or newly versioned, skipped on a digest match, and
|
|
651
|
+
* never silently shipped stale. Before this, a site could go live against a stale
|
|
652
|
+
* extension with nothing noticing — the primary was covered and the rest were not.
|
|
653
|
+
*
|
|
654
|
+
* @param {object} o - same shape as bringFoundationAlong
|
|
655
|
+
* @returns {Promise<{ proceed: boolean, released: number, pins: Object<string,string> }>}
|
|
656
|
+
* `pins` maps each authored declaration (the wire entry's `$id`) → the pinned
|
|
657
|
+
* `@scope/name@version`, for `emitSyncPackages({ injectExtensions })`. Delivery is
|
|
658
|
+
* version-pinned, so an unpinned local name on the wire points at code the host
|
|
659
|
+
* cannot serve — the same reason the primary's ref is stamped.
|
|
660
|
+
*/
|
|
661
|
+
export async function bringExtensionsAlong({
|
|
662
|
+
client,
|
|
663
|
+
siteDir,
|
|
664
|
+
siteYml,
|
|
665
|
+
args,
|
|
666
|
+
say,
|
|
667
|
+
confirm,
|
|
668
|
+
cliBin,
|
|
669
|
+
dryRun = false
|
|
670
|
+
}) {
|
|
671
|
+
const locals = resolveLocalExtensions(siteDir, siteYml)
|
|
672
|
+
const pins = {}
|
|
673
|
+
let released = 0
|
|
674
|
+
for (const local of locals) {
|
|
675
|
+
const r = await bringLocalCodeAlong({
|
|
676
|
+
client,
|
|
677
|
+
local,
|
|
678
|
+
kind: 'extension',
|
|
679
|
+
args,
|
|
680
|
+
say,
|
|
681
|
+
confirm,
|
|
682
|
+
cliBin,
|
|
683
|
+
dryRun
|
|
684
|
+
})
|
|
685
|
+
// A declined prompt aborts the whole publish, exactly as it does for the
|
|
686
|
+
// primary — a site live against half its code is worse than not shipping.
|
|
687
|
+
if (!r.proceed) return { proceed: false, released, pins: {} }
|
|
688
|
+
if (r.released) released += 1
|
|
689
|
+
if (r.ref) pins[local.decl] = r.ref
|
|
690
|
+
}
|
|
691
|
+
return { proceed: true, released, pins }
|
|
692
|
+
}
|