uniweb 0.84.0 → 0.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,358 @@
1
+ /**
2
+ * `uniweb site` — the sites in a workspace, on the backend you are logged in to.
3
+ *
4
+ * uniweb site list [--json] each site's name, uuid, and whether it is live
5
+ * uniweb site unpublish [<site-uuid>] [--yes] take a site offline; its content stays
6
+ * uniweb site delete [<site-uuid>] [--yes] delete a site — final, no restore
7
+ *
8
+ * Without a uuid, `unpublish` and `delete` act on the site this project is synced to on
9
+ * that backend. The workspace is the command's (`--org @x` / `--personal`), else the
10
+ * login's — as for every site request.
11
+ *
12
+ * ⭐ A live site is not deleted: the backend refuses while it is published, so `delete`
13
+ * says to unpublish it first, and anything else still active (a plan, a domain, stored
14
+ * form messages) is named in the backend's own words and resolved in the app. A delete
15
+ * of the project's own site also drops the project's record of it, as `uniweb forget`
16
+ * does, so its next push creates a new site.
17
+ *
18
+ * ⛔ Both write verbs ask first, and `--yes` is the only way past the question. Without
19
+ * a terminal they refuse rather than print what they would have done: a script that
20
+ * reads exit 0 as "deleted" must not be told 0 for nothing.
21
+ *
22
+ * Auth: the session of the backend you are logged in to (`uniweb login`) / UNIWEB_TOKEN.
23
+ */
24
+
25
+ import { existsSync } from 'node:fs'
26
+ import { join, resolve } from 'node:path'
27
+
28
+ import { BackendClient, WorkspaceMismatchError, describeWorkspace } from '../backend/client.js'
29
+ import { resolveWorkspace } from '../backend/workspace.js'
30
+ import { checkFlags } from '../utils/flag-guard.js'
31
+ import { confirm, isNonInteractive } from '../utils/interactive.js'
32
+ import { readSiteIdentity } from '../utils/site-identity.js'
33
+ import { findSites, findWorkspaceRoot } from '../utils/workspace.js'
34
+
35
+ const c = {
36
+ reset: '\x1b[0m',
37
+ bold: '\x1b[1m',
38
+ dim: '\x1b[2m',
39
+ red: '\x1b[31m',
40
+ green: '\x1b[32m',
41
+ yellow: '\x1b[33m',
42
+ cyan: '\x1b[36m'
43
+ }
44
+ // As in every verb: errors on stderr, the rest on stdout — and under `--json` nothing but
45
+ // the JSON on stdout.
46
+ const say = {
47
+ ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
48
+ info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
49
+ warn: (m) => console.log(`${c.yellow}⚠${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
+ const USAGE = [
55
+ 'uniweb site <command>',
56
+ ' list [--json] The sites in the workspace: name, uuid, whether live',
57
+ ' unpublish [<site-uuid>] [--yes] Take a site offline (its content stays)',
58
+ ' delete [<site-uuid>] [--yes] Delete a site — final',
59
+ '',
60
+ 'Without a uuid, unpublish and delete act on the site this project is synced to.',
61
+ 'The workspace is --org @x or --personal, else the one chosen at login.'
62
+ ].join('\n')
63
+
64
+ /** A site-content uuid, loosely: enough to tell one from a site's name. */
65
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
66
+
67
+ /** The arguments that are not flags — skipping `--org`'s value. */
68
+ function positionals(args) {
69
+ const out = []
70
+ for (let i = 0; i < args.length; i++) {
71
+ const a = args[i]
72
+ if (a === '--org') {
73
+ i++
74
+ continue
75
+ }
76
+ if (a.startsWith('-')) continue
77
+ out.push(a)
78
+ }
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * The site directory this command runs in — the site itself (it holds `site.yml`), or a
84
+ * workspace's only site. ⛔ Never a stop: outside a project — or from an install without
85
+ * `@uniweb/build`, which finding a workspace's sites may load — there is simply none.
86
+ */
87
+ async function projectSiteDir() {
88
+ try {
89
+ const cwd = process.cwd()
90
+ if (existsSync(join(cwd, 'site.yml'))) return { dir: cwd }
91
+ const root = findWorkspaceRoot(cwd)
92
+ if (!root) return {}
93
+ const sites = await findSites(root)
94
+ if (sites.length === 1) return { dir: resolve(root, sites[0]) }
95
+ return sites.length > 1 ? { many: sites } : {}
96
+ } catch {
97
+ return {}
98
+ }
99
+ }
100
+
101
+ /** A client on the backend you are logged in to, working in the command's workspace. */
102
+ async function connect(args, command) {
103
+ const client = new BackendClient({ args, command })
104
+ const ws = await resolveWorkspace({ client, args })
105
+ if (ws.refused) return { refused: ws.reason }
106
+ client.setWorkspace(ws.workspace, { source: ws.source })
107
+ return { client, workspace: ws.workspace }
108
+ }
109
+
110
+ /** Every site in the workspace, page by page. */
111
+ async function allSites(client) {
112
+ const limit = 1000
113
+ const sites = []
114
+ for (let offset = 0; ; offset += limit) {
115
+ const res = await client.listSites({ limit, offset })
116
+ if (!res.ok) throw new Error(`Could not list the sites: HTTP ${res.status} ${res.statusText}`)
117
+ const page = (await res.json().catch(() => null))?.sites
118
+ if (!Array.isArray(page)) throw new Error('Could not list the sites: the backend sent no list.')
119
+ sites.push(...page)
120
+ if (page.length < limit) return sites
121
+ }
122
+ }
123
+
124
+ async function list(args) {
125
+ const json = args.includes('--json')
126
+ const conn = await connect(args, 'Listing sites')
127
+ if (conn.refused) {
128
+ say.err(conn.refused)
129
+ return { exitCode: 2 }
130
+ }
131
+ const { client, workspace } = conn
132
+ const sites = await allSites(client)
133
+ const rows = sites.map((s) => ({
134
+ uuid: s.uuid,
135
+ name: s.name ?? null,
136
+ status: s.deployment?.status ?? null,
137
+ published: s.deployment?.status === 'published',
138
+ url: s.deployment?.published_url ?? null,
139
+ updated_at: s.updated_at ?? null
140
+ }))
141
+ if (json) {
142
+ console.log(JSON.stringify({ backend: client.origin, workspace: workspace ?? null, sites: rows }))
143
+ return { exitCode: 0 }
144
+ }
145
+ if (!rows.length) {
146
+ say.info(`No sites in ${describeWorkspace(workspace)} on ${client.origin}.`)
147
+ return { exitCode: 0 }
148
+ }
149
+ console.log(`${c.bold}Sites in ${describeWorkspace(workspace)}${c.reset} ${c.dim}on ${client.origin}${c.reset}`)
150
+ for (const r of rows) {
151
+ const state = r.published ? `${c.green}published${c.reset}` : `${c.dim}${r.status ?? 'never published'}${c.reset}`
152
+ const name = r.name ?? `${c.dim}(no name)${c.reset}`
153
+ console.log(` ${name} ${c.dim}${r.uuid}${c.reset} ${state}${r.published && r.url ? ` ${r.url}` : ''}`)
154
+ }
155
+ return { exitCode: 0 }
156
+ }
157
+
158
+ /**
159
+ * The site a write verb acts on: the uuid given, else this project's site on the
160
+ * backend the client goes to. Null, with the reason said, when there is neither.
161
+ */
162
+ async function targetSite(args, client, verb) {
163
+ const [, given] = positionals(args)
164
+ if (given) {
165
+ if (!UUID.test(given)) {
166
+ say.err(`\`${given}\` is not a site uuid.`)
167
+ say.dim('`uniweb site list` shows each site with its uuid.')
168
+ return null
169
+ }
170
+ const project = await projectSiteDir()
171
+ const mine = project.dir ? readSiteIdentity(project.dir, client.origin).uuid : null
172
+ return { uuid: given, siteDir: mine === given ? project.dir : null }
173
+ }
174
+ const project = await projectSiteDir()
175
+ if (project.many) {
176
+ say.err(`This workspace has ${project.many.length} sites — name one: uniweb site ${verb} <site-uuid>, or run it inside the site.`)
177
+ return null
178
+ }
179
+ const uuid = project.dir ? readSiteIdentity(project.dir, client.origin).uuid : null
180
+ if (!uuid) {
181
+ say.err(
182
+ project.dir
183
+ ? `This project has no site on ${client.origin} — name one: uniweb site ${verb} <site-uuid>`
184
+ : `No site named, and this is not a Uniweb project — name one: uniweb site ${verb} <site-uuid>`
185
+ )
186
+ say.dim('`uniweb site list` shows each site with its uuid.')
187
+ return null
188
+ }
189
+ return { uuid, siteDir: project.dir }
190
+ }
191
+
192
+ /** The site's row in the workspace's list, or null when the workspace does not list it. */
193
+ async function findSite(client, uuid) {
194
+ try {
195
+ return (await allSites(client)).find((s) => s.uuid === uuid) ?? null
196
+ } catch {
197
+ return null
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Ask, unless `--yes`. The exit code when the answer is not yes: 0 for a person who
203
+ * declined, 2 where nobody could be asked — so a script never reads 0 for nothing done.
204
+ */
205
+ async function confirmed(args, question, verb) {
206
+ if (args.includes('--yes')) return { yes: true }
207
+ if (isNonInteractive(args)) {
208
+ say.err(`\`uniweb site ${verb}\` asks before it acts — pass --yes to confirm it here.`)
209
+ return { yes: false, exitCode: 2 }
210
+ }
211
+ if (await confirm(question, false)) return { yes: true }
212
+ say.info('Cancelled — nothing changed.')
213
+ return { yes: false, exitCode: 0 }
214
+ }
215
+
216
+ /** What the backend said about a refused write, in its own words where it gave them. */
217
+ async function sayRefusal(res, { uuid, verb, origin }) {
218
+ const body = await res.json().catch(() => null)
219
+ if (res.status === 409 && Array.isArray(body?.blockers) && body.blockers.length) {
220
+ say.err(`The site cannot be deleted yet:`)
221
+ for (const b of body.blockers) {
222
+ // ⭐ `detail` is written for the site's owner; the set of blockers is open, so an
223
+ // unknown one is shown by its detail, never dropped.
224
+ console.error(` • ${b.detail || b.resource}`)
225
+ if (b.resource === 'published') console.error(` ${c.dim}→ uniweb site unpublish ${uuid}${c.reset}`)
226
+ }
227
+ if (body.blockers.some((b) => b.resource !== 'published')) {
228
+ say.dim('Anything else listed is resolved in the app.')
229
+ }
230
+ return
231
+ }
232
+ if (res.status === 401) {
233
+ say.err(`Your session on ${origin} is not accepted — sign in again: uniweb login`)
234
+ } else if (res.status === 403) {
235
+ say.err(`You may not ${verb} this site — only its owner or an admin of its workspace can.`)
236
+ } else if (res.status === 404) {
237
+ say.err(`${origin} has no site ${uuid}.`)
238
+ } else {
239
+ say.err(`The backend refused: HTTP ${res.status}${body?.detail ? ` — ${body.detail}` : ''}`)
240
+ }
241
+ }
242
+
243
+ /**
244
+ * The project's record of a site it no longer has — what `uniweb forget` removes for one
245
+ * backend. Loaded here, not at the top: it needs `@uniweb/build`, which a command run
246
+ * outside a project does not have.
247
+ */
248
+ async function forgetDeletedSite(siteDir, origin) {
249
+ try {
250
+ const { clearBackend } = await import('@uniweb/build/uwx')
251
+ const { forgetBackendCache } = await import('../backend/site-sync.js')
252
+ const { forgetDeploys } = await import('@uniweb/build/site')
253
+ clearBackend(siteDir, origin)
254
+ forgetBackendCache(siteDir, origin)
255
+ await forgetDeploys(siteDir, origin)
256
+ return true
257
+ } catch {
258
+ return false
259
+ }
260
+ }
261
+
262
+ async function unpublish(args) {
263
+ const conn = await connect(args, 'Unpublishing a site')
264
+ if (conn.refused) {
265
+ say.err(conn.refused)
266
+ return { exitCode: 2 }
267
+ }
268
+ const { client } = conn
269
+ const target = await targetSite(args, client, 'unpublish')
270
+ if (!target) return { exitCode: 2 }
271
+ const site = await findSite(client, target.uuid)
272
+ const label = site?.name ? `“${site.name}” (${target.uuid})` : target.uuid
273
+ const url = site?.deployment?.published_url
274
+ const ok = await confirmed(
275
+ args,
276
+ `Unpublish ${label} on ${client.origin}?${url ? ` Visitors get nothing at ${url} until it is published again.` : ''}`,
277
+ 'unpublish'
278
+ )
279
+ if (!ok.yes) return { exitCode: ok.exitCode }
280
+
281
+ const res = await client.unpublishSite(target.uuid)
282
+ if (!res.ok) {
283
+ await sayRefusal(res, { uuid: target.uuid, verb: 'unpublish', origin: client.origin })
284
+ return { exitCode: 1 }
285
+ }
286
+ const body = await res.json().catch(() => null)
287
+ if (body?.was_published === false) {
288
+ say.info(`${label} was not published — nothing to take down.`)
289
+ } else {
290
+ say.ok(`Unpublished ${label}. Its content stays; \`uniweb publish\` puts it back online.`)
291
+ }
292
+ return { exitCode: 0 }
293
+ }
294
+
295
+ async function remove(args) {
296
+ const conn = await connect(args, 'Deleting a site')
297
+ if (conn.refused) {
298
+ say.err(conn.refused)
299
+ return { exitCode: 2 }
300
+ }
301
+ const { client, workspace } = conn
302
+ const target = await targetSite(args, client, 'delete')
303
+ if (!target) return { exitCode: 2 }
304
+ const site = await findSite(client, target.uuid)
305
+ const label = site?.name ? `“${site.name}” (${target.uuid})` : target.uuid
306
+ if (site?.deployment?.status === 'published') {
307
+ say.err(`${label} is published — unpublish it first: uniweb site unpublish ${target.uuid}`)
308
+ return { exitCode: 1 }
309
+ }
310
+ const ok = await confirmed(
311
+ args,
312
+ `Delete ${label} on ${client.origin}, in ${describeWorkspace(workspace)}? ` +
313
+ 'Its pages, settings and snapshots go with it, and it cannot be undone. Records it pushed stay in the workspace.',
314
+ 'delete'
315
+ )
316
+ if (!ok.yes) return { exitCode: ok.exitCode }
317
+
318
+ const res = await client.deleteSite(target.uuid)
319
+ if (!res.ok) {
320
+ await sayRefusal(res, { uuid: target.uuid, verb: 'delete', origin: client.origin })
321
+ return { exitCode: 1 }
322
+ }
323
+ say.ok(`Deleted ${label}.`)
324
+ if (target.siteDir) {
325
+ if (await forgetDeletedSite(target.siteDir, client.origin)) {
326
+ say.dim(`This project no longer records a site on ${client.origin}; its next push creates a new one.`)
327
+ } else {
328
+ say.dim(`This project still records it — run \`uniweb forget --server ${client.origin}\` in it.`)
329
+ }
330
+ }
331
+ return { exitCode: 0 }
332
+ }
333
+
334
+ export async function site(args = []) {
335
+ const badFlag = checkFlags('site', args)
336
+ if (badFlag) {
337
+ say.err(badFlag.message)
338
+ return { exitCode: 2 }
339
+ }
340
+ const sub = args[0]
341
+ try {
342
+ if (sub === 'list') return await list(args)
343
+ if (sub === 'unpublish') return await unpublish(args)
344
+ if (sub === 'delete') return await remove(args)
345
+ } catch (err) {
346
+ say.err(err instanceof WorkspaceMismatchError ? err.message : err?.message || String(err))
347
+ return { exitCode: 1 }
348
+ }
349
+ if (sub) {
350
+ say.err(`Unknown command: uniweb site ${sub}`)
351
+ console.error(USAGE)
352
+ return { exitCode: 2 }
353
+ }
354
+ console.log(USAGE)
355
+ return { exitCode: 0 }
356
+ }
357
+
358
+ export default site
@@ -33,7 +33,7 @@ import { createRequire } from 'node:module'
33
33
  import { isAbsolute, join, relative, resolve, sep } from 'node:path'
34
34
  import { pathToFileURL } from 'node:url'
35
35
  import yaml from 'js-yaml'
36
- import { upsertYamlScalar } from '@uniweb/build/uwx'
36
+ import { writeSiteConfig } from '@uniweb/build/uwx'
37
37
 
38
38
  import { didYouMean } from '../utils/args.js'
39
39
  import { humanBytes } from '../utils/bytes.js'
@@ -431,14 +431,23 @@ export async function snapshot(args = []) {
431
431
  return
432
432
  }
433
433
  const current = siteYml.data.preview
434
+ // Edited in place, its comments kept (`writeSiteConfig`) — and not at all when site.yml
435
+ // does not parse: that is said, and the file left as the author has it.
436
+ const setPreview = () => {
437
+ try {
438
+ writeSiteConfig(siteDir, { preview: value })
439
+ return true
440
+ } catch (err) {
441
+ console.log(` ${YELLOW}site.yml not changed:${RESET} ${DIM}${err.message}${RESET}`)
442
+ return false
443
+ }
444
+ }
434
445
  switch (previewDecision(current, value)) {
435
446
  case 'set':
436
- upsertYamlScalar(siteYml.file, 'preview', value)
437
- console.log(` site.yml: ${CYAN}preview: ${value}${RESET}`)
447
+ if (setPreview()) console.log(` site.yml: ${CYAN}preview: ${value}${RESET}`)
438
448
  break
439
449
  case 'replace':
440
- upsertYamlScalar(siteYml.file, 'preview', value)
441
- console.log(` site.yml: ${CYAN}preview: ${value}${RESET} ${DIM}(replaces the app-generated preview)${RESET}`)
450
+ if (setPreview()) console.log(` site.yml: ${CYAN}preview: ${value}${RESET} ${DIM}(replaces the app-generated preview)${RESET}`)
442
451
  break
443
452
  case 'unchanged':
444
453
  console.log(` ${DIM}site.yml already has preview: ${value}${RESET}`)
@@ -243,7 +243,7 @@ export async function status(args = []) {
243
243
  if (siteState?.state === 'gone') {
244
244
  say.warn(`The backend has no site ${uuid} — it was deleted there, or the backend was rebuilt.`)
245
245
  say.dim(
246
- `To push this as a new site: uniweb forget --backend ${probeBackend}, then uniweb push.`
246
+ `To push this as a new site: uniweb forget --server ${probeBackend}, then uniweb push.`
247
247
  )
248
248
  } else if (siteState?.state === 'unknown') {
249
249
  say.dim(
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-06T19:21:14.306Z",
3
+ "generatedAt": "2026-10-07T18:16:06.785Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.6.10",
6
+ "version": "0.7.0",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.77.0",
13
+ "version": "0.79.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -35,7 +35,7 @@
35
35
  "deps": []
36
36
  },
37
37
  "@uniweb/core": {
38
- "version": "0.37.2",
38
+ "version": "0.38.0",
39
39
  "path": "framework/core",
40
40
  "deps": [
41
41
  "@uniweb/semantic-parser",
@@ -48,14 +48,14 @@
48
48
  "deps": []
49
49
  },
50
50
  "@uniweb/icons": {
51
- "version": "0.4.28",
51
+ "version": "0.4.29",
52
52
  "path": "framework/icons",
53
53
  "deps": [
54
54
  "@uniweb/core"
55
55
  ]
56
56
  },
57
57
  "@uniweb/kit": {
58
- "version": "0.19.18",
58
+ "version": "0.20.0",
59
59
  "path": "framework/kit",
60
60
  "deps": [
61
61
  "@uniweb/core",
@@ -76,7 +76,7 @@
76
76
  "deps": []
77
77
  },
78
78
  "@uniweb/projections": {
79
- "version": "0.7.14",
79
+ "version": "0.7.15",
80
80
  "path": "framework/projections",
81
81
  "deps": [
82
82
  "@uniweb/content-writer",
@@ -84,7 +84,7 @@
84
84
  ]
85
85
  },
86
86
  "@uniweb/runtime": {
87
- "version": "0.29.2",
87
+ "version": "0.29.3",
88
88
  "path": "framework/runtime",
89
89
  "deps": [
90
90
  "@uniweb/core",
@@ -97,7 +97,7 @@
97
97
  "deps": []
98
98
  },
99
99
  "@uniweb/schemas": {
100
- "version": "0.12.0",
100
+ "version": "0.13.0",
101
101
  "path": "framework/schemas",
102
102
  "deps": []
103
103
  },
@@ -117,7 +117,7 @@
117
117
  "deps": []
118
118
  },
119
119
  "@uniweb/templates": {
120
- "version": "0.17.14",
120
+ "version": "0.18.1",
121
121
  "path": "framework/templates",
122
122
  "deps": []
123
123
  },
@@ -127,7 +127,7 @@
127
127
  "deps": []
128
128
  },
129
129
  "@uniweb/unipress": {
130
- "version": "0.10.21",
130
+ "version": "0.10.23",
131
131
  "path": "framework/unipress",
132
132
  "deps": [
133
133
  "@uniweb/build",
package/src/index.js CHANGED
@@ -645,6 +645,15 @@ async function main() {
645
645
  const command = args[0]
646
646
  const pm = detectPackageManager()
647
647
 
648
+ // ⛔ THE VARIABLE THAT AIMS A PROCESS IS UNIWEB_SERVER — UNIWEB_REGISTER_URL until 2026-10-07.
649
+ // Set ALONE, the old name is refused rather than ignored: ignored, a script that meant a
650
+ // local or staging backend would go to the default one, uniweb.app, and say nothing. Set
651
+ // beside the new one it is ignored, so a script can carry both while CLIs of both ages run it.
652
+ if (process.env.UNIWEB_REGISTER_URL && !process.env.UNIWEB_SERVER) {
653
+ error('UNIWEB_REGISTER_URL is now UNIWEB_SERVER — rename it, and the command goes to the same backend.')
654
+ process.exit(2)
655
+ }
656
+
648
657
  // Handle --version / -v
649
658
  //
650
659
  // Output convention: the version goes to stdout (parseable, scriptable —
@@ -915,13 +924,13 @@ async function main() {
915
924
  }
916
925
 
917
926
  // Handle login command — the backend (username/password · paste a token ·
918
- // --token <bearer>). ⭐ `--backend <url>` names it; without the flag it is the DEFAULT
919
- // backend — UNIWEB_REGISTER_URL, else ~/.uniweb/config.json, else https://uniweb.app —
927
+ // --token <bearer>). ⭐ `--server <url>` names it; without the flag it is the DEFAULT
928
+ // backend — UNIWEB_SERVER, else ~/.uniweb/config.json, else https://uniweb.app —
920
929
  // and never the project's backend *[Diego, 2026-09-21: "the default backend for login, if
921
930
  // not specified, is uniweb.app"]*. ⭐ Except a WORKSPACE SWITCH — `--org` / `--personal`
922
931
  // and no way of signing in — which acts on the backend you are logged in to *[Diego,
923
932
  // 2026-10-06]* (`resolveLoginOrigin`). The backend logged in to becomes CURRENT, and
924
- // every backend command goes there (only UNIWEB_REGISTER_URL outranks it) — which is why
933
+ // every backend command goes there (only UNIWEB_SERVER outranks it) — which is why
925
934
  // this is the one place a backend is chosen.
926
935
  //
927
936
  // ⛔ Until 2026-09-21 a bare login went to the backend of the project in the cwd, and
@@ -932,12 +941,19 @@ async function main() {
932
941
  const { readFlagValue } = await import('./utils/args.js')
933
942
  const { runRegistryLogin } = await import('./utils/registry-auth.js')
934
943
  const { resolveLoginOrigin } = await import('./utils/config.js')
944
+ // `--backend` was this flag's name until 2026-10-07 — renamed so it is not read as a
945
+ // site's `backend` service. Refused with the new name, never treated as unknown and dropped:
946
+ // dropped, the login would go to the default backend.
947
+ if (loginArgs.some((a) => a === '--backend' || a.startsWith('--backend='))) {
948
+ console.error('\x1b[31m✗\x1b[0m `--backend` is now `--server`: uniweb login --server <url>')
949
+ process.exit(2)
950
+ }
935
951
  let apiBase
936
952
  try {
937
- apiBase = resolveLoginOrigin(readFlagValue(loginArgs, '--backend'), loginArgs)
953
+ apiBase = resolveLoginOrigin(readFlagValue(loginArgs, '--server'), loginArgs)
938
954
  } catch (err) {
939
955
  console.error(
940
- `\x1b[31m✗\x1b[0m ${err.message} — e.g. uniweb login --backend http://localhost:8080`
956
+ `\x1b[31m✗\x1b[0m ${err.message} — e.g. uniweb login --server http://localhost:8080`
941
957
  )
942
958
  process.exit(2)
943
959
  }
@@ -973,6 +989,19 @@ async function main() {
973
989
  return
974
990
  }
975
991
 
992
+ // Handle site command — the sites in a workspace: list them, unpublish or delete one.
993
+ // Runs anywhere: inside a project it can act on the site the project is synced to.
994
+ if (command === 'site') {
995
+ const { site } = await import('./commands/site.js')
996
+ const result = await site(args.slice(1))
997
+ // ⛔ EXIT ONLY ONCE STDOUT HAS DRAINED. To a pipe Node writes asynchronously, so an exit
998
+ // straight after a long list cut it at 8192 bytes — measured 2026-10-07: `site list --json`
999
+ // of 79 sites reached a reading process as half a JSON document, while the same command
1000
+ // into a file (synchronous) was whole. Pinned by test/site-list-pipe.test.js.
1001
+ await new Promise((resolve) => process.stdout.write('', resolve))
1002
+ process.exit(result?.exitCode ?? 0)
1003
+ }
1004
+
976
1005
  // Handle org command (new-backend orgs/units — publish-scope management)
977
1006
  if (command === 'org') {
978
1007
  const { org } = await import('./commands/org.js')
@@ -1796,7 +1825,7 @@ logged in to is where the backend commands go (push, pull, publish, status,
1796
1825
  register, clone) — so this is how you switch. Already logged in to it, login
1797
1826
  does nothing; add --password, --browser, --token-paste or --token to log in again.
1798
1827
 
1799
- Without --backend: https://uniweb.app (or \$UNIWEB_REGISTER_URL). No command talks
1828
+ Without --server: https://uniweb.app (or \$UNIWEB_SERVER). No command talks
1800
1829
  to a backend you are not logged in to — run one before logging in and it asks first.
1801
1830
 
1802
1831
  ${colors.bright}The workspace you work in.${colors.reset} A login works in ONE workspace — your
@@ -1807,7 +1836,7 @@ or name it. Already logged in, \`uniweb login --org @other\` (or \`--personal\`)
1807
1836
  the workspace on the backend you are logged in to, without logging in again.
1808
1837
 
1809
1838
  ${colors.bright}Options:${colors.reset}
1810
- --backend <url> The backend to log in to (without it: the default backend — or, for
1839
+ --server <url> The backend to log in to (without it: the default backend — or, for
1811
1840
  --org / --personal alone, the one you are logged in to)
1812
1841
  --org @org Work in @org (an organization you belong to)
1813
1842
  --personal Work in your personal workspace
@@ -1875,13 +1904,13 @@ so it is deliberately kept away from the refresh half.
1875
1904
  ${colors.cyan}${colors.bright}uniweb forget${colors.reset} ${colors.dim}— Remove what this project recorded about where it synced${colors.reset}
1876
1905
 
1877
1906
  ${colors.bright}Usage:${colors.reset}
1878
- uniweb forget --backend <url>
1907
+ uniweb forget --server <url>
1879
1908
  uniweb forget --all
1880
1909
 
1881
1910
  Local files only. Every site stays where it is on its backend.
1882
1911
 
1883
1912
  ${colors.bright}Options:${colors.reset}
1884
- --backend <url> Forget one backend: its entries in sync.json and in the local
1913
+ --server <url> Forget one backend: its entries in sync.json and in the local
1885
1914
  cache, and its deploy records in deploy.yml. Its targets stay,
1886
1915
  so the next publish there creates a new site.
1887
1916
  --all For a COPY of a project that should become a new one. Deletes
@@ -2062,6 +2091,7 @@ ${colors.bright}Commands:${colors.reset}
2062
2091
  sync Catch up, then push (refresh + push)
2063
2092
  status Show a site's sync state (unpushed content, foundation)
2064
2093
  forget Forget a backend, or --all to make a copied project a new one
2094
+ site <cmd> The sites in a workspace: list, unpublish, delete
2065
2095
  inspect <path> Inspect parsed content shape of a markdown file or folder
2066
2096
  docs Generate component documentation
2067
2097
  families List the standard section families (for meta.js family:)
@@ -2093,8 +2123,8 @@ ${colors.bright}Global Options:${colors.reset}
2093
2123
 
2094
2124
  Backend commands (push, pull, publish, status, register, clone) go to the
2095
2125
  backend you are logged in to, with its session. Switch with
2096
- \`uniweb login --backend <url>\` (add \`--token <bearer>\` to sign in with a token);
2097
- a script can aim and authenticate one process with UNIWEB_REGISTER_URL and
2126
+ \`uniweb login --server <url>\` (add \`--token <bearer>\` to sign in with a token);
2127
+ a script can aim and authenticate one process with UNIWEB_SERVER and
2098
2128
  UNIWEB_TOKEN instead.
2099
2129
 
2100
2130
  ${colors.bright}Push Options:${colors.reset}