uniweb 0.25.3 → 0.25.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.25.3",
3
+ "version": "0.25.5",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,7 +47,7 @@
47
47
  "@uniweb/semantic-parser": "^1.2.3"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/build": "^0.24.3",
50
+ "@uniweb/build": "^0.24.5",
51
51
  "@uniweb/content-reader": "^1.2.3",
52
52
  "@uniweb/semantic-parser": "^1.2.3"
53
53
  },
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Bring-the-foundation-along — the freshness loop `uniweb publish` runs before
3
- * it makes a site live (shipping-model.md §4).
2
+ * Bring-the-foundation-along — the freshness loop `uniweb publish` AND `uniweb
3
+ * push` run before handing a site to a backend (shipping-model.md §4).
4
4
  *
5
5
  * A publish must never ship a site pointing at stale or missing foundation code
6
6
  * (the footgun: a site goes live referencing a version the catalog doesn't
7
- * have). So when the site references a LOCAL foundation, publish fingerprints it
7
+ * have). So when the site references a LOCAL foundation, the verb fingerprints it
8
8
  * and reconciles with the catalog. Three cases (§4):
9
9
  *
10
10
  * | version not yet registered | release it, then publish |
@@ -20,6 +20,16 @@
20
20
  * "Release" here is literally `uniweb register` run in the foundation directory
21
21
  * — same build-if-stale → schema submit → code upload → digest the standalone
22
22
  * verb does, so there is exactly one foundation-release path.
23
+ *
24
+ * ⭐ **`push` runs it too, and for a reason publish's framing does not cover.**
25
+ * [Diego, 2026-08-19] — *"A published site can only reference a registered
26
+ * foundation … In fact, not even a push can, because we can't preview the site in
27
+ * the frontend in that case."* A push is the collaboration verb: a teammate opens
28
+ * the site in the visual app straight after, and the app can only render it against
29
+ * foundation code the backend can serve. So an unregistered ref is not merely a
30
+ * publish-time problem — it is a broken preview, which is where a teammate actually
31
+ * meets it. `verb` names the caller in the messages so the fix the user is told to
32
+ * run is the command they ran.
23
33
  */
24
34
 
25
35
  import { readFileSync } from 'node:fs'
