uniweb 0.30.1 → 0.31.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.30.1",
3
+ "version": "0.31.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,13 +41,13 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "^0.12.1",
45
- "@uniweb/kit": "^0.13.6",
46
- "@uniweb/semantic-parser": "^1.3.1",
47
- "@uniweb/runtime": "^0.12.13"
44
+ "@uniweb/core": "^0.13.0",
45
+ "@uniweb/kit": "^0.13.7",
46
+ "@uniweb/runtime": "^0.13.0",
47
+ "@uniweb/semantic-parser": "^1.3.1"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "^0.26.1",
50
+ "@uniweb/build": "^0.27.1",
51
51
  "@uniweb/semantic-parser": "^1.3.1",
52
52
  "@uniweb/content-reader": "^1.2.4"
53
53
  },
@@ -287,7 +287,7 @@ Paths are relative to the site package you identified in step 1 — `site/` in t
287
287
 
288
288
  **Change a nav item.** First check how nav is produced. If `<site>/layout/header.md` lists the links (a markdown list, or a `yaml:nav` block), edit it there. If it doesn't, the Header is generating nav from the page hierarchy — change page titles and order in `site.yml` / `page.yml` instead.
289
289
 
290
- **Update the project's Uniweb dependencies — and this file.** `uniweb update`. One command aligns every `@uniweb/*` dependency *and* refreshes this AGENTS.md together, to the version matrix of the CLI that runs it. Preview with `--dry-run`. **Don't reach for `npm update` / `pnpm update`** — see *Staying current* in Part 5 for why that breaks things quietly.
290
+ **Update the project's Uniweb dependencies — and this file.** `npx uniweb@latest update`. One command aligns every `@uniweb/*` dependency *and* refreshes this AGENTS.md together. Preview with `--dry-run`. ⛔ **The `@latest` is doing the work** a bare `uniweb update` runs the CLI this project already pins, which aligns you to the matrix you already have and correctly reports nothing to do. **Don't reach for `npm update` / `pnpm update`** — see *Staying current* in Part 5 for why that breaks things quietly.
291
291
 
292
292
  **Change one section's columns / spacing / variant.** Check that type's `meta.js` `params:` first. If the knob exists, set it in that section's frontmatter and you're done, in the content lane. If it doesn't, it's a foundation change — see the warning in step 3.
293
293
 
@@ -1751,7 +1751,7 @@ fetch:
1751
1751
  limit: 3
1752
1752
  ```
1753
1753
 
1754
- **Lean lists with `deferred:`.** Collections with heavy fields (article bodies, large nested arrays) can declare `deferred: [body]` in `site.yml`. The cascade payload omits those fields; per-record full files are emitted at `/data/<name>/<slug>.json` (file-based collections) or fetched from an author-declared `detailUrl:` (API-backed). On dynamic-route pages the focused record's full data is delivered automatically; elsewhere components fetch on demand via `useEntityDetail`.
1754
+ **Lean lists with `deferred:`.** Collections with heavy fields (article bodies, large nested arrays) can declare `deferred: [body]` in `site.yml`. The cascade payload omits those fields; per-record full files are emitted at `/data/<name>/<slug>.json` (file-based collections) or fetched from an author-declared `detailUrl:` (API-backed). On dynamic-route pages the focused record's full data is delivered automatically; elsewhere components fetch on demand via `useEntityDetail`. The hook is safe to call on any collection: when there is no separate detail source it returns the record you passed in, because nothing was stripped from it.
1755
1755
 
1756
1756
  **Component-side fetching.** When a component genuinely needs to fetch on its own (a search box, "load more", a lazy popover), use the kit hooks — `useFetched`, `useCacheEntry`, `useEntityDetail`. They share the framework's cache and dispatcher with declarative fetches; same-key requests dedupe automatically.
1757
1757
 
@@ -2024,8 +2024,9 @@ collector's event dimension into a cardinality problem.
2024
2024
 
2025
2025
  ### Choosing what a site sends
2026
2026
 
2027
- By default a site sends `page_view`, `outbound_click` and `section_view`. Narrow
2028
- or widen that with `emit`:
2027
+ By default a site sends `page_view`, `outbound_click` and `section_view` — or,
2028
+ where a host supplies the collector, whatever that host declares it collects.
2029
+ Narrow or widen that with `emit`:
2029
2030
 
2030
2031
  ```yaml
