uniweb 0.12.34 → 0.12.36

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/README.md CHANGED
@@ -62,7 +62,7 @@ pnpm build # Build foundation + site for production
62
62
  pnpm preview # Preview the production build
63
63
  ```
64
64
 
65
- The `build` command outputs to `site/dist/`. With pre-rendering enabled (the default for official templates), you get static HTML files ready to deploy anywhere. For the actual deploy step (and the `uniweb publish` / `uniweb deploy` commands), see [Deployment](#deployment) below.
65
+ The `build` command outputs to `site/dist/`. With pre-rendering enabled (the default for official templates), you get static HTML files ready to deploy anywhere. For the actual deploy step (and the `uniweb deploy` / `uniweb register` commands), see [Deployment](#deployment) below.
66
66
 
67
67
  ## What You Get
68
68
 
@@ -258,7 +258,7 @@ The `src/` folder (your project's foundation) ships with your project as a conve
258
258
  | **Local folder** | Foundation lives in your workspace | Developing site and components together |
259
259
  | **Runtime link** | Foundation loads from a URL | Independent release cycles, platform-managed sites |
260
260
 
261
- You can delete the `src/` folder entirely and point your site at a published foundation. Or develop a foundation locally, then publish it for other sites to consume. The site doesn't care where its components come from.
261
+ You can delete the `src/` folder entirely and point your site at a registered foundation. Or develop a foundation locally, then register it for other sites to consume. The site doesn't care where its components come from.
262
262
 
263
263
  **This enables two development patterns:**
264
264
 
@@ -327,20 +327,20 @@ uniweb add ci --host=github-pages
327
327
 
328
328
  ### Path 2 — Someone else manages the content
329
329
 
330
- You're building a foundation for clients, content authors, or any team that won't write markdown. The foundation is your product; the repo's `site/` is a test harness for the code (run `pnpm dev` to preview your components against sample content). You don't deploy a site — you publish a foundation:
330
+ You're building a foundation for clients, content authors, or any team that won't write markdown. The foundation is your product; the repo's `site/` is a test harness for the code (run `pnpm dev` to preview your components against sample content). You don't deploy a site — you register a foundation:
331
331
 
332
332
  ```bash
333
333
  cd src
