uniweb 0.84.0 → 0.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -6
- package/partials/agents.md +126 -80
- package/src/backend/client.js +59 -6
- package/src/backend/foundation-bring-along.js +5 -3
- package/src/backend/service-request.js +91 -145
- package/src/backend/site-preview.js +1 -1
- package/src/backend/site-sync.js +161 -54
- package/src/commands/build.js +6 -2
- package/src/commands/clone.js +1 -1
- package/src/commands/deploy.js +3 -3
- package/src/commands/doctor.js +32 -36
- package/src/commands/forget.js +7 -7
- package/src/commands/publish.js +28 -30
- package/src/commands/pull.js +22 -11
- package/src/commands/push.js +19 -28
- package/src/commands/refresh.js +2 -1
- package/src/commands/register.js +6 -6
- package/src/commands/rename.js +6 -6
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +14 -5
- package/src/commands/status.js +1 -1
- package/src/framework-index.json +11 -11
- package/src/index.js +41 -11
- package/src/utils/config.js +13 -13
- package/src/utils/flag-guard.js +30 -16
- package/src/utils/site-identity.js +3 -3
- package/src/utils/yaml-edit.js +0 -115
|
@@ -5,36 +5,30 @@
|
|
|
5
5
|
* Two of them, kept by two mechanisms:
|
|
6
6
|
*
|
|
7
7
|
* - ⭐ **The services** — `site.yml::services`, a map by service name *[Diego,
|
|
8
|
-
* 2026-10-06]*.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* kb/framework/reference/site-services-request.md).
|
|
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`).
|
|
12
13
|
* - **The language selection** — `site.yml::publishLanguages` — told apart from the
|
|
13
14
|
* status quo by a fingerprint banked in `deploy.yml` (`reconcile`, `bankLanguages`).
|
|
14
15
|
*
|
|
15
16
|
* ⭐ The model both follow — *"the services in `site.yml` are a request, never a
|
|
16
|
-
* tracking of what is running"* [Diego, 2026-09-05]. You ask by CHANGING the file.
|
|
17
|
-
* unchanged ask is not re-sent: the backend REPLACES the services it is sent, so a
|
|
18
|
-
* re-send would overwrite a decision the owner made in the app since.
|
|
17
|
+
* tracking of what is running"* [Diego, 2026-09-05]. You ask by CHANGING the file.
|
|
19
18
|
*
|
|
20
19
|
* ⛔ *From 2026-09-20 to 2026-10-06 the services lived only in `sync.json`, which
|
|
21
|
-
* nobody edits, so a CLI user could ask for nothing;
|
|
22
|
-
* a fingerprint in `deploy.yml`, which could not tell who moved
|
|
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.*
|
|
23
24
|
*
|
|
24
25
|
* @module
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
28
|
import { createHash } from 'node:crypto'
|
|
28
|
-
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
mergeServiceRows,
|
|
32
|
-
reconcileServices,
|
|
33
|
-
recordAfter,
|
|
34
|
-
readBackendState,
|
|
35
|
-
updateBackendState,
|
|
36
|
-
writeSiteConfig
|
|
37
|
-
} from '@uniweb/build/uwx'
|
|
29
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
30
|
+
import { join } from 'node:path'
|
|
31
|
+
import { readServicesRequest, unreadableServices } from '@uniweb/build/uwx'
|
|
38
32
|
|
|
39
33
|
/**
|
|
40
34
|
* A stable fingerprint of one declared value, or `null` when the key is absent.
|
|
@@ -123,142 +117,94 @@ export function bankLanguages(siteYml) {
|
|
|
123
117
|
return langs ? { publishLanguagesRequest: langs } : {}
|
|
124
118
|
}
|
|
125
119
|
|
|
126
|
-
/**
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
+
}
|
|
137
145
|
}
|
|
138
146
|
|
|
139
|
-
const
|
|
140
|
-
Array.isArray(rows) ? rows.find((r) => r && typeof r === 'object' && r.name === name) : undefined
|
|
147
|
+
const names = (list) => list.map((n) => `\`${n}\``).join(', ')
|
|
141
148
|
|
|
142
149
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
* ask at — is never sent, and the record keeps the earlier agreement for it, so the
|
|
155
|
-
* next run still sees the site's change rather than reading it as the file's.
|
|
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`).
|
|
156
161
|
*
|
|
157
162
|
* @param {object} p
|
|
158
|
-
* @param {object} p.
|
|
159
|
-
* @param {
|
|
160
|
-
* @param {
|
|
161
|
-
* @param {object|null} [p.status] - the site's status, when the caller has just read it
|
|
162
|
-
* @param {boolean} [p.offline] - read nothing from the backend (`--dry-run`, `-o`)
|
|
163
|
-
* @param {boolean} [p.interactive] - whether the owner can be asked
|
|
164
|
-
* @param {(message: string, initial?: boolean) => Promise<boolean>} p.confirm
|
|
165
|
-
* @param {{ info: Function, warn: Function, dim: Function, ok: Function }} p.say
|
|
166
|
-
* @returns {Promise<{ emit: object, after: () => void }>} `emit` — options for the
|
|
167
|
-
* producer; `after` — call once the push succeeded (or had nothing to send)
|
|
163
|
+
* @param {object} p.siteYml - parsed
|
|
164
|
+
* @param {{ warn: Function }} p.say
|
|
165
|
+
* @param {string[]|null} [p.supports] - from `foundationSupports`; null = unknown
|
|
168
166
|
*/
|
|
169
|
-
export
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (!asks) return nothing
|
|
182
|
-
|
|
183
|
-
const state = readBackendState(siteDir, client.origin)
|
|
184
|
-
const record = Array.isArray(state.services) ? state.services : undefined
|
|
185
|
-
const siteUuid = state.site?.uuid || null
|
|
186
|
-
let read = status
|
|
187
|
-
if (read === undefined && !offline && siteUuid) read = await client.siteStatus(siteUuid)
|
|
188
|
-
const stored = Array.isArray(read?.services) ? read.services : undefined
|
|
189
|
-
|
|
190
|
-
const decision = reconcileServices({ asks, record, stored, siteKnown: Boolean(siteUuid) })
|
|
191
|
-
if (decision.unreadable) {
|
|
192
|
-
say.warn("site.yml asks for services, but this project has no record of your site's and could not read them, so none were sent.")
|
|
193
|
-
say.dim(' Run `uniweb pull` to take them, then push again.')
|
|
194
|
-
return nothing
|
|
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\`).`)
|
|
195
179
|
}
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
let offered = [...decision.adopt]
|
|
200
|
-
const open = []
|
|
201
|
-
|
|
202
|
-
// ⛔ BOTH MOVED: only the owner can rank two of their own decisions. Not a stop — the
|
|
203
|
-
// content they asked to push is a separate thing — and never a guess.
|
|
204
|
-
if (decision.conflict.length) {
|
|
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) {
|
|
205
183
|
say.warn(
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
: '
|
|
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.`
|
|
209
187
|
)
|
|
210
|
-
for (const name of decision.conflict) {
|
|
211
|
-
say.dim(` ${name}: site.yml asks ${describeService(askFor(name))} — your site has ${describeService(rowNamed(stored, name))}`)
|
|
212
|
-
}
|
|
213
|
-
if (!interactive) {
|
|
214
|
-
say.dim(" Left as your site has them — run without --non-interactive to choose.")
|
|
215
|
-
open.push(...decision.conflict)
|
|
216
|
-
} else if (await confirm('Use the services in site.yml?', false)) {
|
|
217
|
-
send.push(...decision.conflict)
|
|
218
|
-
} else {
|
|
219
|
-
// Declining to send is not yet a decision to take the site's: offered below.
|
|
220
|
-
offered = [...offered, ...decision.conflict]
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// The site moved and the file did not: the file is behind. Offered, never done — a
|
|
225
|
-
// push changes `site.yml` only when the owner says so.
|
|
226
|
-
if (offered.length) {
|
|
227
|
-
say.info("Your site's services changed since your last sync:")
|
|
228
|
-
for (const name of offered) {
|
|
229
|
-
say.dim(` ${name}: your site has ${describeService(rowNamed(stored, name))} — site.yml says ${describeService(askFor(name))}`)
|
|
230
|
-
}
|
|
231
|
-
if (interactive && (await confirm('Update site.yml to match?', false))) {
|
|
232
|
-
const services = takeServices(siteYml.services, stored, offered)
|
|
233
|
-
writeSiteConfig(siteDir, { services })
|
|
234
|
-
if (services) siteYml.services = services
|
|
235
|
-
else delete siteYml.services
|
|
236
|
-
say.ok('site.yml updated.')
|
|
237
|
-
} else {
|
|
238
|
-
open.push(...offered)
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (send.length) {
|
|
243
|
-
say.info(`Asking for: ${send.map((n) => `${n} ${describeService(askFor(n))}`).join(', ')}`)
|
|
244
188
|
}
|
|
189
|
+
}
|
|
245
190
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
|
|
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
|
+
)
|
|
264
210
|
}
|
|
@@ -46,7 +46,7 @@ export async function readSitePreview({ siteDir, args = [], client = null }) {
|
|
|
46
46
|
refused: [
|
|
47
47
|
`This site's foundation is ${ref}, and the backend the site is on says where that version is served.`,
|
|
48
48
|
known.length
|
|
49
|
-
? `The site is on ${known.join(', ')} — not on ${client.origin}, the backend you are logged in to. To preview it: uniweb login --
|
|
49
|
+
? `The site is on ${known.join(', ')} — not on ${client.origin}, the backend you are logged in to. To preview it: uniweb login --server <url>`
|
|
50
50
|
: `The site is on no backend yet: \`uniweb push\` puts it on ${client.origin}.`
|
|
51
51
|
]
|
|
52
52
|
}
|