2031
2032
  # site.yml — your own collector
@@ -2044,10 +2045,19 @@ are read key by key, so naming `emit` alone overrides nothing else the host
2044
2045
  declared. And declaring your own `endpoint:` always wins, so a site pointing at
2045
2046
  its own collector keeps working on any host, including none.
2046
2047
 
2047
- `minimal` is `page_view` alone. `standard` is the default. `all` is a standing
2048
- yes, so an event added in a later framework release is included without you
2049
- changing anything — which is exactly why `standard` exists as well: it is a
2050
- curated set that a release cannot grow behind your back.
2048
+ `minimal` is `page_view` alone. `standard` is the default when you supply your
2049
+ own `endpoint:`. `all` is a standing yes, so an event added in a later framework
2050
+ release is included without you changing anything — which is exactly why
2051
+ `standard` exists as well: it is a curated set that a release cannot grow behind
2052
+ your back.
2053
+
2054
+ ⭐ **Saying nothing means two different things, and which one depends on who
2055
+ supplies the address.** A site with its own `endpoint:` gets `standard`. A site
2056
+ on a **host-supplied** collector gets **whatever that host declares it
2057
+ collects** — it has no address of its own, so the arrangement is that the host
2058
+ does analytics for it, and the set grows when the host starts collecting
2059
+ something new. **Naming `emit` always wins**, so pin it if you would rather not
2060
+ follow your host.
2051
2061
 
2052
2062
  A site can also set **`flushIntervalMs`** to widen the batching window (default
2053
2063
  5000, milliseconds — `30` is thirty *milliseconds*). A host that supplies your
@@ -2216,7 +2226,8 @@ uniweb i18n init-freeform / update-hash / move / rename / prune --freeform
2216
2226
  uniweb -v # Installed CLI version — and whether a newer one exists
2217
2227
  uniweb doctor # Diagnose project configuration (--fix to auto-repair)
2218
2228
  uniweb validate # Check file-based data against declared schemas (--strict for CI)
2219
- uniweb update # Align @uniweb/* deps + AGENTS.md to the CLI (--dry-run, --yes)
2229
+ npx uniweb@latest update # Align @uniweb/* deps + AGENTS.md (--dry-run, --yes)
2230
+ # bare `uniweb update` aligns to the CLI you ALREADY have
2220
2231
  uniweb inspect <path> # Show parsed content for a section or page (--raw for the AST)
2221
2232
 
2222
2233
  uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
@@ -2284,15 +2295,28 @@ Either side can publish. Nothing about this changes how you build: the same foun
2284
2295
  ```bash
2285
2296
  uniweb -v # installed CLI version, and whether a newer one is available
2286
2297
  uniweb doctor # report drift in this project, changing nothing
2287
- uniweb update --dry-run # preview exactly what update would change
2288
- uniweb update # apply: align @uniweb/* deps AND refresh AGENTS.md
2298
+ npx uniweb@latest update --dry-run # preview exactly what update would change
2299
+ npx uniweb@latest update # apply: align @uniweb/* deps AND refresh AGENTS.md
2289
2300
  ```
2290
2301
 
2291
- **`uniweb update` is the command for bringing a project up to date.** It aligns the project's `@uniweb/*` dependencies *and* this AGENTS.md to the version matrix of the CLI that runs it. Deps and documentation move together — that's the whole point of the verb.
2302
+ **`npx uniweb@latest update` is the command for bringing a project up to date.** It aligns the project's `@uniweb/*` dependencies *and* this AGENTS.md to the version matrix of the CLI that runs it. Deps and documentation move together — that's the whole point of the verb.
2303
+
2304
+ > ### ⛔ WRITE `@latest`. A BARE `uniweb update` IS USUALLY A NO-OP, AND IT SAYS SO CONVINCINGLY
2305
+ >
2306
+ > `update` reconciles this project against **the matrix of the CLI that runs it** — and in a project,
2307
+ > `uniweb` resolves to the copy in your own `node_modules`, pinned by your own `package.json`. That
2308
+ > copy has no way to know a newer release exists.
2309
+ >
2310
+ > ⇒ **It reports everything aligned and changes nothing — correctly.** It answered the question it
2311
+ > was asked, which is why the output looks like success rather than a mistake. The same applies to
2312
+ > `pnpm uniweb update` and to a stale global install.
2313
+ >
2314
+ > ⭐ **`@latest` is what makes it a question about the newest release** rather than about the one you
2315
+ > already have. It also bumps the pin, so the next bare invocation is no longer stale.
2292
2316
 
