uniweb 0.12.34 → 0.12.36

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.
@@ -76,7 +76,7 @@ export async function runtime(args = []) {
76
76
  }
77
77
 
78
78
  // Version from the SPA build's manifest (the backend keys the version on it);
79
- // --version overrides, parity with `uniwebd runtime install --version`.
79
+ // --version overrides, parity with the backend's runtime install --version.
80
80
  let version = readFlagValue(rest, '--version')
81
81
  if (!version) {
82
82
  try {
@@ -0,0 +1,193 @@
1
+ /**
2
+ * uniweb status — show how a site's local files compare to the Uniweb backend:
3
+ * its sync identity, unpushed content changes, and the foundation it references.
4
+ *
5
+ * LOCAL + OFFLINE by default: it builds the sync packages with an OFFLINE Model
6
+ * resolver and diffs them against the send-only-changed cache (the same diff
7
+ * `uniweb push` runs) — no auth, no backend round-trip.
8
+ *
9
+ * `--remote` adds the backend signals (may prompt for login, like `git fetch`):
10
+ * - whether the synced draft differs from what's live (publish needed), and
11
+ * - whether a newer foundation version is registered than the site pins.
12
+ * Those use ASSUMED endpoints (see kb shipping-verbs-and-freshness.md §6.5); until
13
+ * the backend exposes them, `--remote` degrades silently to the local view.
14
+ *
15
+ * Usage:
16
+ * uniweb status Sync identity + unpushed content + foundation ref (local)
17
+ * uniweb status --remote Also: draft-vs-live + a newer-registered-foundation check
18
+ * uniweb status --json One JSON line (adds a `remote` object under --remote)
19
+ *
20
+ * Run from a site, or a workspace with one site.
21
+ */
22
+
23
+ import { existsSync, readFileSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+ import yaml from 'js-yaml'
26
+
27
+ import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
28
+ import { probeUnpushed } from '../backend/site-sync.js'
29
+ import { BackendClient } from '../backend/client.js'
30
+ import { readFlagValue } from '../utils/args.js'
31
+ import { resolveLocalFoundation } from '../backend/foundation-bring-along.js'
32
+ import { computeFoundationDigest } from '../utils/code-upload.js'
33
+
34
+ const c = {
35
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
36
+ cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m',
37
+ }
38
+ const say = {
39
+ ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
40
+ info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
41
+ warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
42
+ dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
43
+ }
44
+
45
+ function readSiteYml(siteDir) {
46
+ const p = join(siteDir, 'site.yml')
47
+ if (!existsSync(p)) return {}
48
+ try {
49
+ return yaml.load(readFileSync(p, 'utf8')) || {}
50
+ } catch {
51
+ return {}
52
+ }
53
+ }
54
+
55
+ function foundationRef(siteYml) {
56
+ const f = siteYml.foundation
57
+ if (!f) return null
58
+ return typeof f === 'string' ? f : f.ref || null
59
+ }
60
+
61
+ // A versioned registry ref `@org/name@1.2.3` → its scoped name `@org/name` and
62
+ // pinned version `1.2.3`. A bare/local/unversioned ref → nulls.
63
+ function splitFoundationRef(fnd) {
64
+ if (!fnd || fnd[0] !== '@') return { scope: null, version: null }
65
+ const at = fnd.lastIndexOf('@')
66
+ return at > 0 ? { scope: fnd.slice(0, at), version: fnd.slice(at + 1) } : { scope: null, version: null }
67
+ }
68
+
69
+ export async function status(args = []) {
70
+ const jsonMode = args.includes('--json')
71
+ const remote = args.includes('--remote')
72
+ const siteDir = await resolveSiteDir(args, 'status')
73
+ const siteYml = readSiteYml(siteDir)
74
+ const uuid = siteYml.$uuid || null
75
+ const fnd = foundationRef(siteYml)
76
+ const { scope: fndScope, version: fndVersion } = splitFoundationRef(fnd)
77
+
78
+ // Local content diff — builds the sync packages, never authenticates.
79
+ let probe = null
80
+ let probeErr = null
81
+ try {
82
+ probe = await probeUnpushed(siteDir)
83
+ } catch (err) {
84
+ probeErr = err.message
85
+ }
86
+
87
+ // Remote signals — opt-in (`--remote`). May prompt for login. Degrades to null
88
+ // on 404 / any failure, so a backend without the endpoints just shows local.
89
+ let site = null
90
+ let fdnLatest = null
91
+ let foundationFresh = null // true/false when both digests are known; else null
92
+ if (remote) {
93
+ try {
94
+ const client = new BackendClient({
95
+ originFlag: readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
96
+ siteBackend: await resolveSiteBackend(siteDir),
97
+ token: readFlagValue(args, '--token') || undefined,
98
+ args,
99
+ command: 'Status',
100
+ })
101
+ if (uuid) site = await client.siteStatus(uuid)
102
+ // Foundation freshness: prefer the LOCAL foundation's scoped name (so a
103
+ // local-foundation site can be checked too); fall back to a scoped
104
+ // site.yml ref. The digest compare is read-only — it never builds, so it
105
+ // only fires when the local foundation is already built (dist present).
106
+ const local = resolveLocalFoundation(siteDir, siteYml)
107
+ const lookupName = local?.scopedName || fndScope
108
+ if (lookupName) fdnLatest = await client.readFoundationLatest(lookupName)
109
+ if (local?.dir && fdnLatest?.digest) {
110
+ const localDigest = computeFoundationDigest(join(local.dir, 'dist'))
111
+ if (localDigest) foundationFresh = localDigest === fdnLatest.digest
112
+ }
113
+ } catch {
114
+ // degrade silently
115
+ }
116
+ }
117
+
118
+ if (jsonMode) {
119
+ console.log(
120
+ JSON.stringify({
121
+ synced: Boolean(uuid),
122
+ uuid,
123
+ foundation: fnd,
124
+ changed: probe ? probe.changed : null,
125
+ unchanged: probe ? probe.unchanged : null,
126
+ ...(probeErr ? { error: probeErr } : {}),
127
+ ...(remote ? { remote: { site, foundation_latest: fdnLatest?.latest_version ?? null, foundation_fresh: foundationFresh } } : {}),
128
+ })
129
+ )
130
+ return { exitCode: 0 }
131
+ }
132
+
133
+ console.log('')
134
+
135
+ // Sync identity
136
+ if (uuid) {
137
+ say.ok(`Synced — site-content ${c.bold}${uuid}${c.reset}`)
138
+ } else {
139
+ say.warn('Not synced — this site has never been pushed to a backend.')
140
+ say.dim('Run `uniweb push` to create it, or `uniweb publish` to sync and go live in one step.')
141
+ }
142
+
143
+ // Content
144
+ if (probeErr) {
145
+ say.warn(`Couldn't compute content changes: ${probeErr}`)
146
+ say.dim('A build error or an unresolved data Model can block the offline diff.')
147
+ } else if (!uuid) {
148
+ const n = probe.changed
149
+ say.info(`${n} content ${n === 1 ? 'entity' : 'entities'} ready to push.`)
150
+ } else if (probe.changed === 0) {
151
+ say.ok('Content is in sync with the last push.')
152
+ } else {
153
+ const n = probe.changed
154
+ say.info(
155
+ `${c.bold}${n}${c.reset} content ${n === 1 ? 'entity' : 'entities'} not pushed` +
156
+ (probe.unchanged ? ` (${probe.unchanged} unchanged)` : '') +
157
+ '.'
158
+ )
159
+ say.dim('Run `uniweb publish` to sync and go live (or `uniweb push` to sync only).')
160
+ }
161
+
162
+ // Foundation
163
+ if (fnd) say.dim(`Foundation: ${fnd}`)
164
+
165
+ // Remote signals
166
+ if (remote) {
167
+ if (site) {
168
+ if (site.draft_dirty) {
169
+ say.info('Synced draft has changes not yet live — run `uniweb publish` to go live.')
170
+ } else if (site.published) {
171
+ say.ok('Live with the latest synced content.')
172
+ } else {
173
+ say.info('Synced but not published yet — run `uniweb publish` to go live.')
174
+ }
175
+ }
176
+ if (fdnLatest?.latest_version && fndVersion && fdnLatest.latest_version !== fndVersion) {
177
+ say.info(`A newer foundation version (${fdnLatest.latest_version}) is registered than the site pins (${fndVersion}).`)
178
+ }
179
+ if (foundationFresh === false) {
180
+ say.info('Local foundation differs from the registered version — `uniweb register` (or `uniweb publish`) to release the change.')
181
+ } else if (foundationFresh === true) {
182
+ say.ok('Local foundation matches the registered version.')
183
+ }
184
+ if (!site && !fdnLatest) {
185
+ say.dim('(No remote signals — the backend may not expose them yet.)')
186
+ }
187
+ }
188
+
189
+ console.log('')
190
+ return { exitCode: 0 }
191
+ }
192
+
193
+ export default status
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-06-23T17:13:47.118Z",
3
+ "generatedAt": "2026-06-25T01:51:21.593Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.14.17",
6
+ "version": "0.14.19",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -20,7 +20,7 @@
20
20
  "deps": []
21
21
  },
22
22
  "@uniweb/content-writer": {
23
- "version": "0.2.5",
23
+ "version": "0.2.6",
24
24
  "path": "framework/content-writer",
25
25
  "deps": []
26
26
  },
@@ -99,7 +99,7 @@
99
99
  "deps": []
100
100
  },
101
101
  "@uniweb/unipress": {
102
- "version": "0.4.23",
102
+ "version": "0.4.25",
103
103
  "path": "framework/unipress",
104
104
  "deps": [
105
105
  "@uniweb/build",
package/src/index.js CHANGED
@@ -606,8 +606,10 @@ async function main() {
606
606
  process.exit(result?.exitCode ?? 0)
607
607
  }
608
608
 
609
- // Handle register command (dynamic import — depends on @uniweb/build)
610
- if (command === 'register') {
609
+ // Handle register command (dynamic import — depends on @uniweb/build).
610
+ // `release` is a code-only synonym: `register` reads naturally the first
611
+ // time, `release` for updates (shipping-model.md §6.1). Same act, same code.
612
+ if (command === 'register' || command === 'release') {
611
613
  const { register } = await importProjectCommand('./commands/register.js')
612
614
  const result = await register(args.slice(1))
613
615
  process.exit(result?.exitCode ?? 0)
@@ -627,6 +629,13 @@ async function main() {
627
629
  process.exit(result?.exitCode ?? 0)
628
630
  }
629
631
 
632
+ // Handle status command (dynamic import — offline emit via @uniweb/build)
633
+ if (command === 'status') {
634
+ const { status } = await importProjectCommand('./commands/status.js')
635
+ const result = await status(args.slice(1))
636
+ process.exit(result?.exitCode ?? 0)
637
+ }
638
+
630
639
  // Handle clone command (global — bootstraps a new project from a backend site;
631
640
  // STANDALONE, so a global `uniweb clone` runs here instead of delegating to a
632
641
  // project-local CLI that doesn't exist yet. clone.js avoids any static
@@ -663,15 +672,16 @@ async function main() {
663
672
  return
664
673
  }
665
674
 
666
- // Handle publish command — CMS-publish a SYNCED site (POST /dev/site/publish).
667
- // Distinct from `deploy` (file-built host) and `register` (foundation publishing).
675
+ // Handle publish command — the smart Uniweb-hosting path: bring the
676
+ // foundation along, sync, settle payment, go live. Distinct from `deploy`
677
+ // (third-party hosts) and `register`/`release` (foundation code → catalog).
668
678
  if (command === 'publish') {
669
679
  const { publish } = await importProjectCommand('./commands/publish.js')
670
680
  const result = await publish(args.slice(1))
671
681
  process.exit(result?.exitCode ?? 0)
672
682
  }
673
683
 
674
- // Handle deploy command (dynamic import — depends on @uniweb/build)
684
+ // Handle deploy command — third-party hosts only (dynamic import — @uniweb/build)
675
685
  if (command === 'deploy') {
676
686
  const { deploy } = await importProjectCommand('./commands/deploy.js')
677
687
  await deploy(args.slice(1))
@@ -1079,18 +1089,26 @@ async function main() {
1079
1089
  * without loading @uniweb/build or any project context.
1080
1090
  */
1081
1091
  function printCommandHelp(command) {
1092
+ // `release` is a synonym of `register` (shipping-model.md §6.1) — show the
1093
+ // same help block.
1094
+ if (command === 'release') command = 'register'
1082
1095
  const blocks = {
1083
1096
  deploy: `
1084
- ${colors.cyan}${colors.bright}uniweb deploy${colors.reset} ${colors.dim}— Deploy a site${colors.reset}
1097
+ ${colors.cyan}${colors.bright}uniweb deploy${colors.reset} ${colors.dim}— Ship a site to its resolved target${colors.reset}
1085
1098
 
1086
1099
  ${colors.bright}Usage:${colors.reset}
1087
- uniweb deploy [options]
1100
+ uniweb deploy --host <name> [options]
1088
1101
 
1089
- The host is determined by the resolved deploy.yml target. Defaults to
1090
- ${colors.cyan}uniweb${colors.reset} hosting (link-mode, edge JIT prerender) when no deploy.yml exists.
1102
+ Ships a site to its resolved target. A THIRD-PARTY host builds dist/ (bundle
1103
+ mode) and hands it to a host adapter for upload + invalidation. A UNIWEB target
1104
+ (\`--host=uniweb\`, or a \`uniweb\` target in deploy.yml) delegates to
1105
+ ${colors.cyan}uniweb publish${colors.reset} (sync + dynamic serving; brings the foundation along) — the
1106
+ canonical direct verb for Uniweb hosting. With no host chosen, deploy prompts for
1107
+ a third-party adapter. For a self-contained dist/ you upload yourself, use
1108
+ ${colors.cyan}uniweb export${colors.reset}.
1091
1109
 
1092
1110
  ${colors.bright}Hosts:${colors.reset}
1093
- uniweb Uniweb hosting (default; requires \`uniweb login\`)
1111
+ uniweb Uniweb hosting (delegates to \`uniweb publish\`)
1094
1112
  cloudflare-pages Cloudflare Pages (build artifact + adapter postBuild)
1095
1113
  netlify Netlify (alias of cloudflare-pages adapter)
1096
1114
  vercel Vercel (build-only — deploy via \`npx vercel\`)
@@ -1099,43 +1117,40 @@ ${colors.bright}Hosts:${colors.reset}
1099
1117
  generic-static Plain static-host build, no host-specific helpers
1100
1118
 
1101
1119
  ${colors.bright}Options:${colors.reset}
1120
+ --host <name> The host to ship to (no value → interactive third-party picker, TTY only)
1102
1121
  --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
1103
- --host <name> Override the resolved target's host (does not persist)
1104
- --host No value → interactive picker (TTY only)
1105
- --dry-run Resolve site.yml + foundation/runtime; print summary; no writes
1106
- --no-auto-publish Don't auto-publish workspace-local foundation as part of deploy
1122
+ --dry-run Resolve the target + adapter; print summary; upload nothing
1107
1123
  --no-save Skip the auto-save of lastDeploy in deploy.yml
1108
- --backend <url> Override the default backend origin (\$UNIWEB_REGISTER_URL or built-in)
1109
1124
  --non-interactive Fail with usage info instead of prompting
1110
1125
 
1111
- ${colors.bright}Auth:${colors.reset}
1112
- \`host: uniweb\` requires authentication. Run \`uniweb login\` first, set
1113
- \`UNIWEB_TOKEN=<bearer>\` env var, or use a static-host adapter that
1114
- doesn't need a Uniweb account. CI / agents / piped stdin auto-detect
1115
- non-interactive mode and bail with an actionable error instead of
1116
- hanging on a browser callback.
1117
-
1118
1126
  ${colors.bright}Examples:${colors.reset}
1119
- uniweb deploy # Default (host=uniweb)
1120
- uniweb deploy --dry-run # Print summary, no writes
1121
- uniweb deploy --host=cloudflare-pages # One-off override
1122
- uniweb deploy --target=preview # Pick named target from deploy.yml
1127
+ uniweb deploy --host=cloudflare-pages # Build + upload to Cloudflare Pages
1128
+ uniweb deploy --host=s3-cloudfront # Build + upload + invalidate
1129
+ uniweb deploy --host=uniweb # delegates to \`uniweb publish\`
1130
+ uniweb deploy --target=preview # Named target from deploy.yml
1123
1131
  `,
1124
1132
  publish: `
1125
- ${colors.cyan}${colors.bright}uniweb publish${colors.reset} ${colors.dim}— Publish a synced site (make its backend state live)${colors.reset}
1133
+ ${colors.cyan}${colors.bright}uniweb publish${colors.reset} ${colors.dim}— Publish a site to Uniweb hosting (the smart path)${colors.reset}
1126
1134
 
1127
1135
  ${colors.bright}Usage:${colors.reset}
1128
1136
  uniweb publish [options]
1129
1137
 
1130
- Publishes a site that's synced to the backend (has site.yml::\$uuid from
1131
- \`uniweb push\`) makes its CURRENT backend state live, including edits made
1132
- through the app. Run \`uniweb push\` first to include local edits. For a
1133
- file-only site use \`uniweb deploy\`; to register a FOUNDATION use \`uniweb register\`.
1138
+ The most ergonomic command in the tool: \`uniweb login && uniweb publish\` reads
1139
+ your project and makes the site live on Uniweb hosting. It resolves which site,
1140
+ BRINGS THE FOUNDATION ALONG (releases the local foundation when its code
1141
+ changed), syncs content, settles payment when go-live needs it (a browser
1142
+ handoff), and goes live. A published-registry foundation needs no release; an
1143
+ already-paid site opens no browser.
1144
+
1145
+ For a third-party host use \`uniweb deploy --host=<name>\`; to register a
1146
+ FOUNDATION on its own use \`uniweb register\` (alias \`uniweb release\`).
1134
1147
 
1135
1148
  ${colors.bright}Options:${colors.reset}
1149
+ --dry-run Resolve everything; release/sync/POST nothing
1150
+ --yes Skip confirmations (CI); never block on a prompt
1151
+ --no-save Skip the deploy.yml lastDeploy auto-save
1136
1152
  --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
1137
1153
  --token <bearer> Auth bearer (skips \`uniweb login\`)
1138
- --dry-run Resolve everything; POST nothing
1139
1154
  `,
1140
1155
  create: `
1141
1156
  ${colors.cyan}${colors.bright}uniweb create${colors.reset} ${colors.dim}— Create a new project${colors.reset}
@@ -1269,15 +1284,18 @@ ${colors.bright}Options:${colors.reset}
1269
1284
  Exit codes: 0 clean (or warn-only), 1 violations under --strict, 2 setup error.
1270
1285
  `,
1271
1286
  register: `
1272
- ${colors.cyan}${colors.bright}uniweb register${colors.reset} ${colors.dim}— Register a foundation + its data schemas with the backend registry${colors.reset}
1287
+ ${colors.cyan}${colors.bright}uniweb register${colors.reset} ${colors.dim}— Register (release) a foundation + its data schemas with the backend registry${colors.reset}
1273
1288
 
1274
1289
  ${colors.bright}Usage:${colors.reset}
1275
1290
  uniweb register [options]
1291
+ uniweb release [options] ${colors.dim}(synonym — reads naturally for updates)${colors.reset}
1276
1292
 
1277
1293
  Builds one \`.uwx\` document and submits it to the registry over HTTP. Run
1278
- \`uniweb login\` first (or pass \`--token\`). \`register\` is for FOUNDATIONS (and
1279
- schemas); \`uniweb publish\` makes a synced SITE live; \`uniweb deploy\` hosts a
1280
- file-built site.
1294
+ \`uniweb login\` first (or pass \`--token\`). \`register\`/\`release\` are for
1295
+ FOUNDATIONS (and schemas) — code only; \`uniweb publish\` makes a synced SITE
1296
+ live (and brings its foundation along); \`uniweb deploy\` hosts on a third-party
1297
+ host. \`register\` and \`release\` are the same act: \`register\` reads naturally
1298
+ the first time, \`release\` for updates.
1281
1299
 
1282
1300
  Auto-detects what you run it in:
1283
1301
  • a foundation the foundation + the data schemas it defines/renders
@@ -1293,7 +1311,7 @@ ${colors.bright}Options:${colors.reset}
1293
1311
  --scope @org Publish under @org (resolves @/x -> @org/x); default: package.json uniweb.scope
1294
1312
  --dry-run Print the .uwx; submit nothing
1295
1313
  -o, --output <f> Write the .uwx to a file; submit nothing
1296
- --registry <url> Submit endpoint (default: \$UNIWEB_REGISTER_URL or a local URL)
1314
+ --backend <url> Backend origin (alias: --registry; default: \$UNIWEB_REGISTER_URL or a local URL)
1297
1315
  --token <bearer> Submit with this bearer; skips \`uniweb login\` (or set UNIWEB_TOKEN)
1298
1316
  --non-interactive Fail with usage info instead of prompting
1299
1317
 
@@ -1463,13 +1481,15 @@ ${colors.bright}Commands:${colors.reset}
1463
1481
  rename <type> Rename a foundation, site, or extension across the workspace
1464
1482
  dev Start a dev server for a site
1465
1483
  build Build the current project
1466
- deploy Deploy a site to Uniweb hosting
1484
+ publish Publish a site to Uniweb hosting (smart: foundation + sync + go live)
1485
+ deploy Deploy a site to a third-party host (--host=<adapter>)
1467
1486
  export Export a self-contained site for third-party hosting
1468
- publish Publish a synced site (make its backend state live)
1469
1487
  register Register a foundation + its data schemas with the backend registry
1488
+ release Release a foundation version (synonym of register)
1470
1489
  runtime register Register an @uniweb/runtime version to the backend (@std only)
1471
1490
  push Push a site's content to the backend
1472
1491
  pull Pull a site's content from the backend
1492
+ status Show a site's sync state (unpushed content, foundation)
1473
1493
  inspect <path> Inspect parsed content shape of a markdown file or folder
1474
1494
  docs Generate component documentation
1475
1495
  doctor Diagnose project configuration issues
@@ -1498,21 +1518,24 @@ ${colors.bright}Global Options:${colors.reset}
1498
1518
  Auto-detected when CI=true or no TTY (pipes, agents)
1499
1519
 
1500
1520
  ${colors.bright}Publish Options:${colors.reset}
1521
+ --dry-run Resolve everything; release/sync/POST nothing
1522
+ --yes Skip confirmations (CI); never block on a prompt
1523
+ --no-save Skip the deploy.yml lastDeploy auto-save
1501
1524
  --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
1502
1525
  --token <bearer> Auth bearer (skips \`uniweb login\`)
1503
- --dry-run Resolve everything; POST nothing
1504
1526
 
1505
- uniweb publish makes a SYNCED site live (run \`uniweb push\` first). To register
1506
- a foundation use \`uniweb register\`; to host a file-built site use \`uniweb deploy\`.
1527
+ uniweb publish is the smart Uniweb-hosting path: it brings the site's
1528
+ foundation along, syncs, and goes live. To register a foundation on its own
1529
+ use \`uniweb register\` (alias \`uniweb release\`); for a third-party host use
1530
+ \`uniweb deploy --host=<name>\`.
1507
1531
 
1508
1532
  ${colors.bright}Deploy Options:${colors.reset}
1533
+ --host <name> The host to ship to (no value → interactive third-party
1534
+ picker, TTY only). Third-party: cloudflare-pages, netlify,
1535
+ vercel, github-pages, s3-cloudfront, generic-static.
1536
+ \`--host=uniweb\` delegates to \`uniweb publish\`.
1509
1537
  --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
1510
- --host <name> Override the resolved target's host (does not persist).
1511
- Without a value, opens an interactive picker (TTY only).
1512
- Hosts: uniweb, cloudflare-pages, netlify, vercel,
1513
- github-pages, s3-cloudfront, generic-static.
1514
- --dry-run Resolve site.yml + foundation/runtime; print summary; no writes
1515
- --no-auto-publish Don't auto-publish workspace-local foundation as part of deploy
1538
+ --dry-run Resolve the target + adapter; print summary; upload nothing
1516
1539
  --no-save Skip the auto-save of lastDeploy in deploy.yml
1517
1540
 
1518
1541
  ${colors.bright}Dev Options:${colors.reset}
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Site asset delivery — the asset lane for `uniweb deploy` (channel
2
+ * Site asset delivery — the asset lane for `uniweb publish` (channel
3
3
  * framework-backend-f90d). After the link build processes a site's media into
4
4
  * `dist/assets/`, those bytes are delivered to the backend's content-addressed
5
- * asset store, and the deploy step rewrites the content's local refs to durable
5
+ * asset store, and the publish step rewrites the content's local refs to durable
6
6
  * serve URLs:
7
7
  *
8
8
  * 1. PLAN — POST {apiBase}/dev/assets with the file list ({ path,
@@ -147,7 +147,7 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
147
147
  onProgress(`↑ ${src.path}`)
148
148
  let putRes
149
149
  try {
150
- // The plan's url may be origin-relative (direct mode → uniwebd) or
150
+ // The plan's url may be origin-relative (direct mode → the backend) or
151
151
  // absolute (presigned → storage); new URL() resolves both.
152
152
  putRes = await fetch(new URL(up.url, origin), { method: up.method || 'PUT', headers, body: src.bytes ?? readFileSync(src.diskPath) })
153
153
  } catch (err) {
@@ -8,7 +8,7 @@
8
8
  * 1. PLAN — POST {apiBase}/dev/registry/code-uploads with the file list
9
9
  * ({ path, content_type, size, sha256? }). The response carries
10
10
  * one upload target per file ({ path, method, url, headers })
11
- * plus mode: 'direct' (dev — URLs point back at uniwebd) or
11
+ * plus mode: 'direct' (dev — URLs point back at the backend) or
12
12
  * 'presigned' (prod — storage PUTs; bytes never transit the
13
13
  * backend). The CLI never branches on the mode.
14
14
  * 2. UPLOAD — PUT each file's raw bytes to its URL with the given headers.
@@ -35,7 +35,7 @@
35
35
  */
36
36
 
37
37
  import { createHash } from 'node:crypto'
38
- import { readdirSync, readFileSync, statSync } from 'node:fs'
38
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
39
39
  import { join } from 'node:path'
40
40
 
41
41
  // Extension → declared content type. Extension-honest by construction (Vite
@@ -111,6 +111,46 @@ export function uploadOrder(files) {
111
111
  return [...rest, ...entry]
112
112
  }
113
113
 
114
+ /**
115
+ * The deterministic content digest of a built foundation — the freshness
116
+ * fingerprint `register` records and `publish`/`status` later compare against
117
+ * (shipping-model.md §4.1). It answers one question — "did the foundation change
118
+ * since it was released?" — with NO local state, so it has to be reproducible:
119
+ * the same source, built on any machine, must yield the same digest.
120
+ *
121
+ * Algorithm (§4.1): `sha256` over the newline-joined, SORTED list of the
122
+ * per-file `sha256` (hex) of exactly what `register` ships for a foundation —
123
+ * - the code-upload set (`collectDistFiles`: entry + chunks + assets, with
124
+ * `meta/**` and `*.map` already excluded), and
125
+ * - the schema source (`meta/schema.json`) — NOT uploaded as a file, and not
126
+ * sent verbatim: register submits its *content* as the foundation-schema
127
+ * ENTITY in the `.uwx`. It's read here only to fold the schema into the
128
+ * local freshness fingerprint (so a schema edit shows as "changed").
129
+ * Hashes are SORTED (so the digest is order-independent) and taken over file
130
+ * CONTENTS, not names, so content-hashed chunk filenames don't perturb it.
131
+ * Returns null when there's nothing to hash (no usable dist), so callers skip
132
+ * cleanly rather than fingerprinting emptiness.
133
+ *
134
+ * The result is OPAQUE to the backend — it stores the string and returns it
135
+ * verbatim; the framework owns the algorithm end-to-end and can change it
136
+ * freely (a digest only ever compares against one the same CLI computed).
137
+ *
138
+ * @param {string} distDir - the built `dist/` directory
139
+ * @returns {string|null} `sha256:<hex>` or null when there's nothing to hash
140
+ */
141
+ export function computeFoundationDigest(distDir) {
142
+ const hashes = collectDistFiles(distDir).map((f) => f.sha256)
143
+ // The schema rides in the register `.uwx`, not the code-upload set, so fold
144
+ // it in explicitly — a schema-only change is still a foundation change.
145
+ const schemaPath = join(distDir, 'meta', 'schema.json')
146
+ if (existsSync(schemaPath)) {
147
+ hashes.push(createHash('sha256').update(readFileSync(schemaPath)).digest('hex'))
148
+ }
149
+ if (!hashes.length) return null
150
+ hashes.sort()
151
+ return 'sha256:' + createHash('sha256').update(hashes.join('\n')).digest('hex')
152
+ }
153
+
114
154
  /**
115
155
  * The gateway serve URL for a file of a registered foundation version.
116
156
  * Mirrors the backend storage convention: scope WITHOUT the '@'.
@@ -180,7 +220,7 @@ export async function uploadFoundationCode({
180
220
  const plan = await planRes.json()
181
221
  const targets = new Map((plan.uploads || []).map((u) => [u.path, u]))
182
222
  const serveBase = plan.serve_base || null
183
- // The ONE mode-aware bit: direct-mode PUTs are bearer-authed uniwebd
223
+ // The ONE mode-aware bit: direct-mode PUTs are bearer-authed backend
184
224
  // routes; presigned URLs are self-authorizing and must NOT carry a
185
225
  // bearer (foreign auth headers can break signed-request validation).
186
226
  const authHeaders = plan.mode === 'direct' ? { Authorization: `Bearer ${token}` } : {}
@@ -49,14 +49,35 @@ function readCliConfig() {
49
49
  return _cliConfig
50
50
  }
51
51
 
52
+ /**
53
+ * The origin the LAST `uniweb login` authenticated against — persisted on the
54
+ * session record so subsequent verbs default to the backend you logged into
55
+ * (no `--backend` per command). Sync read; null when there's no session or it
56
+ * carries no origin (older sessions). Read directly (not via registry-auth.js)
57
+ * to keep this module off the optional-peer / import-cycle path.
58
+ * @returns {string|null}
59
+ */
60
+ function readSessionOrigin() {
61
+ try {
62
+ const p = join(homedir(), '.uniweb', 'registry-auth.json')
63
+ if (!existsSync(p)) return null
64
+ const o = JSON.parse(readFileSync(p, 'utf8'))?.origin
65
+ return o || null
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
52
71
  /**
53
72
  * Get the backend's API base origin. `register` POSTs to
54
73
  * {origin}/dev/registry/register, `login` to {origin}/dev/auth/login, etc.
55
- * (BackendClient.resolveBackendOrigin layers the --backend/--registry flag on
56
- * top of this.)
74
+ * (BackendClient.resolveBackendOrigin layers the --backend flag and a site's
75
+ * deploy.yml backend on top of this.)
57
76
  *
58
- * Priority: UNIWEB_REGISTER_URL's origin > ~/.uniweb/config.json registryApiUrl
59
- * > local default.
77
+ * Priority: UNIWEB_REGISTER_URL's origin > the logged-in session origin >
78
+ * ~/.uniweb/config.json registryApiUrl > the default (uniweb.app). Local dev
79
+ * points at a local backend EXPLICITLY (--backend / UNIWEB_REGISTER_URL /
80
+ * `uniweb login --backend …`), rather than the default being localhost.
60
81
  * @returns {string}
61
82
  */
62
83
  export function getRegistryApiBaseUrl() {
@@ -64,11 +85,15 @@ export function getRegistryApiBaseUrl() {
64
85
  if (fromEnv) {
65
86
  try { return new URL(fromEnv).origin } catch { /* fall through */ }
66
87
  }
88
+ const fromSession = readSessionOrigin()
89
+ if (fromSession) {
90
+ try { return new URL(fromSession).origin } catch { return fromSession }
91
+ }
67
92
  const fromCfg = readCliConfig().registryApiUrl
68
93
  if (fromCfg) {
69
94
  try { return new URL(fromCfg).origin } catch { return fromCfg }
70
95
  }
71
- return 'http://localhost:8080'
96
+ return 'https://uniweb.app'
72
97
  }
73
98
 
74
99
  /**