uniweb 0.12.35 → 0.12.37
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/package.json +2 -2
- package/partials/agents.md +11 -11
- package/src/backend/client.js +78 -24
- package/src/backend/foundation-bring-along.js +229 -0
- package/src/backend/payment-handoff.js +105 -0
- package/src/backend/site-sync.js +2 -2
- package/src/commands/build.js +39 -35
- package/src/commands/clone.js +3 -3
- package/src/commands/deploy.js +95 -424
- package/src/commands/export.js +5 -3
- package/src/commands/publish.js +285 -95
- package/src/commands/pull.js +7 -5
- package/src/commands/push.js +8 -6
- package/src/commands/register.js +13 -5
- package/src/commands/rename.js +3 -2
- package/src/commands/runtime.js +1 -1
- package/src/commands/status.js +24 -5
- package/src/framework-index.json +5 -5
- package/src/index.js +63 -48
- package/src/utils/asset-upload.js +3 -3
- package/src/utils/code-upload.js +43 -3
- package/src/utils/config.js +30 -5
- package/src/utils/registry-auth.js +84 -33
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.37",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@uniweb/content-reader": "1.1.12",
|
|
50
50
|
"@uniweb/semantic-parser": "1.1.17",
|
|
51
|
-
"@uniweb/build": "0.14.
|
|
51
|
+
"@uniweb/build": "0.14.20"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -142,11 +142,13 @@ pnpm install # Install dependencies
|
|
|
142
142
|
pnpm build # Build for production
|
|
143
143
|
pnpm preview # Preview production build (SSG + SPA)
|
|
144
144
|
|
|
145
|
-
# Ship a site to Uniweb hosting (
|
|
146
|
-
uniweb
|
|
147
|
-
|
|
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
|
|
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
153
|
|
|
152
154
|
# Sync a site with the Uniweb backend (git-style; needs `uniweb login`)
|
|
@@ -154,8 +156,6 @@ uniweb push # Push local content to the backend (creates t
|
|
|
154
156
|
uniweb pull # Pull backend content back to local files
|
|
155
157
|
uniweb clone <site-uuid> # Start a new local project from a site already in the backend
|
|
156
158
|
uniweb status # Show what's unpushed / out of sync (local, offline)
|
|
157
|
-
uniweb publish # Make the synced site's current backend state live —
|
|
158
|
-
# does NOT push; run `uniweb push` first, or just `uniweb deploy`
|
|
159
159
|
|
|
160
160
|
# Register a foundation + its data schemas to the Uniweb registry
|
|
161
161
|
uniweb register # Register this foundation + the data schemas it defines
|
|
@@ -172,7 +172,7 @@ uniweb --help # Top-level help
|
|
|
172
172
|
uniweb <command> --help # Per-command help (no side effects)
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
-
`uniweb
|
|
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.
|
|
176
176
|
|
|
177
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`).
|
|
178
178
|
|
|
@@ -203,14 +203,14 @@ The `uniweb` block in `package.json` carries platform-specific configuration tha
|
|
|
203
203
|
|
|
204
204
|
| Field | Where used | Default | Purpose |
|
|
205
205
|
|---|---|---|---|
|
|
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.
|
|
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
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. |
|
|
208
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. |
|
|
209
209
|
|
|
210
|
-
**
|
|
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:
|
|
211
211
|
|
|
212
|
-
-
|
|
213
|
-
-
|
|
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'`).
|
|
214
214
|
|
|
215
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.
|
|
216
216
|
|
package/src/backend/client.js
CHANGED
|
@@ -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
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* 1. `flag` — the raw --backend / --registry value (this command)
|
|
43
|
+
* 2. UNIWEB_REGISTER_URL env — session-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
|
-
|
|
51
|
-
|
|
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
|
}
|
|
@@ -208,19 +228,29 @@ export class BackendClient {
|
|
|
208
228
|
}
|
|
209
229
|
|
|
210
230
|
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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).
|
|
215
241
|
* @param {string} scopedName
|
|
216
|
-
* @returns {Promise<{ latest_version: string }|null>}
|
|
242
|
+
* @returns {Promise<{ latest_version: string|null, digest: string|null }|null>}
|
|
217
243
|
*/
|
|
218
244
|
async readFoundationLatest(scopedName) {
|
|
219
245
|
const m = /^@([^/]+)\/([^@/]+)/.exec(String(scopedName || ''))
|
|
220
246
|
if (!m) return null
|
|
221
247
|
try {
|
|
222
|
-
const res = await this.request(`/dev/registry
|
|
223
|
-
|
|
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 }
|
|
224
254
|
} catch {
|
|
225
255
|
return null
|
|
226
256
|
}
|
|
@@ -331,16 +361,40 @@ export class BackendClient {
|
|
|
331
361
|
}
|
|
332
362
|
|
|
333
363
|
/**
|
|
334
|
-
*
|
|
335
|
-
*
|
|
364
|
+
* GET /dev/site/status/{uuid} → the site's publish lifecycle (Contract 3,
|
|
365
|
+
* shipped backend-side — collab backend-framework-b220):
|
|
336
366
|
* { published: boolean, last_pushed_at?: string, last_published_at?: string, draft_dirty?: boolean }
|
|
337
|
-
* `draft_dirty` = the synced draft
|
|
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.
|
|
338
372
|
* @param {string} uuid - the site-content uuid
|
|
339
373
|
* @returns {Promise<object|null>}
|
|
340
374
|
*/
|
|
341
375
|
async siteStatus(uuid) {
|
|
342
376
|
try {
|
|
343
|
-
const res = await this.request(`/dev/site/${encodeURIComponent(uuid)}
|
|
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`)
|
|
344
398
|
return res.ok ? await res.json().catch(() => null) : null
|
|
345
399
|
} catch {
|
|
346
400
|
return null
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payment handoff — the one piece of `uniweb publish` that's three-way
|
|
3
|
+
* (framework + backend + the `uniweb.app` web app). Design: payment-handoff-plan.md.
|
|
4
|
+
*
|
|
5
|
+
* The intent: `uniweb publish` on an unpaid (or newly-charged) site should just
|
|
6
|
+
* handle it — open a browser to `uniweb.app`, let the user pay, and continue.
|
|
7
|
+
* An already-paid site never opens a browser.
|
|
8
|
+
*
|
|
9
|
+
* The framework's ENTIRE payment knowledge is "open this URL, wait for done" —
|
|
10
|
+
* PROVIDER-AGNOSTIC. The CLI opens whatever `checkout_url` the backend hands it;
|
|
11
|
+
* the app drives the provider (Stripe or anything else) and settles with the
|
|
12
|
+
* backend. We reuse `awaitBrowserCallback` (the same loopback `uniweb login`
|
|
13
|
+
* uses) for the open + wait.
|
|
14
|
+
*
|
|
15
|
+
* DEGRADES: when the backend exposes no can-go-live route (404 / any failure),
|
|
16
|
+
* `canGoLive` returns null and we PROCEED — so publish ships before the payment
|
|
17
|
+
* route lands (same posture as `status --remote` on a missing endpoint). Live
|
|
18
|
+
* acceptance is the three-way test.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { randomBytes } from 'node:crypto'
|
|
22
|
+
|
|
23
|
+
import { awaitBrowserCallback } from '../utils/registry-auth.js'
|
|
24
|
+
import { isNonInteractive } from '../utils/interactive.js'
|
|
25
|
+
|
|
26
|
+
// Append query params to a backend-supplied URL without disturbing its own.
|
|
27
|
+
function withParams(url, params) {
|
|
28
|
+
const u = new URL(url)
|
|
29
|
+
for (const [k, v] of Object.entries(params)) {
|
|
30
|
+
if (v != null) u.searchParams.set(k, String(v))
|
|
31
|
+
}
|
|
32
|
+
return u.toString()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Settle payment for a site before go-live, if the backend says it's needed.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} o
|
|
39
|
+
* @param {import('./client.js').BackendClient} o.client
|
|
40
|
+
* @param {string|null} o.uuid - the site-content uuid (null before the first push mints it)
|
|
41
|
+
* @param {string[]} o.args
|
|
42
|
+
* @param {object} o.say - { ok, info, warn, err, dim } reporters
|
|
43
|
+
* @param {boolean} [o.dryRun]
|
|
44
|
+
* @returns {Promise<{ proceed: boolean }>} proceed:false → the caller aborts go-live.
|
|
45
|
+
*/
|
|
46
|
+
export async function settlePaymentIfNeeded({ client, uuid, args, say, dryRun = false }) {
|
|
47
|
+
// No uuid yet (a first publish mints it on push) → nothing to check here; the
|
|
48
|
+
// post-push go-live is the moment the backend gates on payment.
|
|
49
|
+
if (!uuid) return { proceed: true }
|
|
50
|
+
|
|
51
|
+
// Dry-run reports the intent WITHOUT touching the network — the can-go-live
|
|
52
|
+
// read is auth-gated and must not force a login on a dry-run.
|
|
53
|
+
if (dryRun) {
|
|
54
|
+
say.dim(`Payment : would check whether go-live needs payment for ${uuid}`)
|
|
55
|
+
return { proceed: true }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const verdict = await client.canGoLive(uuid)
|
|
59
|
+
// Degrade (no route) or already-paid → proceed.
|
|
60
|
+
if (!verdict || verdict.ok || !verdict.payment_required) return { proceed: true }
|
|
61
|
+
|
|
62
|
+
const checkoutUrl = verdict.checkout_url
|
|
63
|
+
if (!checkoutUrl) {
|
|
64
|
+
say.warn('The backend reports payment is required but returned no checkout URL — proceeding.')
|
|
65
|
+
return { proceed: true }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (dryRun) {
|
|
69
|
+
say.dim(`Payment : required — would open ${checkoutUrl}`)
|
|
70
|
+
return { proceed: true }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (isNonInteractive(args)) {
|
|
74
|
+
say.err('Payment is required to publish this site, and the CLI is non-interactive.')
|
|
75
|
+
say.dim(`Complete it in a browser, then re-run: ${checkoutUrl}`)
|
|
76
|
+
return { proceed: false }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The CSRF nonce the app echoes back on the done-signal redirect. The
|
|
80
|
+
// wait_token (when present) lets the app correlate the session backend-side.
|
|
81
|
+
const state = randomBytes(16).toString('hex')
|
|
82
|
+
say.info('Payment required — completing it in your browser…')
|
|
83
|
+
try {
|
|
84
|
+
await awaitBrowserCallback({
|
|
85
|
+
buildUrl: (redirectUri) =>
|
|
86
|
+
withParams(checkoutUrl, { redirect_uri: redirectUri, state, wait_token: verdict.wait_token }),
|
|
87
|
+
validate: (params) => {
|
|
88
|
+
if (params.get('error')) return { error: params.get('error') }
|
|
89
|
+
if (params.get('state') !== state) return { error: 'state mismatch — please retry.' }
|
|
90
|
+
return { value: true } // ok=1 / any non-error return = the app settled with the backend
|
|
91
|
+
},
|
|
92
|
+
openingLabel: 'Opening uniweb.app to complete payment…',
|
|
93
|
+
waitingLabel: 'Waiting for payment to complete (5 min)…',
|
|
94
|
+
timeoutMs: 5 * 60 * 1000,
|
|
95
|
+
okTitle: 'Payment complete',
|
|
96
|
+
errTitle: 'Payment failed',
|
|
97
|
+
})
|
|
98
|
+
} catch (err) {
|
|
99
|
+
say.err(`Payment was not completed: ${err.message}`)
|
|
100
|
+
say.dim('Re-run `uniweb publish` once payment is done.')
|
|
101
|
+
return { proceed: false }
|
|
102
|
+
}
|
|
103
|
+
say.ok('Payment complete.')
|
|
104
|
+
return { proceed: true }
|
|
105
|
+
}
|
package/src/backend/site-sync.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* packages, submit them over the two directional lanes (site-content first, then the
|
|
4
4
|
* folder keyed by the site's uuid), back-fill the minted uuids into the source files,
|
|
5
5
|
* and persist the send-only-changed cache. Extracted from the push command so
|
|
6
|
-
* `uniweb
|
|
6
|
+
* `uniweb publish` (the composite path) reuses the exact same lane submission.
|
|
7
7
|
*
|
|
8
8
|
* The command keeps flag parsing, the emit, and the `-o`/`--dry-run` preview;
|
|
9
9
|
* everything from "the packages are built, now POST them" lives here. Logging is
|
|
@@ -162,7 +162,7 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
|
|
|
162
162
|
res = await doRequest()
|
|
163
163
|
} catch (err) {
|
|
164
164
|
error(`Could not reach the backend at ${client.origin}: ${err.message}`)
|
|
165
|
-
note('Set the origin with --
|
|
165
|
+
note('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
|
|
166
166
|
return null
|
|
167
167
|
}
|
|
168
168
|
if (!res.ok) {
|