uniweb 0.12.35 → 0.12.37

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 {
@@ -24,10 +24,12 @@ import { existsSync, readFileSync } from 'node:fs'
24
24
  import { join } from 'node:path'
25
25
  import yaml from 'js-yaml'
26
26
 
27
- import { resolveSiteDir } from './deploy.js'
27
+ import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
28
28
  import { probeUnpushed } from '../backend/site-sync.js'
29
29
  import { BackendClient } from '../backend/client.js'
30
30
  import { readFlagValue } from '../utils/args.js'
31
+ import { resolveLocalFoundation } from '../backend/foundation-bring-along.js'
32
+ import { computeFoundationDigest } from '../utils/code-upload.js'
31
33
 
32
34
  const c = {
33
35
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
@@ -86,16 +88,28 @@ export async function status(args = []) {
86
88
  // on 404 / any failure, so a backend without the endpoints just shows local.
87
89
  let site = null
88
90
  let fdnLatest = null
91
+ let foundationFresh = null // true/false when both digests are known; else null
89
92
  if (remote) {
90
93
  try {
91
94
  const client = new BackendClient({
92
95
  originFlag: readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
96
+ siteBackend: await resolveSiteBackend(siteDir),
93
97
  token: readFlagValue(args, '--token') || undefined,
94
98
  args,
95
99
  command: 'Status',
96
100
  })
97
101
  if (uuid) site = await client.siteStatus(uuid)
98
- if (fndScope) fdnLatest = await client.readFoundationLatest(fndScope)
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
+ }
99
113
  } catch {
100
114
  // degrade silently
101
115
  }
@@ -110,7 +124,7 @@ export async function status(args = []) {
110
124
  changed: probe ? probe.changed : null,
111
125
  unchanged: probe ? probe.unchanged : null,
112
126
  ...(probeErr ? { error: probeErr } : {}),
113
- ...(remote ? { remote: { site, foundation_latest: fdnLatest?.latest_version ?? null } } : {}),
127
+ ...(remote ? { remote: { site, foundation_latest: fdnLatest?.latest_version ?? null, foundation_fresh: foundationFresh } } : {}),
114
128
  })
115
129
  )
116
130
  return { exitCode: 0 }
@@ -123,7 +137,7 @@ export async function status(args = []) {
123
137
  say.ok(`Synced — site-content ${c.bold}${uuid}${c.reset}`)
124
138
  } else {
125
139
  say.warn('Not synced — this site has never been pushed to a backend.')
126
- say.dim('Run `uniweb push` to create it, or `uniweb deploy` to ship it in one step.')
140
+ say.dim('Run `uniweb push` to create it, or `uniweb publish` to sync and go live in one step.')
127
141
  }
128
142
 
129
143
  // Content
@@ -142,7 +156,7 @@ export async function status(args = []) {
142
156
  (probe.unchanged ? ` (${probe.unchanged} unchanged)` : '') +
143
157
  '.'
144
158
  )
145
- say.dim('Run `uniweb push` to sync, then `uniweb publish` to go live (or `uniweb deploy` for both).')
159
+ say.dim('Run `uniweb publish` to sync and go live (or `uniweb push` to sync only).')
146
160
  }
147
161
 
148
162
  // Foundation
@@ -162,6 +176,11 @@ export async function status(args = []) {
162
176
  if (fdnLatest?.latest_version && fndVersion && fdnLatest.latest_version !== fndVersion) {
163
177
  say.info(`A newer foundation version (${fdnLatest.latest_version}) is registered than the site pins (${fndVersion}).`)
164
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
+ }
165
184
  if (!site && !fdnLatest) {
166
185
  say.dim('(No remote signals — the backend may not expose them yet.)')
167
186
  }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-06-24T03:15:27.555Z",
3
+ "generatedAt": "2026-07-01T17:02:37.208Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.14.18",
6
+ "version": "0.14.20",
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
  },
@@ -74,7 +74,7 @@
74
74
  "deps": []
75
75
  },
