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.
@@ -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
- function readSessionOrigin() {
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
- const p = join(homedir(), '.uniweb', 'registry-auth.json')
63
- if (!existsSync(p)) return null
64
- const o = JSON.parse(readFileSync(p, 'utf8'))?.origin
65
- return o || null
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
- * Get the backend's API base origin. `register` POSTs to
73
- * {origin}/dev/registry/register, `login` to {origin}/dev/auth/login, etc.
74
- * (BackendClient.resolveBackendOrigin layers the --backend flag and a site's
75
- * deploy.yml backend on top of this.)
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
- const fromEnv = process.env.UNIWEB_REGISTER_URL
85
- if (fromEnv) {
86
- try {
87
- return new URL(fromEnv).origin
88
- } catch {
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 `site.yml::$backend` is written ONLY when the backend is NOT this
115
- * (`recordSiteBackend`, utils/site-identity.js). That test needs the *built-in* default,
116
- * which `getRegistryApiBaseUrl()` cannot give it — that function returns whatever tier
117
- * won, so on a machine with a session or a saved config it returns something else
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'
@@ -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
- * That is tolerable for a cosmetic flag and dangerous for these two:
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
- * --backend mistyped ⇒ the origin ladder falls through to the session origin,
12
- * ~/.uniweb/config.json, and finally https://uniweb.app. A command
13
- * aimed at a local backend can reach production.
14
- * --token mistyped ⇒ falls back to the stored session, so the request is
15
- * made as whoever is logged in rather than whoever was intended.
16
- *
17
- * Neither produces an error today; both produce a plausible success against the
18
- * wrong host. This turns that class into one sentence.
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
- * `resolveSiteBackend`. Inert on these verbs, and listed rather than filtered:
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', '--backend', '--dry-run', '--force',
58
- '--foundation', '--output', '-o', '--personal', '--token',
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', '--backend', '--dry-run', '--force', '--foundation',
72
- '--personal', '--token',
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
- '--backend', '--content-only', '--dry-run', '--force', '--merge',
79
+ '--content-only', '--dry-run', '--force', '--merge',
79
80
  '--no-assets',
80
- '--no-records', '--no-delete', '--no-prune', '--token',
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
- '--backend', '--content-only', '--no-assets', '--no-records', '--path',
86
- '--project', '--token', '--org', '--as-org'
86
+ '--content-only', '--no-assets', '--no-records', '--path',
87
+ '--project', '--org', '--as-org'
87
88
  ],
88
89
  register: [
89
- '--backend', '--dry-run', '--json', '--output', '-o',
90
- '--schema-only', '--scope', '--token', '--org', '--as-org'
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
- '--backend', '--json', '--remote', '--token', '--dry-run',
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 exactly two flags to that pull — `--backend` /
103
- * `--token`, via its own `collectPassthrough` — and constructs the rest of the
104
- * argv itself. So pull's own flags (`--merge`, `--force`, `--no-delete`,
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
- '--backend', '--no-backend', '--no-git', '--token',
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
@@ -54,7 +54,7 @@ export function siteContentRoots(siteDir) {
54
54
  }
55
55
  roots.add(paths.pages || 'pages')
56
56
  roots.add(paths.layout || 'layout')
57
- roots.add(paths.entities || 'entities')
57
+ roots.add(paths.records || 'records')
58
58
  return [...roots]
59
59
  }
60
60
 
@@ -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
- // `records.yml` is the sync control, and `missing` and `empty` deliberately mean
4
- // different things: missing leaves the server's folder untouched, empty says the
5
- // folder holds nothing and the backend removes what is there. The asymmetry is
6
- // well-shaped — the safe state is the ABSENCE of a file, so a live folder cannot
7
- // be wiped by deleting one, and the destructive act requires affirmatively
8
- // creating one.
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
- // ⚠️ WHICH LEAVES EXACTLY ONE SHARP EDGE: a PLACEHOLDER. Someone creates an empty
11
- // `records.yml` intending to fill it in, pushes, and the live folder empties.
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
- // ⭐ THE FORMAT STAYS HONEST AND THE CLI DOES THE ASKING. Never make "empty" mean
15
- // "missing" to dodge this: that would delete a capability to avoid writing a
16
- // prompt.
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 { readRecordsConfig, FOLDER_EMPTY } from '@uniweb/build/uwx'
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
- * Leaf placements in a banked path→uuid map.
27
+ * Record placements in a banked placement map.
28
28
  *
29
- * A branch's path is a prefix of every path beneath it, so anything that is a
30
- * prefix of another key is a folder rather than a record. Counting raw keys would
31
- * report a two-record site inside one folder as three things to lose.
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 paths = Object.keys(pathToUuid || {})
35
- return paths.filter((p) => !paths.some((q) => q !== p && q.startsWith(`${p}/`))).length
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 `records.yml` from silently emptying a live folder.
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 cfg = await readRecordsConfig(siteDir)
51
- if (cfg.state !== FOLDER_EMPTY) return { ok: true, count: 0 }
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
- `records.yml is empty, and this push would REMOVE ${count} record${count === 1 ? '' : 's'} ` +
61
- `from the live folder.`
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
- 'An empty records.yml means "the folder holds nothing" — it is not the same as ' +
65
- 'having no records.yml, which leaves the live folder alone. If you meant to ' +
66
- 'start listing records, delete the file until you have.'
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 live folder?`, false)
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
  }