uniweb 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/partials/agents.md +18 -3
- package/src/commands/doctor.js +145 -2
- package/src/commands/push.js +10 -5
- package/src/commands/refresh.js +16 -0
- package/src/commands/sync.js +16 -2
- package/src/framework-index.json +7 -7
- package/src/index.js +52 -0
- package/src/utils/flag-guard.js +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/core": "^0.
|
|
45
|
-
"@uniweb/kit": "^0.12.
|
|
46
|
-
"@uniweb/runtime": "^0.
|
|
44
|
+
"@uniweb/core": "^0.10.0",
|
|
45
|
+
"@uniweb/kit": "^0.12.2",
|
|
46
|
+
"@uniweb/runtime": "^0.12.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@uniweb/build": "^0.
|
|
50
|
-
"@uniweb/
|
|
51
|
-
"@uniweb/
|
|
49
|
+
"@uniweb/build": "^0.24.0",
|
|
50
|
+
"@uniweb/semantic-parser": "^1.2.2",
|
|
51
|
+
"@uniweb/content-reader": "^1.2.2"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -1962,17 +1962,27 @@ runtime emits by itself.
|
|
|
1962
1962
|
|
|
1963
1963
|
```yaml
|
|
1964
1964
|
# site.yml — your own collector, on any host
|
|
1965
|
-
tracking: https://
|
|
1965
|
+
tracking: https://collector.example.com/events
|
|
1966
1966
|
|
|
1967
1967
|
# or, when it needs more than an address
|
|
1968
1968
|
tracking:
|
|
1969
|
-
endpoint:
|
|
1969
|
+
endpoint: /collect
|
|
1970
1970
|
consent: required
|
|
1971
1971
|
```
|
|
1972
1972
|
|
|
1973
1973
|
A host may also supply one under `services.tracking`, and the usual precedence
|
|
1974
1974
|
applies: yours wins, then the host's, then neither.
|
|
1975
1975
|
|
|
1976
|
+
⚠️ **The endpoint has to accept the framework's own format** — a batched
|
|
1977
|
+
`{ "events": [ … ] }` POST, documented in `reference/site-configuration.md`. It
|
|
1978
|
+
is not a third-party analytics product's public API, which expects that
|
|
1979
|
+
product's own shape.
|
|
1980
|
+
|
|
1981
|
+
A site may also name a vendor's own script under `tracking.scripts`, which the
|
|
1982
|
+
runtime loads once, after consent when a gate is declared, and never in a frame
|
|
1983
|
+
or during prerender. That is a **separate path with no connection to the stream
|
|
1984
|
+
below** — the vendor measures its own way, and nothing you `track()` reaches it.
|
|
1985
|
+
|
|
1976
1986
|
**The runtime reports `page_view` on every route change, including the first.**
|
|
1977
1987
|
That is the only thing it emits on its own — everything else is yours to report:
|
|
1978
1988
|
|
|
@@ -1991,7 +2001,12 @@ const { track } = useTracker()
|
|
|
1991
2001
|
<button onClick={() => track('brochure_download', { file: 'specs.pdf' })}>…</button>
|
|
1992
2002
|
```
|
|
1993
2003
|
|
|
1994
|
-
The event name is yours — there is no list of permitted names.
|
|
2004
|
+
The event name is yours — there is no list of permitted names. Three are already
|
|
2005
|
+
in use, so reach for these rather than inventing a synonym: **`page_view`** (the
|
|
2006
|
+
runtime), **`scroll_depth`** (kit's `useScrollDepth()`, with a `depth` of 25 /
|
|
2007
|
+
50 / 75 / 100) and **`video_milestone`** (kit's `<Media>`, with `milestone` and
|
|
2008
|
+
`src`). Put the varying part in a **field**, never in the name — four names for
|
|
2009
|
+
one event turn a collector's event dimension into a cardinality problem.
|
|
1995
2010
|
|
|
1996
2011
|
⛔ **Never guard a `track()` call.** A site with **no** tracking destination is
|
|
1997
2012
|
the default and the majority: the call returns having done nothing, opened no
|
package/src/commands/doctor.js
CHANGED
|
@@ -307,7 +307,7 @@ export function checkAgentsBlock({ siteName, siteYml, issues }) {
|
|
|
307
307
|
.join(', ')} ${unknown.length === 1 ? 'is' : 'are'} not recognized.`
|
|
308
308
|
)
|
|
309
309
|
for (const key of unknown) {
|
|
310
|
-
const near =
|
|
310
|
+
const near = nearestKnownKey(key, known)
|
|
311
311
|
if (near) log(` ${colors.dim}'${key}' — did you mean ${colors.reset}${colors.green}${near}${colors.reset}${colors.dim}?${colors.reset}`)
|
|
312
312
|
}
|
|
313
313
|
log(
|
|
@@ -326,7 +326,7 @@ export function checkAgentsBlock({ siteName, siteYml, issues }) {
|
|
|
326
326
|
* for `origins`, and a wrong suggestion is worse than none — it sends the
|
|
327
327
|
* author to change a line that was not their mistake.
|
|
328
328
|
*/
|
|
329
|
-
function
|
|
329
|
+
function nearestKnownKey(input, known) {
|
|
330
330
|
let best = null
|
|
331
331
|
let bestScore = Infinity
|
|
332
332
|
for (const candidate of known) {
|
|
@@ -339,6 +339,148 @@ function nearestAgentsKey(input, known) {
|
|
|
339
339
|
return bestScore <= 3 ? best : null
|
|
340
340
|
}
|
|
341
341
|
|
|
342
|
+
/**
|
|
343
|
+
* The options `tracking:` actually has a reader.
|
|
344
|
+
*
|
|
345
|
+
* ⚠️ **Kept here rather than imported, because nothing at runtime enumerates
|
|
346
|
+
* them** — `wireTracker` reads named properties off the resolved declaration, it
|
|
347
|
+
* does not iterate a list. So there is no existing array to import and this
|
|
348
|
+
* duplicates nothing. It does mean the list can drift: the readers are
|
|
349
|
+
* `runtime/src/wire-foundation.js::wireTracker` (`consent`, `scripts`, `debug`)
|
|
350
|
+
* and `core/src/services.js::readEndpoint` (`endpoint`). Add a key there, add it
|
|
351
|
+
* here.
|
|
352
|
+
*/
|
|
353
|
+
const TRACKING_KEYS = ['endpoint', 'consent', 'scripts', 'debug']
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Spellings that read as an ATTEMPT to require consent but do not require it.
|
|
357
|
+
*
|
|
358
|
+
* `consentRequired` is `options.consent === 'required'` — an exact match — so
|
|
359
|
+
* every one of these silently means *no gate*. That is the one field in this
|
|
360
|
+
* block where a silent no-op has a consequence beyond a missing metric.
|
|
361
|
+
*
|
|
362
|
+
* ⚖️ Deliberately not "anything that is not `required`": `consent: none` is a
|
|
363
|
+
* documented, useful value — it is how a site overrides a gate its **host**
|
|
364
|
+
* declared, under the per-key tier fill. Flagging that would flag correct code.
|
|
365
|
+
*/
|
|
366
|
+
const CONSENT_NEAR_MISSES = new Set(['require', 'requires', 'required.', 'true', 'yes', 'on', '1'])
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* `tracking:` — flag the keys and values that are carried and never acted on.
|
|
370
|
+
*
|
|
371
|
+
* The block is forwarded to the host as opaque data and resolved at render, so
|
|
372
|
+
* nothing downstream rejects a mistake in it. Every error here therefore fails
|
|
373
|
+
* the same way: **silently, at a visitor's browser, as an absence** — which is
|
|
374
|
+
* also exactly what a site that configured nothing looks like. There is no
|
|
375
|
+
* symptom to notice and nothing to grep for.
|
|
376
|
+
*
|
|
377
|
+
* ⭐ That is the whole argument for checking it at `doctor` time: it is the only
|
|
378
|
+
* moment in the chain where a person who can fix it is looking at it.
|
|
379
|
+
*
|
|
380
|
+
* A bare `tracking: <url>` string is the documented shorthand and carries no
|
|
381
|
+
* options — there is nothing in it to be wrong.
|
|
382
|
+
*/
|
|
383
|
+
export function checkTrackingBlock({ siteName, siteYml, issues }) {
|
|
384
|
+
const tracking = siteYml?.tracking
|
|
385
|
+
if (tracking === undefined || tracking === null) return
|
|
386
|
+
if (typeof tracking === 'string') return
|
|
387
|
+
|
|
388
|
+
if (typeof tracking !== 'object' || Array.isArray(tracking)) {
|
|
389
|
+
const id = 'tracking-not-an-object'
|
|
390
|
+
issues.push({
|
|
391
|
+
id,
|
|
392
|
+
type: 'warning',
|
|
393
|
+
site: siteName,
|
|
394
|
+
message: `site.yml: \`tracking:\` should be an endpoint string, or a map of options`
|
|
395
|
+
})
|
|
396
|
+
warn(`[${id}] ${siteName}: \`tracking:\` is neither an endpoint string nor a map of options.`)
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const known = new Set(TRACKING_KEYS)
|
|
401
|
+
const unknown = Object.keys(tracking).filter((k) => !known.has(k))
|
|
402
|
+
if (unknown.length > 0) {
|
|
403
|
+
const id = 'tracking-unknown-key'
|
|
404
|
+
issues.push({
|
|
405
|
+
id,
|
|
406
|
+
type: 'warning',
|
|
407
|
+
site: siteName,
|
|
408
|
+
message: `site.yml: \`tracking:\` has ${unknown.length === 1 ? 'an unknown key' : 'unknown keys'}: ${unknown.join(', ')}`
|
|
409
|
+
})
|
|
410
|
+
warn(
|
|
411
|
+
`[${id}] ${siteName}: \`tracking:\` ${unknown.length === 1 ? 'key' : 'keys'} ${unknown
|
|
412
|
+
.map((k) => `'${k}'`)
|
|
413
|
+
.join(', ')} ${unknown.length === 1 ? 'is' : 'are'} not recognized.`
|
|
414
|
+
)
|
|
415
|
+
for (const key of unknown) {
|
|
416
|
+
const near = nearestKnownKey(key, known)
|
|
417
|
+
if (near) log(` ${colors.dim}'${key}' — did you mean ${colors.reset}${colors.green}${near}${colors.reset}${colors.dim}?${colors.reset}`)
|
|
418
|
+
}
|
|
419
|
+
log(
|
|
420
|
+
` ${colors.dim}Carried to the host as opaque data and never read. Known: ${TRACKING_KEYS.join(', ')}.${colors.reset}`
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// `consent` is exact-matched against 'required'. A near miss is not a typo
|
|
425
|
+
// with a cosmetic cost — it is a consent gate the author believes they asked
|
|
426
|
+
// for and did not get.
|
|
427
|
+
if ('consent' in tracking) {
|
|
428
|
+
const value = tracking.consent
|
|
429
|
+
const spelling = value === null || value === undefined ? '' : String(value).trim().toLowerCase()
|
|
430
|
+
|
|
431
|
+
// ⛔ The gate test is against the RAW value, because the runtime's is:
|
|
432
|
+
// `options.consent === 'required'`, exact and case-sensitive. Lowercasing
|
|
433
|
+
// before this comparison made `Required` look correct to the checker — the
|
|
434
|
+
// check stopped testing the thing it was written to catch. The normalized
|
|
435
|
+
// spelling is only for deciding whether a wrong value reads as an ATTEMPT.
|
|
436
|
+
const gateIsOn = value === 'required'
|
|
437
|
+
const looksIntended =
|
|
438
|
+
spelling === '' || spelling === 'required' || CONSENT_NEAR_MISSES.has(spelling)
|
|
439
|
+
|
|
440
|
+
if (!gateIsOn && looksIntended) {
|
|
441
|
+
const id = 'tracking-consent-not-required'
|
|
442
|
+
const shown = spelling === '' ? 'an empty value' : `\`${String(value)}\``
|
|
443
|
+
issues.push({
|
|
444
|
+
id,
|
|
445
|
+
type: 'warning',
|
|
446
|
+
site: siteName,
|
|
447
|
+
message: `site.yml: \`tracking.consent:\` is ${shown} — only the exact value \`required\` turns the gate on`
|
|
448
|
+
})
|
|
449
|
+
warn(`[${id}] ${siteName}: \`tracking.consent:\` is ${shown}; the gate is OFF.`)
|
|
450
|
+
log(
|
|
451
|
+
` ${colors.dim}Only \`consent: required\` holds events until a visitor answers. Anything else${colors.reset}`
|
|
452
|
+
)
|
|
453
|
+
log(
|
|
454
|
+
` ${colors.dim}sends immediately — including a blank value, which YAML reads as null.${colors.reset}`
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// An entry that yields no URL is dropped by the loader without a sound.
|
|
460
|
+
const declared = tracking.scripts
|
|
461
|
+
if (declared !== undefined) {
|
|
462
|
+
const list = Array.isArray(declared) ? declared : [declared]
|
|
463
|
+
const bad = list.filter(
|
|
464
|
+
(e) => !(typeof e === 'string' ? e.trim() : typeof e?.src === 'string' && e.src.trim())
|
|
465
|
+
)
|
|
466
|
+
if (bad.length > 0) {
|
|
467
|
+
const id = 'tracking-script-without-url'
|
|
468
|
+
issues.push({
|
|
469
|
+
id,
|
|
470
|
+
type: 'warning',
|
|
471
|
+
site: siteName,
|
|
472
|
+
message: `site.yml: \`tracking.scripts:\` has ${bad.length} ${bad.length === 1 ? 'entry' : 'entries'} with no URL`
|
|
473
|
+
})
|
|
474
|
+
warn(
|
|
475
|
+
`[${id}] ${siteName}: ${bad.length} \`tracking.scripts:\` ${bad.length === 1 ? 'entry has' : 'entries have'} no URL and will not load.`
|
|
476
|
+
)
|
|
477
|
+
log(
|
|
478
|
+
` ${colors.dim}An entry is a URL, or an object with a \`src\`. Anything else is dropped silently.${colors.reset}`
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
342
484
|
/** Levenshtein distance, iterative two-row form. */
|
|
343
485
|
function editDistance(a, b) {
|
|
344
486
|
if (a === b) return 0
|
|
@@ -760,6 +902,7 @@ export async function doctor(args = []) {
|
|
|
760
902
|
// Forms need a destination, and having none is only visible on the page.
|
|
761
903
|
await checkFormSubmitTarget({ sitePath, siteName, siteYml, issues })
|
|
762
904
|
checkAgentsBlock({ siteName, siteYml, issues })
|
|
905
|
+
checkTrackingBlock({ siteName, siteYml, issues })
|
|
763
906
|
|
|
764
907
|
checkGeneratedDataDir({
|
|
765
908
|
sitePath,
|
package/src/commands/push.js
CHANGED
|
@@ -113,13 +113,18 @@ function flagValue(args, name) {
|
|
|
113
113
|
return null
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
export async function push(args = []) {
|
|
116
|
+
export async function push(args = [], deps = {}) {
|
|
117
117
|
// An unrecognized flag is invisible to a literal scan, so it silently keeps the
|
|
118
118
|
// default — including for --backend, where the default can be production.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
119
|
+
//
|
|
120
|
+
// `sync` validates the UNION of its two halves' flags and forwards raw argv, so a
|
|
121
|
+
// legal `uniweb sync --no-git` would be rejected here. It passes `skipFlagCheck`.
|
|
122
|
+
if (!deps.skipFlagCheck) {
|
|
123
|
+
const bad = checkFlags('push', args)
|
|
124
|
+
if (bad) {
|
|
125
|
+
error(bad.message)
|
|
126
|
+
return { exitCode: 2 }
|
|
127
|
+
}
|
|
123
128
|
}
|
|
124
129
|
const dryRun = args.includes('--dry-run')
|
|
125
130
|
const output = flagValue(args, '-o') || flagValue(args, '--output')
|
package/src/commands/refresh.js
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
headProvenance
|
|
47
47
|
} from '../utils/git.js'
|
|
48
48
|
import { probeUnpushed } from '../backend/site-sync.js'
|
|
49
|
+
import { checkFlags } from '../utils/flag-guard.js'
|
|
49
50
|
|
|
50
51
|
const c = {
|
|
51
52
|
reset: '\x1b[0m',
|
|
@@ -98,6 +99,21 @@ function siteContentUuid(siteDir) {
|
|
|
98
99
|
* network or a real backend).
|
|
99
100
|
*/
|
|
100
101
|
export async function refresh(args = [], deps = {}) {
|
|
102
|
+
// An unrecognized flag is invisible to a literal scan, so it silently keeps the
|
|
103
|
+
// default — and for `--backend` the default can be production. This verb never
|
|
104
|
+
// sent data anywhere, which is why it went unguarded; but it DELEGATES to `pull`,
|
|
105
|
+
// and a mistyped `--backend` is not forwarded, so the pull runs against whatever
|
|
106
|
+
// the origin ladder resolves instead of the backend the user named.
|
|
107
|
+
//
|
|
108
|
+
// `sync` validates the UNION of both halves and forwards raw argv, so a legal
|
|
109
|
+
// `uniweb sync --force` would be rejected here. It passes `skipFlagCheck`.
|
|
110
|
+
if (!deps.skipFlagCheck) {
|
|
111
|
+
const bad = checkFlags('refresh', args)
|
|
112
|
+
if (bad) {
|
|
113
|
+
say.err(bad.message)
|
|
114
|
+
return { exitCode: 2 }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
101
117
|
const skipGit = args.includes('--no-git')
|
|
102
118
|
const skipBackend = args.includes('--no-backend')
|
|
103
119
|
const resolveSite = deps.resolveSiteDir || resolveSiteDir
|
package/src/commands/sync.js
CHANGED
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
* uniweb sync --backend <url> override the backend origin
|
|
39
39
|
*/
|
|
40
40
|
|
|
41
|
+
import { checkFlags } from '../utils/flag-guard.js'
|
|
42
|
+
|
|
41
43
|
const c = {
|
|
42
44
|
reset: '\x1b[0m',
|
|
43
45
|
dim: '\x1b[2m',
|
|
@@ -55,6 +57,18 @@ const say = {
|
|
|
55
57
|
* @param {object} [deps] - injectable seams for testing: `refresh`, `push`.
|
|
56
58
|
*/
|
|
57
59
|
export async function sync(args = [], deps = {}) {
|
|
60
|
+
// THE ONE flag check for the whole composite, against the union of both halves'
|
|
61
|
+
// accepted sets (`VERBS.sync`, derived in flag-guard.js). It has to happen here
|
|
62
|
+
// and only here: each half's set is a strict subset, so `push` would reject
|
|
63
|
+
// `--no-git` and `refresh` would reject `--force` — both legal on `sync`. Hence
|
|
64
|
+
// `skipFlagCheck` below; the halves are not being trusted, they are being told
|
|
65
|
+
// the caller already validated a superset.
|
|
66
|
+
const bad = checkFlags('sync', args)
|
|
67
|
+
if (bad) {
|
|
68
|
+
say.err(bad.message)
|
|
69
|
+
return { exitCode: 2 }
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
const refresh = deps.refresh || (await import('./refresh.js')).refresh
|
|
59
73
|
const push = deps.push || (await import('./push.js')).push
|
|
60
74
|
|
|
@@ -63,7 +77,7 @@ export async function sync(args = [], deps = {}) {
|
|
|
63
77
|
// to send — the same word meaning opposite things on the two halves.
|
|
64
78
|
const refreshArgs = args.filter((a) => a !== '--force')
|
|
65
79
|
|
|
66
|
-
const r = await refresh(refreshArgs)
|
|
80
|
+
const r = await refresh(refreshArgs, { skipFlagCheck: true })
|
|
67
81
|
if (r?.exitCode) {
|
|
68
82
|
// refresh already explained itself — conflicts, or a git failure. Adding a
|
|
69
83
|
// second summary here would just bury it.
|
|
@@ -73,7 +87,7 @@ export async function sync(args = [], deps = {}) {
|
|
|
73
87
|
|
|
74
88
|
console.log('')
|
|
75
89
|
say.info('Pushing your changes…')
|
|
76
|
-
const p = await push(args)
|
|
90
|
+
const p = await push(args, { skipFlagCheck: true })
|
|
77
91
|
return { exitCode: p?.exitCode ?? 0 }
|
|
78
92
|
}
|
|
79
93
|
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-16T13:24:21.340Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.24.0",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"deps": []
|
|
29
29
|
},
|
|
30
30
|
"@uniweb/core": {
|
|
31
|
-
"version": "0.
|
|
31
|
+
"version": "0.10.0",
|
|
32
32
|
"path": "framework/core",
|
|
33
33
|
"deps": [
|
|
34
34
|
"@uniweb/semantic-parser",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"deps": []
|
|
47
47
|
},
|
|
48
48
|
"@uniweb/kit": {
|
|
49
|
-
"version": "0.12.
|
|
49
|
+
"version": "0.12.2",
|
|
50
50
|
"path": "framework/kit",
|
|
51
51
|
"deps": [
|
|
52
52
|
"@uniweb/core",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"deps": []
|
|
66
66
|
},
|
|
67
67
|
"@uniweb/projections": {
|
|
68
|
-
"version": "0.3.
|
|
68
|
+
"version": "0.3.3",
|
|
69
69
|
"path": "framework/projections",
|
|
70
70
|
"deps": [
|
|
71
71
|
"@uniweb/content-writer",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
]
|
|
74
74
|
},
|
|
75
75
|
"@uniweb/runtime": {
|
|
76
|
-
"version": "0.
|
|
76
|
+
"version": "0.12.0",
|
|
77
77
|
"path": "framework/runtime",
|
|
78
78
|
"deps": [
|
|
79
79
|
"@uniweb/core",
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
"deps": []
|
|
112
112
|
},
|
|
113
113
|
"@uniweb/unipress": {
|
|
114
|
-
"version": "0.8.
|
|
114
|
+
"version": "0.8.10",
|
|
115
115
|
"path": "framework/unipress",
|
|
116
116
|
"deps": [
|
|
117
117
|
"@uniweb/build",
|
package/src/index.js
CHANGED
|
@@ -1544,6 +1544,58 @@ ${colors.bright}Options:${colors.reset}
|
|
|
1544
1544
|
In non-interactive mode (CI / no TTY), pass \`--token <bearer>\`, or set
|
|
1545
1545
|
\`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`, or set \`UNIWEB_TOKEN\` (used per-command,
|
|
1546
1546
|
not stored). Run \`uniweb logout\` to clear the stored session.
|
|
1547
|
+
`,
|
|
1548
|
+
refresh: `
|
|
1549
|
+
${colors.cyan}${colors.bright}uniweb refresh${colors.reset} ${colors.dim}— Catch up with teammates AND app authors${colors.reset}
|
|
1550
|
+
|
|
1551
|
+
${colors.bright}Usage:${colors.reset}
|
|
1552
|
+
uniweb refresh [options]
|
|
1553
|
+
|
|
1554
|
+
A developer on a synced site has TWO external sources: the git remote (teammates'
|
|
1555
|
+
commits) and the backend (content authors' edits in the app). Run only \`git pull\`
|
|
1556
|
+
and you miss every app edit; run only \`uniweb pull\` and you miss your teammates.
|
|
1557
|
+
This does both — git first, then \`pull --merge\`.
|
|
1558
|
+
|
|
1559
|
+
${colors.bright}READ-ONLY.${colors.reset} It never pushes and cannot ship anything, so it is safe to run
|
|
1560
|
+
reflexively. Exits non-zero if a merge leaves conflicts.
|
|
1561
|
+
|
|
1562
|
+
${colors.bright}Options:${colors.reset}
|
|
1563
|
+
--no-git Skip the git remote; backend only
|
|
1564
|
+
--no-backend Skip the backend; git only
|
|
1565
|
+
--backend <url> Override the backend origin
|
|
1566
|
+
--token <bearer> Read with this bearer; skips \`uniweb login\`
|
|
1567
|
+
|
|
1568
|
+
\`--force\` and \`--merge\` are NOT accepted here: this verb builds the delegated
|
|
1569
|
+
pull's arguments itself, and to pull \`--force\` means "discard my local work".
|
|
1570
|
+
Use \`uniweb sync\` when you also mean to share.
|
|
1571
|
+
`,
|
|
1572
|
+
sync: `
|
|
1573
|
+
${colors.cyan}${colors.bright}uniweb sync${colors.reset} ${colors.dim}— Catch up, then share${colors.reset}
|
|
1574
|
+
|
|
1575
|
+
${colors.bright}Usage:${colors.reset}
|
|
1576
|
+
uniweb sync [options]
|
|
1577
|
+
|
|
1578
|
+
\`uniweb refresh\` followed by \`uniweb push\`, and nothing more. It adds no
|
|
1579
|
+
guarantees of its own — refresh stops on conflicts, push refuses when the backend
|
|
1580
|
+
has moved under you, and sync inherits both. \`uniweb refresh && uniweb push\`
|
|
1581
|
+
behaves identically.
|
|
1582
|
+
|
|
1583
|
+
${colors.bright}It does NOT publish.${colors.reset} Sync brings your copy and the backend DRAFT into
|
|
1584
|
+
agreement; going live stays a separate act (\`uniweb publish\`). So the worst case
|
|
1585
|
+
of an unintended sync is work-in-progress visible to authors, not shipped to
|
|
1586
|
+
visitors.
|
|
1587
|
+
|
|
1588
|
+
${colors.bright}Options:${colors.reset}
|
|
1589
|
+
Accepts every option of both halves — see \`uniweb refresh --help\` and the push
|
|
1590
|
+
options in \`uniweb --help\`. The common ones:
|
|
1591
|
+
|
|
1592
|
+
--no-git Skip the git remote half of the refresh
|
|
1593
|
+
--force Overwrite upstream changes (reaches the PUSH half only)
|
|
1594
|
+
--backend <url> Override the backend origin
|
|
1595
|
+
--token <bearer> Auth bearer; skips \`uniweb login\`
|
|
1596
|
+
|
|
1597
|
+
\`--force\` means "overwrite upstream" to push but "discard my local work" to pull,
|
|
1598
|
+
so it is deliberately kept away from the refresh half.
|
|
1547
1599
|
`,
|
|
1548
1600
|
invite: `
|
|
1549
1601
|
${colors.cyan}${colors.bright}uniweb invite${colors.reset} ${colors.dim}— (reserved; not available on the new backend yet)${colors.reset}
|
package/src/utils/flag-guard.js
CHANGED
|
@@ -85,9 +85,44 @@ const VERBS = {
|
|
|
85
85
|
'--backend', '--json', '--registry', '--remote', '--token', '--dry-run',
|
|
86
86
|
'--force', '--no-verify', '--no-validate', '--yes', '--org', '--as-org',
|
|
87
87
|
...VIA_DEPLOY
|
|
88
|
+
],
|
|
89
|
+
/**
|
|
90
|
+
* `refresh` = `git pull`, then a DELEGATED `pull --merge`.
|
|
91
|
+
*
|
|
92
|
+
* It forwards exactly three flags to that pull — `--backend` / `--registry` /
|
|
93
|
+
* `--token`, via its own `collectPassthrough` — and constructs the rest of the
|
|
94
|
+
* argv itself. So pull's own flags (`--merge`, `--force`, `--no-delete`,
|
|
95
|
+
* `--no-prune`, `--content-only`, …) are NOT reachable from a `refresh` argv and
|
|
96
|
+
* are deliberately absent here. ⚠️ `--force` especially: `refresh` is read-only by
|
|
97
|
+
* design, and forwarding it would ask pull to DISCARD local work.
|
|
98
|
+
*
|
|
99
|
+
* The trailing group is inert on this verb but reachable through the
|
|
100
|
+
* `resolveSiteDir` / `probeUnpushed` imports — listed rather than filtered, per
|
|
101
|
+
* the VIA_DEPLOY note above, and required by `flag-guard-coverage.test.js`.
|
|
102
|
+
*/
|
|
103
|
+
refresh: [
|
|
104
|
+
'--backend', '--no-backend', '--no-git', '--registry', '--token',
|
|
105
|
+
'--as-org', '--org', '--dry-run', '--no-validate', '--yes', ...VIA_DEPLOY
|
|
88
106
|
]
|
|
89
107
|
}
|
|
90
108
|
|
|
109
|
+
/**
|
|
110
|
+
* `sync` is `refresh` + `push`, forwarding RAW argv to both halves — so its
|
|
111
|
+
* accepted set is exactly the UNION of theirs. Derived, never hand-written.
|
|
112
|
+
*
|
|
113
|
+
* Hand-writing it would rot the moment either half gained a flag, and it would rot
|
|
114
|
+
* in the expensive direction: `uniweb sync --no-git` and `uniweb sync --force` are
|
|
115
|
+
* both documented in `sync.js`, and a union that forgot either would reject a
|
|
116
|
+
* working command.
|
|
117
|
+
*
|
|
118
|
+
* ⭐ The union is also WHY `sync` checks and the two halves skip (`skipFlagCheck`,
|
|
119
|
+
* see `sync.js`). Each half's set is a strict subset of this one, so letting them
|
|
120
|
+
* re-check would have `push` reject `--no-git` and `refresh` reject `--force` —
|
|
121
|
+
* both legal on `sync`. Validating once, against the union, at the only layer that
|
|
122
|
+
* knows both halves are in play, is what makes the composite safe.
|
|
123
|
+
*/
|
|
124
|
+
VERBS.sync = [...new Set([...VERBS.refresh, ...VERBS.push])]
|
|
125
|
+
|
|
91
126
|
/**
|
|
92
127
|
* The accepted set per verb: its own flags, plus the login-method flags every
|
|
93
128
|
* backend verb can reach, plus the globals.
|