@@ -177,7 +187,8 @@ export async function bringFoundationAlong({
177
187
  say,
178
188
  confirm,
179
189
  cliBin,
180
- dryRun = false
190
+ dryRun = false,
191
+ verb = 'publish'
181
192
  }) {
182
193
  const local = resolveLocalFoundation(siteDir, siteYml)
183
194
  if (!local) {
@@ -194,7 +205,8 @@ export async function bringFoundationAlong({
194
205
  say,
195
206
  confirm,
196
207
  cliBin,
197
- dryRun
208
+ dryRun,
209
+ verb
198
210
  })
199
211
  }
200
212
 
@@ -216,7 +228,8 @@ async function bringLocalCodeAlong({
216
228
  say,
217
229
  confirm,
218
230
  cliBin,
219
- dryRun = false
231
+ dryRun = false,
232
+ verb = 'publish'
220
233
  }) {
221
234
  const Kind = kind === 'extension' ? 'Extension ' : 'Foundation'
222
235
  const label =
@@ -243,7 +256,18 @@ async function bringLocalCodeAlong({
243
256
  say.dim(
244
257
  `${Kind} : ${label} — local; would release if changed or not yet registered`
245
258
  )
246
- return { released: false, proceed: true, ref: null }
259
+ // The ref still comes back where one can be formed: it is read from the
260
+ // foundation's own package.json, so it costs no network, and an offline preview
261
+ // that omitted it would emit a document the real run would not — the one thing
262
+ // `-o` exists to avoid.
263
+ //
264
+ // ⚠️ It is null for a foundation that has NEVER been registered and carries no
265
+ // scope (a freshly scaffolded `name: "src"`), because the scope is what
266
+ // `register` writes back (`writePkgScope`). So the preview shows the authored
267
+ // value there, and the first real push — which releases, and so acquires the
268
+ // scope — sends the pinned ref instead. That gap is unavoidable offline: before
269
+ // the first release there is no registered name to name.
270
+ return { released: false, proceed: true, ref: pinnedRef() }
247
271
  }
248
272
 
249
273
  // Ask the catalog what it has. Null → not registered (or the backend can't
@@ -294,7 +318,7 @@ async function bringLocalCodeAlong({
294
318
  )
295
319
  if (skipPrompts || isNonInteractive(args)) {
296
320
  say.dim(
297
- 'Proceeding without re-releasing — pass nothing to re-deliver, or bump the version to publish a change.'
321
+ 'Proceeding without re-releasing — pass nothing to re-deliver, or bump the version to release a change.'
298
322
  )
299
323
  return { released: false, proceed: true, ref: pinnedRef() }
300
324
  }
@@ -318,19 +342,19 @@ async function bringLocalCodeAlong({
318
342
  `Your local ${label} differs from the registered version ${reg.latest_version}, but the version wasn't bumped.`
319
343
  )
320
344
  say.dim(
321
- `A registered version is immutable. Bump the ${kind}'s version to release the change, then re-run \`uniweb publish\`.`
345
+ `A registered version is immutable. Bump the ${kind}'s version to release the change, then re-run \`uniweb ${verb}\`.`
322
346
  )
323
347
  if (skipPrompts || isNonInteractive(args)) {
324
348
  say.dim(`Proceeding with the already-registered ${reg.latest_version}.`)
325
349
  return { released: false, proceed: true, ref: pinnedRef() }
326
350
  }
327
351
  const proceed = await confirm(
328
- `Publish with the already-registered ${reg.latest_version} anyway?`,
352
+ `Continue with the already-registered ${reg.latest_version} anyway?`,
329
353
  false
330
354
  )
331
355
  if (!proceed) {
332
356
  say.info(
333
- `Aborted — bump the ${kind} version, then re-run \`uniweb publish\`.`
357
+ `Aborted — bump the ${kind} version, then re-run \`uniweb ${verb}\`.`
334
358
  )
335
359
  return { released: false, proceed: false, ref: null }
336
360
  }
@@ -24,7 +24,8 @@ import {
24
24
  diffSiteUnits,
25
25
  describeSiteDiff,
26
26
  computeUnitHashes,
27
- collectUnitUuids
27
+ collectUnitUuids,
28
+ readAssetMap
28
29
  } from '@uniweb/build/uwx'
29
30
 
30
31
  // First entity `$`-document out of a `.uwx` we produced or the backend served.
@@ -181,11 +182,11 @@ function readSyncCacheFile(siteDir) {
181
182
  return {} // missing / unreadable → treat everything as changed
182
183
  }
183
184
  }
184
- // The cache holds three maps written on DIFFERENT events — content hashes on a
185
- // successful push, base versions on push AND pull, unit hashes on push and pull —
186
- // so every writer must preserve the ones it isn't touching. One merge point rather
187
- // than three hand-rolled preserves, because getting that wrong silently disarms
188
- // whichever map got clobbered.
185
+ // The cache holds several maps written on DIFFERENT events — content hashes and the
186
+ // injections that produced them on a successful push, base versions on push AND
187
+ // pull, unit hashes on push and pull — so every writer must preserve the ones it
188
+ // isn't touching. One merge point rather than a hand-rolled preserve per writer,
189
+ // because getting that wrong silently disarms whichever map got clobbered.
189
190
  function updateSyncCache(siteDir, patch) {
190
191
  const p = syncCachePath(siteDir)
191
192
  mkdirSync(dirname(p), { recursive: true })
@@ -203,9 +204,10 @@ const readMap = (siteDir, key) => {
203
204
  * Drop every cache map that describes a BACKEND site, when this clone is bound to
204
205
  * none. Returns the names dropped (empty when there was nothing to do).
205
206
  *
206
- * `.uniweb/sync-cache.json` holds four maps keyed by *unit path* — item uuids,
207
- * content hashes, entity base versions, unit bases and a unit path (`site.yml`,
208
- * `pages/about/about.md`) is the same string for every site. So the cache does not
207
+ * `.uniweb/sync-cache.json` holds five maps keyed by *unit path* — item uuids,
208
+ * content hashes, the injections those hashes were taken over, entity base versions,
209
+ * unit bases — and a unit path (`site.yml`, `pages/about/about.md`) is the same
210
+ * string for every site. So the cache does not
209
211
  * self-invalidate when `site.yml::$uuid` goes away: it keeps describing the site
210
212
  * this folder used to be.
211
213
  *
@@ -228,21 +230,29 @@ const readMap = (siteDir, key) => {
228
230
  * the clone look bound before the check runs.
229
231
  */
230
232
  /**
231
- * Drop the four remote-derived maps unconditionally, stamping the site they now
233
+ * Drop the five remote-derived maps unconditionally, stamping the site they now
232
234
  * describe (or clearing the stamp when there is none). Shared by the pre-flight
233
235
  * guard and the `item_uuid_conflict` recovery — the guard decides WHETHER, this
234
236
  * decides WHAT, and they must not drift.
235
237
  */
236
238
  export function clearRemoteSyncState(siteDir, siteUuid = null) {
237
239
  const prior = readSyncCacheFile(siteDir)
238
- const dropped = ['itemUuids', 'hashes', 'baseVersions', 'unitBases'].filter(
239
- (k) => prior[k] && Object.keys(prior[k]).length
240
- )
240
+ const dropped = [
241
+ 'itemUuids',
242
+ 'hashes',
243
+ 'baseVersions',
244
+ 'unitBases',
245
+ 'applied'
246
+ ].filter((k) => prior[k] && Object.keys(prior[k]).length)
241
247
  updateSyncCache(siteDir, {
242
248
  itemUuids: {},
243
249
  hashes: {},
244
250
  baseVersions: {},
245
251
  unitBases: {},
252
+ // Remote-derived like the rest, and doubly so: it holds the OLD site's asset
253
+ // serve URLs. Surviving the drop, it would rewrite the new site's media to
254
+ // bytes owned by the site this folder used to be.
255
+ applied: {},
246
256
  siteUuid: siteUuid || null
247
257
  })
248
258
  return dropped
@@ -258,7 +268,13 @@ export function clearRemoteSyncStateIfUnbound(siteDir) {
258
268
  }
259
269
 
260
270
  const prior = readSyncCacheFile(siteDir)
261
- const REMOTE_MAPS = ['itemUuids', 'hashes', 'baseVersions', 'unitBases']
271
+ const REMOTE_MAPS = [
272
+ 'itemUuids',
273
+ 'hashes',
274
+ 'baseVersions',
275
+ 'unitBases',
276
+ 'applied'
277
+ ]
262
278
  const populated = REMOTE_MAPS.filter(
263
279
  (k) => prior[k] && Object.keys(prior[k]).length
264
280
  )
@@ -302,8 +318,53 @@ export function clearRemoteSyncStateIfUnbound(siteDir) {
302
318
  export function readSyncCache(siteDir) {
303
319
  return readMap(siteDir, 'hashes')
304
320
  }
305
- export function writeSyncCache(siteDir, hashes) {
306
- updateSyncCache(siteDir, { hashes })
321
+
322
+ /**
323
+ * The hash-affecting injections the emit that produced those hashes applied, in the
324
+ * exact shape `emitSyncPackages` takes back as opts.
325
+ *
326
+ * ⛔ **Only the ones nothing else records.** `assetIds` is deliberately NOT banked
327
+ * here even though the emit applies it: `assets.json` is COMMITTED project state
328
+ * holding exactly that map (local ref → `{id, ext}`), written by the same push, and
329
+ * a gitignored second copy would be a second thing to disagree — the reason that
330
+ * file itself refuses to hold a serve URL. It also has the worse lifetime of the
331
+ * two: `clearRemoteSyncState` wipes this cache, and the committed map correctly
332
+ * survives. ⇒ Bank what a reader cannot re-derive; re-derive the rest.
333
+ *
334
+ * ⛔ Read this whenever you re-emit to COMPARE against `hashes`. A push hashes the
335
+ * DELIVERED document — local `/images/x.png` rewritten to the backend serve URL it
336
+ * just uploaded to, `info.foundation` replaced by the version-pinned ref — and banks
337
+ * that. An offline re-emit produces the AUTHORED document, which is a different
338
+ * document, so it matches nothing and every entity reads as changed forever. That is
339
+ * not hypothetical: it is what `uniweb status` did on any site with one local image
340
+ * (backend-framework-787e, 2026-08-19) — `push` said "1 entity unchanged" and
341
+ * `status --json` said `changed: 1`, from the same cache, seconds apart.
342
+ *
343
+ * ⭐ What is left is what only a backend round-trip produces — an asset **serve URL**
344
+ * and a released foundation version — and `status` is offline by design (measured at
345
+ * zero HTTP requests, a property the cross-client flows rely on). ⚠️ Note the serve
346
+ * URL is REPLAYED, never composed: we re-use the string the host handed us, which is
347
+ * a different act from reconstructing one, and the distinction is the same one that
348
+ * keeps `assets.json` id-only. An asset genuinely new to the site has no recorded
349
+ * mapping and still reads as changed, which is correct.
350
+ */
351
+ export function readAppliedInjections(siteDir) {
352
+ return readMap(siteDir, 'applied')
353
+ }
354
+
355
+ /**
356
+ * Bank the content hashes and the injections that produced them. ⛔ ONE call, both
357
+ * maps: they describe the same document, so writing either alone leaves the cache
358
+ * self-inconsistent — and the failure is silent, since a hash never says which
359
+ * document it is of. `applied` is written even when empty, so it can never be a
360
+ * leftover from an earlier push describing hashes it no longer matches.
361
+ *
362
+ * `assetIds` is dropped rather than stored — see readAppliedInjections: it has a
363
+ * committed source of truth in `assets.json`, and the reader re-derives it there.
364
+ */
365
+ export function writeSyncCache(siteDir, hashes, applied) {
366
+ const { assetIds: _inAssetsJson, ...bankable } = applied || {}
367
+ updateSyncCache(siteDir, { hashes, applied: bankable })
307
368
  }
308
369
 
309
370
  /**
@@ -835,10 +896,24 @@ export function writeUnitBases(siteDir, patch) {
835
896
  */
836
897
  export async function probeUnpushed(siteDir, { sendAll = false } = {}) {
837
898
  const priorHashes = readSyncCache(siteDir)
899
+ // Re-emit the document the last push HASHED, not the one the author wrote — see
900
+ // readAppliedInjections. Two sources, on purpose:
901
+ // · BANKED — the serve URLs and pinned refs only a round-trip produces. Empty
902
+ // for a cache written before this was banked (and for a never-pushed site),
903
+ // which is the pre-fix behaviour and self-heals on the next push. It can never
904
+ // point at the wrong document: it is written with the hashes it belongs to.
905
+ // · RE-DERIVED — asset identity, from the COMMITTED `assets.json` the same push
906
+ // wrote. Reading the live file rather than a snapshot is what makes a moved
907
+ // map (a teammate's push, a pull) read as changed instead of matching a copy
908
+ // of itself.
909
+ const applied = readAppliedInjections(siteDir)
910
+ const assetIds = readAssetMap(siteDir)
838
911
  const pkg = await emitSyncPackages(siteDir, {
839
912
  resolveModel: makeModelResolver({ client: null, offline: true }),
840
913
  priorHashes,
841
- sendAll
914
+ sendAll,
915
+ ...applied,
916
+ ...(Object.keys(assetIds).length ? { assetIds } : {})
842
917
  })
843
918
  const changed =
844
919
  (pkg.siteContent?.entityCount || 0) + (pkg.collections?.entityCount || 0)
@@ -867,7 +942,7 @@ export async function pushSyncPackages({
867
942
  asOrg,
868
943
  report
869
944
  }) {
870
- const { siteContent, collections, siteContentUuid, hashes } = pkg
945
+ const { siteContent, collections, siteContentUuid, hashes, applied } = pkg
871
946
  const { info, note, error } = report
872
947
  const dim = report.dim || ((s) => s)
873
948
 
@@ -1200,7 +1275,7 @@ export async function pushSyncPackages({
1200
1275
  // then bank the post-write tokens so the NEXT push carries a current base.
1201
1276
  // Entities absent from finalized[] (skipped, or not editable) keep their cached
1202
1277
  // value — absence is not invalidation.
1203
- writeSyncCache(siteDir, hashes)
1278
+ writeSyncCache(siteDir, hashes, applied)
1204
1279
  mergeHarvested()
1205
1280
  // Re-base the page attribution: our emitted document and the backend's post-write
1206
1281
  // copy of it are the two sides' new agreed state. Only when the site-content lane
@@ -46,7 +46,6 @@ import { existsSync, readFileSync } from 'node:fs'
46
46
  import { readFile } from 'node:fs/promises'
47
47
  import { join } from 'node:path'
48
48
  import { execSync } from 'node:child_process'
49
- import { createInterface } from 'node:readline/promises'
50
49
  import yaml from 'js-yaml'
51
50
 
52
51
  import {
@@ -66,7 +65,7 @@ import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
66
65
  import { warnIfContentDoesNotConform } from '../utils/conformance.js'
67
66
  import { readFlagValue, readOrgFlag } from '../utils/args.js'
68
67
  import { checkFlags } from '../utils/flag-guard.js'
69
- import { isNonInteractive } from '../utils/interactive.js'
68
+ import { isNonInteractive, confirm } from '../utils/interactive.js'
70
69
  import { headProvenance } from '../utils/git.js'
71
70
  import {
72
71
  makeModelResolver,
@@ -106,22 +105,6 @@ const say = {
106
105
  dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
107
106
  }
108
107
 
109
- // Minimal yes/no prompt. Returns `defaultYes` on an empty answer.
110
- async function confirm(question, defaultYes = false) {
111
- const rl = createInterface({ input: process.stdin, output: process.stdout })
112
- try {
113
- const a = (
114
- await rl.question(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `)
115
- )
116
- .trim()
117
- .toLowerCase()
118
- if (!a) return defaultYes
119
- return a === 'y' || a === 'yes'
120
- } finally {
121
- rl.close()
122
- }
123
- }
124
-
125
108
  // Origin-relative serve path → clickable absolute URL (self-serve default).
126
109
  function absolutizeServeUrl(origin, url) {
127
110
  if (!url || typeof url !== 'string') return null
@@ -692,10 +692,47 @@ export async function pull(args = [], deps = {}) {
692
692
  }
693
693
  }
694
694
 
695
+ // ⛔ Do NOT overwrite a workspace project's `site.yml::foundation`.
696
+ //
697
+ // Same principle as restoring authored asset paths above, and the same
698
+ // failure it prevents: a round trip must not mangle what the author wrote.
699
+ // `publish` stamps the RELEASED, version-pinned ref into `info.foundation`
700
+ // (delivery is version-pinned end to end), so projecting the stored value
701
+ // back turns `foundation: src` into `@org/x@1.2.3` — which the build then
702
+ // REFUSES to resolve, leaving the project unable to `build`, `dev` or
703
+ // `export`. It stays publishable throughout, so nothing surfaces it.
704
+ //
705
+ // ⚖️ Only suppressed when the AUTHORED value resolves to a local foundation.
706
+ // A project from `uniweb clone` has no local foundation on disk, and there
707
+ // the pinned ref is exactly what site.yml should say — so this is a
708
+ // question about which project shape we are writing into, not a blanket
709
+ // "never project it".
710
+ //
711
+ // Lazily imported: the resolver lives behind `@uniweb/build`'s root, which
712
+ // pulls the vite chain, and this file deliberately imports only the `uwx`
713
+ // leaf. Reused rather than reimplemented so "which foundation" cannot drift
714
+ // from what the build itself resolves.
715
+ let keepAuthoredFoundation = false
716
+ try {
717
+ const { resolveLocalFoundation } = await import(
718
+ '../backend/foundation-bring-along.js'
719
+ )
720
+ const authored = yaml.load(
721
+ readFileSync(join(siteDir, 'site.yml'), 'utf8')
722
+ )
723
+ keepAuthoredFoundation = Boolean(
724
+ resolveLocalFoundation(siteDir, authored)
725
+ )
726
+ } catch {
727
+ // No site.yml, unreadable, or nothing local — project the stored value,
728
+ // which is the pre-existing behaviour and right for a fresh clone.
729
+ }
730
+
695
731
  const report = siteContentDocumentToProject({
696
732
  document: siteDoc,
697
733
  siteRoot: siteDir,
698
- prune
734
+ prune,
735
+ keepAuthoredFoundation
699
736
  })
700
737
  wrote.push(...report.pages, ...report.sections, ...report.layout)
701
738
  removed.push(
@@ -60,8 +60,9 @@
60
60
  * the emit, and the `-o`/`--dry-run` preview.
61
61
  */
62
62
 
63
- import { writeFileSync } from 'node:fs'
64
- import { resolve } from 'node:path'
63
+ import { readFileSync, writeFileSync } from 'node:fs'
64
+ import { join, resolve } from 'node:path'
65
+ import yaml from 'js-yaml'
65
66
  import { emitSyncPackages } from '@uniweb/build/uwx'
66
67
  import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
67
68
  import { updateAssetMap, ASSET_MAP_FILE } from '@uniweb/build/uwx'
@@ -71,6 +72,8 @@ import { warnIfContentDoesNotConform } from '../utils/conformance.js'
71
72
  import { reportSchemalessCollections } from '../utils/schemaless-report.js'
72
73
  import { readOrgFlag } from '../utils/args.js'
73
74
  import { checkFlags } from '../utils/flag-guard.js'
75
+ import { confirm } from '../utils/interactive.js'
76
+ import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
74
77
  import {
75
78
  makeModelResolver,
76
79
  readSyncCache,
@@ -179,6 +182,61 @@ export async function push(args = [], deps = {}) {
179
182
  }
180
183
  const asOrg = org.asOrg
181
184
 
185
+ // Bring the foundation along — BEFORE any asset upload, because an upload is
186
+ // chargeable and a push that aborts after one has spent the user's money for
187
+ // nothing.
188
+ //
189
+ // ⭐ Why push and not just publish. [Diego, 2026-08-19] — *"A published site can
190
+ // only reference a registered foundation … In fact, not even a push can, because
191
+ // we can't preview the site in the frontend in that case."* Push is the
192
+ // collaboration verb: a teammate opens the site in the visual app right after,
193
+ // and the app can only render against foundation code the backend can serve. So
194
+ // storing an unregistered ref does not merely defer a problem to publish — it
195
+ // hands the teammate a site that cannot render, which is where they meet it.
196
+ //
197
+ // `fnd.ref` is the pinned `@scope/name@version`, stamped onto the wire below.
198
+ // That also makes push and publish agree about the document they emit; until now
199
+ // push sent the authored string and publish sent the pinned ref, so the two saw
200
+ // each other's pushes as changes.
201
+ const siteYml = (() => {
202
+ try {
203
+ return yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8')) || {}
204
+ } catch {
205
+ return {}
206
+ }
207
+ })()
208
+ const say = {
209
+ ok: success,
210
+ info,
211
+ warn,
212
+ err: error,
213
+ dim: note
214
+ }
215
+ let fnd = { ref: null }
216
+ try {
217
+ fnd = await bringFoundationAlong({
218
+ client,
219
+ siteDir,
220
+ siteYml,
221
+ args,
222
+ say,
223
+ confirm,
224
+ cliBin: process.argv[1],
225
+ // An offline emit reports what it WOULD do and touches no network; the ref
226
+ // still comes back (read from the foundation's package.json) so the preview
227
+ // matches what a real push sends — EXCEPT for a foundation never yet
228
+ // registered, which has no scope to form a ref from until the first release
229
+ // writes one. See the dry-run branch in foundation-bring-along.js.
230
+ dryRun: !!output || dryRun,
231
+ verb: 'push'
232
+ })
233
+ } catch (err) {
234
+ error(`Foundation release failed: ${err.message}`)
235
+ note('Fix the foundation, then re-run `uniweb push`.')
236
+ return { exitCode: 1 }
237
+ }
238
+ if (!fnd.proceed) return { exitCode: 1 }
239
+
182
240
  // Build BOTH directional packages (the producer side). Each carries its own
183
241
  // `index` — the per-entity source-file map for back-fill, correlated by submission
184
242
  // position. Non-local Models are fetched from the registry on demand. `priorHashes`
@@ -346,6 +404,12 @@ export async function push(args = [], deps = {}) {
346
404
  priorHashes,
347
405
  sendAll,
348
406
  itemUuids,
407
+ // The PINNED foundation ref from the bring-along above, stamped over the
408
+ // authored `site.yml` string. Delivery is version-pinned end to end, so an
409
+ // unpinned local name on the wire names code no host can serve. Absent when
410
+ // the site already references a registry ref or URL — then site.yml's own
411
+ // value rides verbatim.
412
+ ...(fnd.ref ? { injectInfo: { foundation: fnd.ref } } : {}),
349
413
  // Both grains are dropped together by --force: one flag, one meaning,
350
414
  // no partial-force mode.
351
415
  ...(force
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-18T22:18:28.572Z",
3
+ "generatedAt": "2026-08-19T04:22:45.477Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.24.3",
6
+ "version": "0.24.5",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -58,7 +58,12 @@ const VERBS = {
58
58
  '--foundation', '--output', '-o', '--personal', '--registry', '--token',
59
59
  // read in utils/conformance.js and backend/site-sync.js respectively —
60
60
  // neither appears in push.js
61
- '--no-validate', '--yes', ...VIA_DEPLOY
61
+ '--no-validate', '--yes',
62
+ // via backend/foundation-bring-along.js, which push runs since 2026-08-19 —
63
+ // one of the three flags that skip its prompts. Found by
64
+ // flag-guard-coverage.test.js the moment push gained the import, which is
65
+ // exactly the hand-enumeration failure that test exists to catch.
66
+ '--no-verify', ...VIA_DEPLOY
62
67
  ],
63
68
  publish: [
64
69
  '--as-org', '--org', '--backend', '--dry-run', '--force', '--foundation',
@@ -18,6 +18,33 @@ export function isNonInteractive(args) {
18
18
  return false
19
19
  }
20
20
 
21
+ /**
22
+ * Minimal yes/no prompt. Returns `defaultYes` on an empty answer.
23
+ *
24
+ * Shared rather than per-command: every verb that can stop and ask needs one, and
25
+ * three hand-rolled copies would eventually disagree about what a bare Enter means
26
+ * — which is the answer the user gives most often and thinks about least.
27
+ *
28
+ * ⛔ Callers must decide NOT to ask before calling: this always reads stdin, and a
29
+ * prompt in a non-interactive run hangs a pipeline. Gate on `isNonInteractive(args)`
30
+ * (and on any --yes/--force style skip the verb defines).
31
+ */
32
+ export async function confirm(question, defaultYes = false) {
33
+ const { createInterface } = await import('node:readline/promises')
34
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
35
+ try {
36
+ const a = (
37
+ await rl.question(`${question} ${defaultYes ? '[Y/n]' : '[y/N]'} `)
38
+ )
39
+ .trim()
40
+ .toLowerCase()
41
+ if (!a) return defaultYes
42
+ return a === 'y' || a === 'yes'
43
+ } finally {
44
+ rl.close()
45
+ }
46
+ }
47
+
21
48
  /**
22
49
  * Get the CLI invocation prefix to use in suggested commands.
23
50
  * Mirrors however the user actually ran the CLI.
@@ -39,7 +39,15 @@
39
39
  * an upload landed.
40
40
  *
41
41
  * Contract ratified 2026-08-18; see `kb/framework/build/data-ball-retirement.md`.
42
- * ⚠️ **The endpoint is not built yet** this is unwired until it is.
42
+ * **Wired into `publish.js` and shipped in `uniweb` 0.25.3.** Both arms of the
43
+ * endpoint exist (presigned landed 2026-08-18, per Diego).
44
+ *
45
+ * ⚠️ **The presigned arm has never been exercised against a real backend from
46
+ * here.** The branch is one line and unit-tested both ways, but a stub is not a
47
+ * presigning deployment — three claims diverged that way in a single day while
48
+ * this lane was being built. A local `uniwebd` answers `direct`, so proving it
49
+ * needs a presigning deployment and one real publish. **Believed correct, not
50
+ * demonstrated.**
43
51
  */
44
52
 
45
53
  import { createHash } from 'node:crypto'