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.
@@ -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
+ }
@@ -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 deploy` (the composite path) reuses the exact same lane submission.
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
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'
15
15
  import { join, dirname } from 'node:path'
16
- import { backfillEntityUuids, writeSiteEntityUuid } from '@uniweb/build/uwx'
16
+ import { backfillEntityUuids, writeSiteEntityUuid, emitSyncPackages } from '@uniweb/build/uwx'
17
17
 
18
18
  // Pull the finalized entities out of the restore response. The backend returns
19
19
  // `{ report: { finalized: [ { index, uuid, changed, document }, … ] } }` — each entry
@@ -106,6 +106,27 @@ export function writeSyncCache(siteDir, hashes) {
106
106
  writeFileSync(p, JSON.stringify({ version: 1, hashes }, null, 2) + '\n')
107
107
  }
108
108
 
109
+ /**
110
+ * Offline-probe how many of a site's entities differ from the last successful push.
111
+ * Runs the SAME emit + send-only-changed diff `uniweb push` runs, but with an
112
+ * OFFLINE Model resolver — no auth, no submit, no backend round-trip. Used by
113
+ * `uniweb status` and the `uniweb publish` pre-flight. Throws if the producer
114
+ * can't build the sync packages (e.g. an unresolved data Model); callers report it.
115
+ *
116
+ * @param {string} siteDir
117
+ * @returns {Promise<{ changed: number, unchanged: number, warnings: string[] }>}
118
+ */
119
+ export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
120
+ const priorHashes = readSyncCache(siteDir)
121
+ const pkg = await emitSyncPackages(siteDir, {
122
+ resolveModel: makeModelResolver({ client: null, offline: true }),
123
+ priorHashes,
124
+ sendAll,
125
+ })
126
+ const changed = (pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
127
+ return { changed, unchanged: pkg.skipped || 0, warnings: pkg.warnings || [] }
128
+ }
129
+
109
130
  /**
110
131
  * Submit a site's emitted sync packages over both directional lanes, back-fill the
111
132
  * minted uuids, and persist the send-only-changed cache. The HTTP + file-write-back
@@ -141,7 +162,7 @@ export async function pushSyncPackages({ client, siteDir, pkg, asOrg, report })
141
162
  res = await doRequest()
142
163
  } catch (err) {
143
164
  error(`Could not reach the backend at ${client.origin}: ${err.message}`)
144
- note('Set the origin with --registry <url> or UNIWEB_REGISTER_URL.')
165
+ note('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
145
166
  return null
146
167
  }
147
168
  if (!res.ok) {
@@ -4,30 +4,31 @@
4
4
  * Builds foundations with schema generation, or sites.
5
5
  *
6
6
  * Two site build pipelines are available, but they're INTERNAL vocabulary
7
- * after Phase 2 of the CLI ergonomics overhaul. Users see `uniweb deploy`
8
- * (uniweb-edge — runtime-linked) and `uniweb export` (third-party host
9
- * concatenated bundle); the build command itself just dispatches to whichever
10
- * pipeline the caller requested.
7
+ * after Phase 2 of the CLI ergonomics overhaul. Users see `uniweb publish`
8
+ * (Uniweb hosting — runtime-linked), `uniweb deploy --host` (third-party host)
9
+ * and `uniweb export` (self-contained bundle); the build command itself just
10
+ * dispatches to whichever pipeline the caller requested.
11
11
  *
12
- * --bundle (internal; called by `uniweb export`)
12
+ * --bundle (internal; called by `uniweb deploy --host` / `uniweb export`)
13
13
  * Full vite + post-vite pipeline. Produces a static-host JS bundle
14
14
  * (`dist/index.html`, `dist/entry.js`, `_importmap/*`, `_pages/*` for
15
15
  * split mode, sitemap/robots/search-index, prerendered HTML when
16
16
  * configured). Foundation is loaded by URL when site.yml's foundation
17
17
  * is a registry ref; statically inlined when it's a workspace-local
18
- * ref with no auto-publish (the narrow self-contained case).
18
+ * ref (the self-contained case).
19
19
  *
20
- * --link (internal; called by `uniweb deploy`)
21
- * Data-only pipeline. No vite. Emits ONLY what the Uniweb-edge
22
- * deploy needs: `dist/site-content.json` (with full sections),
20
+ * --link (internal; called by `uniweb publish`)
21
+ * Data-only pipeline. No vite. Emits ONLY what Uniweb hosting
22
+ * needs: `dist/site-content.json` (with full sections),
23
23
  * `dist/<lang>/site-content.json` per non-default locale,
24
24
  * `dist/data/*.json` (collections), and `dist/assets/<media>` (images,
25
- * fonts, video posters). Edge stitches runtime + foundation per
25
+ * fonts, video posters). The backend stitches runtime + foundation per
26
26
  * request — the site's JS bundle would be dead weight.
27
27
  *
28
28
  * Bare `uniweb build` for a site defaults to --bundle (the historical
29
29
  * behavior). This is mostly useful for inspecting the build output during
30
- * development; for shipping, use `uniweb deploy` or `uniweb export`.
30
+ * development; for shipping, use `uniweb publish` (Uniweb hosting) or
31
+ * `uniweb deploy --host` / `uniweb export` (third-party / self-contained).
31
32
  *
32
33
  * Usage:
33
34
  * uniweb build # Build current directory (sites default to --bundle)
@@ -40,9 +41,9 @@
40
41
  * s3-cloudfront, github-pages,
41
42
  * generic-static). Default: cloudflare-pages.
42
43
  *
43
- * Internal flags (called by `uniweb deploy` / `uniweb export`):
44
- * --link # Data-only pipeline (Uniweb-edge)
45
- * --bundle # Full vite pipeline (third-party hosts)
44
+ * Internal flags:
45
+ * --link # Data-only pipeline (Uniweb hosting; called by `uniweb publish`)
46
+ * --bundle # Full vite pipeline (third-party / self-contained; deploy --host / export)
46
47
  */
