uniweb 0.47.1 → 0.47.2
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 +5 -5
- package/partials/agents.md +23 -0
- package/src/backend/payment-handoff.js +59 -15
- package/src/backend/service-request.js +217 -0
- package/src/commands/publish.js +265 -1
- package/src/framework-index.json +6 -6
- package/src/utils/registry-auth.js +26 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.47.
|
|
3
|
+
"version": "0.47.2",
|
|
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/core": "^0.24.1",
|
|
45
44
|
"@uniweb/kit": "^0.18.0",
|
|
45
|
+
"@uniweb/core": "^0.24.2",
|
|
46
46
|
"@uniweb/semantic-parser": "^1.4.0",
|
|
47
|
-
"@uniweb/runtime": "^0.19.
|
|
47
|
+
"@uniweb/runtime": "^0.19.3"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "^0.43.0",
|
|
51
50
|
"@uniweb/content-reader": "^1.2.4",
|
|
52
|
-
"@uniweb/semantic-parser": "^1.4.0"
|
|
51
|
+
"@uniweb/semantic-parser": "^1.4.0",
|
|
52
|
+
"@uniweb/build": "^0.43.1"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -458,6 +458,29 @@ Read the markdown out loud. If an author would understand what every line does,
|
|
|
458
458
|
|
|
459
459
|
**Parameter naming matters.** Would an author understand it without reading code? `columns: 3` yes, `gridCols: 3` no. `variant: centered` yes, `renderMode: flex-center` no. `align: left` yes, `contentAlignment: flex-start` no.
|
|
460
460
|
|
|
461
|
+
### Repeated values — `placeholders:`
|
|
462
|
+
|
|
463
|
+
A value that appears on many pages goes in `site.yml` once, not in a dozen markdown files:
|
|
464
|
+
|
|
465
|
+
```yaml
|
|
466
|
+
placeholders:
|
|
467
|
+
product: Uniweb
|
|
468
|
+
vendor:
|
|
469
|
+
email: billing@acme.example
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Any page then references it by name — `Write to {vendor.email}.` Dot paths work, and a record's own field of the same name always wins, so `{title}` on an article page is that article's title regardless of what the site declares.
|
|
473
|
+
|
|
474
|
+
⛔ **Resolution is a foundation capability, not a framework one.** `{…}` is resolved by the foundation's `handlers.content` hook — normally `createLoomHandlers` from `@uniweb/loom`. Under a foundation without one, `placeholders:` is inert and pages render the literal `{vendor.email}`, which reads as a typo. The build warns when a site declares the key and the foundation has no content handler.
|
|
475
|
+
|
|
476
|
+
```js
|
|
477
|
+
// main.js — a foundation opts in
|
|
478
|
+
import { createLoomHandlers } from '@uniweb/loom'
|
|
479
|
+
export default { handlers: createLoomHandlers({ vars: (data) => data?.profile?.[0] }) }
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Placeholders are the simplest case of a larger language: the same expressions filter, sort, count and format live data (`{COUNT OF publications WHERE refereed}`). Reach for one when a value repeats; reach for the rest when content is genuinely derived from records.
|
|
483
|
+
|
|
461
484
|
### Icons
|
|
462
485
|
|
|
463
486
|
Image syntax with a library prefix — **two interchangeable spellings, the same everywhere** (markdown, and Kit's `<Icon name>`):
|
|
@@ -29,8 +29,24 @@
|
|
|
29
29
|
* timeout and then reported "payment was not completed", which was false.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* ⛔ THE DOOR IS THE URL, NOT THE REASON — and this replaced a reason allowlist.
|
|
34
|
+
*
|
|
35
|
+
* The rule the allowlist served is right and survives: **never route to a purchase
|
|
36
|
+
* surface from the ABSENCE of a recognised token.** But the hazard it guarded was
|
|
37
|
+
* INFERRING a door from silence, and when the backend NAMES a URL there is no
|
|
38
|
+
* inference left — an explicit remedy is the affirmative signal the allowlist was
|
|
39
|
+
* standing in for.
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ Keeping it had turned into the opposite failure. The reason set is open by
|
|
42
|
+
* design ("includes"), so every new one — `pending_request` today — fell through to
|
|
43
|
+
* `stop` and the owner was shown a sentence **with no link**, for a door the
|
|
44
|
+
* backend had handed us. A stale allowlist now fails by WITHHOLDING help.
|
|
45
|
+
*
|
|
46
|
+
* ⇒ What remains is a shape rule, not a vocabulary one: open exactly what you were
|
|
47
|
+
* handed, when it is an absolute http(s) URL, or say exactly what you were told.
|
|
48
|
+
*/
|
|
49
|
+
const OPENABLE = /^https?:\/\//i
|
|
34
50
|
|
|
35
51
|
/**
|
|
36
52
|
* Read a `402` from the publish call and decide what the CLI does. Pure — no
|
|
@@ -93,21 +109,46 @@ export function readPaymentRefusal({ status, contentType = '', body = '' } = {})
|
|
|
93
109
|
? problem.reason
|
|
94
110
|
: null
|
|
95
111
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
112
|
+
// ⛔ ONLY A `problem+json` BODY CARRIES A DOOR, and this gate is NOT the reason
|
|
113
|
+
// allowlist that used to sit here — it is the other, narrower protection that
|
|
114
|
+
// was tangled up with it.
|
|
115
|
+
//
|
|
116
|
+
// The backend's refusals wear `application/problem+json` and say so as a stable
|
|
117
|
+
// guarantee. A plain `application/json` 402 is some other shape from some other
|
|
118
|
+
// part of the wire — and one of those carries `status` as a STRING where a
|
|
119
|
+
// problem body carries the number, so the two are not distinguishable by their
|
|
120
|
+
// fields. Reading a URL out of a body that never promised this envelope is how
|
|
121
|
+
// a `settle` gets synthesised from something that is not a refusal at all.
|
|
122
|
+
if (!isProblem) return { kind: 'stop', reason, message }
|
|
123
|
+
|
|
124
|
+
// The door, if one was handed over. `remedy_url` is the current spelling — ONE
|
|
125
|
+
// key for every reason, which is what lets a reason we have never heard of still
|
|
126
|
+
// reach its remedy.
|
|
127
|
+
//
|
|
128
|
+
// ⚖️ `settlement.url` is the older shape and is still read. Not an alias we
|
|
129
|
+
// maintain: it is what a backend that has not moved yet still serves, and
|
|
130
|
+
// dropping the read would take away a door those deployments have today. New
|
|
131
|
+
// code never emits it and nothing here prefers it.
|
|
132
|
+
const legacy = problem.settlement
|
|
133
|
+
const candidate =
|
|
134
|
+
(typeof problem.remedy_url === 'string' && problem.remedy_url) ||
|
|
135
|
+
(legacy && typeof legacy.url === 'string' && legacy.url) ||
|
|
136
|
+
null
|
|
99
137
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
const url =
|
|
138
|
+
// ⛔ ONLY AN ABSOLUTE http(s) URL IS OPENED, and this is a safety rule rather
|
|
139
|
+
// than a compatibility one. The value arrives over the network and is handed to
|
|
140
|
+
// the platform's URL opener; a `file:` or a `javascript:` URL is not a place a
|
|
141
|
+
// person goes. Refusing them costs a legitimate backend nothing.
|
|
142
|
+
const url = candidate && OPENABLE.test(candidate) ? candidate : null
|
|
105
143
|
if (!url) return { kind: 'stop', reason, message }
|
|
106
144
|
|
|
107
145
|
return {
|
|
108
146
|
kind: 'settle',
|
|
109
147
|
url,
|
|
110
|
-
handle:
|
|
148
|
+
handle:
|
|
149
|
+
legacy && typeof legacy.handle === 'string' && legacy.handle
|
|
150
|
+
? legacy.handle
|
|
151
|
+
: null,
|
|
111
152
|
reason,
|
|
112
153
|
message
|
|
113
154
|
}
|
|
@@ -132,7 +173,7 @@ export async function reportPaymentRefusal({ verdict, args = [], say, open }) {
|
|
|
132
173
|
// The backend's own sentence is the HEADLINE when there is one. A generic
|
|
133
174
|
// lead would be wrong as often as right — "payment is required" does not
|
|
134
175
|
// describe a declined card — and `detail` is written for this reader.
|
|
135
|
-
say.err(verdict.message || 'This site cannot go live
|
|
176
|
+
say.err(verdict.message || 'This site cannot go live yet.')
|
|
136
177
|
|
|
137
178
|
if (verdict.kind !== 'settle') {
|
|
138
179
|
// The push completed before go-live, so the content is safely stored.
|
|
@@ -142,19 +183,22 @@ export async function reportPaymentRefusal({ verdict, args = [], say, open }) {
|
|
|
142
183
|
|
|
143
184
|
const { isNonInteractive } = await import('../utils/interactive.js')
|
|
144
185
|
if (isNonInteractive(args)) {
|
|
145
|
-
say.dim(`
|
|
186
|
+
say.dim(`Finish this in a browser, then re-run \`uniweb publish\`:`)
|
|
146
187
|
say.dim(` ${verdict.url}`)
|
|
147
188
|
return { opened: false }
|
|
148
189
|
}
|
|
149
190
|
|
|
150
191
|
const openBrowser = open || (await import('../utils/registry-auth.js')).openBrowser
|
|
151
|
-
|
|
192
|
+
// ⛔ Reason-agnostic wording. The backend's own `detail` above carries the
|
|
193
|
+
// specifics; a lead sentence naming payment would be wrong the moment a refusal
|
|
194
|
+
// is a quota or an unverified domain — and the reason set is open by design.
|
|
195
|
+
say.info('Opening your browser to finish this…')
|
|
152
196
|
say.dim(` ${verdict.url}`)
|
|
153
197
|
// VERBATIM. Nothing is appended — see the header.
|
|
154
198
|
const opened = await openBrowser(verdict.url)
|
|
155
199
|
if (!opened) {
|
|
156
200
|
say.warn('Could not open a browser automatically — open the URL above.')
|
|
157
201
|
}
|
|
158
|
-
say.dim('Once
|
|
202
|
+
say.dim('Once that is done, re-run `uniweb publish`.')
|
|
159
203
|
return { opened }
|
|
160
204
|
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The services request — is the file ASKING for something, or just carrying an
|
|
3
|
+
* old answer?
|
|
4
|
+
*
|
|
5
|
+
* ## The defect this exists to close
|
|
6
|
+
*
|
|
7
|
+
* `$services` / `$secrets` are Sections of the site-content document, so they ride
|
|
8
|
+
* inside **every** push — the block is emitted whenever the key exists in
|
|
9
|
+
* `site.yml`. Editing one paragraph on one page therefore re-sends the whole
|
|
10
|
+
* request block.
|
|
11
|
+
*
|
|
12
|
+
* ⛔ And the backend REPLACES what it is sent: a row anchors by its natural key and
|
|
13
|
+
* is updated in place (`SectionScope::DeclaredOnly`; backend's
|
|
14
|
+
* `uuidless_records_anchor_by_natural_key_and_keep_the_stored_uuid`, written
|
|
15
|
+
* against this emitter's shape). So a re-send is not a harmless echo — it
|
|
16
|
+
* **overwrites the stored request**, including a decision the owner made in the
|
|
17
|
+
* app, which is where the consent workflow's publish happens.
|
|
18
|
+
*
|
|
19
|
+
* ⭐ Under the model the file follows — *"the services in `site.yml` are a request,
|
|
20
|
+
* never a tracking of what is running"* [Diego, 2026-09-05] — **re-sending an
|
|
21
|
+
* unchanged block is making a request nobody made.** You ask by CHANGING the file.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ It was mostly inert until 2026-09-08: `enabled` was honoured for `api` alone,
|
|
24
|
+
* so a stale re-send of the other names overwrote rows nothing read. That stopped
|
|
25
|
+
* being true the same day, and `api` was never inert — an `enabled: false` on it
|
|
26
|
+
* with a live plan schedules a paid service to end.
|
|
27
|
+
*
|
|
28
|
+
* ## What "changed" is measured against
|
|
29
|
+
*
|
|
30
|
+
* The last block we are known to have sent, recorded in **`deploy.yml`** — a
|
|
31
|
+
* COMMITTED project file that travels with a clone.
|
|
32
|
+
*
|
|
33
|
+
* ⛔ Deliberately NOT `.uniweb/sync-cache.json`, which is the obvious place and the
|
|
34
|
+
* wrong one: it is gitignored, per-clone and deletable, so a teammate's fresh clone
|
|
35
|
+
* has no base at all — and this workflow is multi-machine by construction (consent
|
|
36
|
+
* in a browser, publish from the app). A base that vanishes turns this gate into
|
|
37
|
+
* either the original defect or a silently dropped request.
|
|
38
|
+
*
|
|
39
|
+
* ## ⛔ A HASH, never the block
|
|
40
|
+
*
|
|
41
|
+
* `$secrets` carries secret material, and `$services[].config` is opaque and
|
|
42
|
+
* per-service — anything may be in it. `deploy.yml` is committed, so recording
|
|
43
|
+
* either verbatim would write them into git. Equality is all this gate needs;
|
|
44
|
+
* *what* differs is a question for the backend's own copy of the request, not for
|
|
45
|
+
* a mirror of our own.
|
|
46
|
+
*
|
|
47
|
+
* @module
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { createHash } from 'node:crypto'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A stable fingerprint of one declared block, or `null` when the key is absent.
|
|
54
|
+
*
|
|
55
|
+
* ⭐ `null` (absent) and the hash of `[]` are DIFFERENT, and must stay so: absent
|
|
56
|
+
* means *"I am not telling you about this"* and `[]` is an explicit clear. This
|
|
57
|
+
* function only has to preserve that distinction — the destructive difference
|
|
58
|
+
* between them is the backend's.
|
|
59
|
+
*
|
|
60
|
+
* Rows are ordered by their serialized form and object keys sorted, so reordering
|
|
61
|
+
* rows or keys in the file is not a change. It is not a request to move a line.
|
|
62
|
+
*
|
|
63
|
+
* @param {*} declared - the raw `site.yml::$services` / `$secrets` value
|
|
64
|
+
* @returns {string|null} 16 hex chars, or null when undeclared
|
|
65
|
+
*/
|
|
66
|
+
export function fingerprintDeclaration(declared) {
|
|
67
|
+
if (declared === undefined || declared === null) return null
|
|
68
|
+
const canonical = Array.isArray(declared)
|
|
69
|
+
? declared.map(stableString).sort()
|
|
70
|
+
: [stableString(declared)]
|
|
71
|
+
return createHash('sha256')
|
|
72
|
+
.update(JSON.stringify(canonical))
|
|
73
|
+
.digest('hex')
|
|
74
|
+
.slice(0, 16)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Deterministic JSON: object keys sorted at every depth, arrays left in order. */
|
|
78
|
+
function stableString(value) {
|
|
79
|
+
return JSON.stringify(value, (_k, v) =>
|
|
80
|
+
v && typeof v === 'object' && !Array.isArray(v)
|
|
81
|
+
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]]))
|
|
82
|
+
: v
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Both blocks' fingerprints, in the shape `deploy.yml::lastDeploy.<target>` keeps
|
|
88
|
+
* them. Keys are omitted rather than set to null, so an undeclared block leaves no
|
|
89
|
+
* trace in the file.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} siteYml
|
|
92
|
+
* @returns {{servicesRequest?: string, secretsRequest?: string}}
|
|
93
|
+
*/
|
|
94
|
+
export function fingerprintRequest(siteYml) {
|
|
95
|
+
const out = {}
|
|
96
|
+
const services = fingerprintDeclaration(siteYml?.$services)
|
|
97
|
+
if (services) out.servicesRequest = services
|
|
98
|
+
const secrets = fingerprintDeclaration(siteYml?.$secrets)
|
|
99
|
+
if (secrets) out.secretsRequest = secrets
|
|
100
|
+
// ⭐ The language selection is a request too, and it is the one that moves a
|
|
101
|
+
// PRICE — the line is billed on how many languages go out. Banking it is what
|
|
102
|
+
// lets a later edit read as "the owner asked for another language" rather than
|
|
103
|
+
// as a value that was always there.
|
|
104
|
+
const langs = fingerprintDeclaration(siteYml?.publishLanguages)
|
|
105
|
+
if (langs) out.publishLanguagesRequest = langs
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Should this push DECLARE the request blocks?
|
|
111
|
+
*
|
|
112
|
+
* ⛔ **Absence of a record means YES.** Three reasons, and the third is the one
|
|
113
|
+
* that decides it:
|
|
114
|
+
*
|
|
115
|
+
* 1. It is the behaviour every CLI has had, so nothing regresses.
|
|
116
|
+
* 2. A project may legitimately have no `deploy.yml` (never published, or
|
|
117
|
+
* `autoSave: off`), and that is not evidence about the request.
|
|
118
|
+
* 3. ⭐ Failing the other way DROPS A REAL REQUEST IN SILENCE. Between the two
|
|
119
|
+
* failure directions, sending an unchanged block writes back what is usually
|
|
120
|
+
* already there, while withholding a changed one leaves an owner's edit with
|
|
121
|
+
* no effect and nothing said. The loud failure is the better one.
|
|
122
|
+
*
|
|
123
|
+
* @param {object} siteYml - the parsed site.yml
|
|
124
|
+
* @param {object|null} lastDeploy - `deploy.yml::lastDeploy.<target>`, or null
|
|
125
|
+
* @returns {{declare: boolean, reason: 'no-record'|'changed'|'unchanged'|'undeclared'}}
|
|
126
|
+
*/
|
|
127
|
+
export function decideDeclaration(siteYml, lastDeploy) {
|
|
128
|
+
const now = fingerprintRequest(siteYml)
|
|
129
|
+
if (!now.servicesRequest && !now.secretsRequest) {
|
|
130
|
+
// Nothing in the file to send. The gate is moot; say so rather than
|
|
131
|
+
// reporting "unchanged", which would imply a comparison happened.
|
|
132
|
+
return { declare: true, reason: 'undeclared' }
|
|
133
|
+
}
|
|
134
|
+
if (!lastDeploy || typeof lastDeploy !== 'object') {
|
|
135
|
+
return { declare: true, reason: 'no-record' }
|
|
136
|
+
}
|
|
137
|
+
const same =
|
|
138
|
+
now.servicesRequest === (lastDeploy.servicesRequest || undefined) &&
|
|
139
|
+
now.secretsRequest === (lastDeploy.secretsRequest || undefined)
|
|
140
|
+
return same
|
|
141
|
+
? { declare: false, reason: 'unchanged' }
|
|
142
|
+
: { declare: true, reason: 'changed' }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The four-way reconcile, once the backend's own copy of the request is in hand.
|
|
147
|
+
*
|
|
148
|
+
* ⭐ THIS SUPERSEDES `decideDeclaration` WHERE THE STATUS READ SUCCEEDS. That one
|
|
149
|
+
* compares the file to our MEMORY of what we last sent, which leaves a window: a
|
|
150
|
+
* request changed in the app between a `uniweb pull` and the next publish reads as
|
|
151
|
+
* unchanged-from-nothing. Comparing to the backend's own rows closes it, because
|
|
152
|
+
* the base stops being something we have to remember correctly.
|
|
153
|
+
*
|
|
154
|
+
* ⛔ It still needs the banked fingerprint. Local and remote differing says the two
|
|
155
|
+
* disagree; it does not say WHO MOVED. Only the last agreed state does, and that is
|
|
156
|
+
* the difference between "the app decided, adopt it" and "you edited, send it".
|
|
157
|
+
*
|
|
158
|
+
* ## The four outcomes
|
|
159
|
+
*
|
|
160
|
+
* | local | remote | |
|
|
161
|
+
* |---|---|---|
|
|
162
|
+
* | unchanged | unchanged | `none` — nobody asked anything |
|
|
163
|
+
* | unchanged | **moved** | `adopt` — the app decided; the file is merely behind |
|
|
164
|
+
* | **edited** | unchanged | `send` — a real request |
|
|
165
|
+
* | **edited** | **moved** | `conflict` — ⛔ two intents, and only the owner ranks them |
|
|
166
|
+
*
|
|
167
|
+
* ⛔ `conflict` never guesses and never sends. A last-write-wins on a field that
|
|
168
|
+
* schedules a paid service to end is not a tie-break, it is a coin toss with the
|
|
169
|
+
* owner's money.
|
|
170
|
+
*
|
|
171
|
+
* ⚖️ No base ⇒ we cannot tell `adopt` from `conflict`, so we fall back to the
|
|
172
|
+
* conservative reading of a difference: if the two differ, `conflict`; if they
|
|
173
|
+
* agree, `none`. That withholds rather than sends, which is safe HERE — unlike
|
|
174
|
+
* `decideDeclaration`, nothing is silently dropped, because a conflict is reported.
|
|
175
|
+
*
|
|
176
|
+
* @param {object} siteYml
|
|
177
|
+
* @param {*} remoteServices - the status read's `services` rows, or undefined
|
|
178
|
+
* @param {object|null} lastDeploy - the banked base
|
|
179
|
+
* @returns {{action:'none'|'adopt'|'send'|'conflict', local:string|null, remote:string|null}}
|
|
180
|
+
*/
|
|
181
|
+
export function reconcileRequest(siteYml, remoteServices, lastDeploy) {
|
|
182
|
+
return reconcile(siteYml?.$services, remoteServices, lastDeploy?.servicesRequest)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The same reconcile over any two-way request field.
|
|
187
|
+
*
|
|
188
|
+
* ⭐ `$services` was the first, not the only one. `site.yml::publishLanguages` is
|
|
189
|
+
* the same shape — the owner's ASK, pushed up, projected back on pull, and stored
|
|
190
|
+
* on the other side where something else may move it.
|
|
191
|
+
*
|
|
192
|
+
* ⛔ AND A BASE IS NEEDED EVEN WHERE NOTHING ELSE WRITES THE FIELD. That was the
|
|
193
|
+
* reasoning that nearly left languages out: "nobody overwrites it, so there is no
|
|
194
|
+
* hazard." Overwriting is not the only thing a base is for — without one, a value
|
|
195
|
+
* that has always been in the file is indistinguishable from one the owner just
|
|
196
|
+
* typed, so the CLI cannot tell an intentional change from the status quo. For
|
|
197
|
+
* languages that difference is money: the count is priced, so a new language is a
|
|
198
|
+
* charge, and saying so before sending requires knowing it is new.
|
|
199
|
+
*
|
|
200
|
+
* @param {*} localValue - the file's declaration
|
|
201
|
+
* @param {*} remoteValue - what the site has stored
|
|
202
|
+
* @param {string|null} baseFingerprint - what we last agreed on
|
|
203
|
+
*/
|
|
204
|
+
export function reconcile(localValue, remoteValue, baseFingerprint) {
|
|
205
|
+
const local = fingerprintDeclaration(localValue)
|
|
206
|
+
const remote = fingerprintDeclaration(remoteValue)
|
|
207
|
+
const base = baseFingerprint || null
|
|
208
|
+
|
|
209
|
+
if (local === remote) return { action: 'none', local, remote }
|
|
210
|
+
if (!base) return { action: 'conflict', local, remote }
|
|
211
|
+
|
|
212
|
+
const localMoved = local !== base
|
|
213
|
+
const remoteMoved = remote !== base
|
|
214
|
+
if (!localMoved && remoteMoved) return { action: 'adopt', local, remote }
|
|
215
|
+
if (localMoved && !remoteMoved) return { action: 'send', local, remote }
|
|
216
|
+
return { action: 'conflict', local, remote }
|
|
217
|
+
}
|
package/src/commands/publish.js
CHANGED
|
@@ -57,6 +57,12 @@ import {
|
|
|
57
57
|
rewriteSchemalessDataAssets
|
|
58
58
|
} from '@uniweb/build/site'
|
|
59
59
|
import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
60
|
+
import {
|
|
61
|
+
decideDeclaration,
|
|
62
|
+
fingerprintRequest,
|
|
63
|
+
reconcile,
|
|
64
|
+
reconcileRequest
|
|
65
|
+
} from '../backend/service-request.js'
|
|
60
66
|
import { isSiteRelativeExtensionUrl } from '@uniweb/build'
|
|
61
67
|
import { resolveDefaultLocale } from '@uniweb/core/locale-config'
|
|
62
68
|
|
|
@@ -172,6 +178,69 @@ function languagesFromSiteYml(siteYml) {
|
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
// Persist deploy.yml lastDeploy memory (skipped on --no-save / autoSave 'off').
|
|
181
|
+
/**
|
|
182
|
+
* A one-line, human-readable account of a service request, for a terminal.
|
|
183
|
+
*
|
|
184
|
+
* ⛔ Deliberately lossy — it names what is on and what is off, not a service's
|
|
185
|
+
* opaque `config`. The owner is being told WHICH decision differs so they can go
|
|
186
|
+
* look; reproducing a per-service config blob in a warning would bury that.
|
|
187
|
+
* "nothing" is a real answer and reads better than an empty string.
|
|
188
|
+
*/
|
|
189
|
+
/**
|
|
190
|
+
* A language selection, for a terminal.
|
|
191
|
+
*
|
|
192
|
+
* ⛔ An ABSENT selection is not an empty one, and the words have to keep them
|
|
193
|
+
* apart: no `publishLanguages` key means every declared language is publishable,
|
|
194
|
+
* while `[]` means explicitly none. "all of them" and "none" are opposite answers
|
|
195
|
+
* and a bare empty string would read as either.
|
|
196
|
+
*/
|
|
197
|
+
function describeLanguages(value) {
|
|
198
|
+
if (value === undefined || value === null) return 'all of them'
|
|
199
|
+
if (!Array.isArray(value) || value.length === 0) return 'none'
|
|
200
|
+
return value.join(', ')
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Languages the site was asked to publish that it did not publish.
|
|
205
|
+
*
|
|
206
|
+
* ⭐ THIS IS NOT COMPUTABLE FROM `site.yml`. The served set is decided where the
|
|
207
|
+
* publish happens — a code naming no declared language is ignored rather than
|
|
208
|
+
* refused, and the site's own declared set is whatever its last push left there,
|
|
209
|
+
* which is not necessarily this file. So the only honest source is what the publish
|
|
210
|
+
* reported back, and until now the CLI wrote that into `deploy.yml` and never
|
|
211
|
+
* looked at it.
|
|
212
|
+
*
|
|
213
|
+
* ⛔ The failure it makes visible is the quiet one: an author believes their site
|
|
214
|
+
* is live in three languages and it is live in two. Nothing errors, the publish
|
|
215
|
+
* succeeds, and the missing locale is indistinguishable from one nobody asked for.
|
|
216
|
+
*
|
|
217
|
+
* Only the missing direction is reported. A site serving MORE than was asked is a
|
|
218
|
+
* different question that nobody has, and inventing a message for it would be
|
|
219
|
+
* machinery for a reason that does not exist.
|
|
220
|
+
*
|
|
221
|
+
* @param {string[]|null} asked - what this publish sent
|
|
222
|
+
* @param {*} served - `locales` from the publish response
|
|
223
|
+
* @returns {string[]} asked-for and not served, in the order asked
|
|
224
|
+
*/
|
|
225
|
+
export function unservedLanguages(asked, served) {
|
|
226
|
+
if (!Array.isArray(asked) || !Array.isArray(served)) return []
|
|
227
|
+
const got = new Set(served)
|
|
228
|
+
return asked.filter((l) => !got.has(l))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function describeServices(rows) {
|
|
232
|
+
if (!Array.isArray(rows) || rows.length === 0) return 'nothing'
|
|
233
|
+
return rows
|
|
234
|
+
.map((r) => {
|
|
235
|
+
const name = typeof r?.name === 'string' ? r.name : '?'
|
|
236
|
+
// A row that omits `enabled` is an ask, not a refusal — the backend's three
|
|
237
|
+
// states. Only an explicit `false` reads as off.
|
|
238
|
+
return r?.enabled === false ? `${name} (off)` : name
|
|
239
|
+
})
|
|
240
|
+
.sort()
|
|
241
|
+
.join(', ')
|
|
242
|
+
}
|
|
243
|
+
|
|
175
244
|
async function persistLastDeploy(siteDir, opts) {
|
|
176
245
|
if (opts.autoSave === 'off') return
|
|
177
246
|
try {
|
|
@@ -295,12 +364,20 @@ export async function publish(args = []) {
|
|
|
295
364
|
// always targets Uniweb hosting; resolveTarget gives us the target name +
|
|
296
365
|
// autoSave for the lastDeploy memo.
|
|
297
366
|
let resolved
|
|
367
|
+
let priorRequest = null
|
|
298
368
|
try {
|
|
299
369
|
const deployYml = await loadDeployYml(siteDir)
|
|
300
370
|
// No --target on publish — it always targets Uniweb hosting; resolveTarget
|
|
301
371
|
// returns the uniweb default (fromFile:false) when there's no deploy.yml, so
|
|
302
372
|
// persistLastDeploy scaffolds the file as the "where it's deployed" record.
|
|
303
373
|
resolved = resolveTarget(deployYml, null)
|
|
374
|
+
// The last request we are known to have sent, for the declaration gate below.
|
|
375
|
+
// Read from the SAME deploy.yml load — one read, and the memo is the only
|
|
376
|
+
// durable record of it (see backend/service-request.js for why not the cache).
|
|
377
|
+
priorRequest =
|
|
378
|
+
deployYml?.lastDeploy?.[resolved?.targetName] ||
|
|
379
|
+
deployYml?.lastDeploy?.uniweb ||
|
|
380
|
+
null
|
|
304
381
|
} catch {
|
|
305
382
|
// Malformed/ambiguous deploy.yml — don't block the publish on the memo.
|
|
306
383
|
resolved = {
|
|
@@ -701,9 +778,155 @@ export async function publish(args = []) {
|
|
|
701
778
|
const injectInfo = {
|
|
702
779
|
...(fnd.ref ? { foundation: fnd.ref } : {})
|
|
703
780
|
}
|
|
781
|
+
// ⛔ IS THE FILE ASKING FOR ANYTHING BY ITS `$services` / `$secrets` BLOCK?
|
|
782
|
+
//
|
|
783
|
+
// The blocks ride inside the site-content document, so without this gate every
|
|
784
|
+
// push re-sends them — and the backend REPLACES what it is sent. A paragraph
|
|
785
|
+
// edit would therefore overwrite whatever the stored request has become, which
|
|
786
|
+
// in the consent workflow is a decision the owner made in the app. Under "the
|
|
787
|
+
// file is a request", an unchanged block is not asking for anything.
|
|
788
|
+
//
|
|
789
|
+
// ⚠️ The residual window, stated because it is real and narrow: the base is
|
|
790
|
+
// banked at publish, so a request changed in the app BETWEEN a `uniweb pull` and
|
|
791
|
+
// the next publish is not seen — the pulled block reads as unchanged-from-nothing
|
|
792
|
+
// and is declared. It closes when the status route carries the stored request
|
|
793
|
+
// (backend is adding it) and we compare against theirs instead of our memory.
|
|
794
|
+
//
|
|
795
|
+
// ⭐ ASK THE BACKEND rather than trusting our memory, when it will tell us. The
|
|
796
|
+
// banked fingerprint says what WE last sent; the status read says what the site
|
|
797
|
+
// actually has. Only the second one sees a change made in the app, which is where
|
|
798
|
+
// the consent workflow's decisions happen — so this is what closes the window
|
|
799
|
+
// between a `uniweb pull` and the next publish.
|
|
800
|
+
//
|
|
801
|
+
// ⚖️ Degrades to the banked comparison on any failure — an older backend, a
|
|
802
|
+
// network blip, a site never pushed. That is the shipped behaviour and it is safe:
|
|
803
|
+
// it withholds an unchanged block and sends a changed one; it merely cannot see
|
|
804
|
+
// the app's side.
|
|
805
|
+
let declaration = decideDeclaration(siteYml, priorRequest)
|
|
806
|
+
let adopted = null
|
|
807
|
+
// Before the push, so a never-synced site has no uuid and simply skips this.
|
|
808
|
+
const status =
|
|
809
|
+
typeof siteYml.$uuid === 'string' && siteYml.$uuid
|
|
810
|
+
? await client.siteStatus(siteYml.$uuid)
|
|
811
|
+
: null
|
|
812
|
+
if (status && Array.isArray(status.services)) {
|
|
813
|
+
const r = reconcileRequest(siteYml, status.services, priorRequest)
|
|
814
|
+
if (r.action === 'none') {
|
|
815
|
+
declaration = { declare: false, reason: 'in-sync' }
|
|
816
|
+
} else if (r.action === 'send') {
|
|
817
|
+
declaration = { declare: true, reason: 'changed' }
|
|
818
|
+
} else if (r.action === 'adopt') {
|
|
819
|
+
// The owner decided in the app and this file is simply behind. Nothing to
|
|
820
|
+
// ask for, so nothing is sent — and the file can be brought in line, which
|
|
821
|
+
// is offered rather than done, because site.yml is theirs.
|
|
822
|
+
declaration = { declare: false, reason: 'adopt' }
|
|
823
|
+
adopted = status.services
|
|
824
|
+
} else {
|
|
825
|
+
// ⛔ CONFLICT — both moved. Withhold and SAY SO. Not a stop: the content
|
|
826
|
+
// publish is a separate thing the owner asked for, and blocking it over a
|
|
827
|
+
// services disagreement couples two unrelated intents. Not a guess either;
|
|
828
|
+
// the request stays in their file, unsent, and they are told.
|
|
829
|
+
declaration = { declare: false, reason: 'conflict' }
|
|
830
|
+
adopted = status.services
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const { isNonInteractive, confirm } = await import('../utils/interactive.js')
|
|
835
|
+
|
|
836
|
+
// ⛔ EVERY STRING BELOW IS FOR A SITE OWNER, NOT FOR US.
|
|
837
|
+
//
|
|
838
|
+
// "request", "declaration", "send", "adopt", "reconcile" are how this file
|
|
839
|
+
// MODELS the problem and they are the wrong words to say out loud: an author
|
|
840
|
+
// does not think they are sending a request, they think they want their site to
|
|
841
|
+
// have search. Say services, on and off, site.yml and your site. The internal
|
|
842
|
+
// vocabulary stays in the code and the comments, where it earns its precision.
|
|
843
|
+
//
|
|
844
|
+
// ⭐ THE OWNER IS THE ONLY ONE WHO CAN RANK TWO OF THEIR OWN INTENTS.
|
|
845
|
+
//
|
|
846
|
+
// `conflict` means the file and the site both moved since we last agreed, so
|
|
847
|
+
// neither is "the" request. ⛔ Withholding silently and saying "edit site.yml"
|
|
848
|
+
// is advice that CANNOT WORK: with no banked base the file has nothing to move
|
|
849
|
+
// relative to, so editing it produces the same conflict forever. That shipped
|
|
850
|
+
// for one commit. Asking is the only thing that resolves it.
|
|
851
|
+
if (declaration.reason === 'conflict') {
|
|
852
|
+
say.warn('Your site\'s services were changed elsewhere, and site.yml changed too.')
|
|
853
|
+
say.dim(` in site.yml: ${describeServices(siteYml.$services)}`)
|
|
854
|
+
say.dim(` on your site: ${describeServices(adopted)}`)
|
|
855
|
+
if (isNonInteractive(args)) {
|
|
856
|
+
say.dim(' Left your site as it is — run without --non-interactive to choose.')
|
|
857
|
+
} else if (await confirm('Use the services listed in site.yml?', false)) {
|
|
858
|
+
declaration = { declare: true, reason: 'resolved-send' }
|
|
859
|
+
adopted = null
|
|
860
|
+
} else {
|
|
861
|
+
// Declining to send is not yet a decision to take theirs, so this falls
|
|
862
|
+
// through to the offer below and "neither, leave it alone" stays available.
|
|
863
|
+
declaration = { declare: false, reason: 'adopt' }
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ⭐ THE SAME QUESTION FOR THE LANGUAGE SELECTION, and it is the one that costs.
|
|
868
|
+
//
|
|
869
|
+
// `publishLanguages` is a request like `$services`: pushed up, projected back on
|
|
870
|
+
// pull, stored on the other side. ⛔ Nothing over there deliberately rewrites it
|
|
871
|
+
// today — which is why this was nearly skipped — but a base is not only for
|
|
872
|
+
// detecting an overwrite. Without one, a selection that has always been in the
|
|
873
|
+
// file cannot be told from one the owner just typed, and for languages that is a
|
|
874
|
+
// charge: the line is billed on how many go out, so adding one costs money and
|
|
875
|
+
// the owner should hear that from us before it is sent, not from a refusal after.
|
|
876
|
+
if (status && !isNonInteractive(args)) {
|
|
877
|
+
const langs = reconcile(
|
|
878
|
+
siteYml.publishLanguages,
|
|
879
|
+
status.publish_languages,
|
|
880
|
+
priorRequest?.publishLanguagesRequest
|
|
881
|
+
)
|
|
882
|
+
if (langs.action === 'send') {
|
|
883
|
+
const asked = siteYml.publishLanguages
|
|
884
|
+
say.info(
|
|
885
|
+
`You changed which languages this site publishes: ${describeLanguages(asked)}.`
|
|
886
|
+
)
|
|
887
|
+
// ⚖️ "may" — the count is what is priced, and only the backend prices it.
|
|
888
|
+
// Framework says a charge is possible and never how much: this package is
|
|
889
|
+
// public and holds no prices, and a number we invented would be wrong.
|
|
890
|
+
say.dim(' Adding a language may cost more. You will be asked to confirm if so.')
|
|
891
|
+
} else if (langs.action === 'adopt' || langs.action === 'conflict') {
|
|
892
|
+
say.info('This site publishes different languages than site.yml lists.')
|
|
893
|
+
say.dim(` in site.yml: ${describeLanguages(siteYml.publishLanguages)}`)
|
|
894
|
+
say.dim(` on your site: ${describeLanguages(status.publish_languages)}`)
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (declaration.reason === 'adopt' && adopted) {
|
|
899
|
+
// ⚖️ Deliberately says WHAT differs, not WHO moved. The usual cause is a
|
|
900
|
+
// decision made in the app — but the same state follows a request of ours the
|
|
901
|
+
// site refused, where nothing of theirs changed and ours simply did not take.
|
|
902
|
+
// We cannot tell those apart here, so the wording claims neither.
|
|
903
|
+
say.info('Your site has different services than site.yml lists.')
|
|
904
|
+
say.dim(` in site.yml: ${describeServices(siteYml.$services)}`)
|
|
905
|
+
say.dim(` on your site: ${describeServices(adopted)}`)
|
|
906
|
+
// ⭐ OFFERED, NEVER DONE. site.yml is the owner's file, and a publish that
|
|
907
|
+
// silently rewrites an authored file is the surprise this seam exists to
|
|
908
|
+
// avoid. Default No, and declining costs nothing: the site is already
|
|
909
|
+
// correct, only the file is behind, and the offer returns next publish.
|
|
910
|
+
//
|
|
911
|
+
// ⚖️ A DECLINED conflict reaches here too, and that is deliberate — having
|
|
912
|
+
// been asked which they meant and said "not mine", taking the site's is the
|
|
913
|
+
// other half of the same question, not a silent overwrite of an edit.
|
|
914
|
+
if (!isNonInteractive(args) && (await confirm('Update site.yml to match?', false))) {
|
|
915
|
+
const { writeSiteConfig } = await import('@uniweb/build/uwx')
|
|
916
|
+
writeSiteConfig(siteDir, { $services: adopted })
|
|
917
|
+
// Keep the in-memory copy in step, or the deploy.yml bank below records the
|
|
918
|
+
// file as it WAS and the offer repeats forever.
|
|
919
|
+
siteYml.$services = adopted
|
|
920
|
+
say.ok('site.yml updated.')
|
|
921
|
+
}
|
|
922
|
+
} else if (!declaration.declare && declaration.reason !== 'adopt') {
|
|
923
|
+
say.dim('Services unchanged.')
|
|
924
|
+
}
|
|
925
|
+
|
|
704
926
|
let pkg
|
|
705
927
|
try {
|
|
706
928
|
pkg = await emitSyncPackages(siteDir, {
|
|
929
|
+
...(declaration.declare ? {} : { declareServices: false }),
|
|
707
930
|
// Placement identity for the folder — see writeFolderItemUuids.
|
|
708
931
|
folderItemUuids: readFolderItemUuids(siteDir),
|
|
709
932
|
// Resolves a foundation-relative `@/x` model ref into `@org/x`.
|
|
@@ -818,6 +1041,24 @@ export async function publish(args = []) {
|
|
|
818
1041
|
lastDeploy: {
|
|
819
1042
|
at: new Date().toISOString(),
|
|
820
1043
|
host: 'uniweb',
|
|
1044
|
+
// The request this publish is known to have sent — the base the declaration
|
|
1045
|
+
// gate compares against next time. ⛔ A FINGERPRINT, never the block:
|
|
1046
|
+
// deploy.yml is committed, `$secrets` carries secret material and a
|
|
1047
|
+
// service's `config` is opaque, so recording either verbatim would write
|
|
1048
|
+
// them into git. Absent when the file declares no block.
|
|
1049
|
+
...(declaration.declare
|
|
1050
|
+
? fingerprintRequest(siteYml)
|
|
1051
|
+
: {
|
|
1052
|
+
// Nothing was sent, so the base is unchanged — carry it forward
|
|
1053
|
+
// rather than dropping it, or the next publish would read "no record"
|
|
1054
|
+
// and declare.
|
|
1055
|
+
...(priorRequest?.servicesRequest
|
|
1056
|
+
? { servicesRequest: priorRequest.servicesRequest }
|
|
1057
|
+
: {}),
|
|
1058
|
+
...(priorRequest?.secretsRequest
|
|
1059
|
+
? { secretsRequest: priorRequest.secretsRequest }
|
|
1060
|
+
: {})
|
|
1061
|
+
}),
|
|
821
1062
|
// What was actually shipped. A version number can't answer that — two
|
|
822
1063
|
// publishes of "0.1.0" are not the same content — and after the fact the
|
|
823
1064
|
// working tree has moved on. `dirty` matters as much as the sha: it says the
|
|
@@ -835,7 +1076,13 @@ export async function publish(args = []) {
|
|
|
835
1076
|
// its foundation's floor and is resolved by whoever serves it. Recording a
|
|
836
1077
|
// value would be a snapshot that silently goes stale, of a decision this
|
|
837
1078
|
// command does not make.
|
|
838
|
-
|
|
1079
|
+
// ⛔ WHAT WENT OUT, or nothing. This fell back to `languages` — what we
|
|
1080
|
+
// ASKED FOR — when the response carried no `locales`, which put two
|
|
1081
|
+
// different facts under one key with no way to tell them apart: a reader
|
|
1082
|
+
// could not distinguish "these were served" from "we asked for these and
|
|
1083
|
+
// were never told". `deploy.yml` records what a publish DID, and when we do
|
|
1084
|
+
// not know what it did, the honest record is silence.
|
|
1085
|
+
...(Array.isArray(result.locales) ? { locales: result.locales } : {})
|
|
839
1086
|
}
|
|
840
1087
|
})
|
|
841
1088
|
|
|
@@ -843,6 +1090,23 @@ export async function publish(args = []) {
|
|
|
843
1090
|
say.ok(
|
|
844
1091
|
`Published ${c.bold}${siteUuid}${c.reset}${result.status ? ` (${result.status})` : ''}`
|
|
845
1092
|
)
|
|
1093
|
+
|
|
1094
|
+
// ⛔ SAY IT WHEN A LANGUAGE ASKED FOR DID NOT GO OUT.
|
|
1095
|
+
//
|
|
1096
|
+
// The publish succeeded, so nothing above this is wrong — and a site live in two
|
|
1097
|
+
// of the three languages its author listed looks exactly like a site live in the
|
|
1098
|
+
// two they wanted. That is the failure with no owner: the file and reality
|
|
1099
|
+
// disagree and the terminal is green.
|
|
1100
|
+
//
|
|
1101
|
+
// ⚖️ A warning, not a failure. The publish DID happen and the content IS live, so
|
|
1102
|
+
// exiting non-zero would tell a script the deploy failed when it did not.
|
|
1103
|
+
const unserved = unservedLanguages(languages, result.locales)
|
|
1104
|
+
if (unserved.length > 0) {
|
|
1105
|
+
say.warn(
|
|
1106
|
+
`Your site went live in ${(result.locales || []).join(', ')} — but not ${unserved.join(', ')}.`
|
|
1107
|
+
)
|
|
1108
|
+
say.dim(` site.yml lists ${unserved.join(', ')}, and ${unserved.length === 1 ? 'it was' : 'they were'} not published.`)
|
|
1109
|
+
}
|
|
846
1110
|
if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
|
|
847
1111
|
if (result.deploy_uuid) say.dim(`deploy: ${result.deploy_uuid}`)
|
|
848
1112
|
return { exitCode: 0 }
|
package/src/framework-index.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"generatedAt": "2026-09-09T00:44:10.215Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
|
-
"version": "0.2.
|
|
6
|
+
"version": "0.2.12",
|
|
7
7
|
"path": "framework/api",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/core"
|
|
10
10
|
]
|
|
11
11
|
},
|
|
12
12
|
"@uniweb/build": {
|
|
13
|
-
"version": "0.43.
|
|
13
|
+
"version": "0.43.1",
|
|
14
14
|
"path": "framework/build",
|
|
15
15
|
"deps": [
|
|
16
16
|
"@uniweb/content-reader",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"deps": []
|
|
35
35
|
},
|
|
36
36
|
"@uniweb/core": {
|
|
37
|
-
"version": "0.24.
|
|
37
|
+
"version": "0.24.2",
|
|
38
38
|
"path": "framework/core",
|
|
39
39
|
"deps": [
|
|
40
40
|
"@uniweb/semantic-parser",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
]
|
|
65
65
|
},
|
|
66
66
|
"@uniweb/loom": {
|
|
67
|
-
"version": "0.2.
|
|
67
|
+
"version": "0.2.5",
|
|
68
68
|
"path": "framework/loom",
|
|
69
69
|
"deps": []
|
|
70
70
|
},
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
]
|
|
83
83
|
},
|
|
84
84
|
"@uniweb/runtime": {
|
|
85
|
-
"version": "0.19.
|
|
85
|
+
"version": "0.19.3",
|
|
86
86
|
"path": "framework/runtime",
|
|
87
87
|
"deps": [
|
|
88
88
|
"@uniweb/core",
|
|
@@ -339,15 +339,35 @@ async function loginViaTokenPaste({ apiBase, nonInteractive }) {
|
|
|
339
339
|
// Exported for the publish payment refusal, which opens the backend's
|
|
340
340
|
// settlement URL VERBATIM and needs no loopback (backend/payment-handoff.js).
|
|
341
341
|
export async function openBrowser(url) {
|
|
342
|
+
// ⛔ THE URL ARRIVES OVER THE NETWORK — a backend hands it to us and we open it.
|
|
343
|
+
//
|
|
344
|
+
// This built a SHELL STRING (`open "${url}"`) and interpolated that value into
|
|
345
|
+
// it. A `"` in the URL closes the quoted argument and everything after it is
|
|
346
|
+
// shell: one crafted or corrupted response, and the rest runs as the user. It
|
|
347
|
+
// was on the login path before it was on the publish path.
|
|
348
|
+
//
|
|
349
|
+
// ⇒ Two changes, and neither is about payment:
|
|
350
|
+
// 1. `execFile` with the URL as an ARGUMENT — no shell, so no quoting to get
|
|
351
|
+
// wrong and nothing to escape.
|
|
352
|
+
// 2. Only `http:` / `https:` are opened. `file:`, `javascript:` and the rest
|
|
353
|
+
// are not places a person goes, and refusing them costs nothing real.
|
|
354
|
+
// (The caller checks too; a safety rule that only holds at one call site
|
|
355
|
+
// is one refactor from being gone.)
|
|
356
|
+
if (!/^https?:\/\//i.test(String(url || ''))) return false
|
|
342
357
|
try {
|
|
343
|
-
const {
|
|
344
|
-
|
|
358
|
+
const { execFile } = await import('node:child_process')
|
|
359
|
+
// `start` is a cmd.exe builtin rather than an executable, so Windows keeps a
|
|
360
|
+
// shell — but through `cmd /c` with the URL as its own argv entry, which is
|
|
361
|
+
// what removes the interpolation. The empty string is `start`'s title slot.
|
|
362
|
+
const [cmd, args] =
|
|
345
363
|
process.platform === 'darwin'
|
|
346
|
-
?
|
|
364
|
+
? ['open', [url]]
|
|
347
365
|
: process.platform === 'win32'
|
|
348
|
-
?
|
|
349
|
-
:
|
|
350
|
-
return await new Promise((resolve) =>
|
|
366
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
367
|
+
: ['xdg-open', [url]]
|
|
368
|
+
return await new Promise((resolve) =>
|
|
369
|
+
execFile(cmd, args, (err) => resolve(!err))
|
|
370
|
+
)
|
|
351
371
|
} catch {
|
|
352
372
|
return false
|
|
353
373
|
}
|