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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. 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
+ }