uniweb 0.31.0 → 0.32.0
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 +7 -7
- package/src/backend/client.js +0 -22
- package/src/backend/payment-handoff.js +136 -100
- package/src/backend/site-sync.js +50 -0
- package/src/commands/publish.js +34 -21
- package/src/commands/push.js +9 -0
- package/src/framework-index.json +4 -4
- package/src/utils/registry-auth.js +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,15 +41,15 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/kit": "^0.13.7",
|
|
45
44
|
"@uniweb/core": "^0.13.0",
|
|
46
|
-
"@uniweb/
|
|
47
|
-
"@uniweb/
|
|
45
|
+
"@uniweb/runtime": "^0.13.0",
|
|
46
|
+
"@uniweb/kit": "^0.13.7",
|
|
47
|
+
"@uniweb/semantic-parser": "^1.3.1"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
52
|
-
"@uniweb/
|
|
50
|
+
"@uniweb/build": "^0.28.0",
|
|
51
|
+
"@uniweb/content-reader": "^1.2.4",
|
|
52
|
+
"@uniweb/semantic-parser": "^1.3.1"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/src/backend/client.js
CHANGED
|
@@ -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
|
|
3
|
-
*
|
|
2
|
+
* Payment refusal — what `uniweb publish` does when the backend says a site
|
|
3
|
+
* cannot go live until it is paid for.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
+
* WHAT THE CLI KNOWS ABOUT PAYMENT: nothing. It opens whatever settlement URL
|
|
20
|
+
* the backend hands it, VERBATIM — provider-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
|
-
|
|
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
|
-
*
|
|
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 {
|
|
40
|
-
* @param {string
|
|
41
|
-
*
|
|
42
|
-
* @
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (!
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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 (
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
87
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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.
|
|
123
|
-
return {
|
|
158
|
+
say.dim('Once payment is complete, re-run `uniweb publish`.')
|
|
159
|
+
return { opened }
|
|
124
160
|
}
|
package/src/backend/site-sync.js
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
describeSiteDiff,
|
|
27
27
|
computeUnitHashes,
|
|
28
28
|
collectUnitUuids,
|
|
29
|
+
collectFolderItemUuids,
|
|
29
30
|
readAssetMap
|
|
30
31
|
} from '@uniweb/build/uwx'
|
|
31
32
|
|
|
@@ -457,6 +458,20 @@ export function mergeBaseVersions(siteDir, versions) {
|
|
|
457
458
|
export function readItemUuids(siteDir) {
|
|
458
459
|
return readMap(siteDir, 'itemUuids')
|
|
459
460
|
}
|
|
461
|
+
/**
|
|
462
|
+
* Placement identity for the site's `@uniweb/folder` — path chain → `$uuid`.
|
|
463
|
+
*
|
|
464
|
+
* Kept separate from `itemUuids` (which is site-content units) because they key
|
|
465
|
+
* different trees: `pages/…/page.yml` there, `members/alice` here. One map with
|
|
466
|
+
* two key languages is a map nobody can validate.
|
|
467
|
+
*/
|
|
468
|
+
export function readFolderItemUuids(siteDir) {
|
|
469
|
+
return readMap(siteDir, 'folderItemUuids')
|
|
470
|
+
}
|
|
471
|
+
export function writeFolderItemUuids(siteDir, map) {
|
|
472
|
+
if (!map || !Object.keys(map).length) return
|
|
473
|
+
updateSyncCache(siteDir, { folderItemUuids: map })
|
|
474
|
+
}
|
|
460
475
|
export function writeItemUuids(siteDir, map) {
|
|
461
476
|
if (!map || !Object.keys(map).length) return
|
|
462
477
|
updateSyncCache(siteDir, { itemUuids: map })
|
|
@@ -1297,6 +1312,21 @@ export async function pushSyncPackages({
|
|
|
1297
1312
|
for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
|
|
1298
1313
|
if (bf.updated.length)
|
|
1299
1314
|
wrote.push(`wrote ${bf.updated.length} record file(s)`)
|
|
1315
|
+
// ⭐ BANK THE FOLDER'S PLACEMENT IDENTITY. The records back-fill their own
|
|
1316
|
+
// `$uuid` into their source files (above); the folder's ITEMS have nowhere to
|
|
1317
|
+
// be written, so they are banked here from the document the backend just
|
|
1318
|
+
// returned.
|
|
1319
|
+
//
|
|
1320
|
+
// ⛔ Without this a `publish` after a `push` is refused: send-only-changed
|
|
1321
|
+
// skips the unchanged records and re-sends the folder ALONE, its `contents`
|
|
1322
|
+
// items carry no `$uuid`, and a `multi` item without one reads as new — so
|
|
1323
|
+
// the backend refuses rather than replacing every placement. Reproduced on a
|
|
1324
|
+
// live manor 2026-08-27; the identities were on the wire all along.
|
|
1325
|
+
const folderDoc = finalized.find((f) => f?.document?.contents)?.document
|
|
1326
|
+
if (folderDoc) {
|
|
1327
|
+
const placements = collectFolderItemUuids(folderDoc)
|
|
1328
|
+
if (Object.keys(placements).length) writeFolderItemUuids(siteDir, placements)
|
|
1329
|
+
}
|
|
1300
1330
|
finalizedTotal += finalized.length
|
|
1301
1331
|
}
|
|
1302
1332
|
|
|
@@ -1323,6 +1353,26 @@ export async function pushSyncPackages({
|
|
|
1323
1353
|
// Replaced wholesale: an item that no longer exists must not keep a uuid that
|
|
1324
1354
|
// would re-target something else.
|
|
1325
1355
|
if (theirs) writeItemUuids(siteDir, collectUnitUuids(theirs))
|
|
1356
|
+
else {
|
|
1357
|
+
// ⛔ BANKING IS BEST-EFFORT AND ITS FAILURE USED TO BE SILENT — say it here,
|
|
1358
|
+
// because the cost lands two commands away and names something else.
|
|
1359
|
+
//
|
|
1360
|
+
// Without `finalized[0].document` (carrying `pages`) this push banks NO item
|
|
1361
|
+
// identity. The push still reports success. The next `push`/`publish` then
|
|
1362
|
+
// emits with no `$uuid` per item, and the backend refuses — correctly, since
|
|
1363
|
+
// silently re-identifying every stored row is far worse. But that refusal
|
|
1364
|
+
// reads as a stale-token or a producer bug, with nothing pointing back at the
|
|
1365
|
+
// push that failed to bank.
|
|
1366
|
+
//
|
|
1367
|
+
// ⚠️ We cannot tell WHY it is absent from here — a response shape that
|
|
1368
|
+
// changed, a lane that shipped nothing, a backend that does not echo the
|
|
1369
|
+
// document. Report the observable fact and let the operator carry it.
|
|
1370
|
+
note(
|
|
1371
|
+
'identity not banked: this push returned no post-write document, so no per-item ' +
|
|
1372
|
+
'$uuid was stored. The next push or publish will be identity-blind and the backend ' +
|
|
1373
|
+
'may refuse it. `uniweb pull` re-arms identity if that happens.'
|
|
1374
|
+
)
|
|
1375
|
+
}
|
|
1326
1376
|
}
|
|
1327
1377
|
return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
|
|
1328
1378
|
}
|
package/src/commands/publish.js
CHANGED
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
readBaseVersions,
|
|
78
78
|
readItemBaseVersions,
|
|
79
79
|
ensureItemUuids,
|
|
80
|
+
readFolderItemUuids,
|
|
80
81
|
ensureSiteExists,
|
|
81
82
|
clearRemoteSyncStateIfUnbound,
|
|
82
83
|
pushSyncPackages,
|
|
@@ -88,7 +89,10 @@ import {
|
|
|
88
89
|
bringFoundationAlong,
|
|
89
90
|
bringExtensionsAlong
|
|
90
91
|
} from '../backend/foundation-bring-along.js'
|
|
91
|
-
import {
|
|
92
|
+
import {
|
|
93
|
+
readPaymentRefusal,
|
|
94
|
+
reportPaymentRefusal
|
|
95
|
+
} from '../backend/payment-handoff.js'
|
|
92
96
|
import { reportSchemalessCollections } from '../utils/schemaless-report.js'
|
|
93
97
|
import { uploadSiteData } from '../utils/site-data-upload.js'
|
|
94
98
|
|
|
@@ -353,13 +357,10 @@ export async function publish(args = []) {
|
|
|
353
357
|
cliBin: process.argv[1],
|
|
354
358
|
dryRun: true
|
|
355
359
|
})
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
say,
|
|
361
|
-
dryRun: true
|
|
362
|
-
})
|
|
360
|
+
// No payment line on a dry run. The backend is the only gate and it
|
|
361
|
+
// answers at go-live, so the honest dry-run answer is silence rather than
|
|
362
|
+
// a guess. (The old "would check whether go-live needs payment" described
|
|
363
|
+
// a pre-flight probe that has been removed — payment-handoff.js.)
|
|
363
364
|
return { exitCode: 0 }
|
|
364
365
|
}
|
|
365
366
|
|
|
@@ -440,6 +441,10 @@ export async function publish(args = []) {
|
|
|
440
441
|
let probe
|
|
441
442
|
try {
|
|
442
443
|
probe = await emitSyncPackages(siteDir, {
|
|
444
|
+
// Placement identity for the folder — see writeFolderItemUuids.
|
|
445
|
+
folderItemUuids: readFolderItemUuids(siteDir),
|
|
446
|
+
// Resolves a foundation-relative `@/x` model ref into `@org/x`.
|
|
447
|
+
...(asOrg ? { org: asOrg } : {}),
|
|
443
448
|
...(foundationDir ? { foundationDir } : {}),
|
|
444
449
|
resolveModel
|
|
445
450
|
})
|
|
@@ -639,6 +644,10 @@ export async function publish(args = []) {
|
|
|
639
644
|
let pkg
|
|
640
645
|
try {
|
|
641
646
|
pkg = await emitSyncPackages(siteDir, {
|
|
647
|
+
// Placement identity for the folder — see writeFolderItemUuids.
|
|
648
|
+
folderItemUuids: readFolderItemUuids(siteDir),
|
|
649
|
+
// Resolves a foundation-relative `@/x` model ref into `@org/x`.
|
|
650
|
+
...(asOrg ? { org: asOrg } : {}),
|
|
642
651
|
...(foundationDir ? { foundationDir } : {}),
|
|
643
652
|
resolveModel,
|
|
644
653
|
priorHashes,
|
|
@@ -678,18 +687,6 @@ export async function publish(args = []) {
|
|
|
678
687
|
return { exitCode: 1 }
|
|
679
688
|
}
|
|
680
689
|
|
|
681
|
-
// 6. Payment gate — the backend says whether go-live needs payment. Settles
|
|
682
|
-
// via a browser handoff to uniweb.app; degrades to "proceed" when the
|
|
683
|
-
// backend exposes no payment route. The draft is already synced, so a
|
|
684
|
-
// decline leaves a recoverable state (re-run after paying).
|
|
685
|
-
const pay = await settlePaymentIfNeeded({ client, uuid: siteUuid, args, say })
|
|
686
|
-
if (!pay.proceed) {
|
|
687
|
-
say.info(
|
|
688
|
-
'Site synced as a draft but not made live. Re-run `uniweb publish` once payment is complete.'
|
|
689
|
-
)
|
|
690
|
-
return { exitCode: 0 }
|
|
691
|
-
}
|
|
692
|
-
|
|
693
690
|
// 7. Go live — make the just-pushed composite live (its current backend state).
|
|
694
691
|
const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
|
|
695
692
|
const languages = languagesFromContent(siteContent)
|
|
@@ -705,13 +702,29 @@ export async function publish(args = []) {
|
|
|
705
702
|
return { exitCode: 1 }
|
|
706
703
|
}
|
|
707
704
|
if (!pubRes.ok) {
|
|
705
|
+
const body = await pubRes.text().catch(() => '')
|
|
706
|
+
|
|
707
|
+
// A 402 is the backend's payment gate — the ONLY gate, evaluated here on
|
|
708
|
+
// every publish against whatever posture that deployment runs. It is a
|
|
709
|
+
// refusal, not a fault: the content is already synced as a draft, so the
|
|
710
|
+
// recovery is to settle and re-run. Give it the backend's own sentence
|
|
711
|
+
// rather than the raw envelope.
|
|
712
|
+
const refusal = readPaymentRefusal({
|
|
713
|
+
status: pubRes.status,
|
|
714
|
+
contentType: pubRes.headers?.get?.('content-type') || '',
|
|
715
|
+
body
|
|
716
|
+
})
|
|
717
|
+
if (refusal.kind !== 'not-payment') {
|
|
718
|
+
await reportPaymentRefusal({ verdict: refusal, args, say })
|
|
719
|
+
return { exitCode: 1 }
|
|
720
|
+
}
|
|
721
|
+
|
|
708
722
|
say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
|
|
709
723
|
if (pubRes.status === 401 || pubRes.status === 403) {
|
|
710
724
|
say.dim(
|
|
711
725
|
"Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>)."
|
|
712
726
|
)
|
|
713
727
|
}
|
|
714
|
-
const body = await pubRes.text().catch(() => '')
|
|
715
728
|
if (body) say.dim(body.slice(0, 800))
|
|
716
729
|
return { exitCode: 1 }
|
|
717
730
|
}
|
package/src/commands/push.js
CHANGED
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
readBaseVersions,
|
|
85
85
|
readItemBaseVersions,
|
|
86
86
|
readItemUuids,
|
|
87
|
+
readFolderItemUuids,
|
|
87
88
|
ensureItemUuids,
|
|
88
89
|
ensureSiteExists,
|
|
89
90
|
clearRemoteSyncStateIfUnbound,
|
|
@@ -373,6 +374,10 @@ export async function push(args = [], deps = {}) {
|
|
|
373
374
|
let mediaRefs = []
|
|
374
375
|
try {
|
|
375
376
|
const probe = await emitSyncPackages(siteDir, {
|
|
377
|
+
// Placement identity for the folder — see writeFolderItemUuids.
|
|
378
|
+
folderItemUuids: readFolderItemUuids(siteDir),
|
|
379
|
+
// Resolves a foundation-relative `@/x` model ref into `@org/x`.
|
|
380
|
+
...(asOrg ? { org: asOrg } : {}),
|
|
376
381
|
...(foundationDir ? { foundationDir } : {}),
|
|
377
382
|
resolveModel: makeModelResolver({ client, offline: false })
|
|
378
383
|
})
|
|
@@ -467,6 +472,10 @@ export async function push(args = [], deps = {}) {
|
|
|
467
472
|
let pkg
|
|
468
473
|
try {
|
|
469
474
|
pkg = await emitSyncPackages(siteDir, {
|
|
475
|
+
// Placement identity for the folder — see writeFolderItemUuids.
|
|
476
|
+
folderItemUuids: readFolderItemUuids(siteDir),
|
|
477
|
+
// Resolves a foundation-relative `@/x` model ref into `@org/x`.
|
|
478
|
+
...(asOrg ? { org: asOrg } : {}),
|
|
470
479
|
...(foundationDir ? { foundationDir } : {}),
|
|
471
480
|
resolveModel: makeModelResolver({
|
|
472
481
|
client,
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-27T21:10:42.589Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.28.0",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"deps": []
|
|
69
69
|
},
|
|
70
70
|
"@uniweb/projections": {
|
|
71
|
-
"version": "0.
|
|
71
|
+
"version": "0.4.1",
|
|
72
72
|
"path": "framework/projections",
|
|
73
73
|
"deps": [
|
|
74
74
|
"@uniweb/content-writer",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"deps": []
|
|
115
115
|
},
|
|
116
116
|
"@uniweb/unipress": {
|
|
117
|
-
"version": "0.8.
|
|
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
|
-
|
|
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 =
|