uniweb 0.13.5 → 0.13.7

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.
@@ -12,8 +12,11 @@
12
12
  * UPDATE by that uuid. The folder lane is keyed by the SAME site-content uuid —
13
13
  * the backend owns the site's `@uniweb/folder`, so the framework never holds a
14
14
  * folder uuid. Records still round-trip their own `$uuid`
15
- * (back-filled into their source files). site-content is pushed wholesale (no per-item
16
- * uuids on the wire). Push-only, last-push-wins (`collision=force`) in v1.
15
+ * (back-filled into their source files). site-content items carry a per-item `$uuid`
16
+ * too, stamped at emit from the identity cache rather than from author files — without
17
+ * it the backend reads every record as new and recreates every page and section row.
18
+ * Push-only, and gated on the backend's per-entity `version`
19
+ * (see "Pushes are GATED by default" below); `--force` restores last-push-wins.
17
20
  *
18
21
  * Order: content first (CREATE or UPDATE — the site must exist before its folder),
19
22
  * then the folder, keyed by the site's uuid. On a brand-new site the backend creates
@@ -30,6 +33,14 @@
30
33
  * uniweb push --token <bearer> Submit with this bearer; skips `uniweb login`
31
34
  * uniweb push --foundation <dir> Use this local foundation for the Model schema
32
35
  * uniweb push --all Send every record (bypass the changed-only cache)
36
+ * uniweb push --force Overwrite upstream changes (drop the staleness gate)
37
+ *
38
+ * Pushes are GATED by default: each entity carries the backend `version` this clone
39
+ * last saw (a top-level `base_version` on the manifest entry), and the backend refuses the whole package
40
+ * atomically — before any write — if its stored version has moved. That prevents a
41
+ * developer who hasn't pulled from silently destroying an app author's edits (the
42
+ * backend's reconcile deletes items absent from the package, so an author's NEW page
43
+ * would be hard-deleted). `--force` omits the token and restores last-push-wins.
33
44
  *
34
45
  * Backend: via BackendClient (the content + folder sync lanes). Origin from
35
46
  * --registry > UNIWEB_REGISTER_URL > the local default.
@@ -44,9 +55,18 @@
44
55
  import { writeFileSync } from 'node:fs'
45
56
  import { resolve } from 'node:path'
46
57
  import { emitSyncPackages } from '@uniweb/build/uwx'
58
+ import { uploadSiteMedia, isStorageRefusal } from '../backend/site-media.js'
47
59
  import { BackendClient } from '../backend/client.js'
48
60
  import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
49
- import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
61
+ import {
62
+ makeModelResolver,
63
+ readSyncCache,
64
+ readBaseVersions,
65
+ readItemBaseVersions,
66
+ readItemUuids,
67
+ ensureItemUuids,
68
+ pushSyncPackages,
69
+ } from '../backend/site-sync.js'
50
70
 
51
71
  // Re-exported for downstream importers (pull.js, push.test.js) that read these
52
72
  // helpers from this module — their canonical home is now ../backend/site-sync.js.