2293
2317
  > **Don't run `npm update` or `pnpm update` on the `@uniweb/*` packages.** They're a matched set resolved by the CLI's version matrix, not independently versioned libraries you upgrade one at a time. Updating them directly gets you a combination nobody tested, and it won't refresh AGENTS.md — so this guide silently drifts out of sync with the code it describes, which is worse than being out of date, because nothing looks wrong.
2294
2318
 
2295
- Two ordering rules: `update` won't refresh AGENTS.md while declared deps still lag the CLI, or while edited deps haven't been installed — either would put the doc ahead of the code. And updating the CLI itself is your package manager's job (`npm i -g uniweb@latest`, `pnpm add -g uniweb@latest`); `uniweb update` does not do that. To pin a project to the newest published release with no global install: `npx uniweb@latest update --yes`.
2319
+ Two ordering rules: `update` won't refresh AGENTS.md while declared deps still lag the CLI, or while edited deps haven't been installed — either would put the doc ahead of the code. And updating the CLI itself is your package manager's job (`npm i -g uniweb@latest`, `pnpm add -g uniweb@latest`); `uniweb update` does not do that.
2296
2320
 
2297
2321
  ### `package.json` `uniweb` block
2298
2322
 
@@ -537,28 +537,6 @@ export class BackendClient {
537
537
  }
538
538
  }
539
539
 
540
- /**
541
- * ASSUMED endpoint (not built yet).
542
- * GET /dev/site/{uuid}/can-go-live — the pre-go-live payment gate:
543
- * { ok: true } // already paid → proceed
544
- * { payment_required: true, checkout_url, wait_token? } // open the URL, settle, retry
545
- * The framework is provider-agnostic: it only opens `checkout_url` and waits.
546
- * null on 404 / any failure → the caller PROCEEDS (degrade: publish ships
547
- * before payment lands; same posture as siteStatus on a missing route).
548
- * @param {string} uuid - the site-content uuid
549
- * @returns {Promise<object|null>}
550
- */
551
- async canGoLive(uuid) {
552
- try {
553
- const res = await this.request(
554
- `/dev/site/${encodeURIComponent(uuid)}/can-go-live`
555
- )
556
- return res.ok ? await res.json().catch(() => null) : null
557
- } catch {
558
- return null
559
- }
560
- }
561
-
562
540
  /**
563
541
  * Deliver a site's processed assets (plan → PUT-per-file) to the backend's
564
542
  * content-addressed store. Thin pass-through to utils/asset-upload.js with this
@@ -1,124 +1,160 @@
1
1
  /**
2
- * Payment handoffthe one piece of `uniweb publish` that's three-way
3
- * (framework + backend + the `uniweb.app` web app). Design: payment-handoff-plan.md.
2
+ * Payment refusalwhat `uniweb publish` does when the backend says a site
3
+ * cannot go live until it is paid for.
4
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.
5
+ * THE BACKEND IS THE ONLY GATE. It evaluates on every publish, on every
6
+ * backend, whatever that deployment is configured to require a backend with
7
+ * subscriptions switched off simply never refuses. The CLI holds no opinion about whether a
8
+ * backend charges and must never form one: it attempts the publish and reads
9
+ * the answer.
8
10
  *
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.
11
+ * THERE IS NO PRE-FLIGHT, AND ADDING ONE BACK IS A REGRESSION. A
12
+ * `GET …/can-go-live` probe used to run before go-live; it called a route no
13
+ * backend serves, and it folded every failure 404 included into
14
+ * "proceed". A check that answers "fine" when it cannot reach the server is not
15
+ * a check, and it made the CLI assume a posture it has no business assuming. A
16
+ * pre-flight also cannot be authoritative: the backend re-evaluates at publish
17
+ * time regardless, so a second asker is a second producer of one decision.
14
18
  *
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
+ * WHAT THE CLI KNOWS ABOUT PAYMENT: nothing. It opens whatever settlement URL
20
+ * the backend hands it, VERBATIMprovider-agnostic, and route-agnostic. The
21
+ * app drives the provider and settles with the backend.
22
+ *
23
+ * ⛔ AND IT APPENDS NOTHING TO THAT URL. The old handoff added
24
+ * `redirect_uri=http://127.0.0.1:<port>/callback`, `state` and `wait_token`,
25
+ * then waited on a loopback for the app to redirect back. Two reasons it is
26
+ * gone: it obliged the web app to know a CLI exists and honour a callback, and
27
+ * a loopback callback requires the browser and the CLI
28
+ * on the SAME MACHINE, so over SSH or in a container it hung for its full
29
+ * timeout and then reported "payment was not completed", which was false.
19
30
  */
