uniweb 0.12.7 → 0.12.8

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.
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Export Command
3
+ *
4
+ * Produces a self-contained, vite-built site artifact in `dist/` for
5
+ * hosting on a third-party CDN (Netlify, Vercel, GitHub Pages, S3 +
6
+ * CloudFront, etc.). Does NOT upload anywhere — that's `uniweb deploy`.
7
+ *
8
+ * The `dist/` output bundles the runtime + foundation + content into
9
+ * concatenated packaging, with a vite-built `index.html` + `entry.js` +
10
+ * `assets/`. The user copies it to whatever host they like.
11
+ *
12
+ * Internally this is `uniweb build --bundle` plus user guidance for the
13
+ * upload step. The `--link` / `--bundle` flag pair is internal-only
14
+ * vocabulary now (Phase 2 of the CLI ergonomics overhaul); users see
15
+ * `uniweb deploy` (uniweb-edge) and `uniweb export` (third-party host).
16
+ *
17
+ * Usage:
18
+ * uniweb export Produce dist/ for static hosting
19
+ * uniweb export --no-prerender Skip per-page prerendered HTML
20
+ */
21
+
22
+ import { execSync } from 'node:child_process'
23
+ import { existsSync } from 'node:fs'
24
+ import { join } from 'node:path'
25
+
26
+ import { resolveSiteDir } from './deploy.js'
27
+
28
+ const c = {
29
+ reset: '\x1b[0m',
30
+ dim: '\x1b[2m',
31
+ cyan: '\x1b[36m',
32
+ green: '\x1b[32m',
33
+ red: '\x1b[31m',
34
+ }
35
+ const say = {
36
+ ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
37
+ info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
38
+ err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
39
+ dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
40
+ }
41
+
42
+ export async function exportSite(args = []) {
43
+ const siteDir = await resolveSiteDir(args, 'export')
44
+
45
+ // Pass through --no-prerender; everything else gets ignored. `uniweb
46
+ // export` is intentionally low-flag: the user picks the destination
47
+ // host themselves outside the CLI, so there's nothing to configure
48
+ // beyond what `uniweb build --bundle` already exposes.
49
+ const noPrerender = args.includes('--no-prerender')
50
+ const buildArgs = ['build', '--bundle']
51
+ if (noPrerender) buildArgs.push('--no-prerender')
52
+
53
+ say.info('Exporting site (vite build → dist/)…')
54
+ console.log('')
55
+
56
+ // Spawn the SAME CLI binary (process.argv[1]) — same reason as deploy.js:
57
+ // npx walks node_modules and could resolve to a different version.
58
+ try {
59
+ execSync(`node ${JSON.stringify(process.argv[1])} ${buildArgs.join(' ')}`, {
60
+ cwd: siteDir,
61
+ stdio: 'inherit',
62
+ })
63
+ } catch {
64
+ say.err('Build failed. See output above.')
65
+ process.exit(1)
66
+ }
67
+
68
+ const distDir = join(siteDir, 'dist')
69
+ if (!existsSync(distDir)) {
70
+ say.err('Build did not produce dist/.')
71
+ process.exit(1)
72
+ }
73
+
74
+ console.log('')
75
+ say.ok('Export complete.')
76
+ console.log('')
77
+ console.log(` ${c.dim}Artifact:${c.reset} ${c.cyan}${distDir}${c.reset}`)
78
+ console.log('')
79
+ console.log(` ${c.dim}Upload the contents of ${c.reset}${c.cyan}dist/${c.reset}${c.dim} to your static host. Examples:${c.reset}`)
80
+ console.log(` ${c.dim}Netlify:${c.reset} ${c.cyan}netlify deploy --prod --dir=dist${c.reset}`)
81
+ console.log(` ${c.dim}Vercel:${c.reset} ${c.cyan}vercel --prod${c.reset}`)
82
+ console.log(` ${c.dim}S3:${c.reset} ${c.cyan}aws s3 sync dist/ s3://your-bucket/${c.reset}`)
83
+ console.log('')
84
+ console.log(` ${c.dim}For Uniweb-hosted sites instead, use ${c.reset}${c.cyan}uniweb deploy${c.reset}${c.dim}.${c.reset}`)
85
+ }
@@ -1,14 +1,36 @@
1
1
  /**
2
2
  * Publish Command
3
3
  *
4
- * Publishes a foundation to the Uniweb Registry.
4
+ * Publishes a foundation to the Uniweb Registry as a CATALOG product —
5
+ * a deliberate, named, versioned artifact that other developers may
6
+ * consume across many sites.
7
+ *
8
+ * For SITE-BOUND foundations (one foundation, one site), use
9
+ * `uniweb deploy` instead. The deploy command auto-publishes a
10
+ * workspace-local foundation as part of the deploy under a registry
11
+ * slot scoped to the site, with no naming ceremony. That's the right
12
+ * flow for the "this foundation only powers this one site" case.
13
+ *
14
+ * Phase 3 of the CLI ergonomics overhaul reshaped this command around
15
+ * the catalog/site-bound distinction:
16
+ *
17
+ * - Bare `uniweb publish` (no explicit name) is no longer accepted.
18
+ * The user must provide a deliberate name via --name, --namespace,
19
+ * a sigil-scoped package.json::name, or package.json::uniweb.id.
20
+ * - Catalog confirmation is required: interactive runs prompt; CI
21
+ * runs need --catalog to skip the prompt.
22
+ * - Both gates are skipped for --local (local mock, no public
23
+ * consequences).
5
24
  *
6
25
  * Usage:
7
- * uniweb publish # Publish to remote registry
8
- * uniweb publish --local # Publish to local registry (.unicloud/)
9
- * uniweb publish --registry <url> # Publish to a specific registry URL
26
+ * uniweb publish @org/my-foundation # Catalog publish (interactive prompt confirms)
27
+ * uniweb publish --name my-foundation # Same; flag form
28
+ * uniweb publish @org/x --catalog # Skip the catalog confirmation prompt
29
+ * uniweb publish --local # Local registry (.unicloud/) — no gates
30
+ * uniweb publish --registry <url> # Specific registry URL
10
31
  * uniweb publish --edit-access open # Anyone can edit in Studio (default: restricted)
11
- * uniweb publish --dry-run # Show what would be published
32
+ * uniweb publish --dry-run # Show what would be published; no writes
33
+ * uniweb publish --propagate # Walk trusting sites' policy waves
12
34
  */
