uniweb 1.0.0 → 1.0.2
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/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uniweb status — show how a site's local files compare to the Uniweb backend:
|
|
3
|
+
* its sync identity, unpushed content changes, and the foundation it references.
|
|
4
|
+
*
|
|
5
|
+
* LOCAL + OFFLINE by default: it builds the sync packages with an OFFLINE Model
|
|
6
|
+
* resolver and diffs them against the send-only-changed cache (the same diff
|
|
7
|
+
* `uniweb push` runs) — no auth, no backend round-trip.
|
|
8
|
+
*
|
|
9
|
+
* `--remote` adds the backend signals (may prompt for login, like `git fetch`):
|
|
10
|
+
* - whether the synced draft differs from what's live (publish needed), and
|
|
11
|
+
* - whether a newer foundation version is registered than the site pins.
|
|
12
|
+
* Those use ASSUMED endpoints (see kb shipping-verbs-and-freshness.md §6.5); until
|
|
13
|
+
* the backend exposes them, `--remote` degrades silently to the local view.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* uniweb status Sync identity + unpushed content + foundation ref (local)
|
|
17
|
+
* uniweb status --remote Also: whether the backend still holds the site, draft-vs-live,
|
|
18
|
+
* and a newer-registered-foundation check
|
|
19
|
+
* uniweb status --json One JSON line (adds a `remote` object under --remote, whose
|
|
20
|
+
* `site_state` is live / gone / unknown — `gone` only on the
|
|
21
|
+
* backend's own word that it holds no such site)
|
|
22
|
+
*
|
|
23
|
+
* Run from a site, or a workspace with one site.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
27
|
+
import { join } from 'node:path'
|
|
28
|
+
import yaml from 'js-yaml'
|
|
29
|
+
|
|
30
|
+
import { resolveSiteDir } from './deploy.js'
|
|
31
|
+
import { probeUnpushed } from '../backend/site-sync.js'
|
|
32
|
+
import { resolveWorkspace } from '../backend/workspace.js'
|
|
33
|
+
import {
|
|
34
|
+
BackendClient,
|
|
35
|
+
resolveBackendOrigin,
|
|
36
|
+
WorkspaceMismatchError
|
|
37
|
+
} from '../backend/client.js'
|
|
38
|
+
import { readBackendState } from '@uniweb/build/uwx'
|
|
39
|
+
import {
|
|
40
|
+
resolveLocalFoundation,
|
|
41
|
+
foundationScopedName
|
|
42
|
+
} from '../backend/foundation-bring-along.js'
|
|
43
|
+
import { computeFoundationDigest } from '../utils/code-upload.js'
|
|
44
|
+
import { compareSemverPrecedence } from '../utils/semver-precedence.js'
|
|
45
|
+
import { checkFlags } from '../utils/flag-guard.js'
|
|
46
|
+
|
|
47
|
+
const c = {
|
|
48
|
+
reset: '\x1b[0m',
|
|
49
|
+
bold: '\x1b[1m',
|
|
50
|
+
dim: '\x1b[2m',
|
|
51
|
+
cyan: '\x1b[36m',
|
|
52
|
+
green: '\x1b[32m',
|
|
53
|
+
yellow: '\x1b[33m'
|
|
54
|
+
}
|
|
55
|
+
const say = {
|
|
56
|
+
ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
|
|
57
|
+
info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
|
|
58
|
+
warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
|
|
59
|
+
dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readSiteYml(siteDir) {
|
|
63
|
+
const p = join(siteDir, 'site.yml')
|
|
64
|
+
if (!existsSync(p)) return {}
|
|
65
|
+
try {
|
|
66
|
+
return yaml.load(readFileSync(p, 'utf8')) || {}
|
|
67
|
+
} catch {
|
|
68
|
+
return {}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function foundationRef(siteYml) {
|
|
73
|
+
const f = siteYml.foundation
|
|
74
|
+
if (!f) return null
|
|
75
|
+
return typeof f === 'string' ? f : f.ref || null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A versioned registry ref `@org/name@1.2.3` → its scoped name `@org/name` and
|
|
79
|
+
// pinned version `1.2.3`. A bare/local/unversioned ref → nulls.
|
|
80
|
+
function splitFoundationRef(fnd) {
|
|
81
|
+
if (!fnd || fnd[0] !== '@') return { scope: null, version: null }
|
|
82
|
+
const at = fnd.lastIndexOf('@')
|
|
83
|
+
return at > 0
|
|
84
|
+
? { scope: fnd.slice(0, at), version: fnd.slice(at + 1) }
|
|
85
|
+
: { scope: null, version: null }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function status(args = []) {
|
|
89
|
+
// See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan.
|
|
90
|
+
// Straight to stderr: this is a usage error, so it must not be mistaken for the
|
|
91
|
+
// status document even under --json.
|
|
92
|
+
const badFlag = checkFlags('status', args)
|
|
93
|
+
if (badFlag) {
|
|
94
|
+
console.error(`\x1b[31m✗\x1b[0m ${badFlag.message}`)
|
|
95
|
+
return { exitCode: 2 }
|
|
96
|
+
}
|
|
97
|
+
const jsonMode = args.includes('--json')
|
|
98
|
+
const remote = args.includes('--remote')
|
|
99
|
+
const siteDir = await resolveSiteDir(args, 'status')
|
|
100
|
+
const siteYml = readSiteYml(siteDir)
|
|
101
|
+
const fnd = foundationRef(siteYml)
|
|
102
|
+
const { scope: fndScope, version: fndVersion } = splitFoundationRef(fnd)
|
|
103
|
+
|
|
104
|
+
// Local content diff — builds the sync packages, never authenticates.
|
|
105
|
+
//
|
|
106
|
+
// ⭐ The ORIGIN is resolved here rather than taken from the client below, because
|
|
107
|
+
// this probe is deliberately offline and the client may never be built. It decides
|
|
108
|
+
// whose asset ids the comparison reads: against the wrong backend every media ref
|
|
109
|
+
// reads as changed.
|
|
110
|
+
const probeBackend = resolveBackendOrigin()
|
|
111
|
+
// ⭐ Identity is this backend's, from sync.json. It read `site.yml::$uuid`, so after
|
|
112
|
+
// step 4 moved the key every project reported itself as never synced.
|
|
113
|
+
const uuid = readBackendState(siteDir, probeBackend).site?.uuid || null
|
|
114
|
+
let probe = null
|
|
115
|
+
let probeErr = null
|
|
116
|
+
try {
|
|
117
|
+
probe = await probeUnpushed(siteDir, { backend: probeBackend })
|
|
118
|
+
} catch (err) {
|
|
119
|
+
probeErr = err.message
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Remote signals — opt-in (`--remote`). May prompt for login. Degrades to null
|
|
123
|
+
// on 404 / any failure, so a backend without the endpoints just shows local.
|
|
124
|
+
let site = null
|
|
125
|
+
// Whether the backend holds the site this binding names — live / gone / unknown, never
|
|
126
|
+
// folded (`client.siteState`). Null when there is no binding to ask about.
|
|
127
|
+
let siteState = null
|
|
128
|
+
let fdnLatest = null
|
|
129
|
+
let foundationFresh = null // true/false when both digests are known; else null
|
|
130
|
+
let localFoundationVersion = null
|
|
131
|
+
let remoteError = null // a refusal about the workspace — the one remote failure worth saying
|
|
132
|
+
if (remote) {
|
|
133
|
+
try {
|
|
134
|
+
const client = new BackendClient({
|
|
135
|
+
args,
|
|
136
|
+
command: 'Status'
|
|
137
|
+
})
|
|
138
|
+
// The workspace this status works in — the login's, unless this command names
|
|
139
|
+
// another (`workspace.js`). None chosen is reported, like a site outside it.
|
|
140
|
+
const ws = await resolveWorkspace({ client, args })
|
|
141
|
+
if (ws.refused) throw Object.assign(new Error(ws.reason), { status: 409 })
|
|
142
|
+
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
143
|
+
if (uuid) {
|
|
144
|
+
siteState = await client.siteState(uuid)
|
|
145
|
+
if (siteState.state === 'live') {
|
|
146
|
+
site = siteState.site || null
|
|
147
|
+
// In another workspace: it exists, and the refusal says where — report it.
|
|
148
|
+
if (siteState.status === 409) remoteError = siteState.detail
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Foundation freshness: prefer the LOCAL foundation's scoped name (so a
|
|
152
|
+
// local-foundation site can be checked too); fall back to a scoped
|
|
153
|
+
// site.yml ref. The digest compare is read-only — it never builds, so it
|
|
154
|
+
// only fires when the local foundation is already built (dist present).
|
|
155
|
+
const local = resolveLocalFoundation(siteDir, siteYml)
|
|
156
|
+
localFoundationVersion = local?.version ?? null
|
|
157
|
+
const lookupName =
|
|
158
|
+
(local && (await foundationScopedName(local.dir))) || fndScope
|
|
159
|
+
if (lookupName) fdnLatest = await client.readFoundationLatest(lookupName)
|
|
160
|
+
if (local?.dir && fdnLatest?.digest) {
|
|
161
|
+
const localDigest = computeFoundationDigest(join(local.dir, 'dist'))
|
|
162
|
+
if (localDigest) foundationFresh = localDigest === fdnLatest.digest
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
// Degrade silently — except a refusal about the workspace: `--org` named one the
|
|
166
|
+
// backend does not work on this site from, or the deployment has another. Saying
|
|
167
|
+
// nothing would read as "fine".
|
|
168
|
+
if (err instanceof WorkspaceMismatchError || err?.status === 409) remoteError = err.message
|
|
169
|
+
// Not asked, or not answered: that is not knowing, never `gone`.
|
|
170
|
+
if (uuid && !siteState) siteState = { state: 'unknown', status: null, detail: err?.message || null }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (jsonMode) {
|
|
175
|
+
console.log(
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
synced: Boolean(uuid),
|
|
178
|
+
uuid,
|
|
179
|
+
foundation: fnd,
|
|
180
|
+
changed: probe ? probe.changed : null,
|
|
181
|
+
unchanged: probe ? probe.unchanged : null,
|
|
182
|
+
...(probeErr ? { error: probeErr } : {}),
|
|
183
|
+
...(remote
|
|
184
|
+
? {
|
|
185
|
+
remote: {
|
|
186
|
+
// ⭐ live · gone · unknown — `gone` only on the backend's own word about
|
|
187
|
+
// this site; null when this directory names no site on this backend.
|
|
188
|
+
site_state: siteState ? siteState.state : null,
|
|
189
|
+
site_state_detail: siteState?.detail ?? null,
|
|
190
|
+
site,
|
|
191
|
+
foundation_latest: fdnLatest?.latest_version ?? null,
|
|
192
|
+
foundation_fresh: foundationFresh,
|
|
193
|
+
...(remoteError ? { error: remoteError } : {})
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
: {})
|
|
197
|
+
})
|
|
198
|
+
)
|
|
199
|
+
return { exitCode: 0 }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
console.log('')
|
|
203
|
+
|
|
204
|
+
// Sync identity
|
|
205
|
+
if (uuid) {
|
|
206
|
+
say.ok(`Synced — site-content ${c.bold}${uuid}${c.reset}`)
|
|
207
|
+
} else {
|
|
208
|
+
say.warn('Not synced — this site has never been pushed to a backend.')
|
|
209
|
+
say.dim(
|
|
210
|
+
'Run `uniweb push` to create it, or `uniweb publish` to sync and go live in one step.'
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Content
|
|
215
|
+
if (probeErr) {
|
|
216
|
+
say.warn(`Couldn't compute content changes: ${probeErr}`)
|
|
217
|
+
say.dim(
|
|
218
|
+
'A build error or an unresolved data Model can block the offline diff.'
|
|
219
|
+
)
|
|
220
|
+
} else if (!uuid) {
|
|
221
|
+
const n = probe.changed
|
|
222
|
+
say.info(`${n} content ${n === 1 ? 'entity' : 'entities'} ready to push.`)
|
|
223
|
+
} else if (probe.changed === 0) {
|
|
224
|
+
say.ok('Content is in sync with the last push.')
|
|
225
|
+
} else {
|
|
226
|
+
const n = probe.changed
|
|
227
|
+
say.info(
|
|
228
|
+
`${c.bold}${n}${c.reset} content ${n === 1 ? 'entity' : 'entities'} not pushed` +
|
|
229
|
+
(probe.unchanged ? ` (${probe.unchanged} unchanged)` : '') +
|
|
230
|
+
'.'
|
|
231
|
+
)
|
|
232
|
+
say.dim(
|
|
233
|
+
'Run `uniweb publish` to sync and go live (or `uniweb push` to sync only).'
|
|
234
|
+
)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Foundation
|
|
238
|
+
if (fnd) say.dim(`Foundation: ${fnd}`)
|
|
239
|
+
|
|
240
|
+
// Remote signals
|
|
241
|
+
if (remote) {
|
|
242
|
+
if (remoteError) say.warn(remoteError)
|
|
243
|
+
if (siteState?.state === 'gone') {
|
|
244
|
+
say.warn(`The backend has no site ${uuid} — it was deleted there, or the backend was rebuilt.`)
|
|
245
|
+
say.dim(
|
|
246
|
+
`To push this as a new site: uniweb forget --server ${probeBackend}, then uniweb push.`
|
|
247
|
+
)
|
|
248
|
+
} else if (siteState?.state === 'unknown') {
|
|
249
|
+
say.dim(
|
|
250
|
+
`Could not tell whether the backend holds this site${siteState.detail ? ` (${siteState.detail})` : ''}.`
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
if (site) {
|
|
254
|
+
if (site.draft_dirty) {
|
|
255
|
+
say.info(
|
|
256
|
+
'Synced draft has changes not yet live — run `uniweb publish` to go live.'
|
|
257
|
+
)
|
|
258
|
+
} else if (site.published) {
|
|
259
|
+
say.ok('Live with the latest synced content.')
|
|
260
|
+
} else {
|
|
261
|
+
say.info(
|
|
262
|
+
'Synced but not published yet — run `uniweb publish` to go live.'
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (
|
|
267
|
+
fdnLatest?.latest_version &&
|
|
268
|
+
fndVersion &&
|
|
269
|
+
fdnLatest.latest_version !== fndVersion
|
|
270
|
+
) {
|
|
271
|
+
say.info(
|
|
272
|
+
`A newer foundation version (${fdnLatest.latest_version}) is registered than the site pins (${fndVersion}).`
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
// What the next push or publish does with it (`backend/foundation-bring-along.js`):
|
|
276
|
+
// release changed code — under the next version when its own is taken — unless the
|
|
277
|
+
// registry holds a NEWER version, which stops them. ⛔ This pointed at `uniweb
|
|
278
|
+
// register` until 2026-09-24 — which cannot release a change under a version
|
|
279
|
+
// already registered, since a registered version is immutable.
|
|
280
|
+
if (foundationFresh === false) {
|
|
281
|
+
if (compareSemverPrecedence(localFoundationVersion, fdnLatest.latest_version) === -1) {
|
|
282
|
+
say.info(
|
|
283
|
+
`The registry holds foundation ${fdnLatest.latest_version}, newer than your ${localFoundationVersion} — a push or publish stops until you pull that change, or pass \`--bump\` to release yours above it.`
|
|
284
|
+
)
|
|
285
|
+
} else {
|
|
286
|
+
say.info(
|
|
287
|
+
'Local foundation differs from the registered version — the next `uniweb push` or `uniweb publish` releases it.'
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
} else if (foundationFresh === true) {
|
|
291
|
+
say.ok('Local foundation matches the registered version.')
|
|
292
|
+
}
|
|
293
|
+
if (!site && !fdnLatest) {
|
|
294
|
+
say.dim('(No remote signals — the backend may not expose them yet.)')
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
console.log('')
|
|
299
|
+
return { exitCode: 0 }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export default status
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { checkFlags } from '../utils/flag-guard.js'
|
|
41
|
+
|
|
42
|
+
const c = {
|
|
43
|
+
reset: '\x1b[0m',
|
|
44
|
+
dim: '\x1b[2m',
|
|
45
|
+
cyan: '\x1b[36m',
|
|
46
|
+
red: '\x1b[31m'
|
|
47
|
+
}
|
|
48
|
+
const say = {
|
|
49
|
+
info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
|
|
50
|
+
err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
|
|
51
|
+
dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string[]} args
|
|
56
|
+
* @param {object} [deps] - injectable seams for testing: `refresh`, `push`.
|
|
57
|
+
*/
|
|
58
|
+
export async function sync(args = [], deps = {}) {
|
|
59
|
+
// THE ONE flag check for the whole composite, against the union of both halves'
|
|
60
|
+
// accepted sets (`VERBS.sync`, derived in flag-guard.js). It has to happen here
|
|
61
|
+
// and only here: each half's set is a strict subset, so `push` would reject
|
|
62
|
+
// `--no-git` and `refresh` would reject `--force` — both legal on `sync`. Hence
|
|
63
|
+
// `skipFlagCheck` below; the halves are not being trusted, they are being told
|
|
64
|
+
// the caller already validated a superset.
|
|
65
|
+
const bad = checkFlags('sync', args)
|
|
66
|
+
if (bad) {
|
|
67
|
+
say.err(bad.message)
|
|
68
|
+
return { exitCode: 2 }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const refresh = deps.refresh || (await import('./refresh.js')).refresh
|
|
72
|
+
const push = deps.push || (await import('./push.js')).push
|
|
73
|
+
|
|
74
|
+
// `--force` means "overwrite upstream" and belongs to the push half only. Passing
|
|
75
|
+
// it to the refresh would ask pull to DISCARD the local work this command exists
|
|
76
|
+
// to send — the same word meaning opposite things on the two halves.
|
|
77
|
+
const refreshArgs = args.filter((a) => a !== '--force')
|
|
78
|
+
|
|
79
|
+
const r = await refresh(refreshArgs, { skipFlagCheck: true })
|
|
80
|
+
if (r?.exitCode) {
|
|
81
|
+
// refresh already explained itself — conflicts, or a git failure. Adding a
|
|
82
|
+
// second summary here would just bury it.
|
|
83
|
+
say.dim('Not pushing while that is unresolved.')
|
|
84
|
+
return { exitCode: r.exitCode }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log('')
|
|
88
|
+
say.info('Pushing your changes…')
|
|
89
|
+
const p = await push(args, { skipFlagCheck: true })
|
|
90
|
+
return { exitCode: p?.exitCode ?? 0 }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default sync
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* template — lists the official templates.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ `list` exists so a SCRIPT can enumerate the official templates. The
|
|
5
|
+
* interactive picker in `create` shows the same list; `--json` is the same
|
|
6
|
+
* data, second rendering, for tooling that scaffolds several templates in a
|
|
7
|
+
* row and needs their names first. A bare `uniweb template` lists too.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ IT REPORTS THIS CLI'S SNAPSHOT, NOT A LIVE FETCH — the roster is baked into
|
|
10
|
+
* the CLI at publish time (`src/framework-index.json`), and `create` downloads
|
|
11
|
+
* these templates from the release that same snapshot names. So it answers
|
|
12
|
+
* "which official templates does THIS CLI scaffold", which is the right
|
|
13
|
+
* question when the next step is resolving one of those names with the same
|
|
14
|
+
* CLI. `cliVersion` is in the JSON for exactly that reason. A newer template
|
|
15
|
+
* than your CLI will not appear — and an unknown name is not fatal either way,
|
|
16
|
+
* since `create` falls through to npm `@uniweb/template-<name>`, so this is the
|
|
17
|
+
* OFFICIAL roster rather than the set of resolvable names.
|
|
18
|
+
*
|
|
19
|
+
* ⛔ There is no `template register` [Diego, 2026-10-08]: a site is offered as a
|
|
20
|
+
* template by the backend it is pushed to when its `site.yml` says
|
|
21
|
+
* `template: true`, so there is nothing for a verb to do. It was a reserved
|
|
22
|
+
* refusal until then, and never worked on the current backend.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { OFFICIAL_TEMPLATE_MAP } from '../templates/resolver.js'
|
|
26
|
+
import { getCliVersion } from '../versions.js'
|
|
27
|
+
|
|
28
|
+
const colors = {
|
|
29
|
+
reset: '\x1b[0m',
|
|
30
|
+
bright: '\x1b[1m',
|
|
31
|
+
dim: '\x1b[2m',
|
|
32
|
+
cyan: '\x1b[36m',
|
|
33
|
+
red: '\x1b[31m'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function listTemplates(args) {
|
|
37
|
+
const entries = Object.entries(OFFICIAL_TEMPLATE_MAP).map(([id, info]) => ({
|
|
38
|
+
id,
|
|
39
|
+
name: info?.name || id,
|
|
40
|
+
description: info?.description || '',
|
|
41
|
+
tags: info?.tags || []
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
if (args.includes('--json')) {
|
|
45
|
+
// ⛔ stdout carries JSON and nothing else, so it pipes. Every human-facing
|
|
46
|
+
// line in this command goes to stderr for the same reason.
|
|
47
|
+
process.stdout.write(
|
|
48
|
+
JSON.stringify(
|
|
49
|
+
{ cliVersion: getCliVersion(), count: entries.length, templates: entries },
|
|
50
|
+
null,
|
|
51
|
+
2
|
|
52
|
+
) + '\n'
|
|
53
|
+
)
|
|
54
|
+
return { exitCode: 0 }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (entries.length === 0) {
|
|
58
|
+
// Not a crash: a CLI whose snapshot failed to load has an empty map, and
|
|
59
|
+
// `create` then routes every name to npm. Say which happened.
|
|
60
|
+
console.error(
|
|
61
|
+
`${colors.red}✗${colors.reset} No official templates found in this CLI's bundled index.`
|
|
62
|
+
)
|
|
63
|
+
console.error(
|
|
64
|
+
` ${colors.dim}A template name will still resolve through npm as @uniweb/template-<name>.${colors.reset}`
|
|
65
|
+
)
|
|
66
|
+
return { exitCode: 1 }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const width = Math.max(...entries.map((e) => e.id.length))
|
|
70
|
+
console.log('')
|
|
71
|
+
console.log(
|
|
72
|
+
`${colors.bright}Official templates${colors.reset} ${colors.dim}(uniweb ${getCliVersion()})${colors.reset}`
|
|
73
|
+
)
|
|
74
|
+
console.log('')
|
|
75
|
+
for (const e of entries) {
|
|
76
|
+
console.log(
|
|
77
|
+
` ${colors.cyan}${e.id.padEnd(width)}${colors.reset} ${e.description || e.name}`
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
console.log('')
|
|
81
|
+
console.log(
|
|
82
|
+
` ${colors.dim}uniweb create <project> --template <name>${colors.reset}`
|
|
83
|
+
)
|
|
84
|
+
console.log(
|
|
85
|
+
` ${colors.dim}--json for scripts. This is the roster THIS CLI ships with, not a live fetch.${colors.reset}`
|
|
86
|
+
)
|
|
87
|
+
console.log('')
|
|
88
|
+
return { exitCode: 0 }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @param {string[]} args - what follows `uniweb template`
|
|
93
|
+
* @returns {{ exitCode: number }}
|
|
94
|
+
*/
|
|
95
|
+
export async function template(args = []) {
|
|
96
|
+
const sub = args.find((a) => !a.startsWith('-'))
|
|
97
|
+
if (sub === undefined || sub === 'list') return listTemplates(args)
|
|
98
|
+
console.error(
|
|
99
|
+
`${colors.red}✗${colors.reset} Unknown subcommand: uniweb template ${sub}`
|
|
100
|
+
)
|
|
101
|
+
console.error(
|
|
102
|
+
` ${colors.dim}uniweb template list lists the official templates${colors.reset}`
|
|
103
|
+
)
|
|
104
|
+
return { exitCode: 2 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default template
|