20
31
 
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
- }
32
+ /** Reasons the CLI knows how to act on. An ALLOWLIST, never an inventory. */
33
+ const ACTIONABLE_REASONS = new Set(['no_subscription'])
34
34
 
35
35
  /**
36
- * Settle payment for a site before go-live, if the backend says it's needed.
36
+ * Read a `402` from the publish call and decide what the CLI does. Pure — no
37
+ * network, no browser, no process exit — so the decision is testable on its own.
38
+ *
39
+ * ⛔ THE RULE, and it is a property of the wire rather than a CLI preference:
40
+ * NEVER route to a purchase surface from the ABSENCE of a recognised token. A
41
+ * purchase surface is opened by a `reason` that NAMES one; everything else
42
+ * surfaces the backend's own sentence and stops. Degrading that way means an
43
+ * older CLI shows you the message — annoying, honest, recoverable. Degrading
44
+ * the other way sends someone to a checkout for something they cannot buy.
45
+ *
46
+ * ⛔ Do NOT turn `ACTIONABLE_REASONS` into "every reason the backend has". The
47
+ * set is open by design and a stale copy here fails in the worst direction; an
48
+ * allowlist is safe precisely because what it misses lands on `stop`.
49
+ * ⛔ Do NOT parse `detail` — it is deliberately not asserted word-for-word, and
50
+ * `reason` exists to do the job parsing it would.
51
+ *
52
+ * NOTE ON `reason`'s PRESENCE: it is NOT guaranteed by the content type. A 402
53
+ * naming a condition the caller can act on carries one; nothing guarantees it
54
+ * in general — a declined card is `problem+json` and deliberately carries none,
55
+ * because there is no machine decision for a client to make about it. This reads `reason` when it is there and needs
56
+ * no invariant, which is why that correction cost this branch nothing.
37
57
  *
38
58
  * @param {object} o
39
- * @param {import('./client.js').BackendClient} o.client
40
- * @param {string|null} o.uuid - the site-content uuid (null only on a dry run; a
41
- * real publish creates the site before anything uploads, so it is set by here)
42
- * @param {string[]} o.args
43
- * @param {object} o.say - { ok, info, warn, err, dim } reporters
44
- * @param {boolean} [o.dryRun]
45
- * @returns {Promise<{ proceed: boolean }>} proceed:false → the caller aborts go-live.
59
+ * @param {number} o.status - the HTTP status
60
+ * @param {string} [o.contentType] - the response's content-type header
61
+ * @param {string} [o.body] - the raw response body
62
+ * @returns {{ kind: 'not-payment' }
63
+ * | { kind: 'settle', url: string, handle: string|null, reason: string, message: string|null }
64
+ * | { kind: 'stop', reason: string|null, message: string|null }}
46
65
  */
