uniweb 0.25.4 → 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 +4 -4
- package/src/backend/foundation-bring-along.js +35 -11
- package/src/commands/publish.js +1 -18
- package/src/commands/pull.js +38 -1
- package/src/commands/push.js +66 -2
- package/src/framework-index.json +2 -2
- package/src/utils/flag-guard.js +6 -1
- package/src/utils/interactive.js +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.5",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,12 +42,12 @@
|
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
44
|
"@uniweb/core": "^0.10.1",
|
|
45
|
-
"@uniweb/
|
|
45
|
+
"@uniweb/kit": "^0.12.3",
|
|
46
46
|
"@uniweb/runtime": "^0.12.2",
|
|
47
|
-
"@uniweb/
|
|
47
|
+
"@uniweb/semantic-parser": "^1.2.3"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "^0.24.
|
|
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`
|
|
3
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
`
|
|
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
|
|
357
|
+
`Aborted — bump the ${kind} version, then re-run \`uniweb ${verb}\`.`
|
|
334
358
|
)
|
|
335
359
|
return { released: false, proceed: false, ref: null }
|
|
336
360
|
}
|
package/src/commands/publish.js
CHANGED
|
@@ -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
|
package/src/commands/pull.js
CHANGED
|
@@ -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(
|
package/src/commands/push.js
CHANGED
|
@@ -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
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-19T04:22:45.477Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.24.
|
|
6
|
+
"version": "0.24.5",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
package/src/utils/flag-guard.js
CHANGED
|
@@ -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',
|
|
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',
|
package/src/utils/interactive.js
CHANGED
|
@@ -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.
|