@@ -77,6 +97,12 @@ export async function push(args = []) {
77
97
  const asOrg = flagValue(args, '--as-org')
78
98
  const foundationDir = flagValue(args, '--foundation')
79
99
  const sendAll = args.includes('--all') // bypass the send-only-changed cache
100
+ // --force drops the optimistic-concurrency precondition, making the push
101
+ // unconditional (the backend then falls back to its `collision` policy). It is
102
+ // deliberately NOT "send collision=force": when a base_version is present the
103
+ // backend consults it and never looks at `collision`, so forcing has to mean
104
+ // OMITTING the token — the HTTP If-Match idiom.
105
+ const force = args.includes('--force')
80
106
 
81
107
  const siteDir = await resolveSiteDir(args, 'push')
82
108
  const siteBackend = await resolveSiteBackend(siteDir)
@@ -98,6 +124,86 @@ export async function push(args = []) {
98
124
  // position. Non-local Models are fetched from the registry on demand. `priorHashes`
99
125
  // (the .uniweb push-cache) drives "send only changed" across both lanes; --all bypasses.
100
126
  const priorHashes = readSyncCache(siteDir)
127
+ // Per-item identity, without which the backend reads every record as new and
128
+ // recreates every page and section row.
129
+ //
130
+ // An offline preview (`-o` / `--dry-run`) still stamps from the CACHE — that is a
131
+ // local file read, so it stays offline, and it keeps the emitted `.uwx` faithful
132
+ // to what a real push would send. Only the network RECOVERY is skipped, so a
133
+ // preview never reaches the backend. (Emitting `{}` here instead would make the
134
+ // preview quietly unrepresentative, which is the one thing `-o` exists to avoid.)
135
+ // Local media rides the SAME asset lane `publish` uses, and it rides it FIRST.
136
+ //
137
+ // Push is the collaboration verb: a teammate opens the site in the visual app
138
+ // right after it. Content that still points at `/images/hero.png` — bytes the
139
+ // backend never received — shows them a broken image, which is precisely what
140
+ // push exists to avoid. `publish` uploaded and rewrote; push dropped
141
+ // `localAssets` on the floor.
142
+ //
143
+ // Ordering is deliberate: BEFORE `ensureItemUuids`, which mints uuids on the
144
+ // backend. A refusal here then leaves nothing minted and nothing submitted.
145
+ //
146
+ // The probe emit runs WITHOUT `priorHashes`, so it surfaces every local ref
147
+ // rather than only the changed ones. That is not waste — the lane is
148
+ // content-addressed with a `present` skip-list, so unchanged bytes are a no-op
149
+ // PUT. It is also what makes the storage rule fall out of the mechanism instead
150
+ // of needing a special case: a content-only push presents the same plan as last
151
+ // time, every file is already present, zero new bytes are requested, and no
152
+ // quota check can refuse it. Only a push that genuinely ADDS bytes can be
153
+ // blocked.
154
+ //
155
+ // It also has to be this emit that carries `assetRewrite` below: the push cache
156
+ // stores hashes of the REWRITTEN content, so the emit compared against it must
157
+ // rewrite too, or every entity reads as changed forever.
158
+ let assetRewrite = null
159
+ if (!output && !dryRun) {
160
+ let mediaRefs = []
161
+ try {
162
+ const probe = await emitSyncPackages(siteDir, {
163
+ ...(foundationDir ? { foundationDir } : {}),
164
+ resolveModel: makeModelResolver({ client, offline: false }),
165
+ })
166
+ mediaRefs = probe.localAssets || []
167
+ } catch (err) {
168
+ error(`Could not scan the site for local media: ${err.message}`)
169
+ return { exitCode: 2 }
170
+ }
171
+ if (mediaRefs.length) {
172
+ info('Uploading media…')
173
+ try {
174
+ const { map, failed } = await uploadSiteMedia(client, siteDir, mediaRefs, {
175
+ onProgress: (m) => note(` ${m}`),
176
+ warn: (m) => note(`! ${m}`),
177
+ })
178
+ // Bytes that did not land must not be pushed around: the content would go
179
+ // up still naming the local path, so the teammate sees the broken image
180
+ // this whole change exists to prevent, and the only trace is a warning. A
181
+ // missing FILE is a different thing — already broken before us, warned by
182
+ // the uploader, and not worth blocking a push over.
183
+ if (failed.length) {
184
+ error(`${failed.length} asset(s) failed to upload — nothing was pushed.`)
185
+ for (const f of failed) note(` ${f.path} (HTTP ${f.status})`)
186
+ return { exitCode: 1 }
187
+ }
188
+ if (Object.keys(map).length) assetRewrite = map
189
+ note(`${Object.keys(map).length}/${mediaRefs.length} media ref(s) → serve URL`)
190
+ } catch (err) {
191
+ if (isStorageRefusal(err)) {
192
+ error('Storage quota reached — this push adds media that does not fit.')
193
+ note(' A push that changes only content costs no storage and still works.')
194
+ note(' Remove or shrink the new assets, or raise the plan limit, then re-run.')
195
+ note(` ${err.message}`)
196
+ return { exitCode: 1 }
197
+ }
198
+ error(`Media upload failed: ${err.message}`)
199
+ return { exitCode: 1 }
200
+ }
201
+ }
202
+ }
203
+
204
+ const itemUuids = (output || dryRun)
205
+ ? readItemUuids(siteDir)
206
+ : await ensureItemUuids({ client, siteDir, note })
101
207
  let pkg
102
208
  try {
103
209
  pkg = await emitSyncPackages(siteDir, {
@@ -105,6 +211,11 @@ export async function push(args = []) {
105
211
  resolveModel: makeModelResolver({ client, offline: Boolean(output) || dryRun }),
106
212
  priorHashes,
107
213
  sendAll,
214
+ itemUuids,
215
+ // Both grains are dropped together by --force: one flag, one meaning,
216
+ // no partial-force mode.
217
+ ...(force ? {} : { baseVersions: readBaseVersions(siteDir), itemBaseVersions: readItemBaseVersions(siteDir) }),
218
+ ...(assetRewrite ? { assetRewrite } : {}),
108
219
  })
109
220
  } catch (err) {
110
221
  error(`Could not build the sync package: ${err.message}`)
@@ -0,0 +1,176 @@
1
+ /**
2
+ * uniweb refresh — catch up with everything outside your working copy.
3
+ *
4
+ * A developer on a synced site has TWO independent external sources, and nothing
5
+ * in the tool used to connect them:
6
+ *
7
+ * - the git remote — teammates' commits;
8
+ * - the backend — content authors' edits, made in the app.
9
+ *
10
+ * Someone who runs only `git pull` silently misses every app edit; someone who
11
+ * runs only `uniweb pull` misses their teammates. The person most likely to be
12
+ * caught out is exactly the one starting their day believing they are current.
13
+ * This is the one command for that: run it in the morning, or before a milestone.
14
+ *
15
+ * READ-ONLY, and that is the load-bearing decision. It never pushes. Because it
16
+ * cannot ship anything, it can be run reflexively without weighing consequences —
17
+ * which is the whole point of a start-of-day command. A version that also pushed
18
+ * would be one you had to think about first, and so one people would stop running.
19
+ *
20
+ * Order is deliberate: git first, then the backend. The three-way merge's common
21
+ * ancestor is the COMMITTED version of each file, so taking teammates' commits
22
+ * first makes that ancestor fresher and the merge more accurate. And if git itself
23
+ * conflicts, refresh stops there — one source of conflict at a time.
24
+ *
25
+ * Exits NON-ZERO when a merge left conflicts, the way a conflicted `git merge`
26
+ * does. That is what makes `uniweb refresh && uniweb push` correct by
27
+ * construction: without it, the obvious one-liner ships conflict markers into live
28
+ * content. If a `sync` verb is ever wanted, it is that chain.
29
+ *
30
+ * Usage:
31
+ * uniweb refresh git pull, then merge the backend's content
32
+ * uniweb refresh --no-git skip the git remote; backend only
33
+ * uniweb refresh --no-backend skip the backend; git only
34
+ * uniweb refresh --backend <url> Override the backend origin
35
+ */
36
+
37
+ import { readFileSync } from 'node:fs'
38
+ import { join } from 'node:path'
39
+ import yaml from 'js-yaml'
40
+
41
+ import { resolveSiteDir } from './deploy.js'
42
+ import { isGitRepo, hasRemote, pullRemote, headProvenance } from '../utils/git.js'
43
+ import { probeUnpushed } from '../backend/site-sync.js'
44
+
45
+ const c = {
46
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
47
+ cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m',
48
+ }
49
+ const say = {
50
+ ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
51
+ info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
52
+ warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
53
+ err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
54
+ dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
55
+ }
56
+
57
+ // Forward a flag AND its value to the delegated verb. `--backend http://x` is two
58
+ // argv entries, so a naive filter passes the flag and drops the URL — leaving the
59
+ // delegated pull pointed at the default backend while the user believes they
60
+ // overrode it. `--backend=http://x` is one entry and passes through as-is.
61
+ function collectPassthrough(args, names) {
62
+ const out = []
63
+ for (let i = 0; i < args.length; i++) {
64
+ const a = args[i]
65
+ const name = names.find((n) => a === n || a.startsWith(`${n}=`))
66
+ if (!name) continue
67
+ out.push(a)
68
+ if (a === name && args[i + 1] && !args[i + 1].startsWith('-')) out.push(args[++i])
69
+ }
70
+ return out
71
+ }
72
+
73
+ // A site is backend-synced once it has an identity to pull by.
74
+ function siteContentUuid(siteDir) {
75
+ try {
76
+ const y = yaml.load(readFileSync(join(siteDir, 'site.yml'), 'utf8'))
77
+ return typeof y?.$uuid === 'string' ? y.$uuid : null
78
+ } catch {
79
+ return null
80
+ }
81
+ }
82
+
83
+ /**
84
+ * @param {string[]} args
85
+ * @param {object} [deps] - injectable seams for testing, mirroring pull.js:
86
+ * `resolveSiteDir`, and `pull` (so the backend step can be driven without a
87
+ * network or a real backend).
88
+ */
89
+ export async function refresh(args = [], deps = {}) {
90
+ const skipGit = args.includes('--no-git')
91
+ const skipBackend = args.includes('--no-backend')
92
+ const resolveSite = deps.resolveSiteDir || resolveSiteDir
93
+ const siteDir = await resolveSite(args, 'refresh')
94
+
95
+ // Which sources this run actually consulted. Reported at the end, because
96
+ // "up to date" is a different claim from "up to date with the two things I
97
+ // happened to be able to check" — and the second is all we can ever honestly
98
+ // say. Silently skipping a source is how someone concludes they are current
99
+ // when they are not.
100
+ const consulted = []
101
+ const skipped = []
102
+ let conflicts = 0
103
+
104
+ // ── 1. the git remote ─────────────────────────────────────────────────────
105
+ if (skipGit) {
106
+ skipped.push('git (--no-git)')
107
+ } else if (!isGitRepo(siteDir)) {
108
+ skipped.push('git (not a repository)')
109
+ } else if (!hasRemote(siteDir)) {
110
+ skipped.push('git (no remote configured)')
111
+ } else {
112
+ say.info('Pulling from the git remote…')
113
+ const r = pullRemote(siteDir)
114
+ if (!r.ok) {
115
+ say.err('git pull failed — resolve this before continuing.')
116
+ for (const line of r.message.split('\n').slice(0, 6)) say.dim(line)
117
+ // Deliberately stop. Layering the backend's content on top of an unresolved
118
+ // git state gives the user two independent conflicts at once, and no clear
119
+ // order to address them in.
120
+ return { exitCode: 1 }
121
+ }
122
+ say.dim(r.changed ? 'Took new commits from the remote.' : 'Already up to date with the remote.')
123
+ consulted.push('git')
124
+ }
125
+
126
+ // ── 2. the backend ────────────────────────────────────────────────────────
127
+ if (skipBackend) {
128
+ skipped.push('backend (--no-backend)')
129
+ } else if (!siteContentUuid(siteDir)) {
130
+ skipped.push('backend (this site has never been synced)')
131
+ } else {
132
+ say.info("Merging the backend's content…")
133
+ const pull = deps.pull || (await import('./pull.js')).pull
134
+ // `--merge` rather than a plain pull: an author editing a different part of the
135
+ // same section is not a conflict, and should not be presented as one.
136
+ const passthrough = collectPassthrough(args, ['--backend', '--registry', '--token'])
137
+ const res = await pull(['--merge', ...passthrough])
138
+ conflicts = res?.merge?.conflicted?.length ?? 0
139
+ if (res?.exitCode && !conflicts) {
140
+ // Failed for a reason other than conflicts — say so plainly rather than
141
+ // reporting a clean refresh over a lane that never ran.
142
+ say.err('Could not merge the backend content.')
143
+ return { exitCode: res.exitCode }
144
+ }
145
+ consulted.push('backend')
146
+ }
147
+
148
+ // ── 3. where that leaves you ──────────────────────────────────────────────
149
+ console.log('')
150
+ if (consulted.length) say.dim(`Checked: ${consulted.join(', ')}`)
151
+ for (const s of skipped) say.dim(`Skipped: ${s}`)
152
+
153
+ const git = headProvenance(siteDir)
154
+ if (git) say.dim(`Commit : ${git.sha.slice(0, 8)}${git.dirty ? ' (working tree has changes)' : ''}`)
155
+
156
+ // What is still yours to send. The point of a milestone check is knowing both
157
+ // directions, not just that you took what was waiting.
158
+ if (!skipBackend && siteContentUuid(siteDir)) {
159
+ try {
160
+ const probe = await probeUnpushed(siteDir)
161
+ if (probe.changed) say.dim(`Unpushed: ${probe.changed} entit${probe.changed === 1 ? 'y' : 'ies'} changed locally`)
162
+ } catch {
163
+ /* the probe is a courtesy; never fail a refresh over it */
164
+ }
165
+ }
166
+
167
+ console.log('')
168
+ if (conflicts) {
169
+ say.err(`${conflicts} file(s) need you to resolve conflicts before pushing.`)
170
+ return { exitCode: 1 }
171
+ }
172
+ say.ok('Up to date.')
173
+ return { exitCode: 0 }
174
+ }
175
+
176
+ export default refresh
@@ -0,0 +1,75 @@
1
+ /**
2
+ * uniweb sync — catch up, then share. `refresh` followed by `push`.
3
+ *
4
+ * The two halves already exist and already carry the safety:
5
+ *
6
+ * - `refresh` takes teammates' commits and merges the backend's content, and
7
+ * exits NON-ZERO when a merge left conflicts;
8
+ * - `push` refuses when the backend has moved under you, and never overwrites
9
+ * an author's work blind.
10
+ *
11
+ * So this verb adds no guarantees of its own — it composes two commands that
12
+ * already hold them. That is deliberate: a safety check you can only get by
13
+ * remembering to type `sync` is not a safety check, which is why the gate lives in
14
+ * `push` and the conflict stop lives in `refresh`. Anyone typing
15
+ * `uniweb refresh && uniweb push` gets the identical behaviour, and the exit codes
16
+ * are what make that chain correct rather than anything here.
17
+ *
18
+ * A THIN COMPOSITION, not a reimplementation. Both halves are called, never
19
+ * re-created. The `deploy`/`publish` split is the cautionary tale: two paths that
20
+ * did nearly the same thing drifted until one of them was quietly wrong.
21
+ *
22
+ * WHY IT STOPS ON CONFLICTS. Pushing after an unresolved merge would put conflict
23
+ * markers into content authors see. `refresh` reports them and exits non-zero; this
24
+ * stops there and says what is left to do.
25
+ *
26
+ * WHAT IT DOES NOT DO: publish. Sync brings the local copy and the backend DRAFT
27
+ * into agreement; going live stays a separate, deliberate act (`uniweb publish`).
28
+ * That separation is what keeps an unintended sync recoverable — the worst case is
29
+ * work-in-progress visible to authors in the app, not shipped to visitors.
30
+ *
31
+ * Reach for `refresh` when you only want to catch up — it cannot ship anything, so
32
+ * it is the one to run reflexively. Reach for `sync` when you also mean to share.
33
+ *
34
+ * Usage:
35
+ * uniweb sync refresh, then push
36
+ * uniweb sync --no-git skip the git remote half of the refresh
37
+ * uniweb sync --force forwarded to push (overwrite upstream changes)
38
+ * uniweb sync --backend <url> override the backend origin
39
+ */
40
+
41
+ const c = { reset: '\x1b[0m', dim: '\x1b[2m', cyan: '\x1b[36m', red: '\x1b[31m' }
42
+ const say = {
43
+ info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
44
+ err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
45
+ dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
46
+ }
47
+
48
+ /**
49
+ * @param {string[]} args
50
+ * @param {object} [deps] - injectable seams for testing: `refresh`, `push`.
51
+ */
52
+ export async function sync(args = [], deps = {}) {
53
+ const refresh = deps.refresh || (await import('./refresh.js')).refresh
54
+ const push = deps.push || (await import('./push.js')).push
55
+
56
+ // `--force` means "overwrite upstream" and belongs to the push half only. Passing
57
+ // it to the refresh would ask pull to DISCARD the local work this command exists
58
+ // to send — the same word meaning opposite things on the two halves.
59
+ const refreshArgs = args.filter((a) => a !== '--force')
60
+
61
+ const r = await refresh(refreshArgs)
62
+ if (r?.exitCode) {
63
+ // refresh already explained itself — conflicts, or a git failure. Adding a
64
+ // second summary here would just bury it.
65
+ say.dim('Not pushing while that is unresolved.')
66
+ return { exitCode: r.exitCode }
67
+ }
68
+
69
+ console.log('')
70
+ say.info('Pushing your changes…')
71
+ const p = await push(args)
72
+ return { exitCode: p?.exitCode ?? 0 }
73
+ }
74
+
75
+ export default sync
@@ -1,31 +1,32 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-07-25T20:40:37.933Z",
3
+ "generatedAt": "2026-07-27T11:29:06.768Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.15.5",
6
+ "version": "0.15.7",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
10
10
  "@uniweb/content-writer",
11
11
  "@uniweb/core",
12
+ "@uniweb/projections",
12
13
  "@uniweb/runtime",
13
14
  "@uniweb/schemas",
14
15
  "@uniweb/theming"
15
16
  ]
16
17
  },
17
18
  "@uniweb/content-reader": {
18
- "version": "1.1.15",
19
+ "version": "1.1.17",
19
20
  "path": "framework/content-reader",
20
21
  "deps": []
21
22
  },
22
23
  "@uniweb/content-writer": {
23
- "version": "0.2.7",
24
+ "version": "0.2.9",
24
25
  "path": "framework/content-writer",
25
26
  "deps": []
26
27
  },
27
28
  "@uniweb/core": {
28
- "version": "0.7.29",
29
+ "version": "0.7.31",
29
30
  "path": "framework/core",
30
31
  "deps": [
31
32
  "@uniweb/semantic-parser",
@@ -43,7 +44,7 @@
43
44
  "deps": []
44
45
  },
45
46
  "@uniweb/kit": {
46
- "version": "0.9.37",
47
+ "version": "0.9.39",
47
48
  "path": "framework/kit",
48
49
  "deps": [
49
50
  "@uniweb/core",
@@ -60,8 +61,16 @@
60
61
  "path": "framework/press",
61
62
  "deps": []
62
63
  },
64
+ "@uniweb/projections": {
65
+ "version": "0.1.2",
66
+ "path": "framework/projections",
67
+ "deps": [
68
+ "@uniweb/content-writer",
69
+ "@uniweb/core"
70
+ ]
71
+ },
63
72
  "@uniweb/runtime": {
64
- "version": "0.8.36",
73
+ "version": "0.8.38",
65
74
  "path": "framework/runtime",
66
75
  "deps": [
67
76
  "@uniweb/core",
@@ -84,7 +93,7 @@
84
93
  "deps": []
85
94
  },
86
95
  "@uniweb/semantic-parser": {
87
- "version": "1.1.18",
96
+ "version": "1.1.20",
88
97
  "path": "framework/semantic-parser",
89
98
  "deps": []
90
99
  },
@@ -99,7 +108,7 @@
99
108
  "deps": []
100
109
  },
101
110
  "@uniweb/unipress": {
102
- "version": "0.5.4",
111
+ "version": "0.5.6",
103
112
  "path": "framework/unipress",
104
113
  "deps": [
105
114
  "@uniweb/build",
package/src/index.js CHANGED
@@ -622,6 +622,20 @@ async function main() {
622
622
  process.exit(result?.exitCode ?? 0)
623
623
  }
624
624
 
625
+ // Handle sync command (refresh + push; both halves imported, never reimplemented)
626
+ if (command === 'sync') {
627
+ const { sync } = await importProjectCommand('./commands/sync.js')
628
+ const result = await sync(args.slice(1))
629
+ process.exit(result?.exitCode ?? 0)
630
+ }
631
+
632
+ // Handle refresh command (dynamic import — depends on @uniweb/build via pull)
633
+ if (command === 'refresh') {
634
+ const { refresh } = await importProjectCommand('./commands/refresh.js')
635
+ const result = await refresh(args.slice(1))
636
+ process.exit(result?.exitCode ?? 0)
637
+ }
638
+
625
639
  // Handle status command (dynamic import — offline emit via @uniweb/build)
626
640
  if (command === 'status') {
627
641
  const { status } = await importProjectCommand('./commands/status.js')
@@ -1490,6 +1504,8 @@ ${colors.bright}Commands:${colors.reset}
1490
1504
  runtime register Register an @uniweb/runtime version to the backend (@std only)
1491
1505
  push Push a site's content to the backend
1492
1506
  pull Pull a site's content from the backend
1507
+ refresh Catch up with the git remote AND the backend (never pushes)
1508
+ sync Catch up, then push (refresh + push)
1493
1509
  status Show a site's sync state (unpushed content, foundation)
1494
1510
  inspect <path> Inspect parsed content shape of a markdown file or folder
1495
1511
  docs Generate component documentation