47
- export async function settlePaymentIfNeeded({
48
- client,
49
- uuid,
50
- args,
51
- say,
52
- dryRun = false
53
- }) {
54
- // No uuid yet (a first publish mints it on push) → nothing to check here; the
55
- // post-push go-live is the moment the backend gates on payment.
56
- if (!uuid) return { proceed: true }
66
+ export function readPaymentRefusal({ status, contentType = '', body = '' } = {}) {
67
+ if (status !== 402) return { kind: 'not-payment' }
68
+
69
+ let problem = null
70
+ try {
71
+ problem = JSON.parse(body)
72
+ } catch {
73
+ /* a non-JSON 402 is simply unrecognised it falls to `stop` below */
74
+ }
75
+ if (!problem || typeof problem !== 'object') {
76
+ return { kind: 'stop', reason: null, message: null }
77
+ }
78
+
79
+ // The human sentence, in the backend's own words. `detail` is the 7807
80
+ // member; `title` is the fallback when a body carries no detail.
81
+ const message =
82
+ (typeof problem.detail === 'string' && problem.detail) ||
83
+ (typeof problem.title === 'string' && problem.title) ||
84
+ null
85
+
86
+ // `status` is NOT a discriminator: the backend's problem bodies carry it as
87
+ // the NUMBER 402 while at least one hand-built 402 elsewhere on their wire
88
+ // carries a STRING. Same key, two types, neither failing loudly — so this
89
+ // reads `reason` and the content type instead, and never `body.status`.
90
+ const isProblem = String(contentType).includes('application/problem+json')
91
+ const reason =
92
+ isProblem && typeof problem.reason === 'string' && problem.reason
93
+ ? problem.reason
94
+ : null
57
95
 
58
- // Dry-run reports the intent WITHOUT touching the network — the can-go-live
59
- // read is auth-gated and must not force a login on a dry-run.
60
- if (dryRun) {
61
- say.dim(
62
- `Payment : would check whether go-live needs payment for ${uuid}`
63
- )
64
- return { proceed: true }
96
+ if (!reason || !ACTIONABLE_REASONS.has(reason)) {
97
+ return { kind: 'stop', reason, message }
65
98
  }
66
99
 
67
- const verdict = await client.canGoLive(uuid)
68
- // Degrade (no route) or already-paid proceed.
69
- if (!verdict || verdict.ok || !verdict.payment_required)
70
- return { proceed: true }
100
+ // Actionable but only if the backend actually handed over somewhere to go.
101
+ // A recognised reason with no settlement block is a backend that has not
102
+ // built that half yet: still a stop, and still with its own sentence.
103
+ const s = problem.settlement
104
+ const url = s && typeof s.url === 'string' && s.url ? s.url : null
105
+ if (!url) return { kind: 'stop', reason, message }
71
106
 
72
- const checkoutUrl = verdict.checkout_url
73
- if (!checkoutUrl) {
74
- say.warn(
75
- 'The backend reports payment is required but returned no checkout URL proceeding.'
76
- )
77
- return { proceed: true }
107
+ return {
108
+ kind: 'settle',
109
+ url,
110
+ handle: s && typeof s.handle === 'string' && s.handle ? s.handle : null,
111
+ reason,
112
+ message
78
113
  }
114
+ }
115
+
116
+ /**
117
+ * Report a payment refusal to the user, and open the settlement page when the
118
+ * backend supplied one.
119
+ *
120
+ * ⛔ Returns rather than exits — the caller owns the exit code, and a refusal
121
+ * is not a crash: the content is already synced as a draft, so re-running
122
+ * after paying is the recovery.
123
+ *
124
+ * @param {object} o
125
+ * @param {ReturnType<typeof readPaymentRefusal>} o.verdict
126
+ * @param {string[]} o.args - argv slice (for --non-interactive detection)
127
+ * @param {object} o.say - { ok, info, warn, err, dim } reporters
128
+ * @param {(url: string) => Promise<boolean>} [o.open] - injected for tests
129
+ * @returns {Promise<{ opened: boolean }>}
130
+ */
131
+ export async function reportPaymentRefusal({ verdict, args = [], say, open }) {
132
+ // The backend's own sentence is the HEADLINE when there is one. A generic
133
+ // lead would be wrong as often as right — "payment is required" does not
134
+ // describe a declined card — and `detail` is written for this reader.
135
+ say.err(verdict.message || 'This site cannot go live until it is paid for.')
79
136
 
80
- if (dryRun) {
81
- say.dim(`Payment : required would open ${checkoutUrl}`)
82
- return { proceed: true }
137
+ if (verdict.kind !== 'settle') {
138
+ // The push completed before go-live, so the content is safely stored.
139
+ say.dim('The site is synced as a draft; nothing was made live.')
140
+ return { opened: false }
83
141
  }
84
142
 
143
+ const { isNonInteractive } = await import('../utils/interactive.js')
85
144
  if (isNonInteractive(args)) {
86
- say.err(
87
- 'Payment is required to publish this site, and the CLI is non-interactive.'
88
- )
89
- say.dim(`Complete it in a browser, then re-run: ${checkoutUrl}`)
90
- return { proceed: false }
145
+ say.dim(`Complete it in a browser, then re-run \`uniweb publish\`:`)
146
+ say.dim(` ${verdict.url}`)
147
+ return { opened: false }
91
148
  }
92
149
 
93
- // The CSRF nonce the app echoes back on the done-signal redirect. The
94
- // wait_token (when present) lets the app correlate the session backend-side.
95
- const state = randomBytes(16).toString('hex')
96
- say.info('Payment required completing it in your browser…')
97
- try {
98
- await awaitBrowserCallback({
99
- buildUrl: (redirectUri) =>
100
- withParams(checkoutUrl, {
101
- redirect_uri: redirectUri,
102
- state,
103
- wait_token: verdict.wait_token
104
- }),
105
- validate: (params) => {
106
- if (params.get('error')) return { error: params.get('error') }
107
- if (params.get('state') !== state)
108
- return { error: 'state mismatch — please retry.' }
109
- return { value: true } // ok=1 / any non-error return = the app settled with the backend
110
- },
111
- openingLabel: 'Opening uniweb.app to complete payment…',
112
- waitingLabel: 'Waiting for payment to complete (5 min)…',
113
- timeoutMs: 5 * 60 * 1000,
114
- okTitle: 'Payment complete',
115
- errTitle: 'Payment failed'
116
- })
117
- } catch (err) {
118
- say.err(`Payment was not completed: ${err.message}`)
119
- say.dim('Re-run `uniweb publish` once payment is done.')
120
- return { proceed: false }
150
+ const openBrowser = open || (await import('../utils/registry-auth.js')).openBrowser
151
+ say.info('Opening your browser to complete it…')
152
+ say.dim(` ${verdict.url}`)
153
+ // VERBATIM. Nothing is appended see the header.
154
+ const opened = await openBrowser(verdict.url)
155
+ if (!opened) {
156
+ say.warn('Could not open a browser automatically — open the URL above.')
121
157
  }
122
- say.ok('Payment complete.')
123
- return { proceed: true }
158
+ say.dim('Once payment is complete, re-run `uniweb publish`.')
159
+ return { opened }
124
160
  }
@@ -88,7 +88,10 @@ import {
88
88
  bringFoundationAlong,
89
89
  bringExtensionsAlong
90
90
  } from '../backend/foundation-bring-along.js'
91
- import { settlePaymentIfNeeded } from '../backend/payment-handoff.js'
91
+ import {
92
+ readPaymentRefusal,
93
+ reportPaymentRefusal
94
+ } from '../backend/payment-handoff.js'
92
95
  import { reportSchemalessCollections } from '../utils/schemaless-report.js'
93
96
  import { uploadSiteData } from '../utils/site-data-upload.js'
94
97
 
@@ -353,13 +356,10 @@ export async function publish(args = []) {
353
356
  cliBin: process.argv[1],
354
357
  dryRun: true
355
358
  })
356
- await settlePaymentIfNeeded({
357
- client,
358
- uuid: siteYml.$uuid || null,
359
- args,
360
- say,
361
- dryRun: true
362
- })
359
+ // No payment line on a dry run. The backend is the only gate and it
360
+ // answers at go-live, so the honest dry-run answer is silence rather than
361
+ // a guess. (The old "would check whether go-live needs payment" described
362
+ // a pre-flight probe that has been removed — payment-handoff.js.)
363
363
  return { exitCode: 0 }
364
364
  }
365
365
 
@@ -409,8 +409,13 @@ export async function publish(args = []) {
409
409
  if (!fnd.proceed) return { exitCode: fnd.refused ? 1 : 0 }
410
410
 
411
411
  // 2. Build the site data (link mode): dist/site-content.json (+ per-locale),
412
- // dist/data/*, dist/_search/*, dist/assets/*. Spawn the SAME CLI binary so
413
- // the inner build can't resolve to a different installed version.
412
+ // dist/data/*, dist/assets/*. Spawn the SAME CLI binary so the inner
413
+ // build can't resolve to a different installed version.
414
+ //
415
+ // ⛔ NO SEARCH INDEX. This listed `dist/_search/*` until 2026-08-26; the
416
+ // link lane stopped emitting one on 2026-08-01 (`@uniweb/build`
417
+ // `site/build-site-data.js` step 5) because only one of the two
418
+ // publishers produced it.
414
419
  say.info('Building site…')
415
420
  console.log('')
416
421
  execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
@@ -435,6 +440,8 @@ export async function publish(args = []) {
435
440
  let probe
436
441
  try {
437
442
  probe = await emitSyncPackages(siteDir, {
443
+ // Resolves a foundation-relative `@/x` model ref into `@org/x`.
444
+ ...(asOrg ? { org: asOrg } : {}),
438
445
  ...(foundationDir ? { foundationDir } : {}),
439
446
  resolveModel
440
447
  })
@@ -634,6 +641,8 @@ export async function publish(args = []) {
634
641
  let pkg
635
642
  try {
636
643
  pkg = await emitSyncPackages(siteDir, {
644
+ // Resolves a foundation-relative `@/x` model ref into `@org/x`.
645
+ ...(asOrg ? { org: asOrg } : {}),
637
646
  ...(foundationDir ? { foundationDir } : {}),
638
647
  resolveModel,
639
648
  priorHashes,
@@ -673,18 +682,6 @@ export async function publish(args = []) {
673
682
  return { exitCode: 1 }
674
683
  }
675
684
 
676
- // 6. Payment gate — the backend says whether go-live needs payment. Settles
677
- // via a browser handoff to uniweb.app; degrades to "proceed" when the
678
- // backend exposes no payment route. The draft is already synced, so a
679
- // decline leaves a recoverable state (re-run after paying).
680
- const pay = await settlePaymentIfNeeded({ client, uuid: siteUuid, args, say })
681
- if (!pay.proceed) {
682
- say.info(
683
- 'Site synced as a draft but not made live. Re-run `uniweb publish` once payment is complete.'
684
- )
685
- return { exitCode: 0 }
686
- }
687
-
688
685
  // 7. Go live — make the just-pushed composite live (its current backend state).
689
686
  const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
690
687
  const languages = languagesFromContent(siteContent)
@@ -700,13 +697,29 @@ export async function publish(args = []) {
700
697
  return { exitCode: 1 }
701
698
  }
702
699
  if (!pubRes.ok) {
700
+ const body = await pubRes.text().catch(() => '')
701
+
702
+ // A 402 is the backend's payment gate — the ONLY gate, evaluated here on
703
+ // every publish against whatever posture that deployment runs. It is a
704
+ // refusal, not a fault: the content is already synced as a draft, so the
705
+ // recovery is to settle and re-run. Give it the backend's own sentence
706
+ // rather than the raw envelope.
707
+ const refusal = readPaymentRefusal({
708
+ status: pubRes.status,
709
+ contentType: pubRes.headers?.get?.('content-type') || '',
710
+ body
711
+ })
712
+ if (refusal.kind !== 'not-payment') {
713
+ await reportPaymentRefusal({ verdict: refusal, args, say })
714
+ return { exitCode: 1 }
715
+ }
716
+
703
717
  say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
704
718
  if (pubRes.status === 401 || pubRes.status === 403) {
705
719
  say.dim(
706
720
  "Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>)."
707
721
  )
708
722
  }
709
- const body = await pubRes.text().catch(() => '')
710
723
  if (body) say.dim(body.slice(0, 800))
711
724
  return { exitCode: 1 }
712
725
  }
@@ -373,6 +373,8 @@ export async function push(args = [], deps = {}) {
373
373
  let mediaRefs = []
374
374
  try {
375
375
  const probe = await emitSyncPackages(siteDir, {
376
+ // Resolves a foundation-relative `@/x` model ref into `@org/x`.
377
+ ...(asOrg ? { org: asOrg } : {}),
376
378
  ...(foundationDir ? { foundationDir } : {}),
377
379
  resolveModel: makeModelResolver({ client, offline: false })
378
380
  })
@@ -467,6 +469,8 @@ export async function push(args = [], deps = {}) {
467
469
  let pkg
468
470
  try {
469
471
  pkg = await emitSyncPackages(siteDir, {
472
+ // Resolves a foundation-relative `@/x` model ref into `@org/x`.
473
+ ...(asOrg ? { org: asOrg } : {}),
470
474
  ...(foundationDir ? { foundationDir } : {}),
471
475
  resolveModel: makeModelResolver({
472
476
  client,
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-26T01:46:58.853Z",
3
+ "generatedAt": "2026-08-27T16:19:55.310Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.26.1",
6
+ "version": "0.27.1",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -29,7 +29,7 @@
29
29
  "deps": []
30
30
  },
31
31
  "@uniweb/core": {
32
- "version": "0.12.1",
32
+ "version": "0.13.0",
33
33
  "path": "framework/core",
34
34
  "deps": [
35
35
  "@uniweb/semantic-parser",
@@ -42,14 +42,14 @@
42
42
  "deps": []
43
43
  },
44
44
  "@uniweb/icons": {
45
- "version": "0.4.3",
45
+ "version": "0.4.4",
46
46
  "path": "framework/icons",
47
47
  "deps": [
48
48
  "@uniweb/core"
49
49
  ]
50
50
  },
51
51
  "@uniweb/kit": {
52
- "version": "0.13.6",
52
+ "version": "0.13.7",
53
53
  "path": "framework/kit",
54
54
  "deps": [
55
55
  "@uniweb/core",
@@ -68,7 +68,7 @@
68
68
  "deps": []
69
69
  },
70
70
  "@uniweb/projections": {
71
- "version": "0.3.6",
71
+ "version": "0.3.7",
72
72
  "path": "framework/projections",
73
73
  "deps": [
74
74
  "@uniweb/content-writer",
@@ -76,7 +76,7 @@
76
76
  ]
77
77
  },
78
78
  "@uniweb/runtime": {
79
- "version": "0.12.13",
79
+ "version": "0.13.0",
80
80
  "path": "framework/runtime",
81
81
  "deps": [
82
82
  "@uniweb/core",
@@ -89,7 +89,7 @@
89
89
  "deps": []
90
90
  },
91
91
  "@uniweb/schemas": {
92
- "version": "0.2.10",
92
+ "version": "0.2.11",
93
93
  "path": "framework/schemas",
94
94
  "deps": []
95
95
  },
@@ -114,7 +114,7 @@
114
114
  "deps": []
115
115
  },
116
116
  "@uniweb/unipress": {
117
- "version": "0.8.13",
117
+ "version": "0.8.14",
118
118
  "path": "framework/unipress",
119
119
  "deps": [
120
120
  "@uniweb/build",
@@ -321,7 +321,9 @@ async function loginViaTokenPaste({ apiBase, nonInteractive }) {
321
321
  }
322
322
 
323
323
  // Open a URL in the default browser. Returns whether it launched.
324
- async function openBrowser(url) {
324
+ // Exported for the publish payment refusal, which opens the backend's
325
+ // settlement URL VERBATIM and needs no loopback (backend/payment-handoff.js).
326
+ export async function openBrowser(url) {
325
327
  try {
326
328
  const { exec } = await import('node:child_process')
327
329
  const cmd =