13
35
 
14
36
  import { existsSync } from 'node:fs'
@@ -18,11 +40,10 @@ import { execSync } from 'node:child_process'
18
40
 
19
41
  import { resolveFoundationSrcPath, classifyPackage } from '@uniweb/build'
20
42
  import { createLocalRegistry, RemoteRegistry } from '../utils/registry.js'
21
- import { ensureAuth, readAuth, decodeJwtPayload } from '../utils/auth.js'
22
- import { getRegistryUrl } from '../utils/config.js'
43
+ import { ensureAuth, readAuth, writeAuth, decodeJwtPayload } from '../utils/auth.js'
44
+ import { getRegistryUrl, getBackendUrl } from '../utils/config.js'
23
45
  import { findWorkspaceRoot, findFoundations, findSites, promptSelect } from '../utils/workspace.js'
24
46
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
25
- import { composeReceipt, deriveReceiptUrl, receiptFromRegistryEntry } from '../utils/receipt.js'
26
47
 
27
48
  // Colors for terminal output
28
49
  const colors = {
@@ -173,6 +194,12 @@ export async function publish(args = []) {
173
194
  // 'silent'), the artifact is stored but no site moves until republish
174
195
  // or manual refresh.
175
196
  const isPropagate = args.includes('--propagate')
197
+ // --catalog confirms the user understands they're publishing to the
198
+ // public catalog. Phase 3 of the CLI ergonomics overhaul: in
199
+ // interactive mode, missing --catalog triggers a confirmation prompt;
200
+ // in non-interactive mode, it's required (otherwise fatal). Skipped
201
+ // entirely for --local (local mock) and --dry-run (no writes).
202
+ const isCatalog = args.includes('--catalog')
176
203
  const registryUrl = parseRegistryUrl(args)
177
204
  const editAccess = parseEditAccess(args)
178
205
  const namespaceFlag = parseNamespace(args)
@@ -212,6 +239,102 @@ export async function publish(args = []) {
212
239
  process.exit(1)
213
240
  }
214
241
 
242
+ // 1b. Phase 4e: catalog-publish gate.
243
+ //
244
+ // `uniweb publish` is for cataloging a foundation as a product —
245
+ // deliberate `@org/{name}` name, version-pinnable, discoverable.
246
+ // Site-bound foundations go through `uniweb deploy` instead, which
247
+ // uploads them to `sites/{siteId}/_src/...` automatically.
248
+ //
249
+ // The gate rejects two shapes:
250
+ // (a) No explicit name at all — running `uniweb publish` from a
251
+ // fresh scaffold would otherwise register `src` (or whatever
252
+ // the workspace name is) as a catalog entry.
253
+ // (b) `~user/...` or personal-UUID scopes — Phase 4e retired the
254
+ // personal scope; site-bound foundations use deploy, catalog
255
+ // uses `@org/`. There is no "personal catalog" any more.
256
+ //
257
+ // `--local` skips the gate (local mock registry, no public consequences).
258
+ const hasExplicitName = !!(
259
+ nameFlag ||
260
+ namespaceFlag ||
261
+ /^[@~]/.test(earlyPkg.name || '') ||
262
+ earlyPkg.uniweb?.id ||
263
+ earlyPkg.uniweb?.namespace
264
+ )
265
+ if (!hasExplicitName && !isLocal) {
266
+ error('uniweb publish needs a deliberate foundation name.')
267
+ console.log('')
268
+ console.log(` ${colors.bright}If this foundation only powers one site, use ${colors.cyan}uniweb deploy${colors.reset}${colors.bright} instead.${colors.reset}`)
269
+ console.log(` ${colors.dim}Deploy uploads your foundation alongside the site's assets — no name ceremony.${colors.reset}`)
270
+ console.log('')
271
+ console.log(` ${colors.bright}If you're cataloging this foundation as a product, name it explicitly:${colors.reset}`)
272
+ console.log(` ${colors.cyan}uniweb publish @your-org/foundation-name${colors.reset}`)
273
+ console.log('')
274
+ console.log(` ${colors.dim}For local development, ${colors.reset}${colors.cyan}--local${colors.reset}${colors.dim} skips this gate.${colors.reset}`)
275
+ process.exit(1)
276
+ }
277
+
278
+ // 1b'. Phase 4e: reject `~`-scoped names. Site-bound foundations don't
279
+ // go through publish at all.
280
+ if (!isLocal) {
281
+ const candidateName = nameFlag || earlyPkg.name || earlyPkg.uniweb?.id || ''
282
+ const candidateNamespace = namespaceFlag || earlyPkg.uniweb?.namespace || ''
283
+ if (candidateName.startsWith('~') || candidateNamespace.startsWith('~')) {
284
+ error('uniweb publish is for cataloged foundations only.')
285
+ console.log('')
286
+ console.log(` ${colors.dim}The personal-UUID scope (${colors.reset}~uuid/name${colors.dim}) is no longer accepted.${colors.reset}`)
287
+ console.log(` ${colors.dim}Site-bound foundations are uploaded automatically by ${colors.reset}${colors.cyan}uniweb deploy${colors.reset}${colors.dim} — they live with site assets, not in the catalog.${colors.reset}`)
288
+ console.log('')
289
+ console.log(` ${colors.bright}For a catalog product, use an org scope:${colors.reset}`)
290
+ console.log(` ${colors.cyan}uniweb publish @your-org/foundation-name${colors.reset}`)
291
+ console.log('')
292
+ console.log(` ${colors.dim}No org yet? The CLI will offer to claim one for you the first time you publish to a handle you don't own.${colors.reset}`)
293
+ process.exit(1)
294
+ }
295
+ }
296
+
297
+ // 1c. Phase 3 catalog confirmation gate.
298
+ //
299
+ // Cataloging a foundation has consequences (visible in the catalog,
300
+ // other developers may pin to versions, propagation system tracks
301
+ // it). Require explicit confirmation:
302
+ // - Interactive: prompt unless --catalog passed.
303
+ // - Non-interactive: fatal unless --catalog passed.
304
+ // - Skipped for --local and --dry-run (no public consequences).
305
+ if (hasExplicitName && !isLocal && !isDryRun && !isCatalog) {
306
+ if (isNonInteractive(process.argv)) {
307
+ error('uniweb publish to the catalog needs --catalog confirmation.')
308
+ console.log('')
309
+ console.log(` ${colors.dim}Catalog publishes are public — other developers can pin to your versions.${colors.reset}`)
310
+ console.log(` ${colors.dim}Pass ${colors.reset}${colors.cyan}--catalog${colors.reset}${colors.dim} to confirm:${colors.reset}`)
311
+ console.log(` ${colors.cyan}uniweb publish ${colors.reset}${colors.dim}<args>${colors.reset} ${colors.cyan}--catalog${colors.reset}`)
312
+ console.log('')
313
+ console.log(` ${colors.dim}For site-bound foundations, use ${colors.reset}${colors.cyan}uniweb deploy${colors.reset}${colors.dim} instead.${colors.reset}`)
314
+ process.exit(1)
315
+ }
316
+
317
+ const prompts = (await import('prompts')).default
318
+ console.log('')
319
+ console.log(`${colors.dim}You're publishing this foundation to the public catalog.${colors.reset}`)
320
+ console.log(`${colors.dim}Other developers will be able to find it and pin to its versions.${colors.reset}`)
321
+ console.log(`${colors.dim}For site-bound foundations, ${colors.reset}${colors.cyan}uniweb deploy${colors.reset}${colors.dim} is the right command.${colors.reset}`)
322
+ console.log('')
323
+ const confirm = await prompts({
324
+ type: 'confirm',
325
+ name: 'go',
326
+ message: 'Continue with catalog publish?',
327
+ initial: false,
328
+ }, {
329
+ onCancel: () => { console.log(''); console.log('Publish cancelled.'); process.exit(0) },
330
+ })
331
+ if (!confirm.go) {
332
+ console.log('')
333
+ console.log(`${colors.dim}Cancelled. Use ${colors.reset}${colors.cyan}uniweb deploy${colors.reset}${colors.dim} for site-bound foundations.${colors.reset}`)
334
+ process.exit(0)
335
+ }
336
+ }
337
+
215
338
  let needsBuild = !existsSync(foundationJs) || !existsSync(schemaJson)
216
339
  let buildReason = needsBuild ? 'no dist/ found' : null
217
340
 
@@ -229,6 +352,25 @@ export async function publish(args = []) {
229
352
  }
230
353
  }
231
354
 
355
+ // --dry-run gate. Must come BEFORE the pre-flight registry check (which
356
+ // may persist `uniweb.id` to package.json on the matching-sha path) and
357
+ // BEFORE the build (which writes to dist/). Earlier the dry-run check
358
+ // sat after both, which violated the zero-writes contract.
359
+ if (isDryRun) {
360
+ const previewName = quickResolveCanonicalName(earlyPkg, { namespaceFlag, nameFlag })
361
+ || earlyPkg.name
362
+ || '(unresolved)'
363
+ const target = isLocal ? 'local registry' : `remote registry (${registryUrl || getRegistryUrl()})`
364
+ console.log('')
365
+ info(`Would publish ${colors.bright}${previewName}@${earlyPkg.version}${colors.reset} to ${target}`)
366
+ if (needsBuild) {
367
+ console.log(` ${colors.dim}Would build first: ${buildReason}${colors.reset}`)
368
+ } else {
369
+ console.log(` ${colors.dim}Source: ${distDir}${colors.reset}`)
370
+ }
371
+ return
372
+ }
373
+
232
374
  // 2b. Pre-flight registry check — runs BEFORE the build so we don't
233
375
  // burn vite cycles on a foundation we already know we can't (or
234
376
  // don't need to) publish.
@@ -271,27 +413,13 @@ export async function publish(args = []) {
271
413
  if (existing) {
272
414
  const { gitSha } = readGitState(foundationDir)
273
415
  if (gitSha && existing.publishedFromGitSha === gitSha) {
274
- // Match refresh receipt, exit clean. NO BUILD.
275
- const refreshed = receiptFromRegistryEntry({
276
- existingEntry: existing,
277
- registry: registryPre,
278
- name: lookupName,
279
- version: preflightVersion,
280
- isLocal: false,
281
- isPropagateDefault: isPropagate,
282
- })
283
- if (refreshed) {
284
- await mkdir(distDir, { recursive: true })
285
- await writeFile(join(distDir, 'publish.json'), JSON.stringify(refreshed, null, 2) + '\n')
286
- console.log('')
287
- success(`${colors.bright}${lookupName}@${preflightVersion}${colors.reset} already published from ${gitSha.slice(0, 7)} — receipt refreshed.`)
288
- return
289
- }
416
+ // Already published from this exact source nothing to do.
417
+ console.log('')
418
+ success(`${colors.bright}${lookupName}@${preflightVersion}${colors.reset} already published from ${gitSha.slice(0, 7)}.`)
419
+ return
290
420
  }
291
421
  // Sha mismatch (or no provenance recorded for the existing
292
- // entry, which shouldn't happen for new publishes after
293
- // the receipt-as-cache work shipped). Clean error before
294
- // any build work.
422
+ // entry). Clean error before any build work.
295
423
  console.log('')
296
424
  error(`Foundation source has changed since the last publish, but ${colors.bright}${lookupName}@${preflightVersion}${colors.reset} is already published.`)
297
425
  console.log('')
@@ -574,35 +702,88 @@ export async function publish(args = []) {
574
702
  name = foundationName
575
703
  }
576
704
 
577
- // 3c. Advisory scope authorization (Worker enforces — this is for early UX feedback)
578
- if (!isLocal) {
705
+ // 3c. Phase 4f: org-claim flow.
706
+ //
707
+ // `uniweb publish @handle/foo` against a handle the user doesn't own
708
+ // yet drops into the org-claim flow instead of failing. Three cases:
709
+ // (a) JWT has no `namespaces` claim at all → token predates org
710
+ // support; tell the user to `uniweb login` again.
711
+ // (b) Handle is already in `namespaces` → proceed.
712
+ // (c) Handle is NOT in `namespaces` → call POST /api/orgs/{handle}.
713
+ // Confirm-and-claim if available; hard-fail if taken; refresh
714
+ // the cached token on success and proceed with publish.
715
+ //
716
+ // Skipped for `--local` (no auth, no org system).
717
+ const claimOrgFlag = args.includes('--claim-org')
718
+ if (!isLocal && scopeSigil === '@') {
579
719
  const auth = await readAuth()
580
- if (scopeSigil === '@') {
581
- // Org scope: must be in the user's namespaces[] claim.
582
- const namespaces = auth?.namespaces
583
- if (Array.isArray(namespaces) && !namespaces.includes(scopeName)) {
584
- error(`You don't have publish access to namespace "${colors.bright}@${scopeName}${colors.reset}"`)
585
- if (namespaces.length > 0) {
586
- console.log(` ${colors.dim}Your organizations: ${namespaces.map(n => '@' + n).join(', ')}${colors.reset}`)
587
- console.log(` ${colors.dim}Or remove the scope from package.json::name to publish under your personal scope.${colors.reset}`)
588
- } else {
589
- console.log(` ${colors.dim}You don't belong to any organizations.${colors.reset}`)
590
- console.log(` ${colors.dim}Use a bare name in package.json (e.g. "src") to publish under your personal scope.${colors.reset}`)
591
- }
720
+ if (!Array.isArray(auth?.namespaces)) {
721
+ // Old token, predates org support.
722
+ error('Your authentication token doesn\'t carry organization claims.')
723
+ console.log('')
724
+ console.log(` ${colors.dim}Run ${colors.reset}${colors.cyan}uniweb login${colors.reset}${colors.dim} to refresh your session, then retry.${colors.reset}`)
725
+ process.exit(1)
726
+ }
727
+ if (!auth.namespaces.includes(scopeName)) {
728
+ // Need to claim. Confirm interactively unless --claim-org was passed.
729
+ if (isNonInteractive(process.argv) && !claimOrgFlag) {
730
+ error(`You don't own ${colors.bright}@${scopeName}${colors.reset} yet.`)
731
+ console.log('')
732
+ console.log(` ${colors.dim}In CI, pass ${colors.reset}${colors.cyan}--claim-org${colors.reset}${colors.dim} to claim available handles automatically.${colors.reset}`)
733
+ console.log(` ${colors.dim}Interactive mode prompts for confirmation.${colors.reset}`)
592
734
  process.exit(1)
593
735
  }
594
- } else if (scopeSigil === '~') {
595
- // Personal alias scope: must match the user's loginName claim
596
- // (until handle-aliasing ships, this is loginName-only).
597
- if (auth?.loginName && auth.loginName !== scopeName) {
598
- error(`Personal scope "${colors.bright}~${scopeName}${colors.reset}" doesn't match your account`)
599
- console.log(` ${colors.dim}Your personal scope: ~${auth.loginName}${colors.reset}`)
600
- console.log(` ${colors.dim}Or remove the scope from package.json::name to publish under your personal scope.${colors.reset}`)
736
+
737
+ if (!claimOrgFlag) {
738
+ const prompts = (await import('prompts')).default
739
+ console.log('')
740
+ console.log(`${colors.dim}You don't own ${colors.reset}${colors.bright}@${scopeName}${colors.reset}${colors.dim} yet.${colors.reset}`)
741
+ console.log(`${colors.dim}Org handles are global and permanent — only the claiming account can publish under them.${colors.reset}`)
742
+ console.log('')
743
+ const confirm = await prompts({
744
+ type: 'confirm',
745
+ name: 'go',
746
+ message: `Claim @${scopeName} for your account?`,
747
+ initial: false,
748
+ }, {
749
+ onCancel: () => { console.log(''); console.log('Publish cancelled.'); process.exit(0) },
750
+ })
751
+ if (!confirm.go) {
752
+ console.log('')
753
+ console.log(`${colors.dim}Cancelled. Publish under a handle you already own, or pick a different one.${colors.reset}`)
754
+ process.exit(0)
755
+ }
756
+ }
757
+
758
+ // Org claim hits the PHP backend (auth/identity is PHP's domain),
759
+ // not the worker. In local dev unicloud serves both on one port, so
760
+ // tests work; in production these are different hosts.
761
+ const claimed = await claimOrgHandle({
762
+ handle: scopeName,
763
+ token: auth.token,
764
+ backendUrl: getBackendUrl(),
765
+ })
766
+ if (claimed.taken) {
767
+ error(`@${scopeName} is already claimed by another account.`)
768
+ console.log('')
769
+ console.log(` ${colors.dim}Pick a different handle. Org names are global and exclusive.${colors.reset}`)
601
770
  process.exit(1)
602
771
  }
772
+ // Swap the cached token for the refreshed one (now carries the new
773
+ // namespace claim). Subsequent publish calls in this run see it via
774
+ // a fresh `readAuth()` and the worker accepts the upload.
775
+ await writeAuth({
776
+ token: claimed.token,
777
+ email: auth.email,
778
+ expiresAt: auth.expiresAt,
779
+ })
780
+ if (claimed.created) {
781
+ success(`Claimed ${colors.bright}@${scopeName}${colors.reset} for your account.`)
782
+ } else {
783
+ info(`Refreshed your token; ${colors.bright}@${scopeName}${colors.reset} is yours.`)
784
+ }
785
+ console.log('')
603
786
  }
604
- // Empty-scope: no client-side check. The server resolves to the
605
- // memberId from the JWT (sub claim) and writes ownership accordingly.
606
787
  }
607
788
 
608
789
  // 4. Create registry (local or remote)
@@ -649,41 +830,28 @@ export async function publish(args = []) {
649
830
 
650
831
  // 5. Check for duplicates. If the registry already has this exact
651
832
  // version recorded as published from the current commit, treat it
652
- // as a fresh-checkout no-op: refresh the local receipt and exit
653
- // successfully. The artifact upstream is already correct; there's
654
- // nothing to upload. See `kb/framework/build/workspace-ergonomics.md`
655
- // (receipt-as-cache).
833
+ // as a fresh-checkout no-op the artifact upstream is already
834
+ // correct; there's nothing to upload.
656
835
  const existingEntry = await registry.getVersionEntry(lookupName, version)
657
836
  if (existingEntry) {
658
837
  if (gitSha && existingEntry.publishedFromGitSha === gitSha) {
659
- const refreshedReceipt = receiptFromRegistryEntry({
660
- existingEntry,
661
- registry,
662
- name: lookupName,
663
- version,
664
- isLocal,
665
- isPropagateDefault: isPropagate,
666
- })
667
- if (refreshedReceipt) {
668
- // Persist uniweb.id BEFORE the early return when an auto-derive
669
- // or prompt-resolved id was set in this run. Without this, the
670
- // next run wouldn't know the id and would have to re-derive
671
- // from scratch which means the pre-flight registry check at
672
- // the top of publish() can't fire either (it relies on a
673
- // resolvable id from pkg.json alone). Persisting here closes
674
- // that loop so future deploys hit the pre-flight bail and skip
675
- // the build entirely.
676
- if (writeBackId) {
677
- pkg.uniweb = pkg.uniweb || {}
678
- pkg.uniweb.id = foundationName
679
- await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
680
- info(`Wrote ${colors.cyan}uniweb.id: "${foundationName}"${colors.reset} to ${colors.dim}package.json${colors.reset}`)
681
- }
682
- await writeFile(join(distDir, 'publish.json'), JSON.stringify(refreshedReceipt, null, 2) + '\n')
683
- console.log('')
684
- success(`${colors.bright}${lookupName}@${version}${colors.reset} already published from ${gitSha.slice(0, 7)} — receipt refreshed.`)
685
- return
838
+ // Persist uniweb.id BEFORE the early return when an auto-derive
839
+ // or prompt-resolved id was set in this run. Without this, the
840
+ // next run wouldn't know the id and would have to re-derive
841
+ // from scratch — which means the pre-flight registry check at
842
+ // the top of publish() can't fire either (it relies on a
843
+ // resolvable id from pkg.json alone). Persisting here closes
844
+ // that loop so future deploys hit the pre-flight bail and skip
845
+ // the build entirely.
846
+ if (writeBackId) {
847
+ pkg.uniweb = pkg.uniweb || {}
848
+ pkg.uniweb.id = foundationName
849
+ await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
850
+ info(`Wrote ${colors.cyan}uniweb.id: "${foundationName}"${colors.reset} to ${colors.dim}package.json${colors.reset}`)
686
851
  }
852
+ console.log('')
853
+ success(`${colors.bright}${lookupName}@${version}${colors.reset} already published from ${gitSha.slice(0, 7)}.`)
854
+ return
687
855
  }
688
856
  console.log('')
689
857
  error(`Foundation source has changed since the last publish, but ${colors.bright}${name}@${version}${colors.reset} is already published.`)
@@ -693,20 +861,7 @@ export async function publish(args = []) {
693
861
  process.exit(1)
694
862
  }
695
863
 
696
- // 6. Dry-run check
697
- if (isDryRun) {
698
- console.log('')
699
- info(`Would publish ${colors.bright}${name}@${version}${colors.reset} to ${registryLabel}`)
700
- console.log(` ${colors.dim}Source: ${distDir}${colors.reset}`)
701
- if (isLocal) {
702
- console.log(` ${colors.dim}Target: ${registry.getPackagePath(name, version)}${colors.reset}`)
703
- } else {
704
- console.log(` ${colors.dim}Target: ${registry.apiUrl}${colors.reset}`)
705
- }
706
- return
707
- }
708
-
709
- // 7. Publish
864
+ // 6. Publish
710
865
  info(`Publishing ${colors.bright}${name}@${version}${colors.reset} to ${registryLabel}...`)
711
866
 
712
867
  // Resolve the publisher's identity
@@ -714,9 +869,8 @@ export async function publish(args = []) {
714
869
  const publishMetadata = {
715
870
  publishedBy: auth?.email || (isLocal ? 'local' : 'cli'),
716
871
  classification: isPropagate ? 'propagate' : 'silent',
717
- // Git provenance lets the registry serve as a recovery source for
718
- // the local `dist/publish.json` cache on fresh checkouts, without
719
- // requiring the cache itself to survive across machines.
872
+ // Git provenance lets `uniweb deploy` decide whether a workspace-local
873
+ // foundation needs republishing see deploy.js's staleness check.
720
874
  ...(gitSha ? { publishedFromGitSha: gitSha } : {}),
721
875
  ...(typeof gitDirty === 'boolean' ? { publishedFromGitDirty: gitDirty } : {}),
722
876
  }
@@ -724,9 +878,8 @@ export async function publish(args = []) {
724
878
  publishMetadata.editAccess = editAccess
725
879
  }
726
880
 
727
- let publishResult
728
881
  try {
729
- publishResult = await registry.publish(name, version, distDir, publishMetadata)
882
+ await registry.publish(name, version, distDir, publishMetadata)
730
883
  } catch (err) {
731
884
  if (err.code === 'CONFLICT') {
732
885
  error(`${colors.bright}${name}@${version}${colors.reset} already exists on the registry.`)
@@ -741,18 +894,6 @@ export async function publish(args = []) {
741
894
  throw err
742
895
  }
743
896
 
744
- // Local event memory — read by `uniweb deploy` to decide whether a
745
- // workspace-local foundation needs republishing. Lives under dist/ which
746
- // is gitignored; not part of the upload.
747
- const receipt = composeReceipt({
748
- gitSha,
749
- gitDirty,
750
- url: deriveReceiptUrl({ publishResult, registry, name, version, isLocal }),
751
- publishedAt: new Date().toISOString(),
752
- classification: isPropagate ? 'propagate' : 'silent',
753
- })
754
- await writeFile(join(distDir, 'publish.json'), JSON.stringify(receipt, null, 2) + '\n')
755
-
756
897
  const prefix = getCliPrefix()
757
898
  const isExtension = schema._self?.role === 'extension'
758
899
  console.log('')
@@ -999,10 +1140,9 @@ async function buildIdSuggestions({ foundationDir, workspaceRoot, pkg }) {
999
1140
  /**
1000
1141
  * Per-directory git state. Mirrors `deploy.js::readGitState` exactly —
1001
1142
  * scopes the sha + dirty check to `dir` rather than reading the whole
1002
- * repo's HEAD. Receipts compare against this; if publish records the
1003
- * repo HEAD but deploy compares against the foundation's last commit,
1004
- * the receipt-as-cache no-op-refresh path drifts. Both sides must read
1005
- * the same shape.
1143
+ * repo's HEAD. Publish records this in registry metadata; deploy
1144
+ * compares against it for staleness. Both sides must read the same
1145
+ * shape or the staleness check drifts.
1006
1146
  */
1007
1147
  function readGitState(dir) {
1008
1148
  try {
@@ -1020,4 +1160,36 @@ function readGitState(dir) {
1020
1160
  }
1021
1161
  }
1022
1162
 
1163
+ /**
1164
+ * POST /api/orgs/{handle} — claim an `@handle` for the calling user.
1165
+ *
1166
+ * Returns one of:
1167
+ * { created: true, token: '<refreshed JWT>' } — handle was free
1168
+ * { created: false, token: '<refreshed JWT>' } — user already owned it
1169
+ * { taken: true } — claimed by someone else
1170
+ *
1171
+ * Other failures throw.
1172
+ */
1173
+ async function claimOrgHandle({ handle, token, backendUrl }) {
1174
+ const url = `${backendUrl.replace(/\/$/, '')}/api/orgs/${encodeURIComponent(handle)}`
1175
+ const res = await fetch(url, {
1176
+ method: 'POST',
1177
+ headers: {
1178
+ 'Content-Type': 'application/json',
1179
+ Authorization: `Bearer ${token}`,
1180
+ },
1181
+ })
1182
+ if (res.status === 409) return { taken: true }
1183
+ if (!res.ok) {
1184
+ let detail = `HTTP ${res.status}`
1185
+ try {
1186
+ const j = await res.json()
1187
+ detail = j.error || detail
1188
+ } catch { /* non-JSON body */ }
1189
+ throw new Error(`Org claim failed: ${detail}`)
1190
+ }
1191
+ const body = await res.json()
1192
+ return { created: !!body.created, token: body.token }
1193
+ }
1194
+
1023
1195
  export default publish
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-04-30T18:07:13.397Z",
3
+ "generatedAt": "2026-05-01T02:07:21.541Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
- "version": "0.13.4",
6
+ "version": "0.13.5",
7
7
  "path": "framework/build",
8
8
  "deps": [
9
9
  "@uniweb/content-reader",
@@ -92,7 +92,7 @@
92
92
  "deps": []
93
93
  },
94
94
  "@uniweb/unipress": {
95
- "version": "0.4.3",
95
+ "version": "0.4.4",
96
96
  "path": "framework/unipress",
97
97
  "deps": [
98
98
  "@uniweb/build",