334
- uniweb publish @your-org/foundation-name
334
+ uniweb register --scope @your-org
335
335
  ```
336
336
 
337
337
  Real sites built on your foundation are managed in the **Uniweb apps** (web + desktop) — visual editors designed for non-technical authors. They never see git, markdown, yaml, or React. They see *your* components, with live previews and visual controls for the params you defined. The foundation becomes the editor's native vocabulary for that site: you keep creative control of the design system, they get an editor that feels custom-built for them.
338
338
 
339
339
  This is the best path when site content has a life independent of the foundation's release cycle — agencies, design studios, multi-client teams, or any project where content authors aren't the same people as the developers.
340
340
 
341
- ### Roadmap — Hybrid
341
+ ### Hybrid
342
342
 
343
- A future version will let markdown in a git repo and content in the Uniweb apps stay in two-way sync. Authors edit visually, devs edit in their IDE, both surfaces work on the same content. On the roadmap; not available today.
343
+ Markdown in a git repo and content in the Uniweb apps can share the same site. Directional sync is available today: `uniweb push` sends local content to the backend, `uniweb pull` brings the backend's content back to local files, and `uniweb clone` bootstraps a local project from a site that already lives in the backend. Authors edit visually, devs edit in their IDE, both surfaces work on the same content. The advanced two-way-merge experience — conflict resolution, branch isolation, and review flows when both surfaces change the same content concurrently — is still evolving.
344
344
 
345
345
  ### Commands at a glance
346
346
 
@@ -349,7 +349,7 @@ A future version will let markdown in a git repo and content in the Uniweb apps
349
349
  | `uniweb add ci --host=<adapter>` | Scaffold a CI workflow in your repo (today: `github-pages`). The host runs `uniweb build` on each push. |
350
350
  | `uniweb deploy` | Deploy to Uniweb hosting (default). With `--host=<adapter>`, push directly to a static host — builds, uploads, invalidates in one step. |
351
351
  | `uniweb export` | Produce a self-contained `dist/` for any static host. You upload it yourself. `--host=<adapter>` adds host-specific helper files. |
352
- | `uniweb publish @org/name` | Publish a foundation to the registry (path 2). |
352
+ | `uniweb register --scope @org` | Register a foundation to the registry (path 2). |
353
353
  | `uniweb build` | Inspect a build locally. For shipping, use `deploy` or `export`. |
354
354
  | `uniweb update` | Align this project with the CLI you're running: bump `@uniweb/*` deps in every `package.json` to the CLI's matrix (then install), and refresh `AGENTS.md`. Pins to *this* CLI's matrix — run `npx uniweb@latest update` to align to the latest release. Updating the CLI itself is your package manager's job (`npm i -g uniweb@latest`). |
355
355
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.12.34",
3
+ "version": "0.12.36",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,8 +46,8 @@
46
46
  "@uniweb/runtime": "0.8.20"
47
47
  },
48
48
  "peerDependencies": {
49
- "@uniweb/build": "0.14.17",
50
49
  "@uniweb/content-reader": "1.1.12",
50
+ "@uniweb/build": "0.14.19",
51
51
  "@uniweb/semantic-parser": "1.1.17"
52
52
  },
53
53
  "peerDependenciesMeta": {
@@ -142,28 +142,39 @@ pnpm install # Install dependencies
142
142
  pnpm build # Build for production
143
143
  pnpm preview # Preview production build (SSG + SPA)
144
144
 
145
- # Ship the site (uniweb verbs)
146
- uniweb deploy # Deploy to Uniweb hosting (default; needs `uniweb login` first)
145
+ # Ship a site to Uniweb hosting (needs `uniweb login`)
146
+ uniweb publish # The smart path: bring the foundation along (releasing it to the
147
+ # catalog if its code changed), sync content, and go live
148
+
149
+ # Ship a site to a third-party host instead
147
150
  uniweb deploy --host=<adapter> # Deploy to a static host: cloudflare-pages, netlify,
148
151
  # vercel, github-pages, s3-cloudfront, generic-static
149
- uniweb deploy --dry-run # Resolve foundation/runtime + print summary; no writes
150
152
  uniweb export # Build dist/ for any static host (no Uniweb account)
151
- uniweb publish # Publish a foundation to the Uniweb registry
152
- uniweb register # Register a foundation + the data schemas it defines
153
+
154
+ # Sync a site with the Uniweb backend (git-style; needs `uniweb login`)
155
+ uniweb push # Push local content to the backend (creates the site on first push)
156
+ uniweb pull # Pull backend content back to local files
157
+ uniweb clone <site-uuid> # Start a new local project from a site already in the backend
158
+ uniweb status # Show what's unpushed / out of sync (local, offline)
159
+
160
+ # Register a foundation + its data schemas to the Uniweb registry
161
+ uniweb register # Register this foundation + the data schemas it defines
162
+ uniweb register --scope @org # Register under @org (also works from a schemas-only package)
163
+
164
+ # Project health + maintenance
153
165
  uniweb doctor # Diagnose project configuration issues (--fix to auto-repair)
154
- uniweb validate # Check your file-based data against your declared schemas (--strict for CI)
155
- uniweb update # Align @uniweb/* deps + this AGENTS.md with the CLI's matrix.
156
- # Use --dry-run to preview, --yes for non-interactive.
157
- # `npx uniweb@latest update` pins to the latest release.
166
+ uniweb validate # Check file-based data against your declared schemas (--strict for CI)
167
+ uniweb update # Align @uniweb/* deps + this AGENTS.md with the CLI's matrix
168
+ # (--dry-run to preview, --yes for non-interactive)
158
169
 
159
170
  # Help
160
171
  uniweb --help # Top-level help
161
172
  uniweb <command> --help # Per-command help (no side effects)
162
173
  ```
163
174
 
164
- `uniweb deploy` auto-publishes a workspace-local foundation as part of the deploy under a site-scoped slot no separate `uniweb publish` step needed for site-bound foundations.
175
+ `uniweb publish` brings the site's local foundation along releasing it to the catalog under your `@org` when its code changed — so a single site needs no separate `uniweb register` step.
165
176
 
166
- **Registering data schemas.** A foundation that defines data schemas (`@/article`, …) uses `uniweb register` to register the foundation together with those schemas in the Uniweb registry — so content authors can create and manage entities of those types. It requires authentication: run `uniweb login`, or supply a bearer token directly with `--token <bearer>` (or the `UNIWEB_TOKEN` env var). Point at a specific registry with `--registry <url>` (or `UNIWEB_REGISTER_URL`). Preview without auth using `--dry-run` (or `-o <file>` to write the submission), and set the publish org with `--scope @org` (default: the foundation's `package.json` `uniweb.scope`).
177
+ **Registering data schemas.** A foundation that defines data schemas (`@/article`, …) uses `uniweb register` to register the foundation together with those schemas in the Uniweb registry — so content authors can create and manage entities of those types. It requires authentication: run `uniweb login`, or supply a bearer token directly with `--token <bearer>` (or the `UNIWEB_TOKEN` env var). Point at a specific registry with `--registry <url>` (or `UNIWEB_REGISTER_URL`). Preview without auth using `--dry-run` (or `-o <file>` to write the submission), and set the org scope with `--scope @org` (default: the foundation's `package.json` `uniweb.scope`).
167
178
 
168
179
  **Registering standard or shared schemas (no foundation).** Data schemas can also be registered on their own — without a foundation — straight from a schemas package. Run `uniweb register` from a package that *exports* schemas (`@uniweb/schemas`, or any `@org/schemas` you maintain), or from a bare folder of `schemas/*.{yml,json,js}` files: it detects the schemas-only package automatically and submits just the data schemas (no foundation) under `--scope`. This is how the standard schemas are published under `@std`, and how an org publishes its own shared `@org/schemas` once for many foundations to reference. The same flags apply (`--scope`, `--dry-run`, `-o`, `--token`, `--registry`); `--dry-run` (or `-o <file>`) previews the exact submission without authenticating.
169
180
 
@@ -192,16 +203,16 @@ The `uniweb` block in `package.json` carries platform-specific configuration tha
192
203
 
193
204
  | Field | Where used | Default | Purpose |
194
205
  |---|---|---|---|
195
- | `id` | `uniweb publish` | (set via `--name` or scoped `package.json::name`) | The foundation's published id — the bare-name segment in `@org/<id>`. Decoupled from `package.json::name` (a workspace concern), so renaming the foundation on the registry doesn't ripple through site dependencies. Only relevant for catalog-published foundations; site-bound foundations skip this. |
196
- | `namespace` | `uniweb publish` | (none — see scope resolution) | Legacy explicit org-namespace override. Equivalent to using a scoped `package.json::name` (`"@myorg/foundation"`). Rarely needed in modern foundations. |
206
+ | `id` | `uniweb register` | (the bare segment of a scoped `package.json::name`, or `uniweb.id`) | The foundation's registered id — the bare-name segment in `@org/<id>`. Decoupled from `package.json::name` (a workspace concern), so renaming the foundation on the registry doesn't ripple through site dependencies. |
207
+ | `namespace` | `uniweb register` | (none — see scope resolution) | Legacy explicit org-namespace override. Equivalent to using a scoped `package.json::name` (`"@myorg/foundation"`). Rarely needed in modern foundations. |
197
208
  | `runtimePolicy` | `dist/runtime-pin.json` (foundation build) | `"auto-minor"` | Controls how sites using this foundation receive runtime updates. Three values: `"exact"`, `"auto-patch"`, `"auto-minor"`. See "Foundation runtime policy" below. |
198
209
 
199
- **Catalog vs site-bound foundations.** Two distribution intents share the same `dist/foundation.js` artifact:
210
+ **How a foundation reaches the catalog.** Foundations on Uniweb hosting always live in the catalog as `@org/name@version`. Two ways to get one there, both from the same `dist/foundation.js` artifact:
200
211
 
201
- - A **catalog foundation** is a deliberate product named, versioned, listed in the catalog, consumable by other developers' sites. Use `uniweb publish @org/name` for these. The CLI requires an explicit name argument so you don't accidentally catalog a foundation that was meant to be site-bound.
202
- - A **site-bound foundation** powers exactly one site. Don't run `uniweb publish` for it. Just run `uniweb deploy` from the site directory the CLI auto-publishes your local foundation as part of the deploy, **uploaded with the site's other published assets** (per-site storage, never to the catalog). With no naming ceremony, no catalog visibility, and no developer-vs-site ownership confusion. To later promote the foundation to a catalog product, run `uniweb publish @org/name` from the foundation directory and update the site's `site.yml` to a versioned ref (`foundation: '@org/name@1.2.3'`).
212
+ - **Brought along by `uniweb publish`.** When a foundation powers a single site, don't run `uniweb register` yourself. Run `uniweb publish` from the site directory it releases the site's local foundation to the catalog under your `@org` (when its code changed) and then goes live, in one step. No separate register ceremony.
213
+ - **Registered deliberately with `uniweb register --scope @org`.** When the foundation is a product meant for multiple sites listed in the catalog, consumable by other developers' sites register it on its own schedule. Consuming sites then pin a versioned ref (`foundation: '@org/name@1.2.3'`).
203
214
 
204
- **On the split between `package.json::name` and `uniweb.id`:** the workspace name is what pnpm uses for `file:` linking and what `site.yml::foundation` references. The published id is what the registry stores. Keeping them separate means renaming on the registry (e.g. `marketing` → `marketing-pro`) is a one-shot `uniweb publish --name marketing-pro` — it persists to `uniweb.id` without touching the workspace.
215
+ **On the split between `package.json::name` and `uniweb.id`:** the workspace name is what pnpm uses for `file:` linking and what `site.yml::foundation` references. The published id is what the registry stores. Keeping them separate means a workspace rename (e.g. `marketing` → `marketing-pro`) is a one-shot `uniweb rename foundation marketing marketing-pro` — it updates the package, folder, and every dependent site, while the foundation's registered id (`uniweb.id`) stays independent.
205
216
 
206
217
  These are the only fields the platform consumes today. Future platform features that need static configuration will land here too.
207
218
 
@@ -28,28 +28,34 @@
28
28
  */