47
48
 
48
49
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
@@ -246,11 +247,12 @@ async function buildFoundation(projectDir, options = {}) {
246
247
  /**
247
248
  * Ensure a local foundation's `dist/entry.js` is current.
248
249
  *
249
- * Whenever a build or deploy reads a local foundation from disk bundle
250
- * mode (vite imports it, prerender loads it for SSG), or link mode where
251
- * the foundation is uploaded alongside the site the foundation must be
252
- * built and current. Otherwise the verb fails with "Foundation not found
253
- * at .../dist/entry.js" or silently ships stale artifacts.
250
+ * Bundle mode reads a local foundation from disk (vite imports it, prerender
251
+ * loads dist/entry.js for SSG), so the foundation must be built and current.
252
+ * Otherwise the verb fails with "Foundation not found at .../dist/entry.js" or
253
+ * silently ships stale artifacts. (Link mode does NOT read the built foundation
254
+ * it forwards only the `foundation:` ref to the backend and ships no
255
+ * foundation code — so buildSiteLink does not call this.)
254
256
  *
255
257
  * `buildWorkspace()` already cascades when invoked from a workspace root,
256
258
  * but verbs invoked from a site directory (`uniweb build` in `sites/x/`,
@@ -260,8 +262,8 @@ async function buildFoundation(projectDir, options = {}) {
260
262
  *
261
263
  * This helper is idempotent: when the workspace-root cascade has already
262
264
  * run, the freshness check sees a current `dist/entry.js` and returns
263
- * without rebuilding. So adding the call inside `buildSite()` /
264
- * `buildSiteLink()` does not double-build under `buildWorkspace()`.
265
+ * without rebuilding. So calling it inside `buildSite()` does not
266
+ * double-build under `buildWorkspace()`.
265
267
  *
266
268
  * Freshness rule: a built artifact (`dist/entry.js` or the legacy
267
269
  * `dist/foundation.js`) exists AND its mtime is >= the newest mtime of
@@ -525,7 +527,7 @@ function resolveFoundationDir(projectDir, siteConfig) {
525
527
  /**
526
528
  * Build a site in link mode — data only, no vite.
527
529
  *
528
- * Emits exactly what `uniweb deploy` ships to Uniweb-edge:
530
+ * Emits exactly what `uniweb publish` ships to Uniweb hosting:
529
531
  * dist/site-content.json (full sections inlined)
530
532
  * dist/<lang>/site-content.json per non-default locale
531
533
  * dist/data/<collection>.json (+ per-record files for `deferred:`)
@@ -564,10 +566,12 @@ async function buildSiteLink(projectDir, options = {}) {
564
566
  // null and theme defaults come from theme.yml only.
565
567
  const foundationDir = await resolveFoundationDirForSite(projectDir, siteConfig).catch(() => null)
566
568
 
567
- // Cascade: a local foundation in link mode is uploaded alongside the
568
- // site (site-bound mode), so its dist must be current. Idempotent under
569
- // buildWorkspace() the freshness check no-ops when already built.
570
- if (foundationDir) await ensureFoundationFresh(foundationDir)
569
+ // Link mode does NOT (re)build the foundation. It reads only the
570
+ // foundation's SOURCE config (foundation.js::theme.vars, passed as
571
+ // foundationPath below) for theme defaults, and ships NO foundation code —
572
+ // the backend serves the foundation from the registry by the `foundation:`
573
+ // ref. (Bundle mode is the one that needs a current dist/entry.js — see
574
+ // buildSite — so the ensureFoundationFresh cascade lives there, not here.)
571
575
 
572
576
  await buildSiteData({
573
577
  siteRoot: projectDir,
@@ -637,7 +641,6 @@ async function resolveFoundationDirForSite(siteDir, siteConfig) {
637
641
  if (!foundation || typeof foundation !== 'string') return null
638
642
  // Registry ref or URL — no local foundation.
639
643
  if (/^@[a-z0-9_-]+\/[a-z0-9_-]+@/.test(foundation)) return null
640
- if (/^~[A-Za-z0-9_-]+\/[a-z0-9_-]+@/.test(foundation)) return null
641
644
  if (foundation.startsWith('http://') || foundation.startsWith('https://')) return null
642
645
 
643
646
  // Workspace sibling.
@@ -910,14 +913,15 @@ function showNextSteps(hasFoundations, hasSites) {
910
913
  if (hasFoundations) {
911
914
  log('')
912
915
  log(`${colors.bright}Share with clients:${colors.reset}`)
913
- log(` ${colors.bright}uniweb publish${colors.reset} Register your foundation (one-time setup)`)
916
+ log(` ${colors.bright}uniweb register${colors.reset} Release your foundation to the catalog (alias: uniweb release)`)
914
917
  log(` ${colors.bright}uniweb handoff <email>${colors.reset} Hand off a site to a client`)
915
918
  }
916
919
  if (hasSites) {
917
920
  log('')
918
- log(`${colors.bright}Deploy:${colors.reset}`)
919
- log(` ${colors.bright}uniweb deploy${colors.reset} Uniweb hosting`)
920
- log(` Or upload ${colors.cyan}dist/${colors.reset} to any static host`)
921
+ log(`${colors.bright}Ship a site:${colors.reset}`)
922
+ log(` ${colors.bright}uniweb publish${colors.reset} Uniweb hosting (brings the foundation along)`)
923
+ log(` ${colors.bright}uniweb deploy --host${colors.reset}=… Third-party host`)
924
+ log(` Or upload ${colors.cyan}dist/${colors.reset} (\`uniweb export\`) to any static host`)
921
925
  }
922
926
  }
923
927
 
@@ -943,10 +947,10 @@ export async function build(args = []) {
943
947
  const prerenderFlag = args.includes('--prerender')
944
948
  const noPrerenderFlag = args.includes('--no-prerender')
945
949
 
946
- // Internal flags — called by `uniweb deploy` (always --link) and
947
- // `uniweb export` (always --bundle). After Phase 2 of the CLI
948
- // ergonomics overhaul, users don't see these flags directly; they
949
- // pick the deploy target (deploy vs export) and the corresponding
950
+ // Internal flags — called by `uniweb publish` (always --link) and
951
+ // `uniweb deploy --host` / `uniweb export` (always --bundle). After Phase 2
952
+ // of the CLI ergonomics overhaul, users don't see these flags directly; they
953
+ // pick the verb (publish vs deploy vs export) and the corresponding
950
954
  // pipeline runs. Bare `uniweb build` for a site defaults to --bundle
951
955
  // (mostly used during development to inspect the vite output).
952
956
  const linkFlag = args.includes('--link')
@@ -163,9 +163,9 @@ export async function clone(args = [], deps = {}) {
163
163
  const pathFlag = flagValue(args, '--path')
164
164
  const projectFlag = flagValue(args, '--project')
165
165
  const tokenFlag = flagValue(args, '--token')
166
- const explicitRegistry = flagValue(args, '--registry')
166
+ const explicitBackend = flagValue(args, '--backend') || flagValue(args, '--registry')
167
167
  const client = new BackendClient({
168
- originFlag: explicitRegistry,
168
+ originFlag: explicitBackend,
169
169
  token: tokenFlag,
170
170
  getToken: deps.getToken,
171
171
  fetchImpl: deps.fetch,
@@ -295,7 +295,7 @@ export async function clone(args = [], deps = {}) {
295
295
  }
296
296
 
297
297
  const pullExtra = []
298
- if (explicitRegistry) pullExtra.push('--registry', explicitRegistry)
298
+ if (explicitBackend) pullExtra.push('--backend', explicitBackend)
299
299
  if (tokenFlag) pullExtra.push('--token', tokenFlag)
300
300
  if (noCollections) pullExtra.push('--no-collections')
301
301