uniweb 0.85.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.
- package/package.json +7 -7
- package/partials/agents.md +44 -28
- package/src/backend/client.js +59 -6
- package/src/backend/foundation-bring-along.js +5 -3
- package/src/backend/service-request.js +6 -2
- package/src/backend/site-preview.js +1 -1
- package/src/backend/site-sync.js +7 -7
- package/src/commands/build.js +6 -2
- package/src/commands/clone.js +1 -1
- package/src/commands/deploy.js +3 -3
- package/src/commands/forget.js +7 -7
- package/src/commands/publish.js +14 -7
- package/src/commands/pull.js +9 -9
- package/src/commands/push.js +13 -7
- package/src/commands/refresh.js +2 -1
- package/src/commands/register.js +6 -6
- package/src/commands/rename.js +6 -6
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +14 -5
- package/src/commands/status.js +1 -1
- package/src/framework-index.json +11 -11
- package/src/index.js +41 -11
- package/src/utils/config.js +13 -13
- package/src/utils/flag-guard.js +30 -16
- package/src/utils/site-identity.js +3 -3
- package/src/utils/yaml-edit.js +0 -115
package/src/commands/publish.js
CHANGED
|
@@ -22,9 +22,9 @@
|
|
|
22
22
|
* Distinct from `uniweb deploy` (third-party hosts) and `uniweb register`
|
|
23
23
|
* (foundation code → catalog). For a self-contained artifact, see `uniweb export`.
|
|
24
24
|
*
|
|
25
|
-
* Backend: the one you are logged in to —
|
|
25
|
+
* Backend: the one you are logged in to — UNIWEB_SERVER overrides it for a script
|
|
26
26
|
* (resolveBackendOrigin). Auth: UNIWEB_TOKEN > the stored session > `uniweb login`.
|
|
27
|
-
* No `--
|
|
27
|
+
* No `--server` or `--token`: switching and signing in are `uniweb login`.
|
|
28
28
|
*
|
|
29
29
|
* Usage:
|
|
30
30
|
* uniweb publish Bring the foundation along, sync, and go live
|
|
@@ -66,6 +66,7 @@ import { resolveDefaultLocale } from '@uniweb/core/locale-config'
|
|
|
66
66
|
|
|
67
67
|
import {
|
|
68
68
|
BackendClient,
|
|
69
|
+
BackendUnreachableError,
|
|
69
70
|
describeRequestError,
|
|
70
71
|
WorkspaceMismatchError
|
|
71
72
|
} from '../backend/client.js'
|
|
@@ -270,10 +271,10 @@ export async function publish(args = []) {
|
|
|
270
271
|
}
|
|
271
272
|
const siteYml = readSiteYml(join(siteDir, 'site.yml'))
|
|
272
273
|
// ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
|
|
273
|
-
// backend verb (resolveBackendOrigin). Only
|
|
274
|
+
// backend verb (resolveBackendOrigin). Only UNIWEB_SERVER — the automation
|
|
274
275
|
// override — outranks it, and nothing talks to a backend the user is not logged in to:
|
|
275
276
|
// logged in nowhere, the origin is the default backend and the first request asks for
|
|
276
|
-
// that login. ⛔ There is no `--
|
|
277
|
+
// that login. ⛔ There is no `--server` here: switching is `uniweb login --server`.
|
|
277
278
|
// ⛔ The project's own record — its synced backend, deploy.yml's default target — routes
|
|
278
279
|
// NOTHING. For part of 2026-09-21 it answered a "logged in nowhere" tier, which cannot
|
|
279
280
|
// be reached: *"We do not allow any communication with backend if the user is not
|
|
@@ -303,8 +304,8 @@ export async function publish(args = []) {
|
|
|
303
304
|
// ⚠️ Logged in to a backend where this project has no site, while it has one
|
|
304
305
|
// elsewhere: following the login CREATES a second site. Say so before the owner
|
|
305
306
|
// question, which otherwise arrives with no reason attached. Not when
|
|
306
|
-
//
|
|
307
|
-
if (!process.env.
|
|
307
|
+
// UNIWEB_SERVER chose the backend — that is already a decision.
|
|
308
|
+
if (!process.env.UNIWEB_SERVER) {
|
|
308
309
|
const known = syncedElsewhere(siteDir, client.origin)
|
|
309
310
|
if (known) {
|
|
310
311
|
const [headline, ...rest] = describeSyncedElsewhere(known, client.origin, 'publish')
|
|
@@ -480,6 +481,12 @@ export async function publish(args = []) {
|
|
|
480
481
|
cliBin: process.argv[1]
|
|
481
482
|
})
|
|
482
483
|
} catch (err) {
|
|
484
|
+
// A backend that did not answer is said as that — not as a release that failed.
|
|
485
|
+
if (err instanceof BackendUnreachableError) {
|
|
486
|
+
say.err(err.message)
|
|
487
|
+
say.dim('Nothing was released or sent. Check that the backend is running, then re-run `uniweb publish`.')
|
|
488
|
+
return { exitCode: 1 }
|
|
489
|
+
}
|
|
483
490
|
// A scope you are not a member of is said as that, with the ways on (`explainReleaseFailure`).
|
|
484
491
|
say.err(err.notMember ? err.message : `Foundation release failed: ${err.message}`)
|
|
485
492
|
for (const line of err.notMember ? err.ways : ['Fix the foundation, then re-run `uniweb publish`.']) say.dim(line)
|
|
@@ -933,7 +940,7 @@ export async function publish(args = []) {
|
|
|
933
940
|
} catch (err) {
|
|
934
941
|
say.err(describeRequestError(err, client.origin))
|
|
935
942
|
if (!(err instanceof WorkspaceMismatchError))
|
|
936
|
-
say.dim('Is that the backend you meant? Switch with: uniweb login --
|
|
943
|
+
say.dim('Is that the backend you meant? Switch with: uniweb login --server <url>')
|
|
937
944
|
return { exitCode: 1 }
|
|
938
945
|
}
|
|
939
946
|
// The body of a publish that did not go through — read once, and kept for the report below.
|
package/src/commands/pull.js
CHANGED
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
*
|
|
45
45
|
* The content + folder pull lanes are both keyed by the site's uuid on that backend
|
|
46
46
|
* (sync.json).
|
|
47
|
-
* Backend: the one you are logged in to —
|
|
47
|
+
* Backend: the one you are logged in to — UNIWEB_SERVER overrides it for a script
|
|
48
48
|
* (resolveBackendOrigin). Auth: UNIWEB_TOKEN > the stored session > `uniweb login`.
|
|
49
|
-
* No `--
|
|
49
|
+
* No `--server` or `--token`: switching and signing in are `uniweb login`.
|
|
50
50
|
*
|
|
51
51
|
* A project that never pushed has no `$uuid` to pull by — pull is a no-op with a
|
|
52
52
|
* clear message. The backend serves each lane as a `.uwx` (ZIP: `manifest.json` +
|
|
@@ -702,10 +702,10 @@ export async function pull(args = [], deps = {}) {
|
|
|
702
702
|
if (blocked) return blocked
|
|
703
703
|
}
|
|
704
704
|
// ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
|
|
705
|
-
// backend verb (resolveBackendOrigin). Only
|
|
705
|
+
// backend verb (resolveBackendOrigin). Only UNIWEB_SERVER — the automation
|
|
706
706
|
// override — outranks it, and nothing talks to a backend the user is not logged in to:
|
|
707
707
|
// logged in nowhere, the origin is the default backend and the first request asks for
|
|
708
|
-
// that login. ⛔ There is no `--
|
|
708
|
+
// that login. ⛔ There is no `--server` here: switching is `uniweb login --server`.
|
|
709
709
|
// ⛔ The project's own record — its synced backend, deploy.yml's default target — routes
|
|
710
710
|
// NOTHING. For part of 2026-09-21 it answered a "logged in nowhere" tier, which cannot
|
|
711
711
|
// be reached: *"We do not allow any communication with backend if the user is not
|
|
@@ -747,14 +747,14 @@ export async function pull(args = [], deps = {}) {
|
|
|
747
747
|
if (!siteContentUuid) {
|
|
748
748
|
// Following the login can land on a backend with no site for this project while it
|
|
749
749
|
// has one elsewhere — say where, rather than only "push first", which would create a
|
|
750
|
-
// second site. Not when
|
|
751
|
-
const known = !process.env.
|
|
750
|
+
// second site. Not when UNIWEB_SERVER chose the backend: that is a decision.
|
|
751
|
+
const known = !process.env.UNIWEB_SERVER
|
|
752
752
|
? syncedElsewhere(siteDir, client.origin)
|
|
753
753
|
: null
|
|
754
754
|
if (known) {
|
|
755
755
|
info(`Nothing to pull — this project has no site on ${client.origin}.`)
|
|
756
756
|
note(
|
|
757
|
-
`Its ${known.length === 1 ? `site is on ${known[0]}` : `sites are on ${known.join(', ')}`}. To pull from there: uniweb login --
|
|
757
|
+
`Its ${known.length === 1 ? `site is on ${known[0]}` : `sites are on ${known.join(', ')}`}. To pull from there: uniweb login --server <url>`
|
|
758
758
|
)
|
|
759
759
|
return { exitCode: 0 }
|
|
760
760
|
}
|
|
@@ -799,7 +799,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
799
799
|
} catch (err) {
|
|
800
800
|
error(describeRequestError(err, client.origin))
|
|
801
801
|
if (err instanceof WorkspaceMismatchError) return { refused: true }
|
|
802
|
-
note('Is that the backend you meant? Switch with: uniweb login --
|
|
802
|
+
note('Is that the backend you meant? Switch with: uniweb login --server <url>')
|
|
803
803
|
return null
|
|
804
804
|
}
|
|
805
805
|
if (res.status === 404) {
|
|
@@ -816,7 +816,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
816
816
|
if (detail) note(detail)
|
|
817
817
|
if (res.status === 401 || res.status === 403)
|
|
818
818
|
note(
|
|
819
|
-
"Credentials weren't accepted — log in again (`uniweb login --
|
|
819
|
+
"Credentials weren't accepted — log in again (`uniweb login --server <url>`), or check UNIWEB_TOKEN."
|
|
820
820
|
)
|
|
821
821
|
return null
|
|
822
822
|
}
|
package/src/commands/push.js
CHANGED
|
@@ -48,9 +48,9 @@
|
|
|
48
48
|
* backend's reconcile deletes items absent from the package, so an author's NEW page
|
|
49
49
|
* would be hard-deleted). `--force` omits the token and restores last-push-wins.
|
|
50
50
|
*
|
|
51
|
-
* Backend: the one you are logged in to —
|
|
51
|
+
* Backend: the one you are logged in to — UNIWEB_SERVER overrides it for a script
|
|
52
52
|
* (resolveBackendOrigin). Auth: UNIWEB_TOKEN > the stored session > `uniweb login`.
|
|
53
|
-
* No `--
|
|
53
|
+
* No `--server` or `--token`: switching and signing in are `uniweb login`.
|
|
54
54
|
*
|
|
55
55
|
* The two-lane SUBMISSION (POST both lanes, back-fill uuids, persist the
|
|
56
56
|
* send-only-changed cache) lives in `../backend/site-sync.js` so `uniweb publish`
|
|
@@ -65,7 +65,7 @@ import { emitSyncPackages, readBackendState } from '@uniweb/build/uwx'
|
|
|
65
65
|
import { findSiteCopies, describeSiteCopies } from '../utils/site-copies.js'
|
|
66
66
|
import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
|
|
67
67
|
import { updateBackendMap, carryServed, SYNC_STORE_FILE } from '@uniweb/build/uwx'
|
|
68
|
-
import { BackendClient } from '../backend/client.js'
|
|
68
|
+
import { BackendClient, BackendUnreachableError } from '../backend/client.js'
|
|
69
69
|
import { resolveSiteDir } from './deploy.js'
|
|
70
70
|
import { refuseIfContentDoesNotConform } from '../utils/conformance.js'
|
|
71
71
|
import { reportSchemalessQueries } from '../utils/schemaless-report.js'
|
|
@@ -162,10 +162,10 @@ export async function push(args = [], deps = {}) {
|
|
|
162
162
|
// arrival or lose a value on the way. See utils/conformance.js.
|
|
163
163
|
if (await refuseIfContentDoesNotConform(siteDir, { args })) return { exitCode: 1 }
|
|
164
164
|
// ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
|
|
165
|
-
// backend verb (resolveBackendOrigin). Only
|
|
165
|
+
// backend verb (resolveBackendOrigin). Only UNIWEB_SERVER — the automation
|
|
166
166
|
// override — outranks it, and nothing talks to a backend the user is not logged in to:
|
|
167
167
|
// logged in nowhere, the origin is the default backend and the first request asks for
|
|
168
|
-
// that login. ⛔ There is no `--
|
|
168
|
+
// that login. ⛔ There is no `--server` here: switching is `uniweb login --server`.
|
|
169
169
|
// ⛔ The project's own record — its synced backend, deploy.yml's default target — routes
|
|
170
170
|
// NOTHING. For part of 2026-09-21 it answered a "logged in nowhere" tier, which cannot
|
|
171
171
|
// be reached: *"We do not allow any communication with backend if the user is not
|
|
@@ -209,8 +209,8 @@ export async function push(args = [], deps = {}) {
|
|
|
209
209
|
// ⚠️ Logged in to a backend where this project has no site, while it has one
|
|
210
210
|
// elsewhere: following the login CREATES a second site. Say so before the owner
|
|
211
211
|
// question, which otherwise arrives with no reason attached. Not for `-o` (nothing is
|
|
212
|
-
// created) and not when
|
|
213
|
-
if (!output && !process.env.
|
|
212
|
+
// created) and not when UNIWEB_SERVER chose the backend — that is a decision.
|
|
213
|
+
if (!output && !process.env.UNIWEB_SERVER) {
|
|
214
214
|
const known = syncedElsewhere(siteDir, client.origin)
|
|
215
215
|
if (known) {
|
|
216
216
|
const [headline, ...rest] = describeSyncedElsewhere(known, client.origin, 'push')
|
|
@@ -278,6 +278,12 @@ export async function push(args = [], deps = {}) {
|
|
|
278
278
|
verb: 'push'
|
|
279
279
|
})
|
|
280
280
|
} catch (err) {
|
|
281
|
+
// A backend that did not answer is said as that — not as a release that failed.
|
|
282
|
+
if (err instanceof BackendUnreachableError) {
|
|
283
|
+
error(err.message)
|
|
284
|
+
note('Nothing was released or sent. Check that the backend is running, then re-run `uniweb push`.')
|
|
285
|
+
return { exitCode: 1 }
|
|
286
|
+
}
|
|
281
287
|
// A scope you are not a member of is said as that, with the ways on (`explainReleaseFailure`).
|
|
282
288
|
error(err.notMember ? err.message : `Foundation release failed: ${err.message}`)
|
|
283
289
|
for (const line of err.notMember ? err.ways : ['Fix the foundation, then re-run `uniweb push`.']) note(line)
|
package/src/commands/refresh.js
CHANGED
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
* uniweb refresh git pull, then merge the backend's content
|
|
32
32
|
* uniweb refresh --no-git skip the git remote; backend only
|
|
33
33
|
* uniweb refresh --no-backend skip the backend; git only
|
|
34
|
-
*
|
|
34
|
+
*
|
|
35
|
+
* The backend is the one you are logged in to, as for every backend verb (no flag picks it).
|
|
35
36
|
*/
|
|
36
37
|
|
|
37
38
|
import { readFileSync } from 'node:fs'
|
package/src/commands/register.js
CHANGED
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
* ({ok,scope,origin,entities:[{name,uuid,version,unchanged}]}),
|
|
36
36
|
* all human output to stderr — for scripted callers
|
|
37
37
|
*
|
|
38
|
-
* Endpoint:
|
|
39
|
-
* (~/.uniweb/config.json, else uniweb.app) — resolveBackendOrigin. No
|
|
40
|
-
* switching is `uniweb login --
|
|
38
|
+
* Endpoint: UNIWEB_SERVER > the backend you are logged in to > the default
|
|
39
|
+
* (~/.uniweb/config.json, else uniweb.app) — resolveBackendOrigin. No backend flag:
|
|
40
|
+
* switching is `uniweb login --server <url>`.
|
|
41
41
|
* Auth (submit only): UNIWEB_TOKEN > the stored session > `uniweb login`.
|
|
42
42
|
*/
|
|
43
43
|
|
|
@@ -595,7 +595,7 @@ async function runRegister(args = []) {
|
|
|
595
595
|
const dryRun = args.includes('--dry-run')
|
|
596
596
|
const output = flagValue(args, '-o') || flagValue(args, '--output')
|
|
597
597
|
const scopeFlag = flagValue(args, '--scope')
|
|
598
|
-
// The backend you are logged in to (
|
|
598
|
+
// The backend you are logged in to (UNIWEB_SERVER overrides it for a script) —
|
|
599
599
|
// resolveBackendOrigin, like every backend command.
|
|
600
600
|
const client = new BackendClient({
|
|
601
601
|
args,
|
|
@@ -845,7 +845,7 @@ async function runRegister(args = []) {
|
|
|
845
845
|
} catch (err) {
|
|
846
846
|
error(`Could not reach the registry at ${client.origin}: ${err.message}`)
|
|
847
847
|
log(
|
|
848
|
-
` ${colors.dim}Is that the backend you meant? Switch with: uniweb login --
|
|
848
|
+
` ${colors.dim}Is that the backend you meant? Switch with: uniweb login --server <url>${colors.reset}`
|
|
849
849
|
)
|
|
850
850
|
return { exitCode: 2 }
|
|
851
851
|
}
|
|
@@ -905,7 +905,7 @@ async function runRegister(args = []) {
|
|
|
905
905
|
` ${colors.dim}The registry didn't accept your credentials — it may use different ones than \`uniweb login\`.${colors.reset}`
|
|
906
906
|
)
|
|
907
907
|
log(
|
|
908
|
-
` ${colors.dim}Log in again (uniweb login --
|
|
908
|
+
` ${colors.dim}Log in again (uniweb login --server <url>), or check UNIWEB_TOKEN — an existing one may be wrong or expired.${colors.reset}`
|
|
909
909
|
)
|
|
910
910
|
}
|
|
911
911
|
// ⭐ **Surface `detail` as a sentence, not as a JSON dump.** The registry
|
package/src/commands/rename.js
CHANGED
|
@@ -62,7 +62,7 @@ import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
|
|
|
62
62
|
import { getExistingPackageNames, validatePackageName } from '../utils/names.js'
|
|
63
63
|
import { detectPackageManager, installCmd } from '../utils/pm.js'
|
|
64
64
|
import { getCliPrefix } from '../utils/interactive.js'
|
|
65
|
-
import {
|
|
65
|
+
import { replaceInYamlList, setYamlKey } from '@uniweb/build/uwx'
|
|
66
66
|
|
|
67
67
|
const colors = {
|
|
68
68
|
reset: '\x1b[0m',
|
|
@@ -307,9 +307,9 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
|
|
|
307
307
|
pkgSrc,
|
|
308
308
|
sitePkgPath,
|
|
309
309
|
siteYmlPath,
|
|
310
|
-
// Edited in place —
|
|
310
|
+
// Edited in place — `setYamlKey`, @uniweb/build — and computed HERE, before
|
|
311
311
|
// anything moves, so a file it cannot edit stops the rename cleanly.
|
|
312
|
-
newYmlText: ymlMatches ?
|
|
312
|
+
newYmlText: ymlMatches ? setYamlKey(ymlText, 'foundation', newName) : null,
|
|
313
313
|
hasDep,
|
|
314
314
|
ymlMatches
|
|
315
315
|
})
|
|
@@ -318,7 +318,7 @@ async function renameFoundation(rootDir, oldName, newName, prefix) {
|
|
|
318
318
|
refuseUneditableSiteYml(
|
|
319
319
|
affectedSites.filter((s) => s.ymlMatches && s.newYmlText === null).map((s) => s.path),
|
|
320
320
|
'foundation',
|
|
321
|
-
`Write
|
|
321
|
+
`Write \`foundation: ${oldName}\` as a plain value — not through a YAML alias (\`*name\`) or merge key (\`<<:\`).`
|
|
322
322
|
)
|
|
323
323
|
|
|
324
324
|
// ─── Print plan, then execute ────────────────────────────────
|
|
@@ -530,14 +530,14 @@ async function renameExtension(rootDir, oldName, newName, prefix) {
|
|
|
530
530
|
siteYmlPath,
|
|
531
531
|
hits,
|
|
532
532
|
// Edited in place, and computed before anything moves — as for foundations.
|
|
533
|
-
newYmlText:
|
|
533
|
+
newYmlText: replaceInYamlList(ymlText, 'extensions', replacements)
|
|
534
534
|
})
|
|
535
535
|
}
|
|
536
536
|
}
|
|
537
537
|
refuseUneditableSiteYml(
|
|
538
538
|
affectedSites.filter((a) => a.newYmlText === null).map((a) => a.site.path),
|
|
539
539
|
'extensions',
|
|
540
|
-
|
|
540
|
+
'Write `extensions:` as a plain list — not through a YAML alias (`*name`) or merge key (`<<:`).'
|
|
541
541
|
)
|
|
542
542
|
|
|
543
543
|
log('')
|
|
@@ -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
|