uniweb 0.59.1 → 0.60.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.
@@ -19,7 +19,8 @@
19
19
  * foundationRef and no foundationPath) + AGENTS.md + deps pinned to this CLI's
20
20
  * version matrix; placement reuses create (new workspace / in-place) and add's
21
21
  * resolver (into an existing workspace, any shape);
22
- * 3. seed the site's one identity — site.yml::$uuid (a plain YAML scalar write).
22
+ * 3. seed the site's identity on that backend — its section of `sync.json` (plain
23
+ * JSON, written with no dependencies).
23
24
  * The folder is pulled by this same uuid, so there is no separate folder uuid to
24
25
  * seed;
25
26
  * 4. install, then delegate the projection to the project-local `uniweb pull` (which
@@ -42,7 +43,8 @@
42
43
  * Backend: via BackendClient (the site-content pull lane). Origin from
43
44
  * UNIWEB_REGISTER_URL > the local default (internal dev overrides;
44
45
  * not the user-facing path — `uniweb login` determines the origin).
45
- * Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
46
+ * Auth: UNIWEB_TOKEN > the stored session > `uniweb login`. No `--token` (retired
47
+ * from the backend commands 2026-09-21).
46
48
  */
47
49
 
48
50
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
@@ -59,7 +61,6 @@ import { extractFoundationRef } from '../utils/site-content-refs.js'
59
61
  import { readUwxDocuments } from '../utils/uwx-read.js'
60
62
  import { recordWritten } from '../utils/pull-written.js'
61
63
  import { checkFlags } from '../utils/flag-guard.js'
62
- import { recordSiteBackend } from '../utils/site-identity.js'
63
64
 
64
65
  const colors = {
65
66
  reset: '\x1b[0m',
@@ -128,24 +129,35 @@ export function extractCloneSeeds(document) {
128
129
  }
129
130
  }
130
131
 
131
- // Insert/replace a top-level `$uuid:` scalar in a YAML file's text without
132
- // disturbing the rest (the scaffolded site.yml is comment-heavy — don't round-trip
133
- // through a YAML dumper). Inserts after the first `name:` line, else prepends.
134
- function seedYamlUuid(filePath, uuid) {
135
- let text = existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
136
- if (/^\$uuid:/m.test(text)) {
137
- text = text.replace(/^\$uuid:.*$/m, `$uuid: ${uuid}`)
138
- } else {
139
- const nameMatch = text.match(/^name:.*$/m)
140
- if (nameMatch) {
141
- const idx = nameMatch.index + nameMatch[0].length
142
- text = text.slice(0, idx) + `\n$uuid: ${uuid}` + text.slice(idx)
143
- } else {
144
- text = `$uuid: ${uuid}\n` + text
132
+ // Seed `sync.json` with the identity this clone was pulled by.
133
+ //
134
+ // ⛔ **Dependency-free on purpose, and this has bitten before.** `clone` runs BEFORE
135
+ // `pnpm install`, so `@uniweb/build`'s store is not on disk yet. The previous version
136
+ // of this recorded the scope through a lazy `import('@uniweb/build/uwx')` whose
137
+ // failure branch returned null and wrote NOTHING — so a fresh clone recorded nothing
138
+ // essentially always, and the next command stopped dead complaining about a backend
139
+ // the user had named on the command line. Measured by the backend lane 2026-09-18:
140
+ // 0.48.5 recorded it, 0.57.0 did not.
141
+ //
142
+ // ⭐ JSON needs no parser, which is most of why the store is JSON rather than YAML —
143
+ // `site.yml`'s equivalent needed a hand-rolled dependency-free WRITER for exactly
144
+ // this. The store stays the one writer everywhere else; this seeds a fresh file.
145
+ // `test/clone.test.js` covers it end to end with `skipInstall: true`.
146
+ function seedSyncJson(siteDir, origin, uuid) {
147
+ const file = join(siteDir, 'sync.json')
148
+ let doc = { version: 1, backends: {} }
149
+ if (existsSync(file)) {
150
+ try {
151
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
152
+ if (parsed?.backends && typeof parsed.backends === 'object') doc = parsed
153
+ } catch {
154
+ /* unreadable — a fresh clone's file is ours to write */
145
155
  }
146
156
  }
147
- mkdirSync(dirname(filePath), { recursive: true })
148
- writeFileSync(filePath, text)
157
+ const key = new URL(origin).origin
158
+ doc.backends[key] = { ...(doc.backends[key] || {}), site: { ...(doc.backends[key]?.site || {}), uuid } }
159
+ mkdirSync(dirname(file), { recursive: true })
160
+ writeFileSync(file, JSON.stringify(doc, null, 2) + '\n')
149
161
  }
150
162
 
151
163
  // Build the package-manager argv to run the project-local `uniweb pull`.
@@ -188,12 +200,7 @@ export async function clone(args = [], deps = {}) {
188
200
  args.includes('--no-records') || args.includes('--content-only')
189
201
  const pathFlag = flagValue(args, '--path')
190
202
  const projectFlag = flagValue(args, '--project')
191
- const tokenFlag = flagValue(args, '--token')
192
- const explicitBackend =
193
- flagValue(args, '--backend')
194
203
  const client = new BackendClient({
195
- originFlag: explicitBackend,
196
- token: tokenFlag,
197
204
  getToken: deps.getToken,
198
205
  fetchImpl: deps.fetch,
199
206
  args,
@@ -222,7 +229,7 @@ export async function clone(args = [], deps = {}) {
222
229
  if (!res.ok) {
223
230
  error(`Could not read the site: HTTP ${res.status} ${res.statusText}`)
224
231
  if (res.status === 401 || res.status === 403)
225
- note('Run `uniweb login` first (or pass --token <bearer>).')
232
+ note('Run `uniweb login` first.')
226
233
  return { exitCode: 1 }
227
234
  }
228
235
  documents = readUwxDocuments(Buffer.from(await res.arrayBuffer()))
@@ -340,21 +347,19 @@ export async function clone(args = [], deps = {}) {
340
347
  await addWorkspaceGlob(existingRoot, placement.relativePath)
341
348
  }
342
349
 
343
- // 4. Seed the site's one identity — site.yml::$uuid. The folder is pulled by this
344
- // same uuid (the backend resolves the site's @uniweb/folder from it), so there is no
345
- // separate folder uuid to seed.
346
- seedYamlUuid(join(siteDir, 'site.yml'), siteUuid)
347
- // …and the SYNC SCOPE that makes it meaningful. `clone` is the sharpest case for it:
348
- // the uuid comes from whoever we read, and until now nothing on disk recorded that —
349
- // so a teammate who cloned this project and ran `uniweb pull` while logged in
350
- // elsewhere sent this backend's uuid to a different one. A no-op on the default
351
- // backend. (The two CREATE paths record it via `recordAndDescribeOwner`; clone seeds
352
- // an existing site and never reaches them, which is why it is written here too.)
353
- const scope = await recordSiteBackend(siteDir, client.origin)
350
+ // 4. Seed the identity this clone was pulled by, under the backend that minted it.
351
+ // The folder is pulled by the same uuid (the backend resolves the site's
352
+ // @uniweb/folder from it), so there is no separate folder uuid to seed.
353
+ //
354
+ // ⭐ The backend is the KEY, not a separate note. `clone` was the sharpest case for
355
+ // the old `$backend`: the uuid came from whoever we read and nothing on disk said
356
+ // so, and a teammate who pulled while logged in elsewhere sent it to the wrong
357
+ // backend. Stored under the origin, that cannot be expressed.
358
+ seedSyncJson(siteDir, client.origin, siteUuid)
354
359
  success(
355
360
  `Scaffolded the site harness${foundationRef ? ` (foundation: ${foundationRef})` : ''}.`
356
361
  )
357
- if (scope) note(`Bound to ${scope} (recorded $backend in site.yml).`)
362
+ note(`Bound to ${client.origin} (recorded in sync.json).`)
358
363
 
359
364
  // 5. Install, then delegate the projection to the project-local `uniweb pull`.
360
365
  const pm = detectWorkspacePm(projectDir) || 'pnpm'
@@ -400,8 +405,6 @@ export async function clone(args = [], deps = {}) {
400
405
  )
401
406
 
402
407
  const pullExtra = []
403
- if (explicitBackend) pullExtra.push('--backend', explicitBackend)
404
- if (tokenFlag) pullExtra.push('--token', tokenFlag)
405
408
  if (noRecords) pullExtra.push('--no-records')
406
409
 
407
410
  if (deps.skipPull) {
@@ -40,7 +40,7 @@
40
40
  * uniweb deploy --host=uniweb Delegate to `uniweb publish` (Uniweb hosting)
41
41
  * uniweb deploy --target <name> Pick a target from deploy.yml
42
42
  * uniweb deploy --dry-run Resolve everything; upload nothing
43
- * uniweb deploy --no-save Skip the deploy.yml lastDeploy auto-save
43
+ * uniweb deploy --no-save Do not record this deploy in deploy.yml
44
44
  *
45
45
  * Escape hatch: UNIWEB_SKIP_BUILD=1 reuses an existing dist/.
46
46
  */
@@ -56,6 +56,8 @@ import {
56
56
  } from '@uniweb/build/site'
57
57
  import { promptForDestination } from '../utils/destination-prompt.js'
58
58
  import { readFlagValue } from '../utils/args.js'
59
+ import { DEFAULT_BACKEND_ORIGIN, getRegistryApiBaseUrl, loggedInOrigin } from '../utils/config.js'
60
+ import { normalizeOrigin } from '../utils/site-identity.js'
59
61
  import { parseBoolEnv } from '../utils/env.js'
60
62
  import { headProvenance } from '../utils/git.js'
61
63
  import { warnIfContentDoesNotConform } from '../utils/conformance.js'
@@ -185,10 +187,42 @@ export async function deploy(args = []) {
185
187
  // Uniweb Cloud is `publish`'s flow — delegate so deploy.yml stays one
186
188
  // actionable record and there's a single implementation of go-live.
187
189
  if (plan.kind === 'uniweb') {
190
+ // ⭐ `--host=uniweb` IS `publish` *[Diego, 2026-09-21]*: it goes to the backend you are
191
+ // logged in to — deploy follows the login like every other command. A deploy.yml
192
+ // uniweb target's `backend:` records where that target's publishes went; it routes
193
+ // nothing (publish files its record under the target naming its backend).
194
+ const goingTo = getRegistryApiBaseUrl()
195
+ const aimedBy = process.env.UNIWEB_REGISTER_URL
196
+ ? 'UNIWEB_REGISTER_URL'
197
+ : loggedInOrigin()
198
+ ? 'the backend you are logged in to'
199
+ : 'the default backend — you are not logged in'
200
+ const fromTarget =
201
+ resolved.fromFile &&
202
+ resolved.host === 'uniweb' &&
203
+ // not after the wizard (`--host` with no value): a pick there names no target
204
+ (hostFromFlag === undefined || hostFromFlag === 'uniweb')
205
+ const targetBackend = fromTarget
206
+ ? normalizeOrigin(resolved.config?.backend || DEFAULT_BACKEND_ORIGIN)
207
+ : null
208
+ if (targetBackend && targetBackend !== goingTo) {
209
+ if (targetFromFlag) {
210
+ // ⛔ A refusal, not a route: the user TYPED this destination, and going live
211
+ // somewhere else would contradict it with nothing said. Switching is a login.
212
+ say.err(
213
+ `Target '${resolved.targetName}' is on ${targetBackend}, but this would publish to ${goingTo} (${aimedBy}).`
214
+ )
215
+ say.dim(`To publish there, log in to it first: uniweb login --backend ${targetBackend}`)
216
+ process.exit(1)
217
+ }
218
+ say.dim(
219
+ `deploy.yml's default target '${resolved.targetName}' is on ${targetBackend}; publishing to ${goingTo} (${aimedBy}).`
220
+ )
221
+ }
188
222
  say.info('Uniweb Cloud → running `uniweb publish`.')
189
223
  console.log('')
190
- // publish ignores deploy's --host/--target; --dry-run/--no-save/--backend
191
- // /--token pass straight through.
224
+ // publish ignores deploy's --host/--target; --dry-run/--no-save pass straight
225
+ // through.
192
226
  const { publish } = await import('./publish.js')
193
227
  // Conformance was already reported above, and publish runs the same check
194
228
  // — without this the user reads one warning twice and learns to skim it.
@@ -225,11 +259,13 @@ export async function deploy(args = []) {
225
259
  // want a one-off experiment to rewrite the file). A host picked interactively
226
260
  // for a bare `deploy` is NOT an override — we DO want to remember it.
227
261
  const hostOverridden = !!hostFromFlag && hostFromFlag !== resolved.host
228
- const autoSave = noSave || hostOverridden ? 'off' : resolved.autoSave
262
+ // An ad-hoc --host override describes no target, so there is nothing to file a
263
+ // deploy under — the switch goes off without anyone typing it.
264
+ const saveDeploys = !(noSave || hostOverridden) && resolved.saveDeploys !== false
229
265
 
230
266
  await deployStaticHost(siteDir, plan.host, resolved, {
231
267
  dryRun,
232
- autoSave,
268
+ saveDeploys,
233
269
  hostOverridden
234
270
  })
235
271
  }
@@ -280,7 +316,7 @@ async function deployStaticHost(
280
316
  siteDir,
281
317
  hostName,
282
318
  resolved,
283
- { dryRun, autoSave, hostOverridden }
319
+ { dryRun, saveDeploys, hostOverridden }
284
320
  ) {
285
321
  let getAdapter
286
322
  try {
@@ -399,7 +435,7 @@ async function deployStaticHost(
399
435
  }
400
436
 
401
437
  // Record a fresh lastDeploy.<target> entry. Skipped on --no-save and
402
- // on ad-hoc --host overrides — see autoSave gating in deploy().
438
+ // on ad-hoc --host overrides — see saveDeploys gating in deploy().
403
439
  const gitStamp = {
404
440
  at: new Date().toISOString(),
405
441
  git: headProvenance(siteDir)
@@ -409,7 +445,7 @@ async function deployStaticHost(
409
445
  targetConfig: resolved.fromFile
410
446
  ? null
411
447
  : { host: hostName, ...deployConfig },
412
- autoSave,
448
+ saveDeploys,
413
449
  lastDeploy: {
414
450
  at: gitStamp.at,
415
451
  host: hostName,
@@ -434,7 +470,7 @@ async function deployStaticHost(
434
470
  // ─── deploy.yml lastDeploy persistence ──────────────────────────
435
471
 
436
472
  async function persistLastDeploy(siteDir, opts) {
437
- if (opts.autoSave === 'off') return
473
+ if (opts.saveDeploys === false) return
438
474
  try {
439
475
  const result = await recordLastDeploy(siteDir, opts)
440
476
  if (result?.created) {
@@ -497,21 +533,3 @@ export async function resolveSiteDir(args, verb = 'deploy') {
497
533
  }
498
534
  process.exit(1)
499
535
  }
500
-
501
- // The site's deploy.yml-bound backend origin (for the resolved `uniweb` target),
502
- // or null when there's no deploy.yml, the target isn't Uniweb hosting, or no
503
- // backend was recorded. Site verbs pass this to BackendClient as `siteBackend`
504
- // so a site stays bound to the backend it publishes to — deploy.yml is the
505
- // record of *where* a site is deployed (the 98% case is uniweb.app, but a B2B
506
- // university backend is just a `backend:` on the target). Sits below --backend
507
- // and UNIWEB_REGISTER_URL but above the logged-in session in the resolution
508
- // ladder (see resolveBackendOrigin). Best-effort: any read/parse error → null.
509
- export async function resolveSiteBackend(siteDir) {
510
- try {
511
- const deployYml = await loadDeployYml(siteDir)
512
- const resolved = resolveTarget(deployYml, null)
513
- return resolved.host === 'uniweb' ? resolved.config?.backend || null : null
514
- } catch {
515
- return null
516
- }
517
- }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `uniweb forget` — remove what this project recorded about where it has synced and
3
+ * deployed. Local files only; nothing on any backend changes.
4
+ *
5
+ * uniweb forget --backend <url> one backend
6
+ * uniweb forget --all everything — for a COPY that is to become a new project
7
+ *
8
+ * ## `--backend <url>`
9
+ *
10
+ * - that backend's section of `sync.json` — the site uuid, the record map, asset
11
+ * ids, provisioned services and the rest of what it minted;
12
+ * - that backend's section of `.uniweb/backend-cache.json`;
13
+ * - the `deploys:` records in `deploy.yml` that name it. ⭐ Its TARGETS stay: a
14
+ * target is where you chose to ship, not something the backend told us, and the
15
+ * next publish there simply creates a new site.
16
+ *
17
+ * Every other backend is untouched, which is only possible because all three are
18
+ * keyed by backend.
19
+ *
20
+ * ## `--all`
21
+ *
22
+ * A copied project carries the original's `sync.json`, so its first push updates the
23
+ * ORIGINAL's site — the copy sends uuids that are the original's. `--all` deletes
24
+ * `sync.json`, `.uniweb/backend-cache.json` and `deploy.yml`, after which the copy's
25
+ * next push creates a new site.
26
+ *
27
+ * ⭐ **`deploy.yml` goes whole, targets included** *[Diego, 2026-09-21]*. Its targets
28
+ * name the original's destinations — its Pages project, its bucket, its domain — so a
29
+ * deploy from the copy would overwrite the original there. The copy's first deploy
30
+ * writes a fresh one.
31
+ *
32
+ * ## What neither form touches
33
+ *
34
+ * ⛔ **Record files.** A record's `$uuid` is its OWN id, not any backend's. It is
35
+ * written once, on the record's first push anywhere, and never changes after;
36
+ * removing it would delete the record's identity, which is not what forgetting a
37
+ * backend means. A copy keeps them too: they never reach a backend again once the
38
+ * map that pointed them at the original's entities is gone. *[Diego, 2026-09-20.]*
39
+ *
40
+ * ⛔ **Any backend.** Every site still exists where it was. Deleting one is a
41
+ * different operation, on the backend's side.
42
+ *
43
+ * ## Why it exists
44
+ *
45
+ * Two jobs. A script pushes a template site to a short-lived dev server and then
46
+ * removes the traces of it without discarding the rest of the project (`--backend`)
47
+ * — which is also why traceless publish was dropped: push and pull leave traces too.
48
+ * And someone duplicates a project to start a new site from it (`--all`).
49
+ *
50
+ * ⚠️ **No default target.** Forgetting a backend you still use means its next push
51
+ * creates a second site there, so the verb makes you name it — `--backend` is
52
+ * required even when the project has synced with only one.
53
+ */
54
+
55
+ import { readFlagValue } from '../utils/args.js'
56
+ import { checkFlags } from '../utils/flag-guard.js'
57
+ import { resolveSiteDir } from './deploy.js'
58
+ import { forgetBackendCache, forgetAllBackendCaches } from '../backend/site-sync.js'
59
+ import { clearBackend, forgetSyncStore, readBackendState } from '@uniweb/build/uwx'
60
+ import { forgetDeploys, forgetDeployYml } from '@uniweb/build/site'
61
+ import { syncedBackends, normalizeOrigin } from '../utils/site-identity.js'
62
+
63
+ const c = {
64
+ reset: '\x1b[0m',
65
+ bold: '\x1b[1m',
66
+ dim: '\x1b[2m',
67
+ cyan: '\x1b[36m',
68
+ green: '\x1b[32m',
69
+ yellow: '\x1b[33m',
70
+ red: '\x1b[31m'
71
+ }
72
+ const say = {
73
+ ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
74
+ info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
75
+ warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
76
+ err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
77
+ dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
78
+ }
79
+
80
+ /** "a", "a and b", "a, b and c" */
81
+ function listed(items) {
82
+ return items.length < 2
83
+ ? items.join('')
84
+ : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`
85
+ }
86
+
87
+ /**
88
+ * @param {string[]} args
89
+ * @returns {Promise<{ exitCode: number, forgot?: string[], removed?: string[] }>}
90
+ */
91
+ export async function forget(args = []) {
92
+ const badFlag = checkFlags('forget', args)
93
+ if (badFlag) {
94
+ say.err(badFlag.message)
95
+ return { exitCode: 2 }
96
+ }
97
+
98
+ const all = args.includes('--all')
99
+ const flag = readFlagValue(args, '--backend')
100
+ if (all && flag) {
101
+ say.err('Use --backend <url> or --all, not both.')
102
+ return { exitCode: 2 }
103
+ }
104
+
105
+ const siteDir = await resolveSiteDir(args, 'forget')
106
+ const known = syncedBackends(siteDir)
107
+
108
+ if (all) return forgetAll(siteDir)
109
+
110
+ if (!flag) {
111
+ say.err('Name what to forget: uniweb forget --backend <url>')
112
+ if (known.length) {
113
+ say.dim(`This project has synced with: ${known.join(', ')}`)
114
+ } else {
115
+ say.dim('This project has not synced with any backend.')
116
+ }
117
+ say.dim('For a copied project that should become a new one: uniweb forget --all')
118
+ return { exitCode: 2 }
119
+ }
120
+
121
+ const origin = normalizeOrigin(flag)
122
+ if (!origin) {
123
+ say.err(`Not a URL: ${flag}`)
124
+ return { exitCode: 2 }
125
+ }
126
+
127
+ // Read before removing, so the report can say what the section held.
128
+ const state = readBackendState(siteDir, origin)
129
+ const removed = []
130
+ if (clearBackend(siteDir, origin)) removed.push('sync.json')
131
+ if (forgetBackendCache(siteDir, origin)) removed.push('.uniweb/backend-cache.json')
132
+ let deploys = []
133
+ try {
134
+ deploys = await forgetDeploys(siteDir, origin)
135
+ if (deploys.length) removed.push('deploy.yml')
136
+ } catch (err) {
137
+ // Not a stop: the identity above is already forgotten, and a deploy.yml that does
138
+ // not parse fails every deploy on its own. Rewriting a file we could not read
139
+ // would be the one thing worse than leaving it.
140
+ say.warn(`${err.message} — its deploy records were left as they are.`)
141
+ }
142
+
143
+ // Idempotent on purpose: a script calls this after every run, and a second call
144
+ // (or a call for a backend the push never reached) is not an error.
145
+ if (!removed.length) {
146
+ say.info(`Nothing recorded for ${origin} — nothing to forget.`)
147
+ if (known.length) say.dim(`This project has synced with: ${known.join(', ')}`)
148
+ return { exitCode: 0, forgot: [origin], removed }
149
+ }
150
+
151
+ say.ok(`Forgot ${c.bold}${origin}${c.reset}`)
152
+ if (state.site?.uuid) say.dim(`site ${state.site.uuid}`)
153
+ const counts = [
154
+ ['records', state.records],
155
+ ['assets', state.assets]
156
+ ]
157
+ .map(([name, map]) => [name, map ? Object.keys(map).length : 0])
158
+ .filter(([, n]) => n > 0)
159
+ .map(([name, n]) => `${n} ${name}`)
160
+ if (counts.length) say.dim(counts.join(' · '))
161
+ if (deploys.length) {
162
+ say.dim(`deploy records: ${deploys.join(', ')} (the targets stay)`)
163
+ }
164
+ say.dim(`removed from ${listed(removed)}`)
165
+ say.dim('The site still exists on that backend; this removed only what this project recorded.')
166
+
167
+ return { exitCode: 0, forgot: [origin], removed }
168
+ }
169
+
170
+ /** `--all`: the three files, whole. */
171
+ async function forgetAll(siteDir) {
172
+ const origins = forgetSyncStore(siteDir)
173
+ const cache = forgetAllBackendCaches(siteDir)
174
+ const targets = await forgetDeployYml(siteDir)
175
+
176
+ const removed = []
177
+ if (origins) removed.push('sync.json')
178
+ if (targets) removed.push('deploy.yml')
179
+ if (cache) removed.push('.uniweb/backend-cache.json')
180
+
181
+ if (!removed.length) {
182
+ say.info('Nothing recorded — this project has not synced or deployed anywhere.')
183
+ return { exitCode: 0, forgot: [], removed }
184
+ }
185
+
186
+ say.ok('Forgot every backend and deploy target')
187
+ if (origins?.length) say.dim(`backends: ${origins.join(', ')}`)
188
+ if (targets?.length) say.dim(`deploy targets: ${targets.join(', ')}`)
189
+ say.dim(`removed ${listed(removed)}`)
190
+ say.dim('Its next push creates a new site. Every existing site is untouched.')
191
+ // ⛔ The mistake this cannot prevent: running it in the ORIGINAL. That project's
192
+ // next push would then mint a second site on every backend. Both files are
193
+ // committed, so the way back is one command — say it now, while it is cheap.
194
+ say.dim('If this was the original rather than a copy, restore sync.json and deploy.yml from git.')
195
+
196
+ return { exitCode: 0, forgot: origins || [], removed }
197
+ }