uniweb 0.59.2 → 0.61.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 +24 -32
- package/src/backend/asset-download.js +11 -9
- package/src/backend/client.js +38 -91
- package/src/backend/foundation-bring-along.js +19 -14
- package/src/backend/service-request.js +14 -9
- package/src/backend/site-media.js +6 -6
- package/src/backend/site-sync.js +323 -221
- package/src/commands/clone.js +42 -39
- package/src/commands/deploy.js +45 -27
- package/src/commands/doctor.js +6 -7
- package/src/commands/forget.js +197 -0
- package/src/commands/i18n.js +19 -10
- package/src/commands/publish.js +112 -77
- package/src/commands/pull.js +63 -49
- package/src/commands/push.js +76 -60
- package/src/commands/refresh.js +22 -29
- package/src/commands/register.js +37 -23
- package/src/commands/status.js +13 -11
- package/src/commands/sync.js +0 -1
- package/src/framework-index.json +7 -7
- package/src/index.js +101 -76
- package/src/utils/args.js +3 -4
- package/src/utils/asset-upload.js +18 -5
- package/src/utils/config.js +79 -42
- package/src/utils/flag-guard.js +70 -27
- package/src/utils/git.js +1 -1
- package/src/utils/interactive.js +38 -0
- package/src/utils/records-guard.js +38 -30
- package/src/utils/registry-auth.js +150 -28
- package/src/utils/registry-orgs.js +19 -0
- package/src/utils/scaffold.js +11 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +4 -3
- package/src/utils/site-identity.js +89 -299
- package/templates/site/site.yml.hbs +11 -9
package/src/utils/config.js
CHANGED
|
@@ -21,6 +21,7 @@ import { homedir } from 'node:os'
|
|
|
21
21
|
import yaml from 'js-yaml'
|
|
22
22
|
import { filterCmd } from './pm.js'
|
|
23
23
|
import { writeJsonPreservingStyleAsync } from './json-file.js'
|
|
24
|
+
import { loggedInOriginOf, readSessionFileSync } from './session-file.js'
|
|
24
25
|
|
|
25
26
|
// ── Backend origin ─────────────────────────────────────────────
|
|
26
27
|
|
|
@@ -57,64 +58,100 @@ function readCliConfig() {
|
|
|
57
58
|
* to keep this module off the optional-peer / import-cycle path.
|
|
58
59
|
* @returns {string|null}
|
|
59
60
|
*/
|
|
60
|
-
|
|
61
|
+
/**
|
|
62
|
+
* **The backend the user is logged in to** — the one session's backend — or null.
|
|
63
|
+
*
|
|
64
|
+
* ⛔ This read `~/.uniweb/registry-auth.json`'s top-level `origin` itself until
|
|
65
|
+
* 2026-09-21: the v1 shape. The file had become one session per backend, which has no
|
|
66
|
+
* such field, so this answered null for every new login and the session tier below
|
|
67
|
+
* silently fell through to the default backend. It reads through the file's one reader
|
|
68
|
+
* now (utils/session-file.js).
|
|
69
|
+
*
|
|
70
|
+
* @returns {string|null}
|
|
71
|
+
*/
|
|
72
|
+
export function loggedInOrigin() {
|
|
73
|
+
return loggedInOriginOf(readSessionFileSync(DEFAULT_BACKEND_ORIGIN))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A value's bare origin, or null when it is not a URL. */
|
|
77
|
+
function originOrNull(value) {
|
|
78
|
+
if (!value) return null
|
|
61
79
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
80
|
+
// ⛔ http(s) only: `new URL('localhost:8080')` PARSES — as the scheme `localhost:` —
|
|
81
|
+
// and its origin is the string "null", which is truthy. A bare host:port is the
|
|
82
|
+
// easy slip, and it must not become a backend.
|
|
83
|
+
const u = new URL(value)
|
|
84
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
|
|
66
85
|
} catch {
|
|
67
86
|
return null
|
|
68
87
|
}
|
|
69
88
|
}
|
|
70
89
|
|
|
71
90
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
91
|
+
* **The default backend** — where a bare `uniweb login` goes, and where a backend command
|
|
92
|
+
* goes when nobody is logged in (the login it asks for is then this one).
|
|
93
|
+
*
|
|
94
|
+
* `UNIWEB_REGISTER_URL`, else `~/.uniweb/config.json` `registryApiUrl`, else
|
|
95
|
+
* https://uniweb.app. ⛔ **Never the current session** *[Diego, 2026-09-21: "the default
|
|
96
|
+
* backend for login, if not specified, is uniweb.app"]* — a bare `uniweb login` means the
|
|
97
|
+
* default backend, not "the one I am already on".
|
|
98
|
+
*
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
export function getDefaultBackendOrigin() {
|
|
102
|
+
return (
|
|
103
|
+
originOrNull(process.env.UNIWEB_REGISTER_URL) ||
|
|
104
|
+
originOrNull(readCliConfig().registryApiUrl) ||
|
|
105
|
+
DEFAULT_BACKEND_ORIGIN
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The backend `uniweb login` logs in to: `--backend`, else the default backend.
|
|
111
|
+
*
|
|
112
|
+
* ⛔ A mistyped `--backend` is an error, never a fallback — it would log you in, and so
|
|
113
|
+
* point every command, somewhere you did not name.
|
|
114
|
+
*
|
|
115
|
+
* @param {string|null|undefined} flag - `readFlagValue`'s answer: undefined when the
|
|
116
|
+
* flag is absent, null when it was given with no value
|
|
117
|
+
* @returns {string}
|
|
118
|
+
* @throws {Error} when --backend was given and is not a URL
|
|
119
|
+
*/
|
|
120
|
+
export function resolveLoginOrigin(flag) {
|
|
121
|
+
if (flag === undefined) return getDefaultBackendOrigin()
|
|
122
|
+
const origin = originOrNull(flag)
|
|
123
|
+
if (!origin) throw new Error(flag ? `Not a URL: ${flag}` : '--backend needs a URL')
|
|
124
|
+
return origin
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* **The backend a command talks to** when no `--backend` is given — the base of every
|
|
129
|
+
* `/dev/*` route (`register` POSTs to {origin}/dev/registry/register, and so on).
|
|
130
|
+
*
|
|
131
|
+
* `UNIWEB_REGISTER_URL` > **the backend the user is logged in to** > the default
|
|
132
|
+
* (`getDefaultBackendOrigin`). ⭐ Nothing talks to a backend the user is not logged in
|
|
133
|
+
* to *[Diego, 2026-09-21]*: when this falls through to the default, the command's first
|
|
134
|
+
* request asks for that login — so the default is where the login goes, never a
|
|
135
|
+
* backend reached without one. `resolveBackendOrigin` puts `--backend` on top.
|
|
76
136
|
*
|
|
77
|
-
* Priority: UNIWEB_REGISTER_URL's origin > the logged-in session origin >
|
|
78
|
-
* ~/.uniweb/config.json registryApiUrl > the default (uniweb.app). Local dev
|
|
79
|
-
* points at a local backend EXPLICITLY (--backend / UNIWEB_REGISTER_URL /
|
|
80
|
-
* `uniweb login --backend …`), rather than the default being localhost.
|
|
81
137
|
* @returns {string}
|
|
82
138
|
*/
|
|
83
139
|
export function getRegistryApiBaseUrl() {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
/* fall through */
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
const fromSession = readSessionOrigin()
|
|
93
|
-
if (fromSession) {
|
|
94
|
-
try {
|
|
95
|
-
return new URL(fromSession).origin
|
|
96
|
-
} catch {
|
|
97
|
-
return fromSession
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
const fromCfg = readCliConfig().registryApiUrl
|
|
101
|
-
if (fromCfg) {
|
|
102
|
-
try {
|
|
103
|
-
return new URL(fromCfg).origin
|
|
104
|
-
} catch {
|
|
105
|
-
return fromCfg
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return DEFAULT_BACKEND_ORIGIN
|
|
140
|
+
return (
|
|
141
|
+
originOrNull(process.env.UNIWEB_REGISTER_URL) ||
|
|
142
|
+
originOrNull(loggedInOrigin()) ||
|
|
143
|
+
getDefaultBackendOrigin()
|
|
144
|
+
)
|
|
109
145
|
}
|
|
110
146
|
|
|
111
147
|
/**
|
|
112
148
|
* The built-in backend origin — the last tier of the ladder, and the 98% case.
|
|
113
149
|
*
|
|
114
|
-
* Exported because
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
150
|
+
* Exported because several readers need the *built-in* default, not the resolved one:
|
|
151
|
+
* a v1 session file with no origin stamp, `login`'s list of known backends, and a
|
|
152
|
+
* deploy.yml `host: uniweb` target with no `backend:` (its documented meaning).
|
|
153
|
+
* `getRegistryApiBaseUrl()` cannot give it — that function returns whatever tier won,
|
|
154
|
+
* so on a machine with a session or a saved config it returns something else
|
|
118
155
|
* entirely. Two copies of the literal would silently disagree exactly there.
|
|
119
156
|
*/
|
|
120
157
|
export const DEFAULT_BACKEND_ORIGIN = 'https://uniweb.app'
|
package/src/utils/flag-guard.js
CHANGED
|
@@ -6,16 +6,17 @@
|
|
|
6
6
|
* does not recognize does not fail: it *disappears*, and the thing it was meant to
|
|
7
7
|
* change silently keeps its default.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* Tolerable for a cosmetic flag; dangerous for any flag that aims the command or picks
|
|
10
|
+
* its identity. This turns that class into one sentence.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
12
|
+
* ⛔ **`--backend` and `--token` are NOT flags of these verbs** *(2026-09-21)*. Every
|
|
13
|
+
* backend verb goes to the backend you are logged in to, with that login's session:
|
|
14
|
+
* switching and signing in are `uniweb login` (`--backend`, `--token`), and a script
|
|
15
|
+
* aims one process with UNIWEB_REGISTER_URL + UNIWEB_TOKEN. Both flags predate
|
|
16
|
+
* per-backend sessions. Passed now, each is an unknown flag — and this guard is what
|
|
17
|
+
* makes that a loud error, with a pointer to the login, instead of a command that
|
|
18
|
+
* quietly runs against wherever and as whoever you happen to be. `--backend` stays on
|
|
19
|
+
* `forget`, where it SELECTS which backend's records to remove.
|
|
19
20
|
*
|
|
20
21
|
* ⚠️ A wrong rejection is worse than a missed one — it breaks an invocation that
|
|
21
22
|
* works — so the per-command lists must be complete, INCLUDING flags read by
|
|
@@ -38,8 +39,8 @@ const GLOBAL = ['--non-interactive', '--help', '-h']
|
|
|
38
39
|
const AUTH = ['--browser', '--password', '--token-paste']
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
* Flags a verb inherits by importing `commands/deploy.js` for `resolveSiteDir
|
|
42
|
-
*
|
|
42
|
+
* Flags a verb inherits by importing `commands/deploy.js` for `resolveSiteDir`.
|
|
43
|
+
* Inert on these verbs, and listed rather than filtered:
|
|
43
44
|
* accepting a flag that does nothing is exactly the pre-guard behaviour, while
|
|
44
45
|
* rejecting one that works is a broken command. The guard's job is catching
|
|
45
46
|
* `--backed`, not policing inert-but-valid spellings.
|
|
@@ -54,8 +55,8 @@ const VIA_DEPLOY = ['--target', '--host', '--no-save']
|
|
|
54
55
|
*/
|
|
55
56
|
const VERBS = {
|
|
56
57
|
push: [
|
|
57
|
-
'--all', '--as-org', '--org', '--
|
|
58
|
-
'--foundation', '--output', '-o', '--personal',
|
|
58
|
+
'--all', '--as-org', '--org', '--dry-run', '--force',
|
|
59
|
+
'--foundation', '--output', '-o', '--personal',
|
|
59
60
|
// read in utils/conformance.js and backend/site-sync.js respectively —
|
|
60
61
|
// neither appears in push.js
|
|
61
62
|
'--no-validate', '--yes',
|
|
@@ -68,29 +69,36 @@ const VERBS = {
|
|
|
68
69
|
'--no-release', ...VIA_DEPLOY
|
|
69
70
|
],
|
|
70
71
|
publish: [
|
|
71
|
-
'--as-org', '--org', '--
|
|
72
|
-
'--personal',
|
|
72
|
+
'--as-org', '--org', '--dry-run', '--force', '--foundation',
|
|
73
|
+
'--personal',
|
|
73
74
|
// read in utils/conformance.js, backend/site-sync.js, and
|
|
74
75
|
// backend/foundation-bring-along.js — none appear in publish.js
|
|
75
76
|
'--no-validate', '--yes', '--no-verify', '--no-release', ...VIA_DEPLOY
|
|
76
77
|
],
|
|
77
78
|
pull: [
|
|
78
|
-
'--
|
|
79
|
+
'--content-only', '--dry-run', '--force', '--merge',
|
|
79
80
|
'--no-assets',
|
|
80
|
-
'--no-records', '--no-delete', '--no-prune',
|
|
81
|
+
'--no-records', '--no-delete', '--no-prune',
|
|
81
82
|
// via backend/site-sync.js (the owner resolver) and utils/conformance.js
|
|
82
83
|
'--yes', '--org', '--as-org', '--no-validate', ...VIA_DEPLOY
|
|
83
84
|
],
|
|
84
85
|
clone: [
|
|
85
|
-
'--
|
|
86
|
-
'--project', '--
|
|
86
|
+
'--content-only', '--no-assets', '--no-records', '--path',
|
|
87
|
+
'--project', '--org', '--as-org'
|
|
87
88
|
],
|
|
88
89
|
register: [
|
|
89
|
-
'--
|
|
90
|
-
'--schema-only', '--scope', '--
|
|
90
|
+
'--dry-run', '--json', '--output', '-o',
|
|
91
|
+
'--schema-only', '--scope', '--org', '--as-org'
|
|
91
92
|
],
|
|
93
|
+
/**
|
|
94
|
+
* `forget` = remove one backend's records (`--backend <url>`), or everything a
|
|
95
|
+
* copied project inherited (`--all`). One of the two is required — the verb refuses
|
|
96
|
+
* without it — and they exclude each other. `--non-interactive` reaches it through
|
|
97
|
+
* resolveSiteDir in a workspace of several sites.
|
|
98
|
+
*/
|
|
99
|
+
forget: ['--backend', '--all'],
|
|
92
100
|
status: [
|
|
93
|
-
'--
|
|
101
|
+
'--json', '--remote', '--dry-run',
|
|
94
102
|
'--force', '--no-verify', '--no-validate', '--yes', '--org', '--as-org',
|
|
95
103
|
// inert here, reachable through the bring-along module status imports for
|
|
96
104
|
// `resolveLocalFoundation` — listed per the over-approximation note above
|
|
@@ -99,10 +107,9 @@ const VERBS = {
|
|
|
99
107
|
/**
|
|
100
108
|
* `refresh` = `git pull`, then a DELEGATED `pull --merge`.
|
|
101
109
|
*
|
|
102
|
-
* It forwards
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* `--no-prune`, `--content-only`, …) are NOT reachable from a `refresh` argv and
|
|
110
|
+
* It forwards nothing of its own argv to that pull — it builds `['--merge']`
|
|
111
|
+
* itself. So pull's own flags (`--merge`, `--force`, `--no-delete`, `--no-prune`,
|
|
112
|
+
* `--content-only`, …) are NOT reachable from a `refresh` argv and
|
|
106
113
|
* are deliberately absent here. ⚠️ `--force` especially: `refresh` is read-only by
|
|
107
114
|
* design, and forwarding it would ask pull to DISCARD local work.
|
|
108
115
|
*
|
|
@@ -111,7 +118,7 @@ const VERBS = {
|
|
|
111
118
|
* the VIA_DEPLOY note above, and required by `flag-guard-coverage.test.js`.
|
|
112
119
|
*/
|
|
113
120
|
refresh: [
|
|
114
|
-
'--
|
|
121
|
+
'--no-backend', '--no-git',
|
|
115
122
|
'--as-org', '--org', '--dry-run', '--no-validate', '--yes', ...VIA_DEPLOY
|
|
116
123
|
]
|
|
117
124
|
}
|
|
@@ -143,6 +150,18 @@ VERBS.sync = [...new Set([...VERBS.refresh, ...VERBS.push])]
|
|
|
143
150
|
* flag accepted here but inert costs nothing (it was ignored before the guard
|
|
144
151
|
* existed), while one rejected here breaks a working command.
|
|
145
152
|
*/
|
|
153
|
+
/**
|
|
154
|
+
* Read in the verbs' import graphs, and deliberately NOT accepted by them.
|
|
155
|
+
*
|
|
156
|
+
* `--token` is read by `runRegistryLogin` (utils/registry-auth.js) for
|
|
157
|
+
* `uniweb login --token <bearer>`, which seeds and STORES a session. The verbs reach
|
|
158
|
+
* that function through `ensureRegistryAuth`, which strips `--token` from their argv
|
|
159
|
+
* first — so a static walk sees the read, and no verb can honour it.
|
|
160
|
+
* `flag-guard-coverage.test.js` subtracts this list; add to it only with the same
|
|
161
|
+
* proof: a read no verb's argv can reach.
|
|
162
|
+
*/
|
|
163
|
+
export const LOGIN_ONLY = ['--token']
|
|
164
|
+
|
|
146
165
|
export const VERB_FLAGS = Object.fromEntries(
|
|
147
166
|
Object.entries(VERBS).map(([verb, flags]) => [
|
|
148
167
|
verb,
|
|
@@ -170,6 +189,30 @@ export function checkFlags(verb, args = []) {
|
|
|
170
189
|
if (!unknown.length) return null
|
|
171
190
|
|
|
172
191
|
const flag = unknown[0]
|
|
192
|
+
// ⭐ `--backend` and `--token` are not typos on these verbs — they are RETIRED
|
|
193
|
+
// (2026-09-21), and the useful answer is what replaced them, not "run --help".
|
|
194
|
+
if (flag === '--backend') {
|
|
195
|
+
return {
|
|
196
|
+
flag,
|
|
197
|
+
suggestion: null,
|
|
198
|
+
message: [
|
|
199
|
+
`\`uniweb ${verb}\` has no \`--backend\`: it goes to the backend you are logged in to.`,
|
|
200
|
+
' Switch with: uniweb login --backend <url>',
|
|
201
|
+
' (A script can aim one process with UNIWEB_REGISTER_URL instead.)'
|
|
202
|
+
].join('\n')
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (flag === '--token') {
|
|
206
|
+
return {
|
|
207
|
+
flag,
|
|
208
|
+
suggestion: null,
|
|
209
|
+
message: [
|
|
210
|
+
`\`uniweb ${verb}\` has no \`--token\`: it uses the session of the backend you are logged in to.`,
|
|
211
|
+
' Sign in with a token: uniweb login --backend <url> --token <bearer>',
|
|
212
|
+
' (A script can authenticate one process with UNIWEB_TOKEN instead.)'
|
|
213
|
+
].join('\n')
|
|
214
|
+
}
|
|
215
|
+
}
|
|
173
216
|
const suggestion = didYouMean(flag, all)
|
|
174
217
|
const lines = [`Unknown flag \`${flag}\` for \`uniweb ${verb}\`.`]
|
|
175
218
|
if (suggestion) lines.push(` Did you mean \`${suggestion}\`?`)
|
package/src/utils/git.js
CHANGED
package/src/utils/interactive.js
CHANGED
|
@@ -45,6 +45,44 @@ export async function confirm(question, defaultYes = false) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Ask the user to pick one of a short list, returning `fallback` on an empty answer
|
|
50
|
+
* or anything unparseable.
|
|
51
|
+
*
|
|
52
|
+
* ⛔ **Prompts on STDERR**, like everything else a human reads here — `stdout` is the
|
|
53
|
+
* CLI's data channel and a prompt written there corrupts any caller piping it.
|
|
54
|
+
*
|
|
55
|
+
* ⛔ Callers must decide NOT to ask before calling (see `confirm`): this reads stdin,
|
|
56
|
+
* and a prompt in a non-interactive run hangs a pipeline.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} question
|
|
59
|
+
* @param {string[]} options - shown in order, numbered from 1
|
|
60
|
+
* @param {string} fallback - returned for an empty or unparseable answer
|
|
61
|
+
* @returns {Promise<string>}
|
|
62
|
+
*/
|
|
63
|
+
export async function pickOne(question, options, fallback) {
|
|
64
|
+
if (!Array.isArray(options) || options.length === 0) return fallback
|
|
65
|
+
if (options.length === 1) return options[0]
|
|
66
|
+
const { createInterface } = await import('node:readline/promises')
|
|
67
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
68
|
+
try {
|
|
69
|
+
process.stderr.write(`${question}\n`)
|
|
70
|
+
options.forEach((o, i) => {
|
|
71
|
+
const mark = o === fallback ? ' \x1b[2m(default)\x1b[0m' : ''
|
|
72
|
+
process.stderr.write(` \x1b[1m${i + 1}\x1b[0m) ${o}${mark}\n`)
|
|
73
|
+
})
|
|
74
|
+
const a = (await rl.question(`Choose [1-${options.length}]: `)).trim()
|
|
75
|
+
if (!a) return fallback
|
|
76
|
+
const n = Number.parseInt(a, 10)
|
|
77
|
+
if (Number.isInteger(n) && n >= 1 && n <= options.length) return options[n - 1]
|
|
78
|
+
// A pasted origin is a reasonable answer to a list of origins.
|
|
79
|
+
if (options.includes(a)) return a
|
|
80
|
+
return fallback
|
|
81
|
+
} finally {
|
|
82
|
+
rl.close()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
/**
|
|
49
87
|
* Get the CLI invocation prefix to use in suggested commands.
|
|
50
88
|
* Mirrors however the user actually ran the CLI.
|
|
@@ -1,69 +1,77 @@
|
|
|
1
1
|
// ⛔ THE ONE PLACE AN ORDINARY ACT IS DESTRUCTIVE.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// well-shaped — the safe state is the ABSENCE of
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// A push sends the site's records folder whole, and it REPLACES the backend's. A site
|
|
4
|
+
// with no records directory sends none and leaves the backend's alone; a records
|
|
5
|
+
// directory that holds no records sends an EMPTY folder, and the backend removes what
|
|
6
|
+
// is there. The asymmetry is well-shaped — the safe state is the ABSENCE of the
|
|
7
|
+
// directory, so a site whose records live only on the backend is never emptied by a
|
|
8
|
+
// push of its pages — and it leaves exactly one sharp edge: a directory emptied by
|
|
9
|
+
// accident, or kept with only a placeholder (`records/.gitkeep`), and pushed.
|
|
9
10
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// That is plausible and it is the only path where a normal act destroys content.
|
|
11
|
+
// ⭐ THE CLI DOES THE ASKING. Never make "empty" mean "missing" to dodge this: that
|
|
12
|
+
// would delete a capability to avoid writing a prompt.
|
|
13
13
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// ⛔ Until 2026-09-21 both states were `records.yml`'s — missing and empty — because
|
|
15
|
+
// that file listed the records. The directory holds them now (`@uniweb/build`'s
|
|
16
|
+
// `sendsFolder` decides what a push sends, and this asks about exactly its empty case).
|
|
17
17
|
//
|
|
18
18
|
// The count comes from the placement identity a previous push banked — what WE
|
|
19
19
|
// last saw the folder hold. It needs no network call, and it is the right source:
|
|
20
20
|
// a site that has never pushed has nothing to lose and is never asked.
|
|
21
21
|
|
|
22
|
-
import {
|
|
22
|
+
import { readEntityPool } from '@uniweb/build/uwx'
|
|
23
23
|
import { readFolderItemUuids } from '../backend/site-sync.js'
|
|
24
24
|
import { confirm, isNonInteractive, getCliPrefix } from './interactive.js'
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
27
|
+
* Record placements in a banked placement map.
|
|
28
28
|
*
|
|
29
|
-
* A
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* ⭐ A placement banked since 2026-09-21 is keyed by the record it references —
|
|
30
|
+
* `@<record uuid>`, one per record — so where those exist they ARE the count. A map
|
|
31
|
+
* banked before holds `name` chains only: a branch's path is a prefix of every path
|
|
32
|
+
* beneath it, so anything that is a prefix of another key is a folder rather than a
|
|
33
|
+
* record. Counting raw keys would report a two-record site inside one folder as
|
|
34
|
+
* three things to lose — or, with both kinds of key, each record twice.
|
|
32
35
|
*/
|
|
33
36
|
export function countPlacedRecords(pathToUuid) {
|
|
34
|
-
const
|
|
35
|
-
|
|
37
|
+
const keys = Object.keys(pathToUuid || {})
|
|
38
|
+
const byRecord = keys.filter((k) => k.startsWith('@'))
|
|
39
|
+
if (byRecord.length) return byRecord.length
|
|
40
|
+
return keys.filter((p) => !keys.some((q) => q !== p && q.startsWith(`${p}/`))).length
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
/**
|
|
39
|
-
* Stop an empty
|
|
44
|
+
* Stop an empty records directory from silently emptying the backend's folder.
|
|
40
45
|
*
|
|
41
46
|
* @param {object} params
|
|
42
47
|
* @param {string} params.siteDir
|
|
48
|
+
* @param {string} params.backend - whose placements to count; they are minted per backend
|
|
43
49
|
* @param {string[]} params.args - the verb's argv, for --yes / non-interactive
|
|
44
50
|
* @param {(m: string) => void} params.warn - the CALLER's reporter. Each verb owns
|
|
45
51
|
* its own output style; a second copy here would drift from all of them.
|
|
46
52
|
* @param {(m: string) => void} params.note
|
|
47
53
|
* @returns {Promise<{ ok: boolean, count: number }>} `ok: false` means abort
|
|
48
54
|
*/
|
|
49
|
-
export async function guardEmptyRecords({ siteDir, args = [], warn, note }) {
|
|
50
|
-
const
|
|
51
|
-
|
|
55
|
+
export async function guardEmptyRecords({ siteDir, backend, args = [], warn, note }) {
|
|
56
|
+
const pool = await readEntityPool(siteDir)
|
|
57
|
+
// Only a directory that is there and holds nothing at all — no records, and no
|
|
58
|
+
// files that fail to be records — sends the empty folder.
|
|
59
|
+
if (!pool.exists || pool.entities.length > 0 || pool.errors.length > 0) return { ok: true, count: 0 }
|
|
52
60
|
|
|
53
|
-
const count = countPlacedRecords(readFolderItemUuids(siteDir))
|
|
61
|
+
const count = countPlacedRecords(readFolderItemUuids(siteDir, backend))
|
|
54
62
|
// Nothing banked ⇒ nothing this push can remove. A first push of an empty
|
|
55
63
|
// folder is a legitimate (if odd) thing to do, and asking about it would train
|
|
56
64
|
// people to type y.
|
|
57
65
|
if (count === 0) return { ok: true, count: 0 }
|
|
58
66
|
|
|
59
67
|
warn(
|
|
60
|
-
|
|
61
|
-
`from the
|
|
68
|
+
`${pool.dir}/ holds no records, and this push would REMOVE ${count} record${count === 1 ? '' : 's'} ` +
|
|
69
|
+
`from the site's folder on the backend.`
|
|
62
70
|
)
|
|
63
71
|
note(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
`An empty ${pool.dir}/ means "the folder holds nothing" — it is not the same as having no ` +
|
|
73
|
+
`${pool.dir}/ at all, which leaves the backend's folder alone. If you did not mean to remove ` +
|
|
74
|
+
`them, put the files back (or pull) before pushing.`
|
|
67
75
|
)
|
|
68
76
|
|
|
69
77
|
// ⚠️ `--yes` ONLY. `-y` is not a flag this CLI has anywhere, and adding one here
|
|
@@ -75,6 +83,6 @@ export async function guardEmptyRecords({ siteDir, args = [], warn, note }) {
|
|
|
75
83
|
return { ok: false, count }
|
|
76
84
|
}
|
|
77
85
|
|
|
78
|
-
const yes = await confirm(`Remove ${count} record${count === 1 ? '' : 's'} from the
|
|
86
|
+
const yes = await confirm(`Remove ${count} record${count === 1 ? '' : 's'} from the site's folder?`, false)
|
|
79
87
|
return { ok: yes, count }
|
|
80
88
|
}
|