uniweb 0.12.2 → 0.12.4

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,
@@ -193,16 +195,35 @@ function composeFoundationUrl(ref, registryBase) {
193
195
  * Inspect a workspace-local foundation's `dist/publish.json` (Phase 1 receipt)
194
196
  * and decide whether it's stale relative to the current source tree.
195
197
  *
196
- * Returns `{ stale, reason, receipt }`. The caller decides whether to
197
- * auto-publish (Phase 2 default) or fail (`--no-auto-publish`).
198
+ * When the receipt file is missing and a `registry` is provided, attempt
199
+ * to refill it from the registry's index entry for `<name>@<version>`.
200
+ * That makes the receipt pure cache: a fresh clone with a matching
201
+ * upstream artifact resolves without an unnecessary republish. If the
202
+ * registry has no record (or the stored entry lacks git provenance), the
203
+ * inspector returns `stale: true` and the caller's auto-publish path
204
+ * runs as before.
205
+ *
206
+ * Returns `{ stale, reason, receipt, refilled? }`. The caller decides
207
+ * whether to auto-publish (Phase 2 default) or fail (`--no-auto-publish`).
198
208
  */
199
- async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale }) {
209
+ async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale, registry }) {
200
210
  const receiptPath = join(localPath, 'dist', 'publish.json')
201
211
  let receipt = null
212
+ let refilled = false
202
213
  try {
203
214
  receipt = JSON.parse(await readFile(receiptPath, 'utf8'))
204
215
  } catch {
205
- return { stale: true, reason: 'no dist/publish.json (foundation has not been published from this checkout)' }
216
+ if (registry) {
217
+ const refill = await tryRefillReceiptFromRegistry({ localPath, registry })
218
+ if (refill) {
219
+ await writeFile(receiptPath, JSON.stringify(refill, null, 2) + '\n')
220
+ receipt = refill
221
+ refilled = true
222
+ }
223
+ }
224
+ if (!receipt) {
225
+ return { stale: true, reason: 'no dist/publish.json (foundation has not been published from this checkout)' }
226
+ }
206
227
  }
207
228
 
208
229
  const { gitSha, gitDirty } = readGitState(localPath)
@@ -219,14 +240,94 @@ async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale }) {
219
240
  if (gitDirty && dirtyAsStale) {
220
241
  return { stale: true, reason: 'foundation working tree is dirty', receipt }
221
242
  }
222
- return { stale: false, receipt }
243
+ return { stale: false, receipt, refilled }
244
+ }
245
+
246
+ /**
247
+ * When a receipt is missing, try to reconstruct it from the registry's
248
+ * own record of the publish. We resolve `name@version` from the same
249
+ * package metadata `deriveLocalFoundationRef` uses, then ask the registry
250
+ * for its stored version entry. The entry's `publishedFromGitSha` is the
251
+ * load-bearing field — without it (entries written before git provenance
252
+ * was added) we can't compare against HEAD, so we don't synthesize a
253
+ * misleading "fresh" receipt and let the caller fall through to the
254
+ * republish path.
255
+ *
256
+ * Returns the receipt body, or null when refill is not possible.
257
+ */
258
+ async function tryRefillReceiptFromRegistry({ localPath, registry }) {
259
+ // Two ways to derive the canonical `<name>@<version>`:
260
+ // 1. `deriveLocalFoundationRef` — works for org-scope foundations
261
+ // (where the namespace is in package.json) and for any foundation
262
+ // where a previous receipt already exists. On the wipe-and-deploy
263
+ // path that fired this code, the receipt is gone, so this only
264
+ // works for org-scope.
265
+ // 2. Empty-scope fallback — if package.json has `uniweb.id` and the
266
+ // user's auth.json carries a `memberUuid` claim, we can synthesize
267
+ // `~<memberUuid>/<id>@<version>` directly. Same shape the server
268
+ // stores under.
269
+ let ref = await deriveLocalFoundationRef(localPath)
270
+ if (!ref) ref = await refFromAuthAndPkg(localPath)
271
+ const split = splitRegistryRef(ref)
272
+ if (!split) return null
273
+ let existingEntry
274
+ try {
275
+ existingEntry = await registry.getVersionEntry(split.name, split.version)
276
+ } catch {
277
+ return null
278
+ }
279
+ if (!existingEntry) return null
280
+ return receiptFromRegistryEntry({
281
+ existingEntry,
282
+ registry,
283
+ name: split.name,
284
+ version: split.version,
285
+ isLocal: false,
286
+ isPropagateDefault: false,
287
+ })
288
+ }
289
+
290
+ /**
291
+ * Last-resort canonical-name derivation for empty-scope foundations.
292
+ * Combines `package.json::uniweb.id` (the foundation's bare name) with
293
+ * the user's `memberUuid` claim from auth.json to produce
294
+ * `~<memberUuid>/<id>@<version>`. Only fires when both inputs are
295
+ * available — otherwise returns null and the caller falls through to
296
+ * the republish path.
297
+ */
298
+ async function refFromAuthAndPkg(localPath) {
299
+ let pkg
300
+ try {
301
+ pkg = JSON.parse(await readFile(join(localPath, 'package.json'), 'utf8'))
302
+ } catch {
303
+ return null
304
+ }
305
+ const id = pkg?.uniweb?.id
306
+ const version = pkg?.version
307
+ if (!id || !version || !/^[a-z0-9_-]+$/.test(id)) return null
308
+ try {
309
+ const auth = await readAuth()
310
+ const claims = decodeJwtPayload(auth?.token)
311
+ if (claims?.memberUuid) return `~${claims.memberUuid}/${id}@${version}`
312
+ } catch { /* no auth — fall through to null */ }
313
+ return null
223
314
  }
