uniweb 0.64.1 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/partials/agents.md +19 -19
- package/src/backend/client.js +61 -51
- package/src/backend/foundation-bring-along.js +154 -97
- package/src/backend/site-sync.js +237 -243
- package/src/backend/workspace.js +199 -0
- package/src/commands/clone.js +20 -30
- package/src/commands/deploy.js +9 -7
- package/src/commands/publish.js +37 -43
- package/src/commands/pull.js +232 -81
- package/src/commands/push.js +36 -45
- package/src/commands/status.js +24 -15
- package/src/commands/validate.js +16 -12
- package/src/framework-index.json +4 -4
- package/src/index.js +39 -27
- package/src/utils/conformance.js +68 -10
- package/src/utils/flag-guard.js +12 -9
- package/src/utils/git.js +48 -1
- package/src/utils/pull-written.js +5 -0
- package/src/utils/registry-auth.js +75 -16
- package/src/utils/registry-orgs.js +2 -2
- package/src/utils/semver-precedence.js +23 -0
package/src/commands/push.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - folder lane → one `@uniweb/folder` + the collection-record entities it
|
|
6
6
|
* references (the dynamic half; the `$ref` closure rides together).
|
|
7
7
|
*
|
|
8
|
-
* Each entity is an entity-content document (`$id` + `$
|
|
8
|
+
* Each entity is an entity-content document (`$id` + `$schema` + sections). The site's
|
|
9
9
|
* identity on a backend is its site-content uuid, in that backend's section of
|
|
10
10
|
* `sync.json`. A first push to a backend has none — it CREATEs the site (uuid-less),
|
|
11
11
|
* the backend mints + adopts it and returns the new uuid, which `push` records in
|
|
@@ -27,15 +27,11 @@
|
|
|
27
27
|
*
|
|
28
28
|
* Usage:
|
|
29
29
|
* uniweb push Build, push both lanes, back-fill $uuid
|
|
30
|
-
* uniweb push --org @org
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* once. Later, it must match the workspace the
|
|
36
|
-
* backend works on the site from, or push stops.
|
|
37
|
-
* uniweb push --personal Own the new site personally, deliberately:
|
|
38
|
-
* its requests name no workspace.
|
|
30
|
+
* uniweb push --org @org Work in @org for this push, instead of the
|
|
31
|
+
* uniweb push --personal workspace chosen at `uniweb login` (or your
|
|
32
|
+
* personal workspace). A site this push creates
|
|
33
|
+
* is created — owned — there; a site the backend
|
|
34
|
+
* keeps in another workspace stops the push.
|
|
39
35
|
* uniweb push --dry-run Report what would be pushed; submit nothing
|
|
40
36
|
* uniweb push -o out.uwx Write the .uwx file(s) per lane; submit nothing
|
|
41
37
|
* uniweb push --foundation <dir> Use this local foundation for the Model schema
|
|
@@ -62,15 +58,14 @@
|
|
|
62
58
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
63
59
|
import { join, resolve } from 'node:path'
|
|
64
60
|
import yaml from 'js-yaml'
|
|
65
|
-
import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
61
|
+
import { emitSyncPackages, readBackendState } from '@uniweb/build/uwx'
|
|
66
62
|
import { findSiteCopies, describeSiteCopies } from '../utils/site-copies.js'
|
|
67
63
|
import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
|
|
68
64
|
import { updateBackendMap, carryServed, SYNC_STORE_FILE } from '@uniweb/build/uwx'
|
|
69
65
|
import { BackendClient } from '../backend/client.js'
|
|
70
66
|
import { resolveSiteDir } from './deploy.js'
|
|
71
|
-
import {
|
|
67
|
+
import { refuseIfContentDoesNotConform } from '../utils/conformance.js'
|
|
72
68
|
import { reportSchemalessQueries } from '../utils/schemaless-report.js'
|
|
73
|
-
import { readOrgFlag } from '../utils/args.js'
|
|
74
69
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
75
70
|
import {
|
|
76
71
|
syncedElsewhere,
|
|
@@ -88,14 +83,13 @@ import {
|
|
|
88
83
|
readFolderItemUuids,
|
|
89
84
|
readQueryUuids,
|
|
90
85
|
ensureItemUuids,
|
|
86
|
+
refuseUnsendableRecords,
|
|
91
87
|
ensureSiteExists,
|
|
92
88
|
clearRemoteSyncStateIfUnbound,
|
|
93
89
|
dropSiteBoundValues,
|
|
94
|
-
pushSyncPackages
|
|
95
|
-
resolveSiteOrgForCreate,
|
|
96
|
-
nameSiteWorkspace,
|
|
97
|
-
EXPLICIT_OWNER
|
|
90
|
+
pushSyncPackages
|
|
98
91
|
} from '../backend/site-sync.js'
|
|
92
|
+
import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
|
|
99
93
|
|
|
100
94
|
// Re-exported for downstream importers (pull.js, push.test.js) that read these
|
|
101
95
|
// helpers from this module — their canonical home is now ../backend/site-sync.js.
|
|
@@ -155,9 +149,10 @@ export async function push(args = [], deps = {}) {
|
|
|
155
149
|
|
|
156
150
|
const siteDir = await resolveSiteDir(args, 'push')
|
|
157
151
|
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
152
|
+
// ⭐ A GATE, before anything is sent: the backend checks these records against the
|
|
153
|
+
// schemas this push registers, so one that does not conform would be refused on
|
|
154
|
+
// arrival or lose a value on the way. See utils/conformance.js.
|
|
155
|
+
if (await refuseIfContentDoesNotConform(siteDir, { args })) return { exitCode: 1 }
|
|
161
156
|
// ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
|
|
162
157
|
// backend verb (resolveBackendOrigin). Only UNIWEB_REGISTER_URL — the automation
|
|
163
158
|
// override — outranks it, and nothing talks to a backend the user is not logged in to:
|
|
@@ -216,21 +211,13 @@ export async function push(args = [], deps = {}) {
|
|
|
216
211
|
}
|
|
217
212
|
}
|
|
218
213
|
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
args,
|
|
227
|
-
flag: readOrgFlag(args),
|
|
228
|
-
personal: args.includes('--personal'),
|
|
229
|
-
offline: !!output || dryRun
|
|
230
|
-
})
|
|
231
|
-
if (org.refused) {
|
|
232
|
-
error('Refusing to create this site without naming an owner.')
|
|
233
|
-
note(org.reason)
|
|
214
|
+
// ⭐ THE WORKSPACE THIS PUSH WORKS IN — the one chosen with the login, unless this
|
|
215
|
+
// command names another (`workspace.js`). Resolved before any lane runs: a site this
|
|
216
|
+
// push creates is created in it, and a site the backend keeps elsewhere is refused.
|
|
217
|
+
const ws = await resolveWorkspace({ client, args, offline: !!output || dryRun })
|
|
218
|
+
if (ws.refused) {
|
|
219
|
+
error('This push works in one workspace, and none is chosen.')
|
|
220
|
+
note(ws.reason)
|
|
234
221
|
return { exitCode: 2 }
|
|
235
222
|
}
|
|
236
223
|
|
|
@@ -289,16 +276,12 @@ export async function push(args = [], deps = {}) {
|
|
|
289
276
|
}
|
|
290
277
|
if (!fnd.proceed) return { exitCode: 1 }
|
|
291
278
|
|
|
292
|
-
// ⭐
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
workspace: org.workspace,
|
|
299
|
-
explicit: EXPLICIT_OWNER.has(org.source),
|
|
300
|
-
note
|
|
301
|
-
})
|
|
279
|
+
// ⭐ Every site request names the workspace; a site outside it stops the push
|
|
280
|
+
// (`WorkspaceMismatchError`). A site this push creates is created in it — said first.
|
|
281
|
+
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
282
|
+
if (ws.source !== 'offline' && !readBackendState(siteDir, client.origin).site?.uuid) {
|
|
283
|
+
note(`This push creates the site in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source]}).`)
|
|
284
|
+
}
|
|
302
285
|
|
|
303
286
|
// Build BOTH directional packages (the producer side). Each carries its own
|
|
304
287
|
// `index` — the per-entity source-file map for back-fill, correlated by submission
|
|
@@ -413,6 +396,7 @@ export async function push(args = [], deps = {}) {
|
|
|
413
396
|
let assetIds = null
|
|
414
397
|
if (!output && !dryRun) {
|
|
415
398
|
let mediaRefs = []
|
|
399
|
+
let refusals = []
|
|
416
400
|
try {
|
|
417
401
|
const probe = await emitSyncPackages(siteDir, {
|
|
418
402
|
backend: client.origin,
|
|
@@ -422,10 +406,14 @@ export async function push(args = [], deps = {}) {
|
|
|
422
406
|
resolveModel: makeModelResolver({ client, offline: false })
|
|
423
407
|
})
|
|
424
408
|
mediaRefs = probe.localAssets || []
|
|
409
|
+
refusals = probe.refusals || []
|
|
425
410
|
} catch (err) {
|
|
426
411
|
error(`Could not scan the site for local media: ${err.message}`)
|
|
427
412
|
return { exitCode: 2 }
|
|
428
413
|
}
|
|
414
|
+
// A record the backend would refuse stops the push HERE — before the site is
|
|
415
|
+
// created or a byte uploaded, not merely before the send (`refuseUnsendableRecords`).
|
|
416
|
+
if (refuseUnsendableRecords(refusals, { error, note })) return { exitCode: 1 }
|
|
429
417
|
if (mediaRefs.length) {
|
|
430
418
|
// The site has to exist before its bytes do — an upload with no owning
|
|
431
419
|
// entity is charged and cannot be freed, because freeing means deleting the
|
|
@@ -549,6 +537,9 @@ export async function push(args = [], deps = {}) {
|
|
|
549
537
|
const { siteContent, records, siteContentUuid, warnings, skipped } = pkg
|
|
550
538
|
log('')
|
|
551
539
|
for (const w of warnings) note(`! ${w}`)
|
|
540
|
+
// The same stop for the paths the probe above does not run on — `-o` and
|
|
541
|
+
// `--dry-run` say what a push would do, and a push would stop here.
|
|
542
|
+
if (refuseUnsendableRecords(pkg.refusals, { error, note })) return { exitCode: 1 }
|
|
552
543
|
// Warn level, not dim: this is the author choosing entities vs static files.
|
|
553
544
|
reportSchemalessQueries(pkg.schemaless, { warn, dim: note })
|
|
554
545
|
|
package/src/commands/status.js
CHANGED
|
@@ -25,12 +25,11 @@ import { join } from 'node:path'
|
|
|
25
25
|
import yaml from 'js-yaml'
|
|
26
26
|
|
|
27
27
|
import { resolveSiteDir } from './deploy.js'
|
|
28
|
-
import { probeUnpushed
|
|
29
|
-
import {
|
|
28
|
+
import { probeUnpushed } from '../backend/site-sync.js'
|
|
29
|
+
import { resolveWorkspace } from '../backend/workspace.js'
|
|
30
30
|
import {
|
|
31
31
|
BackendClient,
|
|
32
32
|
resolveBackendOrigin,
|
|
33
|
-
workspaceHandle,
|
|
34
33
|
WorkspaceMismatchError
|
|
35
34
|
} from '../backend/client.js'
|
|
36
35
|
import { readBackendState } from '@uniweb/build/uwx'
|
|
@@ -39,6 +38,7 @@ import {
|
|
|
39
38
|
foundationScopedName
|
|
40
39
|
} from '../backend/foundation-bring-along.js'
|
|
41
40
|
import { computeFoundationDigest } from '../utils/code-upload.js'
|
|
41
|
+
import { compareSemverPrecedence } from '../utils/semver-precedence.js'
|
|
42
42
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
43
43
|
|
|
44
44
|
const c = {
|
|
@@ -121,6 +121,7 @@ export async function status(args = []) {
|
|
|
121
121
|
let site = null
|
|
122
122
|
let fdnLatest = null
|
|
123
123
|
let foundationFresh = null // true/false when both digests are known; else null
|
|
124
|
+
let localFoundationVersion = null
|
|
124
125
|
let remoteError = null // a refusal about the workspace — the one remote failure worth saying
|
|
125
126
|
if (remote) {
|
|
126
127
|
try {
|
|
@@ -128,21 +129,18 @@ export async function status(args = []) {
|
|
|
128
129
|
args,
|
|
129
130
|
command: 'Status'
|
|
130
131
|
})
|
|
131
|
-
// The workspace
|
|
132
|
-
//
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
workspace: orgFlag ? workspaceHandle(orgFlag) : readSiteWorkspace(siteDir, client.origin),
|
|
137
|
-
explicit: Boolean(orgFlag),
|
|
138
|
-
note: jsonMode ? undefined : say.dim
|
|
139
|
-
})
|
|
132
|
+
// The workspace this status works in — the login's, unless this command names
|
|
133
|
+
// another (`workspace.js`). None chosen is reported, like a site outside it.
|
|
134
|
+
const ws = await resolveWorkspace({ client, args })
|
|
135
|
+
if (ws.refused) throw Object.assign(new Error(ws.reason), { status: 409 })
|
|
136
|
+
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
140
137
|
if (uuid) site = await client.siteStatus(uuid)
|
|
141
138
|
// Foundation freshness: prefer the LOCAL foundation's scoped name (so a
|
|
142
139
|
// local-foundation site can be checked too); fall back to a scoped
|
|
143
140
|
// site.yml ref. The digest compare is read-only — it never builds, so it
|
|
144
141
|
// only fires when the local foundation is already built (dist present).
|
|
145
142
|
const local = resolveLocalFoundation(siteDir, siteYml)
|
|
143
|
+
localFoundationVersion = local?.version ?? null
|
|
146
144
|
const lookupName =
|
|
147
145
|
(local && (await foundationScopedName(local.dir))) || fndScope
|
|
148
146
|
if (lookupName) fdnLatest = await client.readFoundationLatest(lookupName)
|
|
@@ -245,10 +243,21 @@ export async function status(args = []) {
|
|
|
245
243
|
`A newer foundation version (${fdnLatest.latest_version}) is registered than the site pins (${fndVersion}).`
|
|
246
244
|
)
|
|
247
245
|
}
|
|
246
|
+
// What the next push or publish does with it (`backend/foundation-bring-along.js`):
|
|
247
|
+
// release changed code — under the next version when its own is taken — unless the
|
|
248
|
+
// registry holds a NEWER version, which stops them. ⛔ This pointed at `uniweb
|
|
249
|
+
// register` until 2026-09-24 — which cannot release a change under a version
|
|
250
|
+
// already registered, since a registered version is immutable.
|
|
248
251
|
if (foundationFresh === false) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
+
if (compareSemverPrecedence(localFoundationVersion, fdnLatest.latest_version) === -1) {
|
|
253
|
+
say.info(
|
|
254
|
+
`The registry holds foundation ${fdnLatest.latest_version}, newer than your ${localFoundationVersion} — a push or publish stops until you pull that change, or pass \`--bump\` to release yours above it.`
|
|
255
|
+
)
|
|
256
|
+
} else {
|
|
257
|
+
say.info(
|
|
258
|
+
'Local foundation differs from the registered version — the next `uniweb push` or `uniweb publish` releases it.'
|
|
259
|
+
)
|
|
260
|
+
}
|
|
252
261
|
} else if (foundationFresh === true) {
|
|
253
262
|
say.ok('Local foundation matches the registered version.')
|
|
254
263
|
}
|
package/src/commands/validate.js
CHANGED
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
*
|
|
5
5
|
* For each section that consumes file-based data, this resolves the schema the
|
|
6
6
|
* foundation bound to that input (via `meta.js` `data:`) and checks the data
|
|
7
|
-
* items against it
|
|
8
|
-
*
|
|
7
|
+
* items against it — and every record file in the records directory against the
|
|
8
|
+
* schema its folder names, whether or not a section reads it. It answers "does my
|
|
9
|
+
* data match what I promised?" — distinct from `doctor`, which checks your project
|
|
10
|
+
* against framework conventions.
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* ⭐ A violation fails it (exit 1); `--lax` reports without failing. It warned by
|
|
13
|
+
* default until 2026-09-24, with `--strict` to fail — a gate that passes is one
|
|
14
|
+
* nobody reads, and `push` / `publish` now refuse the same findings. The live render
|
|
15
|
+
* path stays tolerant. Dynamic (remote) inputs and entity references can't be
|
|
16
|
+
* resolved without a running backend, so they're reported as deferred, never
|
|
17
|
+
* silently skipped.
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
import { existsSync, readFileSync } from 'node:fs'
|
|
@@ -201,7 +204,9 @@ function dedupeUsers(users) {
|
|
|
201
204
|
|
|
202
205
|
export async function validate(args = []) {
|
|
203
206
|
const asJson = args.includes('--json')
|
|
204
|
-
|
|
207
|
+
// A violation fails the run unless `--lax` says to only report it. (`--strict`, the
|
|
208
|
+
// flag that turned failing on until 2026-09-24, is simply the default now.)
|
|
209
|
+
const strict = !args.includes('--lax')
|
|
205
210
|
const siteFilter = flagValue(args, '--site')
|
|
206
211
|
const positional = args.find(
|
|
207
212
|
(a, i) => !a.startsWith('--') && args[i - 1] !== '--site'
|
|
@@ -353,7 +358,7 @@ export async function validate(args = []) {
|
|
|
353
358
|
? `${colors.red}error${colors.reset}`
|
|
354
359
|
: `${colors.yellow}warning${colors.reset}`
|
|
355
360
|
log(
|
|
356
|
-
`${totalViolations} violation(s) — reported as ${mode}${strict ? '' : ` ${colors.dim}(
|
|
361
|
+
`${totalViolations} violation(s) — reported as ${mode}${strict ? '' : ` ${colors.dim}(--lax: without it, this fails)${colors.reset}`}`
|
|
357
362
|
)
|
|
358
363
|
}
|
|
359
364
|
if (hadError)
|
|
@@ -362,9 +367,8 @@ export async function validate(args = []) {
|
|
|
362
367
|
}
|
|
363
368
|
}
|
|
364
369
|
|
|
365
|
-
// Exit semantics: 2 = couldn't run; 1 = violations
|
|
366
|
-
//
|
|
367
|
-
// default). Setup/read failures are surfaced but don't fail the build.
|
|
370
|
+
// Exit semantics: 2 = couldn't run; 1 = violations (unless --lax); 0 = clean, or
|
|
371
|
+
// violations reported under --lax. Setup/read failures are surfaced but don't fail.
|
|
368
372
|
if (hadError) return { exitCode: 2 }
|
|
369
373
|
if (totalViolations > 0 && strict) return { exitCode: 1 }
|
|
370
374
|
return { exitCode: 0 }
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"generatedAt": "2026-09-24T15:29:04.711Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
6
|
"version": "0.6.1",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
]
|
|
11
11
|
},
|
|
12
12
|
"@uniweb/build": {
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.60.0",
|
|
14
14
|
"path": "framework/build",
|
|
15
15
|
"deps": [
|
|
16
16
|
"@uniweb/content-reader",
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
"deps": []
|
|
97
97
|
},
|
|
98
98
|
"@uniweb/schemas": {
|
|
99
|
-
"version": "0.
|
|
99
|
+
"version": "0.5.0",
|
|
100
100
|
"path": "framework/schemas",
|
|
101
101
|
"deps": []
|
|
102
102
|
},
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"deps": []
|
|
127
127
|
},
|
|
128
128
|
"@uniweb/unipress": {
|
|
129
|
-
"version": "0.10.
|
|
129
|
+
"version": "0.10.3",
|
|
130
130
|
"path": "framework/unipress",
|
|
131
131
|
"deps": [
|
|
132
132
|
"@uniweb/build",
|
package/src/index.js
CHANGED
|
@@ -1418,7 +1418,8 @@ ${colors.bright}Options:${colors.reset}
|
|
|
1418
1418
|
--target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
|
|
1419
1419
|
--dry-run Resolve the target + adapter; print summary; upload nothing
|
|
1420
1420
|
--no-save Do not record this deploy in deploy.yml
|
|
1421
|
-
--no-validate Skip the content-conformance check (
|
|
1421
|
+
--no-validate Skip the content-conformance check (a static host warns;
|
|
1422
|
+
Uniweb Cloud stops on content that does not conform)
|
|
1422
1423
|
--non-interactive Fail with usage info instead of prompting
|
|
1423
1424
|
|
|
1424
1425
|
${colors.bright}Examples:${colors.reset}
|
|
@@ -1439,9 +1440,10 @@ ${colors.bright}Usage:${colors.reset}
|
|
|
1439
1440
|
The most ergonomic command in the tool: \`uniweb login && uniweb publish\` reads
|
|
1440
1441
|
your project and makes the site live on Uniweb hosting. It resolves which site,
|
|
1441
1442
|
BRINGS THE FOUNDATION ALONG (releases the local foundation when its code
|
|
1442
|
-
changed
|
|
1443
|
-
|
|
1444
|
-
|
|
1443
|
+
changed — under the next version when its own is registered; commit the
|
|
1444
|
+
package.json it writes), syncs content, settles payment when go-live needs it
|
|
1445
|
+
(a browser handoff), and goes live. A published-registry foundation needs no
|
|
1446
|
+
release; an already-paid site opens no browser.
|
|
1445
1447
|
|
|
1446
1448
|
For a third-party host use \`uniweb deploy --host=<name>\`; to register a
|
|
1447
1449
|
FOUNDATION on its own use \`uniweb register\` (alias \`uniweb release\`).
|
|
@@ -1454,17 +1456,15 @@ ${colors.bright}Options:${colors.reset}
|
|
|
1454
1456
|
when only the content should go live. Refused if the
|
|
1455
1457
|
foundation has never been released — there is nothing to
|
|
1456
1458
|
bind to, and the app cannot open such a site.
|
|
1459
|
+
--bump When the registry holds a newer version of the foundation
|
|
1460
|
+
than yours, release yours above it instead of stopping
|
|
1457
1461
|
--no-save Do not record this deploy in deploy.yml
|
|
1458
|
-
--no-validate Skip the content-conformance check
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
works on the site from, or publish stops.
|
|
1465
|
-
--personal Create the site under your personal account, deliberately.
|
|
1466
|
-
Only needed on a first publish, and only to answer the owner
|
|
1467
|
-
question without a prompt (CI, agents, scripts).
|
|
1462
|
+
--no-validate Skip the content-conformance check, which stops a publish
|
|
1463
|
+
whose content does not conform to its data schemas
|
|
1464
|
+
--org @org Work in @org for this publish, instead of the workspace
|
|
1465
|
+
--personal chosen at \`uniweb login\` (or your personal workspace). A
|
|
1466
|
+
site this publish creates is created — owned — there, and a
|
|
1467
|
+
site the backend keeps in another workspace stops it.
|
|
1468
1468
|
`,
|
|
1469
1469
|
create: `
|
|
1470
1470
|
${colors.cyan}${colors.bright}uniweb create${colors.reset} ${colors.dim}— Create a new project${colors.reset}
|
|
@@ -1695,17 +1695,18 @@ foundation declared for that input (meta.js \`data:\`). Answers "does my
|
|
|
1695
1695
|
data match what I promised?" — distinct from \`doctor\`, which checks your
|
|
1696
1696
|
project against framework conventions.
|
|
1697
1697
|
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1698
|
+
Every record file is checked against the schema its folder names, whether or
|
|
1699
|
+
not a section reads it. A violation fails the run; \`push\` and \`publish\` refuse
|
|
1700
|
+
the same findings. The live render path stays tolerant. Dynamic (\`url:\`)
|
|
1701
|
+
inputs and entity references can't be resolved without a running backend, so
|
|
1702
|
+
they're reported as deferred, never silently skipped.
|
|
1702
1703
|
|
|
1703
1704
|
${colors.bright}Options:${colors.reset}
|
|
1704
|
-
--
|
|
1705
|
+
--lax Report violations without failing (exit 0)
|
|
1705
1706
|
--json Machine-readable output (for CI annotations)
|
|
1706
1707
|
--site <name> Check one site in a multi-site workspace
|
|
1707
1708
|
|
|
1708
|
-
Exit codes: 0 clean (or
|
|
1709
|
+
Exit codes: 0 clean (or --lax), 1 violations, 2 setup error.
|
|
1709
1710
|
`,
|
|
1710
1711
|
register: `
|
|
1711
1712
|
${colors.cyan}${colors.bright}uniweb register${colors.reset} ${colors.dim}— Register (release) a foundation + its data schemas with the backend registry${colors.reset}
|
|
@@ -1793,16 +1794,26 @@ does nothing; add --password, --browser, --token-paste or --token to log in agai
|
|
|
1793
1794
|
Without --backend: https://uniweb.app (or \$UNIWEB_REGISTER_URL). No command talks
|
|
1794
1795
|
to a backend you are not logged in to — run one before logging in and it asks first.
|
|
1795
1796
|
|
|
1797
|
+
${colors.bright}The workspace you work in.${colors.reset} A login works in ONE workspace — your
|
|
1798
|
+
personal one, or an organization's — and every push, pull and publish works in it:
|
|
1799
|
+
a site it creates is created there, and a site kept in another workspace is refused.
|
|
1800
|
+
With no organization it is your personal workspace; with organizations you are asked,
|
|
1801
|
+
or name it. Already logged in, \`uniweb login --org @other\` switches without logging
|
|
1802
|
+
in again.
|
|
1803
|
+
|
|
1796
1804
|
${colors.bright}Options:${colors.reset}
|
|
1797
1805
|
--backend <url> The backend to log in to
|
|
1806
|
+
--org @org Work in @org (an organization you belong to)
|
|
1807
|
+
--personal Work in your personal workspace
|
|
1798
1808
|
--token <bearer> Seed + verify a session from a bearer token (non-interactive)
|
|
1799
1809
|
--browser Force the browser method
|
|
1800
1810
|
--password Force the username/password method
|
|
1801
1811
|
--token-paste Force the paste-a-token prompt
|
|
1802
1812
|
|
|
1803
|
-
In non-interactive mode (
|
|
1804
|
-
\`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`, or set
|
|
1805
|
-
not stored)
|
|
1813
|
+
In non-interactive mode (no TTY — an agent, a script), pass \`--token <bearer>\` with
|
|
1814
|
+
\`--org @org\` or \`--personal\`, or set \`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`, or set
|
|
1815
|
+
\`UNIWEB_TOKEN\` (per process, not stored) with \`UNIWEB_WORKSPACE=@org\` or \`personal\`.
|
|
1816
|
+
\`uniweb logout\` logs you out.
|
|
1806
1817
|
`,
|
|
1807
1818
|
refresh: `
|
|
1808
1819
|
${colors.cyan}${colors.bright}uniweb refresh${colors.reset} ${colors.dim}— Catch up with teammates AND app authors${colors.reset}
|
|
@@ -2082,10 +2093,11 @@ ${colors.bright}Publish Options:${colors.reset}
|
|
|
2082
2093
|
--dry-run Resolve everything; release/sync/POST nothing
|
|
2083
2094
|
--yes Skip confirmations (CI); never block on a prompt
|
|
2084
2095
|
--no-release Ship content against the already-released code; release nothing
|
|
2085
|
-
--
|
|
2086
|
-
--
|
|
2096
|
+
--bump Release above a newer registered foundation version
|
|
2097
|
+
--org @org Work in @org for this publish, not your login's workspace
|
|
2098
|
+
--personal Work in your personal workspace for this publish
|
|
2087
2099
|
--no-save Do not record this deploy in deploy.yml
|
|
2088
|
-
--no-validate Skip the content-conformance check (it
|
|
2100
|
+
--no-validate Skip the content-conformance check (it stops a publish)
|
|
2089
2101
|
|
|
2090
2102
|
uniweb publish is the smart Uniweb-hosting path: it brings the site's
|
|
2091
2103
|
foundation along, syncs, and goes live. To register a foundation on its own
|
|
@@ -2099,7 +2111,7 @@ ${colors.bright}Deploy Options:${colors.reset}
|
|
|
2099
2111
|
--target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
|
|
2100
2112
|
--dry-run Resolve the target + adapter; print summary; upload nothing
|
|
2101
2113
|
--no-save Do not record this deploy in deploy.yml
|
|
2102
|
-
--no-validate Skip the content-conformance check (
|
|
2114
|
+
--no-validate Skip the content-conformance check (a static host warns)
|
|
2103
2115
|
To deploy on every push instead, see \`uniweb add ci --help\`.
|
|
2104
2116
|
|
|
2105
2117
|
${colors.bright}Dev Options:${colors.reset}
|
package/src/utils/conformance.js
CHANGED
|
@@ -8,17 +8,26 @@
|
|
|
8
8
|
* it. The first symptom was a section rendering nothing on a live site, with no
|
|
9
9
|
* error anywhere in the chain.
|
|
10
10
|
*
|
|
11
|
-
* ##
|
|
11
|
+
* ## On a STATIC host it warns and never blocks — and on a backend it is a gate.
|
|
12
|
+
*
|
|
13
|
+
* ⭐ Two answers, one per destination [Diego, 2026-09-24: "The old rationale was in
|
|
14
|
+
* the context of static sites. It was never meant for pushed sites since that didn't
|
|
15
|
+
* even exist at the time."]. `push` and `publish` register the foundation first, so
|
|
16
|
+
* the backend checks the records against the very schemas checked here: a finding
|
|
17
|
+
* there is a refusal on arrival, or a value lost on the way — never a disagreement to
|
|
18
|
+
* ship through. `refuseIfContentDoesNotConform` is that gate; `deploy` to a static
|
|
19
|
+
* host keeps the warning below, whose reasoning is unchanged for it:
|
|
12
20
|
*
|
|
13
21
|
* A schema can be newer than the content that was valid when it was authored —
|
|
14
22
|
* a foundation upgrade, a `@std/*` revision — so a finding does not mean the
|
|
15
23
|
* content is wrong, it means the two disagree. A framework that refused to
|
|
16
24
|
* publish over that would make an author's site hostage to a schema release,
|
|
17
|
-
* for a defect whose real-world cost is one section rendering wrong.
|
|
18
|
-
*
|
|
25
|
+
* for a defect whose real-world cost is one section rendering wrong. `uniweb
|
|
26
|
+
* validate` is where the gate lives for a static site, and CI is where it belongs.
|
|
19
27
|
*
|
|
20
|
-
* So: silent when everything conforms, silent when it cannot
|
|
21
|
-
* block when it finds something, and **the ship proceeds
|
|
28
|
+
* So, on a static host: silent when everything conforms, silent when it cannot
|
|
29
|
+
* check, one compact block when it finds something, and **the ship proceeds
|
|
30
|
+
* either way**.
|
|
22
31
|
*
|
|
23
32
|
* ## Why it is quiet about being unable to check
|
|
24
33
|
*
|
|
@@ -41,7 +50,8 @@ const c = {
|
|
|
41
50
|
reset: '\x1b[0m',
|
|
42
51
|
bold: '\x1b[1m',
|
|
43
52
|
dim: '\x1b[2m',
|
|
44
|
-
yellow: '\x1b[33m'
|
|
53
|
+
yellow: '\x1b[33m',
|
|
54
|
+
red: '\x1b[31m'
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
/** How many findings to print before summarizing the rest. */
|
|
@@ -144,6 +154,47 @@ export async function warnIfContentDoesNotConform(siteDir, options = {}) {
|
|
|
144
154
|
return formatted.total
|
|
145
155
|
}
|
|
146
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The same check as a GATE, for `push` and `publish` — see the header for why a
|
|
159
|
+
* backend gets a gate where a static host gets a warning.
|
|
160
|
+
*
|
|
161
|
+
* Only VIOLATIONS stop it: a record the backend would refuse, or whose value it would
|
|
162
|
+
* lose. A setup error is about how data reaches a section — a rendering question, not
|
|
163
|
+
* a record — so it is still printed as a warning and the ship goes on. A checker that
|
|
164
|
+
* cannot run says nothing and stops nothing, as above. `--no-validate` skips the check
|
|
165
|
+
* and leaves the records to the backend.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} siteDir
|
|
168
|
+
* @param {object} [options] - `args`, and the caller's `error` / `warn` / `dim` printers
|
|
169
|
+
* @returns {Promise<number>} the violations that stop the ship (0 to go on)
|
|
170
|
+
*/
|
|
171
|
+
export async function refuseIfContentDoesNotConform(siteDir, options = {}) {
|
|
172
|
+
const {
|
|
173
|
+
args = [],
|
|
174
|
+
error = (m) => console.log(`${c.red}✗${c.reset} ${m}`),
|
|
175
|
+
warn = (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
|
|
176
|
+
dim = (m) => console.log(` ${c.dim}${m}${c.reset}`)
|
|
177
|
+
} = options
|
|
178
|
+
|
|
179
|
+
if (args.includes('--no-validate')) return 0
|
|
180
|
+
|
|
181
|
+
let result
|
|
182
|
+
try {
|
|
183
|
+
result = await checkSiteConformance(siteDir)
|
|
184
|
+
} catch {
|
|
185
|
+
return 0
|
|
186
|
+
}
|
|
187
|
+
if (result.status !== 'checked') return 0
|
|
188
|
+
|
|
189
|
+
const violations = result.report?.violations?.length || 0
|
|
190
|
+
const formatted = formatConformanceWarning(result, siteDir, { stops: violations > 0 })
|
|
191
|
+
if (!formatted) return 0
|
|
192
|
+
|
|
193
|
+
;(violations > 0 ? error : warn)(formatted.headline)
|
|
194
|
+
for (const line of formatted.details) dim(line)
|
|
195
|
+
return violations
|
|
196
|
+
}
|
|
197
|
+
|
|
147
198
|
/**
|
|
148
199
|
* Turn a report into the lines to print, or `null` when there is nothing to say.
|
|
149
200
|
*
|
|
@@ -154,9 +205,11 @@ export async function warnIfContentDoesNotConform(siteDir, options = {}) {
|
|
|
154
205
|
*
|
|
155
206
|
* @param {{report?: object, foundation?: string}} result
|
|
156
207
|
* @param {string} siteDir
|
|
208
|
+
* @param {{ stops?: boolean }} [options] - `stops`: the ship stops on this report
|
|
209
|
+
* (`refuseIfContentDoesNotConform`), so the last line says nothing was sent
|
|
157
210
|
* @returns {{headline: string, details: string[], total: number}|null}
|
|
158
211
|
*/
|
|
159
|
-
export function formatConformanceWarning(result, siteDir = '') {
|
|
212
|
+
export function formatConformanceWarning(result, siteDir = '', { stops = false } = {}) {
|
|
160
213
|
const violations = result?.report?.violations || []
|
|
161
214
|
const setupErrors = result?.report?.setupErrors || []
|
|
162
215
|
const total = violations.length + setupErrors.length
|
|
@@ -201,9 +254,14 @@ export function formatConformanceWarning(result, siteDir = '') {
|
|
|
201
254
|
const details = lines.slice(0, MAX_SHOWN).map((l) => `• ${l}`)
|
|
202
255
|
if (lines.length > MAX_SHOWN) details.push(`…and ${lines.length - MAX_SHOWN} more`)
|
|
203
256
|
|
|
204
|
-
// Name the command that explains it, and say plainly
|
|
205
|
-
//
|
|
206
|
-
|
|
257
|
+
// Name the command that explains it, and say plainly which this is — a warning
|
|
258
|
+
// during a ship reads as a failure unless it says so, and a refusal must say what
|
|
259
|
+
// did not happen.
|
|
260
|
+
details.push(
|
|
261
|
+
stops
|
|
262
|
+
? 'Nothing was sent. Run `uniweb validate` for the full report, or pass `--no-validate` to send it anyway and let the backend decide.'
|
|
263
|
+
: 'Shipping anyway. Run `uniweb validate` for the full report.'
|
|
264
|
+
)
|
|
207
265
|
|
|
208
266
|
return { headline, details, total }
|
|
209
267
|
}
|