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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. 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