29
29
 
30
30
  import { getRegistryApiBaseUrl } from '../utils/config.js'
31
- import { ensureRegistryAuth, fetchMe } from '../utils/registry-auth.js'
31
+ import { ensureRegistryAuth, fetchMe, readRegistryAuth, isExpired } from '../utils/registry-auth.js'
32
32
  import { fetchOrgs as fetchOrgsImpl, createOrg as createOrgImpl } from '../utils/registry-orgs.js'
33
33
  import { uploadFoundationCode } from '../utils/code-upload.js'
34
34
  import { uploadSiteAssets } from '../utils/asset-upload.js'
35
35
  import { uploadRuntime } from '../utils/runtime-upload.js'
36
36
 
37
37
  /**
38
- * Resolve the backend origin: an explicit flag value wins; otherwise defer to
39
- * the shared origin resolver (env override > saved config > local default). A
40
- * full URL is reduced to its origin, so callers may pass a whole endpoint URL.
38
+ * Resolve the backend origin via the resolution ladder (highest precedence
39
+ * first). A full URL is reduced to its origin, so callers may pass a whole
40
+ * endpoint URL; an unparseable value falls through to the next tier.
41
41
  *
42
- * One resolver, one place to revisit when we settle the single-origin-input +
43
- * discovery-handshake decisionkept delegating for now so this stays a pure,
44
- * behavior-preserving consolidation.
42
+ * 1. `flag` the raw --backend / --registry value (this command)
43
+ * 2. UNIWEB_REGISTER_URL envsession-wide override (CI / local dev)
44
+ * 3. `siteBackend` — the site's bound backend from deploy.yml (site verbs)
45
+ * 4–6. the logged-in session origin > ~/.uniweb/config.json > the default
46
+ * (uniweb.app) — all via getRegistryApiBaseUrl()
45
47
  *
46
48
  * @param {string} [flag] - the raw value of --backend / --registry, if supplied
49
+ * @param {object} [opts]
50
+ * @param {string} [opts.siteBackend] - a site's deploy.yml-bound backend origin
47
51
  * @returns {string} a bare origin with no trailing slash
48
52
  */
