uniweb 1.0.0 → 1.0.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/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payment refusal — what `uniweb publish` does when the backend says a site
|
|
3
|
+
* cannot go live until it is paid for.
|
|
4
|
+
*
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ THERE IS NO PRE-FLIGHT, AND ADDING ONE BACK IS A REGRESSION. A
|
|
12
|
+
* `can-go-live` probe used to run before go-live, and it folded every failure —
|
|
13
|
+
* 404 included — into "proceed". A check that answers "fine" when it cannot
|
|
14
|
+
* reach the server is not a check, and it made the CLI assume a posture it has no
|
|
15
|
+
* business assuming. A pre-flight also cannot be authoritative: the backend
|
|
16
|
+
* re-evaluates at publish time regardless, so a second asker is a second producer
|
|
17
|
+
* of one decision — which is the reason that would stand even if the probe had
|
|
18
|
+
* worked.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ **THIS COMMENT USED TO SAY THE PROBE "called a route no backend serves".
|
|
21
|
+
* That was FALSE, corrected 2026-09-12.** What it called was
|
|
22
|
+
* `/dev/site/{uuid}/can-go-live` — and the 404 came from the PATH being wrong,
|
|
23
|
+
* not from the capability being absent. So the probe never worked at any version
|
|
24
|
+
* that shipped it, and every failure was swallowed by the fold above, which is
|
|
25
|
+
* exactly why nobody noticed.
|
|
26
|
+
*
|
|
27
|
+
* ⭐ Kept as a warning about the SHAPE of the old justification, not just the
|
|
28
|
+
* fact: a correct rule was resting on a claim about another system that this
|
|
29
|
+
* repo cannot see, and the claim was wrong. The rule survives because its real
|
|
30
|
+
* reasons are local — the fold, and the second-producer argument. **If you find
|
|
31
|
+
* yourself defending a rule here with an assertion about what a server does,
|
|
32
|
+
* check it or drop it.**
|
|
33
|
+
*
|
|
34
|
+
* WHAT THE CLI KNOWS ABOUT PAYMENT: nothing. It opens whatever settlement URL
|
|
35
|
+
* the backend hands it, VERBATIM — provider-agnostic, and route-agnostic. The
|
|
36
|
+
* app drives the provider and settles with the backend.
|
|
37
|
+
*
|
|
38
|
+
* ⛔ AND IT APPENDS NOTHING TO THAT URL. The old handoff added
|
|
39
|
+
* `redirect_uri=http://127.0.0.1:<port>/callback`, `state` and `wait_token`,
|
|
40
|
+
* then waited on a loopback for the app to redirect back. Two reasons it is
|
|
41
|
+
* gone: it obliged the web app to know a CLI exists and honour a callback, and
|
|
42
|
+
* a loopback callback requires the browser and the CLI
|
|
43
|
+
* on the SAME MACHINE, so over SSH or in a container it hung for its full
|
|
44
|
+
* timeout and then reported "payment was not completed", which was false.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* ⛔ THE DOOR IS THE URL, NOT THE REASON — and this replaced a reason allowlist.
|
|
49
|
+
*
|
|
50
|
+
* The rule the allowlist served is right and survives: **never route to a purchase
|
|
51
|
+
* surface from the ABSENCE of a recognised token.** But the hazard it guarded was
|
|
52
|
+
* INFERRING a door from silence, and when the backend NAMES a URL there is no
|
|
53
|
+
* inference left — an explicit remedy is the affirmative signal the allowlist was
|
|
54
|
+
* standing in for.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ Keeping it had turned into the opposite failure. The reason set is open by
|
|
57
|
+
* design ("includes"), so every new one — `pending_request` today — fell through to
|
|
58
|
+
* `stop` and the owner was shown a sentence **with no link**, for a door the
|
|
59
|
+
* backend had handed us. A stale allowlist now fails by WITHHOLDING help.
|
|
60
|
+
*
|
|
61
|
+
* ⇒ What remains is a shape rule, not a vocabulary one: open exactly what you were
|
|
62
|
+
* handed, when it is an absolute http(s) URL, or say exactly what you were told.
|
|
63
|
+
*/
|
|
64
|
+
const OPENABLE = /^https?:\/\//i
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read a `402` from the publish call and decide what the CLI does. Pure — no
|
|
68
|
+
* network, no browser, no process exit — so the decision is testable on its own.
|
|
69
|
+
*
|
|
70
|
+
* ⛔ THE RULE, and it is a property of the wire rather than a CLI preference:
|
|
71
|
+
* NEVER route to a purchase surface from the ABSENCE of a recognised token. A
|
|
72
|
+
* purchase surface is opened by a `reason` that NAMES one; everything else
|
|
73
|
+
* surfaces the backend's own sentence and stops. Degrading that way means an
|
|
74
|
+
* older CLI shows you the message — annoying, honest, recoverable. Degrading
|
|
75
|
+
* the other way sends someone to a checkout for something they cannot buy.
|
|
76
|
+
*
|
|
77
|
+
* ⛔ Do NOT turn `ACTIONABLE_REASONS` into "every reason the backend has". The
|
|
78
|
+
* set is open by design and a stale copy here fails in the worst direction; an
|
|
79
|
+
* allowlist is safe precisely because what it misses lands on `stop`.
|
|
80
|
+
* ⛔ Do NOT parse `detail` — it is deliberately not asserted word-for-word, and
|
|
81
|
+
* `reason` exists to do the job parsing it would.
|
|
82
|
+
*
|
|
83
|
+
* NOTE ON `reason`'s PRESENCE: it is NOT guaranteed by the content type. A 402
|
|
84
|
+
* naming a condition the caller can act on carries one; nothing guarantees it
|
|
85
|
+
* in general — a declined card is `problem+json` and deliberately carries none,
|
|
86
|
+
* because there is no machine decision for a client to make about it. This reads `reason` when it is there and needs
|
|
87
|
+
* no invariant, which is why that correction cost this branch nothing.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} o
|
|
90
|
+
* @param {number} o.status - the HTTP status
|
|
91
|
+
* @param {string} [o.contentType] - the response's content-type header
|
|
92
|
+
* @param {string} [o.body] - the raw response body
|
|
93
|
+
* @returns {{ kind: 'not-payment' }
|
|
94
|
+
* | { kind: 'settle', url: string, handle: string|null, reason: string, message: string|null, confirm: { prompt: string, token: string, reduction: boolean }|null }
|
|
95
|
+
* | { kind: 'stop', reason: string|null, message: string|null, confirm: { prompt: string, token: string, reduction: boolean }|null }}
|
|
96
|
+
*/
|
|
97
|
+
export function readPaymentRefusal({ status, contentType = '', body = '' } = {}) {
|
|
98
|
+
if (status !== 402) return { kind: 'not-payment' }
|
|
99
|
+
|
|
100
|
+
let problem = null
|
|
101
|
+
try {
|
|
102
|
+
problem = JSON.parse(body)
|
|
103
|
+
} catch {
|
|
104
|
+
/* a non-JSON 402 is simply unrecognised — it falls to `stop` below */
|
|
105
|
+
}
|
|
106
|
+
if (!problem || typeof problem !== 'object') {
|
|
107
|
+
return { kind: 'stop', reason: null, message: null }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The human sentence, in the backend's own words. `detail` is the 7807
|
|
111
|
+
// member; `title` is the fallback when a body carries no detail.
|
|
112
|
+
const message =
|
|
113
|
+
(typeof problem.detail === 'string' && problem.detail) ||
|
|
114
|
+
(typeof problem.title === 'string' && problem.title) ||
|
|
115
|
+
null
|
|
116
|
+
|
|
117
|
+
// `status` is NOT a discriminator: the backend's problem bodies carry it as
|
|
118
|
+
// the NUMBER 402 while at least one hand-built 402 elsewhere on their wire
|
|
119
|
+
// carries a STRING. Same key, two types, neither failing loudly — so this
|
|
120
|
+
// reads `reason` and the content type instead, and never `body.status`.
|
|
121
|
+
const isProblem = String(contentType).includes('application/problem+json')
|
|
122
|
+
const reason =
|
|
123
|
+
isProblem && typeof problem.reason === 'string' && problem.reason
|
|
124
|
+
? problem.reason
|
|
125
|
+
: null
|
|
126
|
+
|
|
127
|
+
// ⛔ ONLY A `problem+json` BODY CARRIES A DOOR, and this gate is NOT the reason
|
|
128
|
+
// allowlist that used to sit here — it is the other, narrower protection that
|
|
129
|
+
// was tangled up with it.
|
|
130
|
+
//
|
|
131
|
+
// The backend's refusals wear `application/problem+json` and say so as a stable
|
|
132
|
+
// guarantee. A plain `application/json` 402 is some other shape from some other
|
|
133
|
+
// part of the wire — and one of those carries `status` as a STRING where a
|
|
134
|
+
// problem body carries the number, so the two are not distinguishable by their
|
|
135
|
+
// fields. Reading a URL out of a body that never promised this envelope is how
|
|
136
|
+
// a `settle` gets synthesised from something that is not a refusal at all.
|
|
137
|
+
if (!isProblem) return { kind: 'stop', reason, message, confirm: null }
|
|
138
|
+
|
|
139
|
+
// ⭐ A CHANGE THE TERMINAL MAY CONFIRM (since 2026-10-03): `{ prompt, token, reduction }`
|
|
140
|
+
// beside the door, when everything the publish owes is a change to a plan the site already has.
|
|
141
|
+
// `prompt` is the backend's sentence, shown verbatim; `token` goes back as it came. Read only
|
|
142
|
+
// whole — a confirm missing either half is not one.
|
|
143
|
+
const confirm =
|
|
144
|
+
problem.confirm && typeof problem.confirm.prompt === 'string' && problem.confirm.prompt &&
|
|
145
|
+
typeof problem.confirm.token === 'string' && problem.confirm.token
|
|
146
|
+
? { prompt: problem.confirm.prompt, token: problem.confirm.token, reduction: problem.confirm.reduction === true }
|
|
147
|
+
: null
|
|
148
|
+
|
|
149
|
+
// The door, if one was handed over. `remedy_url` is the current spelling — ONE
|
|
150
|
+
// key for every reason, which is what lets a reason we have never heard of still
|
|
151
|
+
// reach its remedy.
|
|
152
|
+
//
|
|
153
|
+
// ⚖️ `settlement.url` is the older shape and is still read, as a DEFENSIVE
|
|
154
|
+
// read and nothing more.
|
|
155
|
+
//
|
|
156
|
+
// ⛔ **This comment used to justify it as "what a backend that has not moved yet
|
|
157
|
+
// still serves". That is unverified and probably false** — corrected 2026-09-12,
|
|
158
|
+
// after the backend searched its whole tree and reported serving neither this
|
|
159
|
+
// nor `remedy_url` on any route. ⇒ Keep the read (it costs nothing and a door
|
|
160
|
+
// withheld is worse than a door read twice); do not repeat the claim about who
|
|
161
|
+
// serves it. The current contract is `remedy_url`, one key for every reason.
|
|
162
|
+
const legacy = problem.settlement
|
|
163
|
+
const candidate =
|
|
164
|
+
(typeof problem.remedy_url === 'string' && problem.remedy_url) ||
|
|
165
|
+
(legacy && typeof legacy.url === 'string' && legacy.url) ||
|
|
166
|
+
null
|
|
167
|
+
|
|
168
|
+
// ⛔ ONLY AN ABSOLUTE http(s) URL IS OPENED, and this is a safety rule rather
|
|
169
|
+
// than a compatibility one. The value arrives over the network and is handed to
|
|
170
|
+
// the platform's URL opener; a `file:` or a `javascript:` URL is not a place a
|
|
171
|
+
// person goes. Refusing them costs a legitimate backend nothing.
|
|
172
|
+
const url = candidate && OPENABLE.test(candidate) ? candidate : null
|
|
173
|
+
if (!url) return { kind: 'stop', reason, message, confirm }
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
kind: 'settle',
|
|
177
|
+
url,
|
|
178
|
+
handle:
|
|
179
|
+
legacy && typeof legacy.handle === 'string' && legacy.handle
|
|
180
|
+
? legacy.handle
|
|
181
|
+
: null,
|
|
182
|
+
reason,
|
|
183
|
+
message,
|
|
184
|
+
confirm
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Report a payment refusal to the user, and open the settlement page when the
|
|
190
|
+
* backend supplied one.
|
|
191
|
+
*
|
|
192
|
+
* ⛔ Returns rather than exits — the caller owns the exit code, and a refusal
|
|
193
|
+
* is not a crash: the content is already synced as a draft, so re-running
|
|
194
|
+
* after paying is the recovery.
|
|
195
|
+
*
|
|
196
|
+
* @param {object} o
|
|
197
|
+
* @param {ReturnType<typeof readPaymentRefusal>} o.verdict
|
|
198
|
+
* @param {string[]} o.args - argv slice (for --non-interactive detection)
|
|
199
|
+
* @param {object} o.say - { ok, info, warn, err, dim } reporters
|
|
200
|
+
* @param {(url: string) => Promise<boolean>} [o.open] - injected for tests
|
|
201
|
+
* @param {boolean} [o.interactive] - whether someone is at the terminal, when the caller has decided
|
|
202
|
+
* it already (`settleRefusal`); else read from `args` and stdin
|
|
203
|
+
* @returns {Promise<{ opened: boolean }>}
|
|
204
|
+
*/
|
|
205
|
+
export async function reportPaymentRefusal({ verdict, args = [], say, open, interactive }) {
|
|
206
|
+
// The backend's own sentence is the HEADLINE when there is one. A generic
|
|
207
|
+
// lead would be wrong as often as right — "payment is required" does not
|
|
208
|
+
// describe a declined card — and `detail` is written for this reader.
|
|
209
|
+
say.err(verdict.message || 'This site cannot go live yet.')
|
|
210
|
+
|
|
211
|
+
if (verdict.kind !== 'settle') {
|
|
212
|
+
// The push completed before go-live, so the content is safely stored.
|
|
213
|
+
say.dim('The site is synced as a draft; nothing was made live.')
|
|
214
|
+
return { opened: false }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (interactive === undefined) {
|
|
218
|
+
const { isNonInteractive } = await import('../utils/interactive.js')
|
|
219
|
+
interactive = !isNonInteractive(args)
|
|
220
|
+
}
|
|
221
|
+
if (!interactive) {
|
|
222
|
+
say.dim(`Finish this in a browser, then re-run \`uniweb publish\`:`)
|
|
223
|
+
say.dim(` ${verdict.url}`)
|
|
224
|
+
return { opened: false }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return openDoor({ url: verdict.url, say, open })
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Open the backend's door, verbatim, and say how to come back. */
|
|
231
|
+
async function openDoor({ url, say, open }) {
|
|
232
|
+
const openBrowser = open || (await import('../utils/registry-auth.js')).openBrowser
|
|
233
|
+
// ⛔ Reason-agnostic wording. The backend's own `detail` carries the specifics;
|
|
234
|
+
// a lead sentence naming payment would be wrong the moment a refusal is a quota
|
|
235
|
+
// or an unverified domain — and the reason set is open by design.
|
|
236
|
+
say.info('Opening your browser to finish this…')
|
|
237
|
+
say.dim(` ${url}`)
|
|
238
|
+
// VERBATIM. Nothing is appended — see the header.
|
|
239
|
+
const opened = await openBrowser(url)
|
|
240
|
+
if (!opened) {
|
|
241
|
+
say.warn('Could not open a browser automatically — open the URL above.')
|
|
242
|
+
}
|
|
243
|
+
say.dim('Once that is done, re-run `uniweb publish`.')
|
|
244
|
+
return { opened }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const CONFIRM = 'Confirm and publish'
|
|
248
|
+
const CONTINUE = 'Continue and publish'
|
|
249
|
+
const OPEN = 'Open it in the app'
|
|
250
|
+
const CANCEL = 'Cancel'
|
|
251
|
+
|
|
252
|
+
// A reduction offered again and again, with no one to ask, is answered this many times and no more.
|
|
253
|
+
const MAX_UNATTENDED = 3
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* ⭐ A REFUSAL THE TERMINAL MAY SETTLE (since 2026-10-03). When everything a publish owes is a change
|
|
257
|
+
* to a plan the site already has, the `402` carries `confirm: { prompt, token, reduction }` beside its
|
|
258
|
+
* door, and the backend's sentence is asked at an interactive terminal: confirm, open the app, or
|
|
259
|
+
* cancel. Confirming publishes again with the token (`?confirm=`):
|
|
260
|
+
*
|
|
261
|
+
* - `200` — settled and published: the response is returned, for the caller to finish with.
|
|
262
|
+
* - `402` with a fresh `confirm` — the change or its price moved and nothing was applied: asked again,
|
|
263
|
+
* with the new sentence.
|
|
264
|
+
* - `402` without one — what is owed can no longer be confirmed here (it now asks for a first plan,
|
|
265
|
+
* or the plan's card can no longer be named): its sentence, and its door. ⛔ Not a declined card —
|
|
266
|
+
* a confirmed change is invoiced as it applies, and a decline later shows in the app's billing.
|
|
267
|
+
* *(This said "the card could not settle it" until the day it was written, 2026-10-03.)*
|
|
268
|
+
*
|
|
269
|
+
* ⭐ **A CHARGE AND A REDUCTION ARE ASKED DIFFERENTLY** (`reduction`, a field the backend keeps
|
|
270
|
+
* stable). A charge is confirmed only by someone at the terminal: Enter cancels, no flag answers it,
|
|
271
|
+
* and with `--yes` — which promises never to block on a prompt — it is not asked either, but reported
|
|
272
|
+
* with the way to confirm it. A reduction — the offer only lowers what the site pays — asks lightly: Enter continues, and
|
|
273
|
+
* `--yes` answers it without asking, with or without a terminal, telling the backend it was answered
|
|
274
|
+
* unattended (`&unattended=true`), which settles a reduction and nothing else. *[Diego, 2026-10-03:
|
|
275
|
+
* "allow it for reductions only, keep charges interactive".]*
|
|
276
|
+
*
|
|
277
|
+
* With no one at the terminal and nothing to answer it, there is no prompt: the refusal is reported as
|
|
278
|
+
* before, its door printed, and the publish fails. A refusal with no `confirm` behaves as it always has.
|
|
279
|
+
*
|
|
280
|
+
* @param {object} o
|
|
281
|
+
* @param {ReturnType<typeof readPaymentRefusal>} o.verdict - the first refusal
|
|
282
|
+
* @param {string[]} o.args - argv slice (for `--yes` and --non-interactive detection)
|
|
283
|
+
* @param {object} o.say - { ok, info, warn, err, dim } reporters
|
|
284
|
+
* @param {(token: string, options?: { unattended?: boolean }) => Promise<Response>} o.republish - the
|
|
285
|
+
* same publish, with `?confirm=<token>` (and `&unattended=true` when no one answered)
|
|
286
|
+
* @param {boolean} [o.interactive] - whether someone is at the terminal; read from `args` and stdin
|
|
287
|
+
* @param {boolean} [o.yes] - whether `--yes` answers a reduction; read from `args`
|
|
288
|
+
* @param {(question: string, options: string[], fallback: string) => Promise<string>} [o.ask]
|
|
289
|
+
* @param {(url: string) => Promise<boolean>} [o.open]
|
|
290
|
+
* @returns {Promise<{ exitCode: number } | { response: Response, body?: string }>} `response` when
|
|
291
|
+
* the publish went through, or failed for a reason that is not payment (with its body, read)
|
|
292
|
+
*/
|
|
293
|
+
export async function settleRefusal({ verdict, args = [], say, republish, interactive, yes, ask, open }) {
|
|
294
|
+
if (interactive === undefined) {
|
|
295
|
+
const { isNonInteractive } = await import('../utils/interactive.js')
|
|
296
|
+
interactive = !isNonInteractive(args)
|
|
297
|
+
}
|
|
298
|
+
if (yes === undefined) yes = args.includes('--yes')
|
|
299
|
+
let current = verdict
|
|
300
|
+
let unattended = 0
|
|
301
|
+
for (;;) {
|
|
302
|
+
const offer = current.confirm
|
|
303
|
+
const reduction = offer?.reduction === true
|
|
304
|
+
let answered
|
|
305
|
+
|
|
306
|
+
if (reduction && yes && unattended < MAX_UNATTENDED) {
|
|
307
|
+
// `--yes` answers a change that only lowers the bill — said, so the log shows what was accepted.
|
|
308
|
+
unattended++
|
|
309
|
+
say.info(offer.prompt)
|
|
310
|
+
say.dim('Continuing: `--yes` answers a change that only lowers what the site pays.')
|
|
311
|
+
answered = { unattended: true }
|
|
312
|
+
} else if (!offer || !interactive || (yes && !reduction)) {
|
|
313
|
+
await reportPaymentRefusal({ verdict: current, args, say, open, interactive: interactive && !yes })
|
|
314
|
+
if (offer && !reduction) {
|
|
315
|
+
say.dim('This change charges the site\'s card, so only you can confirm it: run `uniweb publish` at a terminal, without `--yes`.')
|
|
316
|
+
} else if (reduction) {
|
|
317
|
+
say.dim('This change only lowers what the site pays: `uniweb publish --yes` continues it without a prompt.')
|
|
318
|
+
}
|
|
319
|
+
return { exitCode: 1 }
|
|
320
|
+
} else {
|
|
321
|
+
const go = reduction ? CONTINUE : CONFIRM
|
|
322
|
+
const choices = [go, ...(current.kind === 'settle' ? [OPEN] : []), CANCEL]
|
|
323
|
+
// Enter continues a reduction and cancels a charge.
|
|
324
|
+
const fallback = reduction ? CONTINUE : CANCEL
|
|
325
|
+
const pick = ask
|
|
326
|
+
? await ask(offer.prompt, choices, fallback)
|
|
327
|
+
: await (await import('../utils/interactive.js')).pickOne(offer.prompt, choices, fallback)
|
|
328
|
+
|
|
329
|
+
if (pick === OPEN) {
|
|
330
|
+
await openDoor({ url: current.url, say, open })
|
|
331
|
+
return { exitCode: 1 }
|
|
332
|
+
}
|
|
333
|
+
if (pick !== go) {
|
|
334
|
+
say.dim('Cancelled — nothing changed, and nothing was made live. The site is synced as a draft.')
|
|
335
|
+
return { exitCode: 1 }
|
|
336
|
+
}
|
|
337
|
+
answered = {}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let response
|
|
341
|
+
try {
|
|
342
|
+
response = await republish(offer.token, answered)
|
|
343
|
+
} catch (err) {
|
|
344
|
+
say.err(String(err?.message || err))
|
|
345
|
+
return { exitCode: 1 }
|
|
346
|
+
}
|
|
347
|
+
if (response.ok) return { response }
|
|
348
|
+
|
|
349
|
+
const body = await response.text().catch(() => '')
|
|
350
|
+
const next = readPaymentRefusal({
|
|
351
|
+
status: response.status,
|
|
352
|
+
contentType: response.headers?.get?.('content-type') || '',
|
|
353
|
+
body
|
|
354
|
+
})
|
|
355
|
+
if (next.kind === 'not-payment') return { response, body }
|
|
356
|
+
if (next.confirm) {
|
|
357
|
+
// Nothing was applied: the change or its price moved since the offer. Ask again.
|
|
358
|
+
say.warn('The change or its price moved since that offer, so nothing was applied.')
|
|
359
|
+
}
|
|
360
|
+
current = next
|
|
361
|
+
}
|
|
362
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The requests a push or publish carries — what the owner asks for, and whether it
|
|
3
|
+
* is new.
|
|
4
|
+
*
|
|
5
|
+
* Two of them, kept by two mechanisms:
|
|
6
|
+
*
|
|
7
|
+
* - ⭐ **The services** — `site.yml::services`, a map by service name *[Diego,
|
|
8
|
+
* 2026-10-06]*. A push STATES them — the file's, and off for each held one it no
|
|
9
|
+
* longer lists — and the backend decides per service from the versions sent
|
|
10
|
+
* (`statedServices`, `@uniweb/build/uwx`; spec:
|
|
11
|
+
* kb/framework/reference/site-services-request.md). All this module does for them
|
|
12
|
+
* is say what the file asks that will not be sent (`announceServices`).
|
|
13
|
+
* - **The language selection** — `site.yml::publishLanguages` — told apart from the
|
|
14
|
+
* status quo by a fingerprint banked in `deploy.yml` (`reconcile`, `bankLanguages`).
|
|
15
|
+
*
|
|
16
|
+
* ⭐ The model both follow — *"the services in `site.yml` are a request, never a
|
|
17
|
+
* tracking of what is running"* [Diego, 2026-09-05]. You ask by CHANGING the file.
|
|
18
|
+
*
|
|
19
|
+
* ⛔ *From 2026-09-20 to 2026-10-06 the services lived only in `sync.json`, which
|
|
20
|
+
* nobody edits, so a CLI user could ask for nothing; this module then compared them by
|
|
21
|
+
* a fingerprint in `deploy.yml`, which could not tell who moved; and until 2026-10-07
|
|
22
|
+
* it read the site's services before every push and settled each against a record in
|
|
23
|
+
* `sync.json` (`settleServices`), because the backend replaced the list it was sent.*
|
|
24
|
+
*
|
|
25
|
+
* @module
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createHash } from 'node:crypto'
|
|
29
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
import { readServicesRequest, unreadableServices } from '@uniweb/build/uwx'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A stable fingerprint of one declared value, or `null` when the key is absent.
|
|
35
|
+
*
|
|
36
|
+
* ⭐ `null` (absent) and the hash of `[]` are DIFFERENT, and must stay so: for the
|
|
37
|
+
* language selection, no key means "every declared language" and `[]` means none.
|
|
38
|
+
*
|
|
39
|
+
* List entries are ordered by their serialized form and object keys sorted, so
|
|
40
|
+
* reordering them in the file is not a change.
|
|
41
|
+
*
|
|
42
|
+
* @param {*} declared
|
|
43
|
+
* @returns {string|null} 16 hex chars, or null when undeclared
|
|
44
|
+
*/
|
|
45
|
+
export function fingerprintDeclaration(declared) {
|
|
46
|
+
if (declared === undefined || declared === null) return null
|
|
47
|
+
const canonical = Array.isArray(declared)
|
|
48
|
+
? declared.map(stableString).sort()
|
|
49
|
+
: [stableString(declared)]
|
|
50
|
+
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex').slice(0, 16)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Absent, or an empty list — a stored value that asks for nothing. */
|
|
54
|
+
function isNothing(value) {
|
|
55
|
+
return value === undefined || value === null || (Array.isArray(value) && value.length === 0)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Deterministic JSON: object keys sorted at every depth, arrays left in order. */
|
|
59
|
+
function stableString(value) {
|
|
60
|
+
return JSON.stringify(value, (_k, v) =>
|
|
61
|
+
v && typeof v === 'object' && !Array.isArray(v)
|
|
62
|
+
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]]))
|
|
63
|
+
: v
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A two-way request field, reconciled against what the site has stored.
|
|
69
|
+
*
|
|
70
|
+
* `site.yml::publishLanguages` is the owner's ASK, pushed up, projected back on pull,
|
|
71
|
+
* and stored on the other side where something else may move it.
|
|
72
|
+
*
|
|
73
|
+
* ⛔ A BASE IS NEEDED EVEN WHERE NOTHING ELSE WRITES THE FIELD. Overwriting is not the
|
|
74
|
+
* only thing a base is for — without one, a value that has always been in the file
|
|
75
|
+
* is indistinguishable from one the owner just typed, so the CLI cannot tell an
|
|
76
|
+
* intentional change from the status quo. For languages that difference is money:
|
|
77
|
+
* the count is priced, so a new language is a charge, and saying so before sending
|
|
78
|
+
* requires knowing it is new.
|
|
79
|
+
*
|
|
80
|
+
* @param {*} localValue - the file's declaration
|
|
81
|
+
* @param {*} remoteValue - what the site has stored
|
|
82
|
+
* @param {string|null} baseFingerprint - what we last agreed on
|
|
83
|
+
* @returns {{action: 'none'|'adopt'|'send'|'conflict', local: string|null, remote: string|null}}
|
|
84
|
+
*/
|
|
85
|
+
export function reconcile(localValue, remoteValue, baseFingerprint) {
|
|
86
|
+
const local = fingerprintDeclaration(localValue)
|
|
87
|
+
const remote = fingerprintDeclaration(remoteValue)
|
|
88
|
+
const base = baseFingerprint || null
|
|
89
|
+
|
|
90
|
+
if (local === remote) return { action: 'none', local, remote }
|
|
91
|
+
// ⛔ A FILE THAT DECLARES NOTHING ASKS NOTHING, so it is never one side of a conflict.
|
|
92
|
+
// With nothing stored either there is nothing to do; with something stored, the file
|
|
93
|
+
// is merely behind.
|
|
94
|
+
if (local === null) return { action: isNothing(remoteValue) ? 'none' : 'adopt', local, remote }
|
|
95
|
+
if (!base) return { action: 'conflict', local, remote }
|
|
96
|
+
|
|
97
|
+
const localMoved = local !== base
|
|
98
|
+
const remoteMoved = remote !== base
|
|
99
|
+
if (!localMoved && remoteMoved) return { action: 'adopt', local, remote }
|
|
100
|
+
if (localMoved && !remoteMoved) return { action: 'send', local, remote }
|
|
101
|
+
return { action: 'conflict', local, remote }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* What a publish records in `deploy.yml` about the language selection: its
|
|
106
|
+
* fingerprint, on EVERY publish — it rides the content push, so what went live is
|
|
107
|
+
* what was agreed.
|
|
108
|
+
*
|
|
109
|
+
* ⛔ A FINGERPRINT, never the list: `deploy.yml` records what a publish did, and the
|
|
110
|
+
* selection itself is in `site.yml`.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} siteYml
|
|
113
|
+
* @returns {{publishLanguagesRequest?: string}}
|
|
114
|
+
*/
|
|
115
|
+
export function bankLanguages(siteYml) {
|
|
116
|
+
const langs = fingerprintDeclaration(siteYml?.publishLanguages)
|
|
117
|
+
return langs ? { publishLanguagesRequest: langs } : {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The services the site's foundation says it renders — the build's `_self.supports`, else
|
|
122
|
+
* `package.json::uniweb.supports` — or null when that is unknown: no local foundation, or
|
|
123
|
+
* one that declares nothing. ⛔ Absent is UNKNOWN, never "none".
|
|
124
|
+
*
|
|
125
|
+
* @param {string} siteDir
|
|
126
|
+
* @param {object} siteYml - parsed
|
|
127
|
+
* @returns {Promise<string[]|null>}
|
|
128
|
+
*/
|
|
129
|
+
export async function foundationSupports(siteDir, siteYml) {
|
|
130
|
+
try {
|
|
131
|
+
if (!siteYml?.foundation) return null
|
|
132
|
+
const { detectFoundationType } = await import('@uniweb/build')
|
|
133
|
+
const found = detectFoundationType(siteYml.foundation, siteDir)
|
|
134
|
+
if (found?.type !== 'local' || !found.path) return null
|
|
135
|
+
const built = join(found.path, 'dist', 'meta', 'schema.json')
|
|
136
|
+
if (existsSync(built)) {
|
|
137
|
+
const derived = JSON.parse(readFileSync(built, 'utf8'))?._self?.supports
|
|
138
|
+
if (Array.isArray(derived)) return derived
|
|
139
|
+
}
|
|
140
|
+
const declared = JSON.parse(readFileSync(join(found.path, 'package.json'), 'utf8'))?.uniweb?.supports
|
|
141
|
+
return Array.isArray(declared) ? declared : null
|
|
142
|
+
} catch {
|
|
143
|
+
return null
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const names = (list) => list.map((n) => `\`${n}\``).join(', ')
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Say what `site.yml::services` asks that will not be sent as written — a credential,
|
|
151
|
+
* an entry that is not one, a `backend` address — and where it and the foundation disagree,
|
|
152
|
+
* before a push or publish sends the rest. What a push sends is the producer's
|
|
153
|
+
* (`statedServices`), from the same file.
|
|
154
|
+
*
|
|
155
|
+
* ⭐ The foundation INFORMS and never decides [Diego, 2026-10-07]: a service the file turns
|
|
156
|
+
* on that the foundation does not say it renders is said; so is one it renders that the
|
|
157
|
+
* file does not mention, since every service is off unless the site asks for it. An
|
|
158
|
+
* explicit `false` is a decision, and quiets the second. `tracking` is in neither — a
|
|
159
|
+
* foundation may not claim it, since it renders nothing — and `records` is left to
|
|
160
|
+
* publish, which knows whether the pages show any (`recordsNotAsked`).
|
|
161
|
+
*
|
|
162
|
+
* @param {object} p
|
|
163
|
+
* @param {object} p.siteYml - parsed
|
|
164
|
+
* @param {{ warn: Function }} p.say
|
|
165
|
+
* @param {string[]|null} [p.supports] - from `foundationSupports`; null = unknown
|
|
166
|
+
*/
|
|
167
|
+
export function announceServices({ siteYml, say, supports = null }) {
|
|
168
|
+
// An entry the push cannot read stops the package build next, which says what to write
|
|
169
|
+
// (`refuseUnreadableServices`). Nothing to add before it — and what this would say, read
|
|
170
|
+
// past that entry, is wrong: `search: yes` was "site.yml turns on `search`" (F14).
|
|
171
|
+
if (unreadableServices(siteYml?.services).length) return
|
|
172
|
+
const asks = readServicesRequest(siteYml?.services, { warn: (m) => say.warn(m) }) || []
|
|
173
|
+
if (!Array.isArray(supports)) return
|
|
174
|
+
const ignored = new Set(['tracking', 'records'])
|
|
175
|
+
const has = (ask) => ask.enabled !== false || typeof ask.config?.endpoint === 'string'
|
|
176
|
+
const unrendered = asks.filter((a) => has(a) && !ignored.has(a.name) && !supports.includes(a.name)).map((a) => a.name)
|
|
177
|
+
if (unrendered.length) {
|
|
178
|
+
say.warn(`site.yml turns on ${names(unrendered)}, which your foundation does not say it renders (\`uniweb.supports\`).`)
|
|
179
|
+
}
|
|
180
|
+
const mentioned = new Set(asks.map((a) => a.name))
|
|
181
|
+
const unasked = supports.filter((n) => !ignored.has(n) && !mentioned.has(n))
|
|
182
|
+
if (unasked.length) {
|
|
183
|
+
say.warn(
|
|
184
|
+
`Your foundation renders ${names(unasked)}, which site.yml does not ask for — a service is off ` +
|
|
185
|
+
`unless the site asks for it. Add ${unasked.length === 1 ? 'it' : 'them'} under \`services:\`, or set ` +
|
|
186
|
+
`${unasked.length === 1 ? 'it' : 'each'} to \`false\` if that is what you mean.`
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The publish warning for `records`: the site's pages show records live, and `site.yml`
|
|
193
|
+
* does not ask for the service that delivers them on a published site [Diego, 2026-10-07:
|
|
194
|
+
* "When records is off and the site is published with us, there is no meant to be a fall
|
|
195
|
+
* back at all"]. Syncing records does not depend on it, so push and pull say nothing.
|
|
196
|
+
*
|
|
197
|
+
* @param {object} p
|
|
198
|
+
* @param {object} p.siteYml - parsed
|
|
199
|
+
* @param {string[]} p.shown - from the package (`recordsShown`)
|
|
200
|
+
* @returns {string|null} the warning, or null
|
|
201
|
+
*/
|
|
202
|
+
export function recordsNotAsked({ siteYml, shown }) {
|
|
203
|
+
if (!Array.isArray(shown) || !shown.length) return null
|
|
204
|
+
const records = (readServicesRequest(siteYml?.services) || []).find((a) => a.name === 'records')
|
|
205
|
+
if (records && records.enabled !== false) return null
|
|
206
|
+
return (
|
|
207
|
+
`Your pages show records from ${names(shown)}, but site.yml does not ask for \`records\` — ` +
|
|
208
|
+
'a published site delivers them only with it on. Add `records: true` under `services:`.'
|
|
209
|
+
)
|
|
210
|
+
}
|