76
76
  "@uniweb/schemas": {
77
- "version": "0.2.3",
77
+ "version": "0.2.4",
78
78
  "path": "framework/schemas",
79
79
  "deps": []
80
80
  },
@@ -99,7 +99,7 @@
99
99
  "deps": []
100
100
  },
101
101
  "@uniweb/unipress": {
102
- "version": "0.4.24",
102
+ "version": "0.4.26",
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)
@@ -670,15 +672,16 @@ async function main() {
670
672
  return
671
673
  }
672
674
 
673
- // Handle publish command — CMS-publish a SYNCED site (POST /dev/site/publish).
674
- // 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).
675
678
  if (command === 'publish') {
676
679
  const { publish } = await importProjectCommand('./commands/publish.js')
677
680
  const result = await publish(args.slice(1))
678
681
  process.exit(result?.exitCode ?? 0)
679
682
  }
680
683
 
681
- // Handle deploy command (dynamic import — depends on @uniweb/build)
684
+ // Handle deploy command — third-party hosts only (dynamic import — @uniweb/build)
682
685
  if (command === 'deploy') {
683
686
  const { deploy } = await importProjectCommand('./commands/deploy.js')
684
687
  await deploy(args.slice(1))
@@ -1086,18 +1089,26 @@ async function main() {
1086
1089
  * without loading @uniweb/build or any project context.
1087
1090
  */
1088
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'
1089
1095
  const blocks = {
1090
1096
  deploy: `
1091
- ${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}
1092
1098
 
1093
1099
  ${colors.bright}Usage:${colors.reset}
1094
- uniweb deploy [options]
1100
+ uniweb deploy --host <name> [options]
1095
1101
 
1096
- The host is determined by the resolved deploy.yml target. Defaults to
1097
- ${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}.
1098
1109
 
1099
1110
  ${colors.bright}Hosts:${colors.reset}
1100
- uniweb Uniweb hosting (default; requires \`uniweb login\`)
1111
+ uniweb Uniweb hosting (delegates to \`uniweb publish\`)
1101
1112
  cloudflare-pages Cloudflare Pages (build artifact + adapter postBuild)
1102
1113
  netlify Netlify (alias of cloudflare-pages adapter)
1103
1114
  vercel Vercel (build-only — deploy via \`npx vercel\`)
@@ -1106,43 +1117,40 @@ ${colors.bright}Hosts:${colors.reset}
1106
1117
  generic-static Plain static-host build, no host-specific helpers
1107
1118
 
1108
1119
  ${colors.bright}Options:${colors.reset}
1120
+ --host <name> The host to ship to (no value → interactive third-party picker, TTY only)
1109
1121
  --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
1110
- --host <name> Override the resolved target's host (does not persist)
1111
- --host No value → interactive picker (TTY only)
1112
- --dry-run Resolve site.yml + foundation/runtime; print summary; no writes
1113
- --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
1114
1123
  --no-save Skip the auto-save of lastDeploy in deploy.yml
1115
- --backend <url> Override the default backend origin (\$UNIWEB_REGISTER_URL or built-in)
1116
1124
  --non-interactive Fail with usage info instead of prompting
1117
1125
 
1118
- ${colors.bright}Auth:${colors.reset}
1119
- \`host: uniweb\` requires authentication. Run \`uniweb login\` first, set
1120
- \`UNIWEB_TOKEN=<bearer>\` env var, or use a static-host adapter that
1121
- doesn't need a Uniweb account. CI / agents / piped stdin auto-detect
1122
- non-interactive mode and bail with an actionable error instead of
1123
- hanging on a browser callback.
1124
-
1125
1126
  ${colors.bright}Examples:${colors.reset}
1126
- uniweb deploy # Default (host=uniweb)
1127
- uniweb deploy --dry-run # Print summary, no writes
1128
- uniweb deploy --host=cloudflare-pages # One-off override
1129
- 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
1130
1131
  `,
1131
1132
  publish: `
1132
- ${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}
1133
1134
 
1134
1135
  ${colors.bright}Usage:${colors.reset}
1135
1136
  uniweb publish [options]
1136
1137
 
1137
- Publishes a site that's synced to the backend (has site.yml::\$uuid from
1138
- \`uniweb push\`) makes its CURRENT backend state live, including edits made
1139
- through the app. Run \`uniweb push\` first to include local edits. For a
1140
- 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\`).
1141
1147
 
1142
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
1143
1152
  --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
1144
1153
  --token <bearer> Auth bearer (skips \`uniweb login\`)
1145
- --dry-run Resolve everything; POST nothing
1146
1154
  `,
1147
1155
  create: `
1148
1156
  ${colors.cyan}${colors.bright}uniweb create${colors.reset} ${colors.dim}— Create a new project${colors.reset}
@@ -1276,15 +1284,18 @@ ${colors.bright}Options:${colors.reset}
1276
1284
  Exit codes: 0 clean (or warn-only), 1 violations under --strict, 2 setup error.
1277
1285
  `,
1278
1286
  register: `
1279
- ${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}
1280
1288
 
1281
1289
  ${colors.bright}Usage:${colors.reset}
1282
1290
  uniweb register [options]
1291
+ uniweb release [options] ${colors.dim}(synonym — reads naturally for updates)${colors.reset}
1283
1292
 
1284
1293
  Builds one \`.uwx\` document and submits it to the registry over HTTP. Run
1285
- \`uniweb login\` first (or pass \`--token\`). \`register\` is for FOUNDATIONS (and
1286
- schemas); \`uniweb publish\` makes a synced SITE live; \`uniweb deploy\` hosts a
1287
- 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.
1288
1299
 
1289
1300
  Auto-detects what you run it in:
1290
1301
  • a foundation the foundation + the data schemas it defines/renders
@@ -1300,7 +1311,7 @@ ${colors.bright}Options:${colors.reset}
1300
1311
  --scope @org Publish under @org (resolves @/x -> @org/x); default: package.json uniweb.scope
1301
1312
  --dry-run Print the .uwx; submit nothing
1302
1313
  -o, --output <f> Write the .uwx to a file; submit nothing
1303
- --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)
1304
1315
  --token <bearer> Submit with this bearer; skips \`uniweb login\` (or set UNIWEB_TOKEN)
1305
1316
  --non-interactive Fail with usage info instead of prompting
1306
1317
 
@@ -1470,10 +1481,11 @@ ${colors.bright}Commands:${colors.reset}
1470
1481
  rename <type> Rename a foundation, site, or extension across the workspace
1471
1482
  dev Start a dev server for a site
1472
1483
  build Build the current project
1473
- 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>)
1474
1486
  export Export a self-contained site for third-party hosting
1475
- publish Publish a synced site (make its backend state live)
1476
1487
  register Register a foundation + its data schemas with the backend registry
1488
+ release Release a foundation version (synonym of register)
1477
1489
  runtime register Register an @uniweb/runtime version to the backend (@std only)
1478
1490
  push Push a site's content to the backend
1479
1491
  pull Pull a site's content from the backend
@@ -1506,21 +1518,24 @@ ${colors.bright}Global Options:${colors.reset}
1506
1518
  Auto-detected when CI=true or no TTY (pipes, agents)
1507
1519
 
1508
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
1509
1524
  --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
1510
1525
  --token <bearer> Auth bearer (skips \`uniweb login\`)
1511
- --dry-run Resolve everything; POST nothing
1512
1526
 
1513
- uniweb publish makes a SYNCED site live (run \`uniweb push\` first). To register
1514
- 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>\`.
1515
1531
 
1516
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\`.
1517
1537
  --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
1518
- --host <name> Override the resolved target's host (does not persist).
1519
- Without a value, opens an interactive picker (TTY only).
1520
- Hosts: uniweb, cloudflare-pages, netlify, vercel,
1521
- github-pages, s3-cloudfront, generic-static.
1522
- --dry-run Resolve site.yml + foundation/runtime; print summary; no writes
1523
- --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
1524
1539
  --no-save Skip the auto-save of lastDeploy in deploy.yml
1525
1540
 
1526
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
  /**