uniweb 0.12.3 → 0.12.5

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.
@@ -44,8 +44,10 @@ import yaml from 'js-yaml'
44
44
 
45
45
  import { detectFoundationType } from '@uniweb/build'
46
46
 
47
- import { ensureAuth } from '../utils/auth.js'
47
+ import { ensureAuth, readAuth, decodeJwtPayload } from '../utils/auth.js'
48
48
  import { getBackendUrl, getRegistryUrl } from '../utils/config.js'
49
+ import { RemoteRegistry } from '../utils/registry.js'
50
+ import { receiptFromRegistryEntry, splitRegistryRef } from '../utils/receipt.js'
49
51
  import {
50
52
  findWorkspaceRoot,
51
53
  findSites,
@@ -164,13 +166,36 @@ const say = {
164
166
  dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
165
167
  }
166
168
 
169
+ /**
170
+ * Read the git state for `dir`, scoped to that directory's history and
171
+ * working tree — NOT the whole repo's HEAD.
172
+ *
173
+ * `gitSha` : last commit that touched `dir` (`git log -1 -- .`).
174
+ * `gitDirty`: uncommitted changes inside `dir` only (`git status -- .`).
175
+ *
176
+ * Why scope it. In a multi-package monorepo, `git rev-parse HEAD` is
177
+ * the same value for every directory — the repo's current HEAD. That
178
+ * meant editing a SITE then deploying triggered the foundation's
179
+ * staleness check (its receipt's recorded sha didn't match the new
180
+ * repo HEAD), even though the foundation source was unchanged. The
181
+ * receipt's `publishedFromGitSha` field is per-foundation by design;
182
+ * the comparison side has to be too.
183
+ *
184
+ * If the path is outside a git repo, or has no commits touching it
185
+ * yet, the function returns `{ gitSha: null, gitDirty: false }` —
186
+ * same fallback shape as before.
187
+ */
167
188
  function readGitState(dir) {
168
189
  try {
169
- const sha = execSync('git rev-parse HEAD', {
190
+ // `git log -1 --format=%H -- .` returns the SHA of the last
191
+ // commit that touched the cwd path. If no such commit exists
192
+ // yet (path was never committed), output is empty — caller
193
+ // treats null as "no published-from-sha to compare against."
194
+ const sha = execSync('git log -1 --format=%H -- .', {
170
195
  cwd: dir,
171
196
  stdio: ['ignore', 'pipe', 'ignore'],
172
197
  }).toString().trim()
173
- const status = execSync('git status --porcelain', {
198
+ const status = execSync('git status --porcelain -- .', {
174
199
  cwd: dir,
175
200
  stdio: ['ignore', 'pipe', 'ignore'],
176
201
  }).toString()
@@ -193,16 +218,35 @@ function composeFoundationUrl(ref, registryBase) {
193
218
  * Inspect a workspace-local foundation's `dist/publish.json` (Phase 1 receipt)
194
219
  * and decide whether it's stale relative to the current source tree.
195
220
  *
196
- * Returns `{ stale, reason, receipt }`. The caller decides whether to
197
- * auto-publish (Phase 2 default) or fail (`--no-auto-publish`).
221
+ * When the receipt file is missing and a `registry` is provided, attempt
222
+ * to refill it from the registry's index entry for `<name>@<version>`.
223
+ * That makes the receipt pure cache: a fresh clone with a matching
224
+ * upstream artifact resolves without an unnecessary republish. If the
225
+ * registry has no record (or the stored entry lacks git provenance), the
226
+ * inspector returns `stale: true` and the caller's auto-publish path
227
+ * runs as before.
228
+ *
229
+ * Returns `{ stale, reason, receipt, refilled? }`. The caller decides
230
+ * whether to auto-publish (Phase 2 default) or fail (`--no-auto-publish`).
198
231
  */
199
- async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale }) {
232
+ async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale, registry }) {
200
233
  const receiptPath = join(localPath, 'dist', 'publish.json')
201
234
  let receipt = null
235
+ let refilled = false
202
236
  try {
203
237
  receipt = JSON.parse(await readFile(receiptPath, 'utf8'))
204
238
  } catch {
205
- return { stale: true, reason: 'no dist/publish.json (foundation has not been published from this checkout)' }
239
+ if (registry) {
240
+ const refill = await tryRefillReceiptFromRegistry({ localPath, registry })
241
+ if (refill) {
242
+ await writeFile(receiptPath, JSON.stringify(refill, null, 2) + '\n')
243
+ receipt = refill
244
+ refilled = true
245
+ }
246
+ }
247
+ if (!receipt) {
248
+ return { stale: true, reason: 'no dist/publish.json (foundation has not been published from this checkout)' }
249
+ }
206
250
  }
207
251
 
208
252
  const { gitSha, gitDirty } = readGitState(localPath)
@@ -219,14 +263,94 @@ async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale }) {
219
263
  if (gitDirty && dirtyAsStale) {
220
264
  return { stale: true, reason: 'foundation working tree is dirty', receipt }
221
265
  }
222
- return { stale: false, receipt }
266
+ return { stale: false, receipt, refilled }
267
+ }
268
+
269
+ /**
270
+ * When a receipt is missing, try to reconstruct it from the registry's
271
+ * own record of the publish. We resolve `name@version` from the same
272
+ * package metadata `deriveLocalFoundationRef` uses, then ask the registry
273
+ * for its stored version entry. The entry's `publishedFromGitSha` is the
274
+ * load-bearing field — without it (entries written before git provenance
275
+ * was added) we can't compare against HEAD, so we don't synthesize a
276
+ * misleading "fresh" receipt and let the caller fall through to the
277
+ * republish path.
278
+ *
279
+ * Returns the receipt body, or null when refill is not possible.
280
+ */
281
+ async function tryRefillReceiptFromRegistry({ localPath, registry }) {
282
+ // Two ways to derive the canonical `<name>@<version>`:
283
+ // 1. `deriveLocalFoundationRef` — works for org-scope foundations
284
+ // (where the namespace is in package.json) and for any foundation
285
+ // where a previous receipt already exists. On the wipe-and-deploy
286
+ // path that fired this code, the receipt is gone, so this only
287
+ // works for org-scope.
288
+ // 2. Empty-scope fallback — if package.json has `uniweb.id` and the
289
+ // user's auth.json carries a `memberUuid` claim, we can synthesize
290
+ // `~<memberUuid>/<id>@<version>` directly. Same shape the server
291
+ // stores under.
292
+ let ref = await deriveLocalFoundationRef(localPath)
293
+ if (!ref) ref = await refFromAuthAndPkg(localPath)
294
+ const split = splitRegistryRef(ref)
295
+ if (!split) return null
296
+ let existingEntry
297
+ try {
298
+ existingEntry = await registry.getVersionEntry(split.name, split.version)
299
+ } catch {
300
+ return null
301
+ }
302
+ if (!existingEntry) return null
303
+ return receiptFromRegistryEntry({
304
+ existingEntry,
305
+ registry,
306
+ name: split.name,
307
+ version: split.version,
308
+ isLocal: false,
309
+ isPropagateDefault: false,
310
+ })
311
+ }
312
+
313
+ /**
314
+ * Last-resort canonical-name derivation for empty-scope foundations.
315
+ * Combines `package.json::uniweb.id` (the foundation's bare name) with
316
+ * the user's `memberUuid` claim from auth.json to produce
317
+ * `~<memberUuid>/<id>@<version>`. Only fires when both inputs are
318
+ * available — otherwise returns null and the caller falls through to
319
+ * the republish path.
320
+ */
321
+ async function refFromAuthAndPkg(localPath) {
322
+ let pkg
323
+ try {
324
+ pkg = JSON.parse(await readFile(join(localPath, 'package.json'), 'utf8'))
325
+ } catch {
326
+ return null
327
+ }
328
+ const id = pkg?.uniweb?.id
329
+ const version = pkg?.version
330
+ if (!id || !version || !/^[a-z0-9_-]+$/.test(id)) return null
331
+ try {
332
+ const auth = await readAuth()
333
+ const claims = decodeJwtPayload(auth?.token)
334
+ if (claims?.memberUuid) return `~${claims.memberUuid}/${id}@${version}`
335
+ } catch { /* no auth — fall through to null */ }
336
+ return null
223
337
  }
224
338
 
225
339
  /**
226
340
  * Read a workspace-local foundation's identity (scoped name + version) from
227
341
  * its `dist/meta/schema.json` + `package.json`, mirroring `publish.js`'s
228
- * namespace resolution. Returns the registry ref (`@ns/name@ver`), or null
229
- * if any of the inputs are missing.
342
+ * namespace resolution. Returns the registry ref (`@ns/name@ver` or
343
+ * `~uuid/name@ver`), or null if no shape can be resolved.
344
+ *
345
+ * Resolution order:
346
+ * 1. Org scope from `pkg.uniweb.namespace` or `pkg.name`'s `@org/...` prefix.
347
+ * 2. The receipt at `dist/publish.json`. After a successful publish, the
348
+ * receipt's `url` carries the canonical server-rewritten name —
349
+ * including empty-scope publishes, which the server rewrites to
350
+ * `~<memberUuid>/<name>`. The CLI can't synthesize that locally
351
+ * because the memberUuid lives in the JWT, not in the workspace.
352
+ * 3. null — caller falls through to the helpful "set uniweb.namespace"
353
+ * error message.
230
354
  */
231
355
  async function deriveLocalFoundationRef(localPath) {
232
356
  let pkg
@@ -248,14 +372,134 @@ async function deriveLocalFoundationRef(localPath) {
248
372
  version = version || pkg.version
249
373
  if (!rawName || !version) return null
250
374
 
375
+ // Org-scope path — derived purely from local files.
251
376
  const uniwebNamespace = pkg.uniweb?.namespace
252
377
  const pkgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\//)
253
378
  const selfScopeMatch = rawName.match(/^@([a-z0-9_-]+)\//)
254
379
  const namespace = uniwebNamespace || pkgScopeMatch?.[1] || selfScopeMatch?.[1]
255
- if (!namespace) return null
380
+ if (namespace) {
381
+ const bareName = selfScopeMatch ? rawName.slice(selfScopeMatch[0].length) : rawName
382
+ return `@${namespace}/${bareName}@${version}`
383
+ }
384
+
385
+ // Empty-scope path — read the canonical name from the receipt's URL.
386
+ // The server rewrites bare names to `~<memberUuid>/<name>`; the URL it
387
+ // returns carries that canonical form (e.g.
388
+ // `/foundations/~<uuid>/<name>@<ver>/foundation.js`). Parse it back.
389
+ const fromReceipt = await refFromReceiptUrl(localPath)
390
+ if (fromReceipt) return fromReceipt
391
+
392
+ return null
393
+ }
394
+
395
+ // Personal-scope namespaces are base58 memberUuids (mixed case; the
396
+ // alphabet is `[A-HJ-NP-Za-km-z1-9]`). Org-scope handles are
397
+ // lowercase-only. Allow both shapes here so the regex captures personal
398
+ // scopes correctly. The bare-name portion (after the `/`) stays
399
+ // lowercase per BARE_NAME_RE.
400
+ const RECEIPT_URL_REF_RE = /\/foundations\/((?:@[a-z0-9_-]+|~[A-Za-z0-9_-]+)\/[a-z0-9_-]+)@([^/]+)\//
401
+
402
+ async function refFromReceiptUrl(localPath) {
403
+ try {
404
+ const receipt = JSON.parse(await readFile(join(localPath, 'dist', 'publish.json'), 'utf8'))
405
+ const m = RECEIPT_URL_REF_RE.exec(receipt?.url || '')
406
+ if (m) return `${m[1]}@${m[2]}`
407
+ } catch {
408
+ // No receipt, malformed JSON, or URL doesn't carry the canonical
409
+ // shape — fall through to null.
410
+ }
411
+ return null
412
+ }
413
+
414
+ /**
415
+ * Resolve the deploy mode for this site.
416
+ *
417
+ * Returns `'link'` or `'bundle'`, optionally prompting the user when
418
+ * neither inference nor an explicit flag yields an answer. Mode choice
419
+ * is persisted server-side after the first successful deploy; switching
420
+ * modes later requires deleting the site and redeploying fresh.
421
+ *
422
+ * Ladder (first match wins):
423
+ * 1. `--link` or `--bundle` flag passed explicitly.
424
+ * 2. `site.yml::foundation` is a registry ref (`@org/x@ver` or
425
+ * `~uuid/x@ver`) or full HTTPS URL → infer link. The user already
426
+ * declared "load this foundation by URL at runtime."
427
+ * 3. `site.yml::foundation` is a workspace-local sibling AND that
428
+ * foundation has a publish receipt (`dist/publish.json` with a
429
+ * registry URL) → infer link. The user has already done the work
430
+ * to make the foundation loadable from the registry.
431
+ * 4. Otherwise → ask (TTY prompt) or error (CI).
432
+ *
433
+ * @param {Object} ctx
434
+ * @param {string} ctx.foundationRef - the raw value of site.yml::foundation (string or normalized object).
435
+ * @param {string} ctx.siteDir - absolute path to the site dir (for resolving local foundation siblings).
436
+ * @param {boolean} ctx.linkFlag
437
+ * @param {boolean} ctx.bundleFlag
438
+ * @returns {Promise<{ mode: 'link'|'bundle', source: 'flag'|'inferred-registry-ref'|'inferred-published-local'|'asked' }>}
439
+ */
440
+ async function resolveDeployMode({ foundationRef, siteDir, linkFlag, bundleFlag }) {
441
+ // 1. Explicit flag wins.
442
+ if (linkFlag) return { mode: 'link', source: 'flag' }
443
+ if (bundleFlag) return { mode: 'bundle', source: 'flag' }
444
+
445
+ // 2. Registry ref or URL in site.yml → link mode is the only sensible choice.
446
+ if (typeof foundationRef === 'string') {
447
+ if (foundationRef.startsWith('http://') || foundationRef.startsWith('https://')) {
448
+ return { mode: 'link', source: 'inferred-registry-ref' }
449
+ }
450
+ if (/^@[a-z0-9_-]+\/[a-z0-9_-]+@/.test(foundationRef)) {
451
+ return { mode: 'link', source: 'inferred-registry-ref' }
452
+ }
453
+ if (/^~[A-Za-z0-9_-]+\/[a-z0-9_-]+@/.test(foundationRef)) {
454
+ return { mode: 'link', source: 'inferred-registry-ref' }
455
+ }
456
+ }
457
+
458
+ // 3. Workspace-local foundation with an existing publish receipt → infer link.
459
+ // The receipt being there means the user has already published this
460
+ // foundation at least once, so they've shown intent to ship via the
461
+ // registry. We don't validate the receipt's freshness here — that's
462
+ // the existing inspectLocalFoundationReceipt path's job, which runs
463
+ // later in the deploy flow.
464
+ if (typeof foundationRef === 'string') {
465
+ const detected = detectFoundationType(foundationRef, siteDir)
466
+ if (detected.type === 'local') {
467
+ const receiptPath = join(detected.path, 'dist', 'publish.json')
468
+ if (existsSync(receiptPath)) {
469
+ return { mode: 'link', source: 'inferred-published-local' }
470
+ }
471
+ }
472
+ }
256
473
 
257
- const bareName = selfScopeMatch ? rawName.slice(selfScopeMatch[0].length) : rawName
258
- return `@${namespace}/${bareName}@${version}`
474
+ // 4. Ambiguous local foundation never published, or unknown shape. Ask.
475
+ if (isNonInteractive(process.argv)) {
476
+ say.err('First deploy of this site needs an explicit mode.')
477
+ console.log('')
478
+ console.log(' Pick one and re-run:')
479
+ console.log(` ${c.cyan}uniweb deploy --link${c.reset} ${c.dim}Uniweb-edge hosting (data only; worker generates HTML)${c.reset}`)
480
+ console.log(` ${c.cyan}uniweb deploy --bundle${c.reset} ${c.dim}Static-host artifact (vite build; for non-Uniweb hosts)${c.reset}`)
481
+ console.log('')
482
+ console.log(` ${c.dim}Mode is persisted after the first deploy and can't be changed in place.${c.reset}`)
483
+ process.exit(1)
484
+ }
485
+
486
+ const prompts = (await import('prompts')).default
487
+ console.log('')
488
+ console.log(`${c.dim}First deploy of this site. Pick a deployment mode:${c.reset}`)
489
+ const resp = await prompts({
490
+ type: 'select',
491
+ name: 'mode',
492
+ message: 'Deployment mode',
493
+ choices: [
494
+ { title: 'Link mode (Uniweb-edge hosting)', description: 'Data only; worker generates HTML at request time', value: 'link' },
495
+ { title: 'Bundle mode (static-host artifact)', description: 'vite-built; deploy to Netlify, Vercel, GitHub Pages, etc.', value: 'bundle' },
496
+ ],
497
+ initial: 0,
498
+ }, {
499
+ onCancel: () => { console.log(''); console.log('Deploy cancelled.'); process.exit(0) },
500
+ })
501
+ if (!resp.mode) process.exit(0)
502
+ return { mode: resp.mode, source: 'asked' }
259
503
  }
260
504
 
261
505
  // ─── Main ───────────────────────────────────────────────────
@@ -276,6 +520,16 @@ export async function deploy(args = []) {
276
520
  // its `dist/publish.json` receipt is missing/stale. These flags opt out.
277
521
  const autoPublishFoundation = !args.includes('--no-auto-publish')
278
522
  const treatDirtyAsStale = !args.includes('--no-dirty-as-stale')
523
+ // Site mode — `--link` ships data only (Uniweb-edge hosting); `--bundle`
524
+ // ships a vite-built static-host artifact. Mutually exclusive. Resolution
525
+ // ladder runs further below: explicit flag → registry-ref / URL infer →
526
+ // published-local-foundation infer → ask-or-error.
527
+ const linkFlag = args.includes('--link')
528
+ const bundleFlag = args.includes('--bundle')
529
+ if (linkFlag && bundleFlag) {
530
+ say.err('Cannot pass both --link and --bundle.')
531
+ process.exit(1)
532
+ }
279
533
 
280
534
  const siteDir = await resolveSiteDir(args)
281
535
  const backendUrl = getBackendUrl()
@@ -308,6 +562,29 @@ export async function deploy(args = []) {
308
562
  say.dim('Foundation policy: exact (pinned)')
309
563
  }
310
564
 
565
+ // Resolve deploy mode (link vs bundle) BEFORE the foundation
566
+ // staleness check, because mode selection feeds into how we run
567
+ // the site build later. The resolver may prompt the user on first
568
+ // deploy; subsequent deploys infer or use the explicit flag.
569
+ // TODO: when PHP authorize starts returning a persisted mode for
570
+ // this site, reconcile it with the resolved mode here and reject
571
+ // any mismatch ("delete site and redeploy fresh to change modes").
572
+ const { mode: deployMode, source: modeSource } = await resolveDeployMode({
573
+ foundationRef: foundation,
574
+ siteDir,
575
+ linkFlag,
576
+ bundleFlag,
577
+ })
578
+ if (modeSource === 'inferred-registry-ref') {
579
+ say.dim('Deploy mode: link (inferred — foundation is a registry ref)')
580
+ } else if (modeSource === 'inferred-published-local') {
581
+ say.dim('Deploy mode: link (inferred — local foundation has a publish receipt)')
582
+ } else if (modeSource === 'asked') {
583
+ say.dim(`Deploy mode: ${deployMode} (selected)`)
584
+ } else {
585
+ say.dim(`Deploy mode: ${deployMode}`)
586
+ }
587
+
311
588
  // Phase 2: resolve workspace-local `file:` foundation refs.
312
589
  //
313
590
  // The object form of `foundation:` already requires a registry ref
@@ -325,9 +602,17 @@ export async function deploy(args = []) {
325
602
  const localPath = detected.path
326
603
  const relPath = relative(siteDir, localPath) || localPath
327
604
 
605
+ // Pass a RemoteRegistry into the inspector so it can refill a missing
606
+ // `dist/publish.json` from the registry's index (fresh-clone case).
607
+ // No auth needed — `getVersionEntry` reads the public listing.
608
+ const refillRegistry = new RemoteRegistry(workerUrl)
328
609
  const inspection = await inspectLocalFoundationReceipt(localPath, {
329
610
  dirtyAsStale: treatDirtyAsStale,
611
+ registry: refillRegistry,
330
612
  })
613
+ if (inspection.refilled) {
614
+ say.dim(`Foundation receipt at ${relPath} refilled from registry.`)
615
+ }
331
616
 
332
617
  if (inspection.stale && !autoPublishFoundation) {
333
618
  say.err(`Local foundation at ${relPath} is stale: ${inspection.reason}.`)
@@ -338,7 +623,16 @@ export async function deploy(args = []) {
338
623
  say.info(`Foundation at ${relPath} is stale (${inspection.reason}). Auto-publishing…`)
339
624
  console.log('')
340
625
  try {
341
- execSync('npx uniweb publish', { cwd: localPath, stdio: 'inherit' })
626
+ // Spawn the SAME CLI binary that's currently running, not via
627
+ // `npx uniweb` — npx resolves through node_modules and could
628
+ // pick up a stale npm-published version that doesn't share
629
+ // this CLI's behavior (e.g. doesn't recognize new flags).
630
+ // Using `process.argv[1]` keeps the outer/inner CLI version
631
+ // identical, eliminating the skew.
632
+ execSync(`node ${JSON.stringify(process.argv[1])} publish`, {
633
+ cwd: localPath,
634
+ stdio: 'inherit',
635
+ })
342
636
  } catch {
343
637
  say.err(`Auto-publish of foundation at ${relPath} failed. See output above.`)
344
638
  process.exit(1)
@@ -394,10 +688,19 @@ export async function deploy(args = []) {
394
688
  //
395
689
  // For workspace-local foundations (Phase 2 resolution above),
396
690
  // UNIWEB_FOUNDATION_REF tells defineSiteConfig to use the resolved
397
- // registry ref instead of site.yml's literal value, so the build
398
- // produces a runtime-mode bundle pointing at the just-published
399
- // foundation rather than embedding the local source.
400
- execSync('npx uniweb build', {
691
+ // registry ref instead of site.yml's literal value. In bundle mode
692
+ // the build produces a runtime-mode bundle pointing at the just-
693
+ // published foundation rather than embedding the local source.
694
+ // Link mode doesn't run vite at all, so the env var is harmless
695
+ // there but still passed through for consistency.
696
+ //
697
+ // Spawn the SAME CLI binary that's currently running rather than
698
+ // `npx uniweb build` — npx walks node_modules and would resolve to
699
+ // whatever version is installed there (which might be older than
700
+ // the deploy CLI and silently ignore --link). `process.argv[1]`
701
+ // pins the inner build to the outer's exact version.
702
+ const buildModeFlag = deployMode === 'link' ? '--link' : '--bundle'
703
+ execSync(`node ${JSON.stringify(process.argv[1])} build ${buildModeFlag}`, {
401
704
  cwd: siteDir,
402
705
  stdio: 'inherit',
403
706
  env: foundationBuildOverride
@@ -484,6 +787,13 @@ export async function deploy(args = []) {
484
787
  // User-forced review (`uniweb deploy --review`). PHP refuses to
485
788
  // fast-path even when nothing else has drifted.
486
789
  forceReview: forceReview || undefined,
790
+ // Deploy mode for this site. On first deploy PHP should persist
791
+ // it to the site row; on subsequent deploys PHP should return the
792
+ // persisted value back so the CLI can detect mode mismatches and
793
+ // refuse with "delete and redeploy fresh" rather than silently
794
+ // reshape the storage layout. PHP versions that pre-date mode
795
+ // persistence ignore this field — back-compat is built in.
796
+ mode: deployMode,
487
797
  }
488
798
  let authRes
489
799
  try {
@@ -504,6 +814,21 @@ export async function deploy(args = []) {
504
814
  }
505
815
  }
506
816
 
817
+ // Mode lock — once a site has a persisted mode on the server,
818
+ // deploying with a different mode would silently reshape its R2
819
+ // storage layout. Refuse and direct the user to start fresh.
820
+ // Until PHP starts returning `persistedMode`, this check is a
821
+ // no-op (forward-compatible).
822
+ if (authRes.persistedMode && authRes.persistedMode !== deployMode) {
823
+ say.err(`Deploy mode mismatch: this site is configured for ${authRes.persistedMode}, but this deploy resolved to ${deployMode}.`)
824
+ console.log('')
825
+ console.log(` ${c.dim}Mode is locked after the first deploy. To switch:${c.reset}`)
826
+ console.log(` 1. Delete the site (manage.uniweb.app or the dashboard)`)
827
+ console.log(` 2. Remove ${c.cyan}site.id${c.reset} and ${c.cyan}site.handle${c.reset} from site.yml`)
828
+ console.log(` 3. Re-run ${c.cyan}uniweb deploy --${deployMode}${c.reset}`)
829
+ process.exit(1)
830
+ }
831
+
507
832
  if (authRes.needsReview) {
508
833
  const flowLabel = authRes.intent === 'create' ? 'site creation' : 'review'
509
834
  // openBrowser returns a hint about whether a GUI was available. On