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.
- package/package.json +7 -7
- package/partials/agents.md +66 -1
- package/src/backend/site-media.js +34 -5
- package/src/backend/site-sync.js +326 -14
- package/src/commands/deploy.js +6 -1
- package/src/commands/doctor.js +20 -0
- package/src/commands/publish.js +42 -3
- package/src/commands/pull.js +387 -7
- package/src/commands/push.js +114 -3
- package/src/commands/refresh.js +176 -0
- package/src/commands/sync.js +75 -0
- package/src/framework-index.json +18 -9
- package/src/index.js +16 -0
- package/src/utils/git.js +203 -0
package/src/commands/push.js
CHANGED
|
@@ -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
|
|
16
|
-
*
|
|
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 {
|
|
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
|
package/src/framework-index.json
CHANGED
|
@@ -1,31 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-07-
|
|
3
|
+
"generatedAt": "2026-07-27T11:29:06.768Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.15.
|
|
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.
|
|
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.
|
|
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
|
+
"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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|