49
- export function resolveBackendOrigin(flag) {
50
- if (flag) {
51
- try { return new URL(flag).origin } catch { /* not a URL fall through */ }
52
- }
53
+ export function resolveBackendOrigin(flag, { siteBackend } = {}) {
54
+ const norm = (v) => { try { return new URL(v).origin } catch { return null } }
55
+ if (flag) { const o = norm(flag); if (o) return o }
56
+ const env = process.env.UNIWEB_REGISTER_URL
57
+ if (env) { const o = norm(env); if (o) return o }
58
+ if (siteBackend) { const o = norm(siteBackend); if (o) return o }
53
59
  return getRegistryApiBaseUrl()
54
60
  }
55
61
 
@@ -74,6 +80,7 @@ export class BackendClient {
74
80
  * @param {object} [opts]
75
81
  * @param {string} [opts.origin] - explicit origin (wins over originFlag/env)
76
82
  * @param {string} [opts.originFlag] - raw --backend/--registry value to resolve
83
+ * @param {string} [opts.siteBackend] - a site's deploy.yml-bound backend (site verbs)
77
84
  * @param {string} [opts.token] - explicit bearer (wins over env + stored session)
78
85
  * @param {() => Promise<string>} [opts.getToken] - injected bearer resolver (tests, or
79
86
  * callers with their own auth); used when no explicit token/env is present
@@ -81,8 +88,8 @@ export class BackendClient {
81
88
  * @param {string} [opts.command] - label for the login prompt ('Pushing', 'Registering', …)
82
89
  * @param {typeof fetch} [opts.fetchImpl] - injectable fetch (tests)
83
90
  */
84
- constructor({ origin, originFlag, token, getToken, args = [], command = 'This command', fetchImpl } = {}) {
85
- this.origin = (origin || resolveBackendOrigin(originFlag)).replace(/\/+$/, '')
91
+ constructor({ origin, originFlag, siteBackend, token, getToken, args = [], command = 'This command', fetchImpl } = {}) {
92
+ this.origin = (origin || resolveBackendOrigin(originFlag, { siteBackend })).replace(/\/+$/, '')
86
93
  this._token = token || process.env.UNIWEB_TOKEN || null
87
94
  this._getToken = getToken || null
88
95
  this._args = args
@@ -103,6 +110,19 @@ export class BackendClient {
103
110
  this._token = await this._getToken()
104
111
  return this._token
105
112
  }
113
+ // Origin-mismatch guard: a usable session authed against a DIFFERENT origin
114
+ // than this command targets means the bearer will be rejected — warn once
115
+ // and point at the fix (the stored token IS for `stored.origin`, not here).
116
+ if (!this._warnedOriginMismatch) {
117
+ this._warnedOriginMismatch = true
118
+ try {
119
+ const stored = await readRegistryAuth()
120
+ if (stored?.token && !isExpired(stored) && stored.origin &&
121
+ stored.origin.replace(/\/+$/, '') !== this.origin) {
122
+ console.error(`\x1b[33m⚠\x1b[0m Logged in to ${stored.origin}, but this command targets ${this.origin} — the session may be rejected. Run \`uniweb login --backend ${this.origin}\`, or pass --token.`)
123
+ }
124
+ } catch { /* advisory only */ }
125
+ }
106
126
  this._token = await ensureRegistryAuth({ apiBase: this.origin, command: this._command, args: this._args })
107
127
  return this._token
108
128
  }
@@ -207,6 +227,35 @@ export class BackendClient {
207
227
  return res.json()
208
228
  }
209
229
 
230
+ /**
231
+ * GET /dev/registry/{scope}/{name} → the latest registered foundation version
232
+ * + its content digest, or null on 404 / any failure (callers degrade).
233
+ *
234
+ * The bare `{scope}/{name}` path resolves the latest foundation version (the
235
+ * data-schema sibling is `/dev/registry/data-schemas/{scope}/{name}`). The
236
+ * backend returns `version` (+ schema ids); we normalize it to `latest_version`
237
+ * for callers and tolerate either key. `digest` is the framework-computed
238
+ * fingerprint the backend stores OPAQUE and echoes here (null when the version
239
+ * carries none) — the freshness signal for publish/status (shipping-model.md
240
+ * §4.1). A bare/unscoped name → null (only `@org/name` can be looked up).
241
+ * @param {string} scopedName
242
+ * @returns {Promise<{ latest_version: string|null, digest: string|null }|null>}
243
+ */
244
+ async readFoundationLatest(scopedName) {
245
+ const m = /^@([^/]+)\/([^@/]+)/.exec(String(scopedName || ''))
246
+ if (!m) return null
247
+ try {
248
+ const res = await this.request(`/dev/registry/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`)
249
+ if (!res.ok) return null
250
+ const body = await res.json().catch(() => null)
251
+ if (!body) return null
252
+ // The read returns `version`; callers use `latest_version`. Tolerate both.
253
+ return { ...body, latest_version: body.latest_version ?? body.version ?? null }
254
+ } catch {
255
+ return null
256
+ }
257
+ }
258
+
210
259
  // ── Orgs ──────────────────────────────────────────────────────────────────────
211
260
 
212
261
  /** GET /dev/orgs → { account_handle, personal_org_exists, orgs[] }. */
@@ -311,6 +360,47 @@ export class BackendClient {
311
360
  return this.request(`/dev/site/unpublish/${encodeURIComponent(uuid)}`, { method: 'POST' })
312
361
  }
313
362
 
363
+ /**
364
+ * GET /dev/site/status/{uuid} → the site's publish lifecycle (Contract 3,
365
+ * shipped backend-side — collab backend-framework-b220):
366
+ * { published: boolean, last_pushed_at?: string, last_published_at?: string, draft_dirty?: boolean }
367
+ * `draft_dirty` = never-published, or the synced draft changed since the last
368
+ * publish ("pushed but not published"). The path is VERB-FIRST (`status/{uuid}`)
369
+ * to match the lane (`publish/{uuid}`, `content/push/{uuid}`, `folder/pull/{uuid}`),
370
+ * not the `{uuid}/status` the shipping-verbs §8 sketch assumed. null on
371
+ * 404 (unknown/not-yours) / 401 / any failure — `status --remote` degrades to local.
372
+ * @param {string} uuid - the site-content uuid
373
+ * @returns {Promise<object|null>}
374
+ */
375
+ async siteStatus(uuid) {
376
+ try {
377
+ const res = await this.request(`/dev/site/status/${encodeURIComponent(uuid)}`)
378
+ return res.ok ? await res.json().catch(() => null) : null
379
+ } catch {
380
+ return null
381
+ }
382
+ }
383
+
384
+ /**
385
+ * ASSUMED endpoint (not built yet — kb/framework/build/payment-handoff-plan.md).
386
+ * GET /dev/site/{uuid}/can-go-live — the pre-go-live payment gate:
387
+ * { ok: true } // already paid → proceed
388
+ * { payment_required: true, checkout_url, wait_token? } // open the URL, settle, retry
389
+ * The framework is provider-agnostic: it only opens `checkout_url` and waits.
390
+ * null on 404 / any failure → the caller PROCEEDS (degrade: publish ships
391
+ * before payment lands; same posture as siteStatus on a missing route).
392
+ * @param {string} uuid - the site-content uuid
393
+ * @returns {Promise<object|null>}
394
+ */
395
+ async canGoLive(uuid) {
396
+ try {
397
+ const res = await this.request(`/dev/site/${encodeURIComponent(uuid)}/can-go-live`)
398
+ return res.ok ? await res.json().catch(() => null) : null
399
+ } catch {
400
+ return null
401
+ }
402
+ }
403
+
314
404
  /**
315
405
  * Deliver a site's processed assets (plan → PUT-per-file) to the backend's
316
406
  * content-addressed store. Thin pass-through to utils/asset-upload.js with this
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Bring-the-foundation-along — the freshness loop `uniweb publish` runs before
3
+ * it makes a site live (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, publish fingerprints it
8
+ * and reconciles with the catalog. Three cases (§4):
9
+ *
10
+ * | version not yet registered | release it, then publish |
11
+ * | registered, code unchanged | skip the release (digest match) |
12
+ * | registered, code CHANGED | warn / prompt — never silent |
13
+ *
14
+ * The freshness signal is the backend-stored, framework-computed digest (§4.1):
15
+ * no local state, multi-machine-safe. When the site references a published
16
+ * registry ref or a URL there's nothing to bring along. When the backend
17
+ * doesn't expose the stored digest yet, the compare DEGRADES to "ask" (same
18
+ * posture as `status --remote` on a 404).
19
+ *
20
+ * "Release" here is literally `uniweb register` run in the foundation directory
21
+ * — same build-if-stale → schema submit → code upload → digest the standalone
22
+ * verb does, so there is exactly one foundation-release path.
23
+ */
24
+
25
+ import { readFileSync } from 'node:fs'
26
+ import { join } from 'node:path'
27
+ import { execFileSync } from 'node:child_process'
28
+
29
+ import { detectFoundationType } from '@uniweb/build'
30
+ import { computeFoundationDigest } from '../utils/code-upload.js'
31
+ import { readFlagValue } from '../utils/args.js'
32
+ import { isNonInteractive } from '../utils/interactive.js'
33
+
34
+ /**
35
+ * Resolve the site's LOCAL foundation — the one publish should bring along — or
36
+ * null when the site references a published registry ref / URL (the catalog
37
+ * already has it; nothing to do). Uses the SAME resolver the build uses
38
+ * (`detectFoundationType`), so "which foundation" never drifts between them.
39
+ *
40
+ * @param {string} siteDir
41
+ * @param {object} siteYml - parsed site.yml
42
+ * @returns {{ dir: string, scopedName: string|null, version: string|null }|null}
43
+ */
44
+ export function resolveLocalFoundation(siteDir, siteYml) {
45
+ const decl = siteYml?.foundation
46
+ if (!decl) return null
47
+ let info
48
+ try {
49
+ info = detectFoundationType(decl, siteDir)
50
+ } catch {
51
+ // Unresolved declaration — the site build will surface the canonical
52
+ // error; bring-along simply has nothing local to act on.
53
+ return null
54
+ }
55
+ if (!info || info.type !== 'local' || !info.path) return null
56
+ return {
57
+ dir: info.path,
58
+ scopedName: foundationScopedName(info.path),
59
+ version: readPkgField(info.path, 'version'),
60
+ }
61
+ }
62
+
63
+ // The foundation's scoped catalog name (`@org/name`) from its package.json — an
64
+ // already-scoped `name`, else `uniweb.scope` + a bare `name`. Null when neither
65
+ // yields a scoped name (then we can't look up the registered version, so the
66
+ // caller treats the foundation as "release it and let register pick the scope").
67
+ function foundationScopedName(dir) {
68
+ try {
69
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
70
+ const name = pkg?.name
71
+ if (typeof name === 'string' && name.startsWith('@')) return name
72
+ const scope = pkg?.uniweb?.scope
73
+ if (scope && name) return `${String(scope).replace(/\/+$/, '')}/${name}`
74
+ return null
75
+ } catch {
76
+ return null
77
+ }
78
+ }
79
+
80
+ function readPkgField(dir, field) {
81
+ try {
82
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))?.[field] || null
83
+ } catch {
84
+ return null
85
+ }
86
+ }
87
+
88
+ // Forward the origin + auth flags so the spawned `uniweb register` / `build`
89
+ // hits the SAME backend with the SAME session as the publish that called it.
90
+ function forwardedFlags(args) {
91
+ const out = []
92
+ for (const name of ['--backend', '--registry', '--token']) {
93
+ const v = readFlagValue(args, name)
94
+ if (v) out.push(name, v)
95
+ }
96
+ if (isNonInteractive(args)) out.push('--non-interactive')
97
+ return out
98
+ }
99
+
100
+ /**
101
+ * Decide + (maybe) act on the site's local foundation before go-live.
102
+ *
103
+ * @param {object} o
104
+ * @param {import('./client.js').BackendClient} o.client
105
+ * @param {string} o.siteDir
106
+ * @param {object} o.siteYml
107
+ * @param {string[]} o.args
108
+ * @param {object} o.say - { ok, info, warn, err, dim } reporters
109
+ * @param {(q: string, def?: boolean) => Promise<boolean>} o.confirm
110
+ * @param {string} o.cliBin - the CLI entry (process.argv[1]) to re-spawn
111
+ * @param {boolean} [o.dryRun]
112
+ * @returns {Promise<{ released: boolean, proceed: boolean, ref: string|null }>}
113
+ * proceed:false → the caller should abort the publish (user declined). `ref`
114
+ * is the pinned `@scope/name@version` to stamp on the pushed site — read AFTER
115
+ * any release, so it reflects the released version + the scope register
116
+ * derived. Delivery is version-pinned end-to-end (the gateway serves a
117
+ * foundation only by a concrete version, no latest-resolution at serve time —
118
+ * collab framework-backend-5c3e), so an unversioned local ref MUST be pinned
119
+ * on the wire or the live site points at code the gateway can't serve. null
120
+ * when the site already references a registry ref / URL (no override needed)
121
+ * or no scoped ref can be formed.
122
+ */
123
+ export async function bringFoundationAlong({ client, siteDir, siteYml, args, say, confirm, cliBin, dryRun = false }) {
124
+ const local = resolveLocalFoundation(siteDir, siteYml)
125
+ if (!local) {
126
+ // Published registry ref / URL — the catalog (or the URL host) already
127
+ // serves the code, and site.yml already pins the version. Nothing to bring
128
+ // along, and no ref override (forward the site.yml ref verbatim).
129
+ return { released: false, proceed: true, ref: null }
130
+ }
131
+
132
+ const label = local.scopedName || local.version ? `${local.scopedName || 'foundation'}${local.version ? `@${local.version}` : ''}` : 'the local foundation'
133
+ const skipPrompts = args.includes('--yes') || args.includes('--force') || args.includes('--no-verify')
134
+
135
+ // The pinned ref to stamp on the pushed site — read at RETURN time (after any
136
+ // release), so it reflects the released version + the scope register derived.
137
+ // null when no scoped ref can be formed (then the site.yml ref is forwarded).
138
+ const pinnedRef = () => {
139
+ const s = foundationScopedName(local.dir)
140
+ const v = readPkgField(local.dir, 'version')
141
+ return s && v ? `${s}@${v}` : null
142
+ }
143
+
144
+ // Dry-run reports the intent WITHOUT touching the network — it must not force
145
+ // a login (the digest read is auth-gated). The real run does the compare.
146
+ if (dryRun) {
147
+ say.dim(`Foundation : ${label} — local; would release if changed or not yet registered`)
148
+ return { released: false, proceed: true, ref: null }
149
+ }
150
+
151
+ // Ask the catalog what it has. Null → not registered (or the backend can't
152
+ // answer / no scoped name to look up) → release.
153
+ const reg = local.scopedName ? await client.readFoundationLatest(local.scopedName) : null
154
+
155
+ if (!reg) {
156
+ say.info(`Releasing the foundation ${label} (not yet registered)…`)
157
+ return { released: releaseFoundation(local, args, cliBin, say), proceed: true, ref: pinnedRef() }
158
+ }
159
+
160
+ // Registered — fingerprint the local build and compare. Build first so the
161
+ // digest reflects current source (idempotent: a no-op when already fresh).
162
+ buildFoundation(local, cliBin)
163
+ const localDigest = computeFoundationDigest(join(local.dir, 'dist'))
164
+
165
+ if (reg.digest && localDigest && reg.digest === localDigest) {
166
+ say.dim(`Foundation : ${label} — unchanged since release (digest matches); nothing to release.`)
167
+ return { released: false, proceed: true, ref: pinnedRef() }
168
+ }
169
+
170
+ // A different version locally → a new version to release.
171
+ if (local.version && local.version !== reg.latest_version) {
172
+ say.info(`Releasing the foundation ${label} (new version; registered latest is ${reg.latest_version})…`)
173
+ return { released: releaseFoundation(local, args, cliBin, say), proceed: true, ref: pinnedRef() }
174
+ }
175
+
176
+ // Same version, but the digest differs or the backend can't confirm it.
177
+ if (!reg.digest) {
178
+ // Degrade: the backend doesn't return the stored digest yet, so we can't
179
+ // be sure the registered version matches local. Offer to re-deliver.
180
+ say.warn(`Can't verify the registered ${label} matches your local copy (backend returned no digest).`)
181
+ if (skipPrompts || isNonInteractive(args)) {
182
+ say.dim('Proceeding without re-releasing — pass nothing to re-deliver, or bump the version to publish a change.')
183
+ return { released: false, proceed: true, ref: pinnedRef() }
184
+ }
185
+ const reRelease = await confirm(`Re-release ${label} to be sure its code is current?`, false)
186
+ if (reRelease) return { released: releaseFoundation(local, args, cliBin, say), proceed: true, ref: pinnedRef() }
187
+ return { released: false, proceed: true, ref: pinnedRef() }
188
+ }
189
+
190
+ // Case 3 (§4): the foundation was edited but the version wasn't bumped. The
191
+ // registered version is immutable, so we never silently ship the old code —
192
+ // the deliberate release gate is a version bump (§3.1).
193
+ say.warn(`Your local ${label} differs from the registered version ${reg.latest_version}, but the version wasn't bumped.`)
194
+ say.dim('A registered version is immutable. Bump the foundation\'s version to release the change, then re-run `uniweb publish`.')
195
+ if (skipPrompts || isNonInteractive(args)) {
196
+ say.dim(`Proceeding with the already-registered ${reg.latest_version}.`)
197
+ return { released: false, proceed: true, ref: pinnedRef() }
198
+ }
199
+ const proceed = await confirm(`Publish with the already-registered ${reg.latest_version} anyway?`, false)
200
+ if (!proceed) {
201
+ say.info('Aborted — bump the foundation version, then re-run `uniweb publish`.')
202
+ return { released: false, proceed: false, ref: null }
203
+ }
204
+ return { released: false, proceed: true, ref: pinnedRef() }
205
+ }
206
+
207
+ // Build the foundation so its dist/ can be fingerprinted. Idempotent — the
208
+ // foundation build no-ops when already fresh.
209
+ function buildFoundation(local, cliBin) {
210
+ execFileSync('node', [cliBin, 'build', '--target', 'foundation'], {
211
+ cwd: local.dir,
212
+ stdio: 'inherit',
213
+ env: process.env,
214
+ })
215
+ }
216
+
217
+ // Release = `uniweb register` in the foundation directory (the one foundation
218
+ // release path). Returns true on success; throws to the caller on failure so
219
+ // publish stops before going live with missing code.
220
+ function releaseFoundation(local, args, cliBin, say) {
221
+ console.log('')
222
+ execFileSync('node', [cliBin, 'register', ...forwardedFlags(args)], {
223
+ cwd: local.dir,
224
+ stdio: 'inherit',
225
+ env: process.env,
226
+ })
227
+ console.log('')
228
+ return true
229
+ }