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.
@@ -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` + `$model` + sections). The site's
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 Own the new site under @org. On the FIRST
31
- * push it decides which org owns the site, and
32
- * whose storage its assets are charged to;
33
- * recorded in `sync.json`, and every request
34
- * after names it. Without it, you are asked
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 { warnIfContentDoesNotConform } from '../utils/conformance.js'
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
- // Advisory only — warns and pushes. A malformed data block otherwise rides
159
- // the sync wire unchecked; see utils/conformance.js.
160
- await warnIfContentDoesNotConform(siteDir, { args })
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
- // WHO will own this site, if this push is the one that creates it. Resolved
220
- // before any lane runs, because both create paths below consume it and neither
221
- // should be reached with the question still open. A site that already exists
222
- // resolves to null without asking — ownership was settled at its create.
223
- const org = await resolveSiteOrgForCreate({
224
- client,
225
- siteDir,
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
- // ⭐ Name the workspace every site request works in. The user's own choice (`--org`,
293
- // `--personal`, the picker) is explicit, so a `409 wrong_workspace` stops the push; a
294
- // recorded one is adopted afresh from the backend's answer when stale
295
- // (`nameSiteWorkspace`).
296
- nameSiteWorkspace(client, {
297
- siteDir,
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
 
@@ -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, nameSiteWorkspace, readSiteWorkspace } from '../backend/site-sync.js'
29
- import { readOrgFlag } from '../utils/args.js'
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 the site's requests name — as pull names it. A stale record is
132
- // re-adopted from the backend's answer; `--json` keeps stdout to the document.
133
- const orgFlag = readOrgFlag(args)
134
- nameSiteWorkspace(client, {
135
- siteDir,
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
- say.info(
250
- 'Local foundation differs from the registered version — `uniweb register` (or `uniweb publish`) to release the change.'
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
  }
@@ -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. It answers "does my data match what I promised?" — distinct
8
- * from `doctor`, which checks your project against framework conventions.
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
- * It warns by default; `--strict` turns findings into a non-zero exit for CI.
11
- * The live render path stays tolerant — this gate runs before a site is live,
12
- * by choice. Dynamic (remote) inputs and entity references can't be resolved
13
- * without a running backend, so they're reported as deferred, never silently
14
- * skipped.
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
- const strict = args.includes('--strict')
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}(pass --strict to fail CI)${colors.reset}`}`
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 under --strict; 0 = clean
366
- // or warn-only (the live path stays tolerant, so findings don't fail by
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 }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-23T16:44:03.282Z",
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.59.1",
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.4.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.2",
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 (it only warns)
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), syncs content, settles payment when go-live needs it (a browser
1443
- handoff), and goes live. A published-registry foundation needs no release; an
1444
- already-paid site opens no browser.
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 (it only warns)
1459
- --org @org Publish under @org (membership-gated). On a site's FIRST
1460
- publish it decides which org owns the site, and whose storage
1461
- its assets are charged to. Recorded in sync.json and named on
1462
- every request after, so it is never re-typed. Without it, you
1463
- are asked once. Later, it must match the workspace the backend
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
- Warns by default; the live render path stays tolerant, so this is a
1699
- pre-live / CI gate. Dynamic (\`url:\`) inputs and entity references can't be
1700
- resolved without a running backend, so they're reported as deferred,
1701
- never silently skipped.
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
- --strict Treat findings as errors (non-zero exit for CI)
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 warn-only), 1 violations under --strict, 2 setup error.
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 (CI / no TTY), pass \`--token <bearer>\`, or set
1804
- \`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`, or set \`UNIWEB_TOKEN\` (used per-command,
1805
- not stored). \`uniweb logout\` logs you out.
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
- --org @org Publish under @org (first publish only; then remembered)
2086
- --personal Own the new site personally, deliberately (first publish only)
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 only warns)
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 (it only warns)
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}
@@ -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
- * ## It WARNS. It never blocks, and that is a decision, not a TODO.
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. `--strict`
18
- * on `uniweb validate` is where the gate lives, and CI is where it belongs.
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 check, one compact
21
- * block when it finds something, and **the ship proceeds either way**.
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 that this is not a
205
- // refusal — a warning during a ship reads as a failure unless it says so.
206
- details.push('Shipping anyway. Run `uniweb validate` for the full report.')
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
  }