224
315
 
225
316
  /**
226
317
  * Read a workspace-local foundation's identity (scoped name + version) from
227
318
  * 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.
319
+ * namespace resolution. Returns the registry ref (`@ns/name@ver` or
320
+ * `~uuid/name@ver`), or null if no shape can be resolved.
321
+ *
322
+ * Resolution order:
323
+ * 1. Org scope from `pkg.uniweb.namespace` or `pkg.name`'s `@org/...` prefix.
324
+ * 2. The receipt at `dist/publish.json`. After a successful publish, the
325
+ * receipt's `url` carries the canonical server-rewritten name —
326
+ * including empty-scope publishes, which the server rewrites to
327
+ * `~<memberUuid>/<name>`. The CLI can't synthesize that locally
328
+ * because the memberUuid lives in the JWT, not in the workspace.
329
+ * 3. null — caller falls through to the helpful "set uniweb.namespace"
330
+ * error message.
230
331
  */
231
332
  async function deriveLocalFoundationRef(localPath) {
232
333
  let pkg
@@ -248,14 +349,134 @@ async function deriveLocalFoundationRef(localPath) {
248
349
  version = version || pkg.version
249
350
  if (!rawName || !version) return null
250
351
 
352
+ // Org-scope path — derived purely from local files.
251
353
  const uniwebNamespace = pkg.uniweb?.namespace
252
354
  const pkgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\//)
253
355
  const selfScopeMatch = rawName.match(/^@([a-z0-9_-]+)\//)
254
356
  const namespace = uniwebNamespace || pkgScopeMatch?.[1] || selfScopeMatch?.[1]
255
- if (!namespace) return null
357
+ if (namespace) {
358
+ const bareName = selfScopeMatch ? rawName.slice(selfScopeMatch[0].length) : rawName
359
+ return `@${namespace}/${bareName}@${version}`
360
+ }
256
361
 
257
- const bareName = selfScopeMatch ? rawName.slice(selfScopeMatch[0].length) : rawName
258
- return `@${namespace}/${bareName}@${version}`
362
+ // Empty-scope path read the canonical name from the receipt's URL.
363
+ // The server rewrites bare names to `~<memberUuid>/<name>`; the URL it
364
+ // returns carries that canonical form (e.g.
365
+ // `/foundations/~<uuid>/<name>@<ver>/foundation.js`). Parse it back.
366
+ const fromReceipt = await refFromReceiptUrl(localPath)
367
+ if (fromReceipt) return fromReceipt
368
+
369
+ return null
370
+ }
371
+
372
+ // Personal-scope namespaces are base58 memberUuids (mixed case; the
373
+ // alphabet is `[A-HJ-NP-Za-km-z1-9]`). Org-scope handles are
374
+ // lowercase-only. Allow both shapes here so the regex captures personal
375
+ // scopes correctly. The bare-name portion (after the `/`) stays
376
+ // lowercase per BARE_NAME_RE.
377
+ const RECEIPT_URL_REF_RE = /\/foundations\/((?:@[a-z0-9_-]+|~[A-Za-z0-9_-]+)\/[a-z0-9_-]+)@([^/]+)\//
378
+
379
+ async function refFromReceiptUrl(localPath) {
380
+ try {
381
+ const receipt = JSON.parse(await readFile(join(localPath, 'dist', 'publish.json'), 'utf8'))
382
+ const m = RECEIPT_URL_REF_RE.exec(receipt?.url || '')
383
+ if (m) return `${m[1]}@${m[2]}`
384
+ } catch {
385
+ // No receipt, malformed JSON, or URL doesn't carry the canonical
386
+ // shape — fall through to null.
387
+ }
388
+ return null
389
+ }
390
+
391
+ /**
392
+ * Resolve the deploy mode for this site.
393
+ *
394
+ * Returns `'link'` or `'bundle'`, optionally prompting the user when
395
+ * neither inference nor an explicit flag yields an answer. Mode choice
396
+ * is persisted server-side after the first successful deploy; switching
397
+ * modes later requires deleting the site and redeploying fresh.
398
+ *
399
+ * Ladder (first match wins):
400
+ * 1. `--link` or `--bundle` flag passed explicitly.
401
+ * 2. `site.yml::foundation` is a registry ref (`@org/x@ver` or
402
+ * `~uuid/x@ver`) or full HTTPS URL → infer link. The user already
403
+ * declared "load this foundation by URL at runtime."
404
+ * 3. `site.yml::foundation` is a workspace-local sibling AND that
405
+ * foundation has a publish receipt (`dist/publish.json` with a
406
+ * registry URL) → infer link. The user has already done the work
407
+ * to make the foundation loadable from the registry.
408
+ * 4. Otherwise → ask (TTY prompt) or error (CI).
409
+ *
410
+ * @param {Object} ctx
411
+ * @param {string} ctx.foundationRef - the raw value of site.yml::foundation (string or normalized object).
412
+ * @param {string} ctx.siteDir - absolute path to the site dir (for resolving local foundation siblings).
413
+ * @param {boolean} ctx.linkFlag
414
+ * @param {boolean} ctx.bundleFlag
415
+ * @returns {Promise<{ mode: 'link'|'bundle', source: 'flag'|'inferred-registry-ref'|'inferred-published-local'|'asked' }>}
416
+ */
417
+ async function resolveDeployMode({ foundationRef, siteDir, linkFlag, bundleFlag }) {
418
+ // 1. Explicit flag wins.
419
+ if (linkFlag) return { mode: 'link', source: 'flag' }
420
+ if (bundleFlag) return { mode: 'bundle', source: 'flag' }
421
+
422
+ // 2. Registry ref or URL in site.yml → link mode is the only sensible choice.
423
+ if (typeof foundationRef === 'string') {
424
+ if (foundationRef.startsWith('http://') || foundationRef.startsWith('https://')) {
425
+ return { mode: 'link', source: 'inferred-registry-ref' }
426
+ }
427
+ if (/^@[a-z0-9_-]+\/[a-z0-9_-]+@/.test(foundationRef)) {
428
+ return { mode: 'link', source: 'inferred-registry-ref' }
429
+ }
430
+ if (/^~[A-Za-z0-9_-]+\/[a-z0-9_-]+@/.test(foundationRef)) {
431
+ return { mode: 'link', source: 'inferred-registry-ref' }
432
+ }
433
+ }
434
+
435
+ // 3. Workspace-local foundation with an existing publish receipt → infer link.
436
+ // The receipt being there means the user has already published this
437
+ // foundation at least once, so they've shown intent to ship via the
438
+ // registry. We don't validate the receipt's freshness here — that's
439
+ // the existing inspectLocalFoundationReceipt path's job, which runs
440
+ // later in the deploy flow.
441
+ if (typeof foundationRef === 'string') {
442
+ const detected = detectFoundationType(foundationRef, siteDir)
443
+ if (detected.type === 'local') {
444
+ const receiptPath = join(detected.path, 'dist', 'publish.json')
445
+ if (existsSync(receiptPath)) {
446
+ return { mode: 'link', source: 'inferred-published-local' }
447
+ }
448
+ }
449
+ }
450
+
451
+ // 4. Ambiguous — local foundation never published, or unknown shape. Ask.
452
+ if (isNonInteractive(process.argv)) {
453
+ say.err('First deploy of this site needs an explicit mode.')
454
+ console.log('')
455
+ console.log(' Pick one and re-run:')
456
+ console.log(` ${c.cyan}uniweb deploy --link${c.reset} ${c.dim}Uniweb-edge hosting (data only; worker generates HTML)${c.reset}`)
457
+ console.log(` ${c.cyan}uniweb deploy --bundle${c.reset} ${c.dim}Static-host artifact (vite build; for non-Uniweb hosts)${c.reset}`)
458
+ console.log('')
459
+ console.log(` ${c.dim}Mode is persisted after the first deploy and can't be changed in place.${c.reset}`)
460
+ process.exit(1)
461
+ }
462
+
463
+ const prompts = (await import('prompts')).default
464
+ console.log('')
465
+ console.log(`${c.dim}First deploy of this site. Pick a deployment mode:${c.reset}`)
466
+ const resp = await prompts({
467
+ type: 'select',
468
+ name: 'mode',
469
+ message: 'Deployment mode',
470
+ choices: [
471
+ { title: 'Link mode (Uniweb-edge hosting)', description: 'Data only; worker generates HTML at request time', value: 'link' },
472
+ { title: 'Bundle mode (static-host artifact)', description: 'vite-built; deploy to Netlify, Vercel, GitHub Pages, etc.', value: 'bundle' },
473
+ ],
474
+ initial: 0,
475
+ }, {
476
+ onCancel: () => { console.log(''); console.log('Deploy cancelled.'); process.exit(0) },
477
+ })
478
+ if (!resp.mode) process.exit(0)
479
+ return { mode: resp.mode, source: 'asked' }
259
480
  }
260
481
 
261
482
  // ─── Main ───────────────────────────────────────────────────
@@ -276,6 +497,16 @@ export async function deploy(args = []) {
276
497
  // its `dist/publish.json` receipt is missing/stale. These flags opt out.
277
498
  const autoPublishFoundation = !args.includes('--no-auto-publish')
278
499
  const treatDirtyAsStale = !args.includes('--no-dirty-as-stale')
500
+ // Site mode — `--link` ships data only (Uniweb-edge hosting); `--bundle`
501
+ // ships a vite-built static-host artifact. Mutually exclusive. Resolution
502
+ // ladder runs further below: explicit flag → registry-ref / URL infer →
503
+ // published-local-foundation infer → ask-or-error.
504
+ const linkFlag = args.includes('--link')
505
+ const bundleFlag = args.includes('--bundle')
506
+ if (linkFlag && bundleFlag) {
507
+ say.err('Cannot pass both --link and --bundle.')
508
+ process.exit(1)
509
+ }
279
510
 
280
511
  const siteDir = await resolveSiteDir(args)
281
512
  const backendUrl = getBackendUrl()
@@ -308,6 +539,29 @@ export async function deploy(args = []) {
308
539
  say.dim('Foundation policy: exact (pinned)')
309
540
  }
310
541
 
542
+ // Resolve deploy mode (link vs bundle) BEFORE the foundation
543
+ // staleness check, because mode selection feeds into how we run
544
+ // the site build later. The resolver may prompt the user on first
545
+ // deploy; subsequent deploys infer or use the explicit flag.
546
+ // TODO: when PHP authorize starts returning a persisted mode for
547
+ // this site, reconcile it with the resolved mode here and reject
548
+ // any mismatch ("delete site and redeploy fresh to change modes").
549
+ const { mode: deployMode, source: modeSource } = await resolveDeployMode({
550
+ foundationRef: foundation,
551
+ siteDir,
552
+ linkFlag,
553
+ bundleFlag,
554
+ })
555
+ if (modeSource === 'inferred-registry-ref') {
556
+ say.dim('Deploy mode: link (inferred — foundation is a registry ref)')
557
+ } else if (modeSource === 'inferred-published-local') {
558
+ say.dim('Deploy mode: link (inferred — local foundation has a publish receipt)')
559
+ } else if (modeSource === 'asked') {
560
+ say.dim(`Deploy mode: ${deployMode} (selected)`)
561
+ } else {
562
+ say.dim(`Deploy mode: ${deployMode}`)
563
+ }
564
+
311
565
  // Phase 2: resolve workspace-local `file:` foundation refs.
312
566
  //
313
567
  // The object form of `foundation:` already requires a registry ref
@@ -325,9 +579,17 @@ export async function deploy(args = []) {
325
579
  const localPath = detected.path
326
580
  const relPath = relative(siteDir, localPath) || localPath
327
581
 
582
+ // Pass a RemoteRegistry into the inspector so it can refill a missing
583
+ // `dist/publish.json` from the registry's index (fresh-clone case).
584
+ // No auth needed — `getVersionEntry` reads the public listing.
585
+ const refillRegistry = new RemoteRegistry(workerUrl)
328
586
  const inspection = await inspectLocalFoundationReceipt(localPath, {
329
587
  dirtyAsStale: treatDirtyAsStale,
588
+ registry: refillRegistry,
330
589
  })
590
+ if (inspection.refilled) {
591
+ say.dim(`Foundation receipt at ${relPath} refilled from registry.`)
592
+ }
331
593
 
332
594
  if (inspection.stale && !autoPublishFoundation) {
333
595
  say.err(`Local foundation at ${relPath} is stale: ${inspection.reason}.`)
@@ -338,7 +600,16 @@ export async function deploy(args = []) {
338
600
  say.info(`Foundation at ${relPath} is stale (${inspection.reason}). Auto-publishing…`)
339
601
  console.log('')
340
602
  try {
341
- execSync('npx uniweb publish', { cwd: localPath, stdio: 'inherit' })
603
+ // Spawn the SAME CLI binary that's currently running, not via
604
+ // `npx uniweb` — npx resolves through node_modules and could
605
+ // pick up a stale npm-published version that doesn't share
606
+ // this CLI's behavior (e.g. doesn't recognize new flags).
607
+ // Using `process.argv[1]` keeps the outer/inner CLI version
608
+ // identical, eliminating the skew.
609
+ execSync(`node ${JSON.stringify(process.argv[1])} publish`, {
610
+ cwd: localPath,
611
+ stdio: 'inherit',
612
+ })
342
613
  } catch {
343
614
  say.err(`Auto-publish of foundation at ${relPath} failed. See output above.`)
344
615
  process.exit(1)
@@ -394,10 +665,19 @@ export async function deploy(args = []) {
394
665
  //
395
666
  // For workspace-local foundations (Phase 2 resolution above),
396
667
  // 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', {
668
+ // registry ref instead of site.yml's literal value. In bundle mode
669
+ // the build produces a runtime-mode bundle pointing at the just-
670
+ // published foundation rather than embedding the local source.
671
+ // Link mode doesn't run vite at all, so the env var is harmless
672
+ // there but still passed through for consistency.
673
+ //
674
+ // Spawn the SAME CLI binary that's currently running rather than
675
+ // `npx uniweb build` — npx walks node_modules and would resolve to
676
+ // whatever version is installed there (which might be older than
677
+ // the deploy CLI and silently ignore --link). `process.argv[1]`
678
+ // pins the inner build to the outer's exact version.
679
+ const buildModeFlag = deployMode === 'link' ? '--link' : '--bundle'
680
+ execSync(`node ${JSON.stringify(process.argv[1])} build ${buildModeFlag}`, {
401
681
  cwd: siteDir,
402
682
  stdio: 'inherit',
403
683
  env: foundationBuildOverride
@@ -484,6 +764,13 @@ export async function deploy(args = []) {
484
764
  // User-forced review (`uniweb deploy --review`). PHP refuses to
485
765
  // fast-path even when nothing else has drifted.
486
766
  forceReview: forceReview || undefined,
767
+ // Deploy mode for this site. On first deploy PHP should persist
768
+ // it to the site row; on subsequent deploys PHP should return the
769
+ // persisted value back so the CLI can detect mode mismatches and
770
+ // refuse with "delete and redeploy fresh" rather than silently
771
+ // reshape the storage layout. PHP versions that pre-date mode
772
+ // persistence ignore this field — back-compat is built in.
773
+ mode: deployMode,
487
774
  }
488
775
  let authRes
489
776
  try {
@@ -504,6 +791,21 @@ export async function deploy(args = []) {
504
791
  }
505
792
  }
506
793
 
794
+ // Mode lock — once a site has a persisted mode on the server,
795
+ // deploying with a different mode would silently reshape its R2
796
+ // storage layout. Refuse and direct the user to start fresh.
797
+ // Until PHP starts returning `persistedMode`, this check is a
798
+ // no-op (forward-compatible).
799
+ if (authRes.persistedMode && authRes.persistedMode !== deployMode) {
800
+ say.err(`Deploy mode mismatch: this site is configured for ${authRes.persistedMode}, but this deploy resolved to ${deployMode}.`)
801
+ console.log('')
802
+ console.log(` ${c.dim}Mode is locked after the first deploy. To switch:${c.reset}`)
803
+ console.log(` 1. Delete the site (manage.uniweb.app or the dashboard)`)
804
+ console.log(` 2. Remove ${c.cyan}site.id${c.reset} and ${c.cyan}site.handle${c.reset} from site.yml`)
805
+ console.log(` 3. Re-run ${c.cyan}uniweb deploy --${deployMode}${c.reset}`)
806
+ process.exit(1)
807
+ }
808
+
507
809
  if (authRes.needsReview) {
508
810
  const flowLabel = authRes.intent === 'create' ? 'site creation' : 'review'
509
811
  // openBrowser returns a hint about whether a GUI was available. On