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.
@@ -18,10 +18,11 @@ import { execSync } from 'node:child_process'
18
18
 
19
19
  import { resolveFoundationSrcPath, classifyPackage } from '@uniweb/build'
20
20
  import { createLocalRegistry, RemoteRegistry } from '../utils/registry.js'
21
- import { ensureAuth, readAuth } from '../utils/auth.js'
21
+ import { ensureAuth, readAuth, decodeJwtPayload } from '../utils/auth.js'
22
22
  import { getRegistryUrl } from '../utils/config.js'
23
23
  import { findWorkspaceRoot, findFoundations, findSites, promptSelect } from '../utils/workspace.js'
24
24
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
25
+ import { composeReceipt, deriveReceiptUrl, receiptFromRegistryEntry } from '../utils/receipt.js'
25
26
 
26
27
  // Colors for terminal output
27
28
  const colors = {
@@ -310,53 +311,106 @@ export async function publish(args = []) {
310
311
  foundationName = uniwebId
311
312
  }
312
313
  if (!foundationName) {
313
- // No id resolvable from any field. Prompt or fail in non-interactive.
314
+ // No id resolvable from any field. Build a set of suggestions
315
+ // contextual to this workspace, then either prompt (TTY) or print
316
+ // them as guidance (CI). The bare `pkg.name` is intentionally NOT
317
+ // a suggestion when it equals the scaffold default `src` — picking
318
+ // that name would couple the registry id to a generic placeholder
319
+ // that future renames couldn't undo.
320
+ const workspaceRoot = findWorkspaceRoot(foundationDir) || foundationDir
321
+ const suggestions = await buildIdSuggestions({ foundationDir, workspaceRoot, pkg })
322
+
314
323
  if (isNonInteractive(process.argv)) {
315
- error('Foundation id is required for publishing.')
316
- console.log('')
317
- console.log(` ${colors.dim}Use one of:${colors.reset}`)
318
- console.log(` ${colors.cyan}uniweb publish --name <id>${colors.reset}`)
319
- console.log(` ${colors.dim}Add ${colors.reset}"uniweb": { "id": "<your-id>" }${colors.dim} to package.json${colors.reset}`)
320
- console.log(` ${colors.dim}Or use a scoped name in package.json: ${colors.reset}"name": "@org/<id>"${colors.reset}`)
321
- process.exit(1)
322
- }
324
+ // CI: when there's a high-confidence signal the workspace
325
+ // package.json's name (the user typed it via `uniweb create
326
+ // <name>`) — auto-derive and persist. This unblocks first-deploy
327
+ // CI flows (pp-01 etc.) where stopping to ask isn't an option.
328
+ // Other suggestion sources (sibling-site name, M-code) are NOT
329
+ // auto-picked because they're ambiguous in multi-package
330
+ // workspaces; they remain available via the error message
331
+ // when no high-confidence signal exists.
332
+ const autoId = await pickAutoDerivedId({ workspaceRoot, foundationDir })
333
+ if (autoId) {
334
+ info(`Auto-deriving ${colors.bright}uniweb.id: "${autoId}"${colors.reset} ${colors.dim}(matches workspace name; persisted to package.json)${colors.reset}`)
335
+ foundationName = autoId
336
+ writeBackId = true
337
+ } else {
338
+ error('Foundation id is required for publishing.')
339
+ console.log('')
340
+ if (suggestions.length > 0) {
341
+ console.log(` ${colors.bright}Suggestions for your workspace:${colors.reset}`)
342
+ for (const { id, why } of suggestions) {
343
+ console.log(` ${colors.cyan}${id}${colors.reset} ${colors.dim}${why}${colors.reset}`)
344
+ }
345
+ console.log('')
346
+ }
347
+ console.log(` ${colors.dim}Use one of:${colors.reset}`)
348
+ const example = suggestions[0]?.id || '<id>'
349
+ console.log(` ${colors.cyan}uniweb publish --name ${example}${colors.reset}`)
350
+ console.log(` ${colors.dim}Add ${colors.reset}"uniweb": { "id": "<your-id>" }${colors.dim} to package.json${colors.reset}`)
351
+ console.log(` ${colors.dim}Or use a scoped name in package.json: ${colors.reset}"name": "@org/<id>"${colors.reset}`)
352
+ process.exit(1)
353
+ }
354
+ } else {
323
355
 
324
356
  const prompts = (await import('prompts')).default
325
- // Default suggestion: derive from the workspace folder or pkg.name.
326
- // Strip the suffix `-src` so a foundation in `marketing-src/` defaults
327
- // to `marketing` as its publish id.
328
- const workspaceRoot = findWorkspaceRoot(foundationDir) || foundationDir
329
- const folderName = workspaceRoot === foundationDir
330
- ? null
331
- : foundationDir.replace(workspaceRoot + '/', '').split('/')[0]
332
- const suggestion =
333
- (typeof pkg.name === 'string' && ID_RE.test(pkg.name) ? pkg.name : null) ||
334
- (folderName ? folderName.replace(/-src$/, '') : null) ||
335
- 'foundation'
336
-
337
357
  console.log('')
338
358
  console.log(`${colors.dim}This is the first publish of this foundation. Pick a name${colors.reset}`)
339
359
  console.log(`${colors.dim}for the registry — what your foundation will be known as.${colors.reset}`)
340
- const response = await prompts({
341
- type: 'text',
342
- name: 'id',
343
- message: 'Foundation name',
344
- initial: suggestion,
345
- validate: (v) => {
346
- if (!v) return 'Required'
347
- if (!ID_RE.test(v)) return 'Lowercase letters, digits, hyphens, underscores only'
348
- return true
349
- },
350
- }, {
351
- onCancel: () => {
352
- console.log('')
353
- console.log('Publish cancelled.')
354
- process.exit(0)
355
- },
356
- })
357
- if (!response.id) process.exit(0)
358
- foundationName = response.id
360
+ console.log('')
361
+
362
+ let chosen
363
+ if (suggestions.length > 0) {
364
+ // Surface contextual suggestions first (sibling site, workspace name,
365
+ // M-code series). Always include "Type a different name…" so the
366
+ // user is never trapped in a list.
367
+ const choices = [
368
+ ...suggestions.map(s => ({ title: s.id, description: s.why, value: s.id })),
369
+ { title: 'Type a different name…', value: '__custom__' },
370
+ ]
371
+ const pickResp = await prompts({
372
+ type: 'select',
373
+ name: 'pick',
374
+ message: 'Foundation name',
375
+ choices,
376
+ initial: 0,
377
+ }, {
378
+ onCancel: () => { console.log(''); console.log('Publish cancelled.'); process.exit(0) },
379
+ })
380
+ if (!pickResp.pick) process.exit(0)
381
+ chosen = pickResp.pick
382
+ } else {
383
+ chosen = '__custom__'
384
+ }
385
+
386
+ if (chosen === '__custom__') {
387
+ const folderName = workspaceRoot === foundationDir
388
+ ? null
389
+ : foundationDir.replace(workspaceRoot + '/', '').split('/')[0]
390
+ const suggestion =
391
+ suggestions[0]?.id ||
392
+ (folderName ? folderName.replace(/-src$/, '') : null) ||
393
+ ''
394
+ const textResp = await prompts({
395
+ type: 'text',
396
+ name: 'id',
397
+ message: 'Foundation name',
398
+ initial: suggestion,
399
+ validate: (v) => {
400
+ if (!v) return 'Required'
401
+ if (!ID_RE.test(v)) return 'Lowercase letters, digits, hyphens, underscores only'
402
+ return true
403
+ },
404
+ }, {
405
+ onCancel: () => { console.log(''); console.log('Publish cancelled.'); process.exit(0) },
406
+ })
407
+ if (!textResp.id) process.exit(0)
408
+ chosen = textResp.id
409
+ }
410
+
411
+ foundationName = chosen
359
412
  writeBackId = true
413
+ }
360
414
  }
361
415
 
362
416
  // Validate the resolved id (may have come from any source).
@@ -443,8 +497,56 @@ export async function publish(args = []) {
443
497
 
444
498
  const registryLabel = isLocal ? 'local registry' : `registry`
445
499
 
446
- // 5. Check for duplicates
447
- if (await registry.exists(name, version)) {
500
+ // Git state read up-front so it can both gate the duplicate check
501
+ // (fresh-checkout no-op vs. true conflict) and ride along in the
502
+ // publish payload.
503
+ const { gitSha, gitDirty } = readGitState(foundationDir)
504
+
505
+ // Compute the canonical name the server stores under. Empty-scope
506
+ // (bare-name) publishes go to the registry as `<name>` but are
507
+ // server-side rewritten to `~<memberUuid>/<name>`. The duplicate
508
+ // check below queries the registry's index, which uses the canonical
509
+ // form as the key — so we have to mirror the rewrite locally.
510
+ // Org / personal-scope publishes skip this (their `name` is already
511
+ // canonical).
512
+ let lookupName = name
513
+ if (!scopeSigil && !isLocal) {
514
+ try {
515
+ const localAuth = await readAuth()
516
+ const claims = decodeJwtPayload(localAuth?.token)
517
+ if (claims?.memberUuid) {
518
+ lookupName = `~${claims.memberUuid}/${foundationName}`
519
+ }
520
+ } catch {
521
+ // No usable auth — fall back to the bare name. The publish call
522
+ // itself will fail later with an auth error if a token is needed.
523
+ }
524
+ }
525
+
526
+ // 5. Check for duplicates. If the registry already has this exact
527
+ // version recorded as published from the current commit, treat it
528
+ // as a fresh-checkout no-op: refresh the local receipt and exit
529
+ // successfully. The artifact upstream is already correct; there's
530
+ // nothing to upload. See `kb/framework/build/workspace-ergonomics.md`
531
+ // (receipt-as-cache).
532
+ const existingEntry = await registry.getVersionEntry(lookupName, version)
533
+ if (existingEntry) {
534
+ if (gitSha && existingEntry.publishedFromGitSha === gitSha) {
535
+ const refreshedReceipt = receiptFromRegistryEntry({
536
+ existingEntry,
537
+ registry,
538
+ name: lookupName,
539
+ version,
540
+ isLocal,
541
+ isPropagateDefault: isPropagate,
542
+ })
543
+ if (refreshedReceipt) {
544
+ await writeFile(join(distDir, 'publish.json'), JSON.stringify(refreshedReceipt, null, 2) + '\n')
545
+ console.log('')
546
+ success(`${colors.bright}${lookupName}@${version}${colors.reset} already published from ${gitSha.slice(0, 7)} — receipt refreshed.`)
547
+ return
548
+ }
549
+ }
448
550
  console.log('')
449
551
  error(`${colors.bright}${name}@${version}${colors.reset} is already published.`)
450
552
  console.log('')
@@ -474,6 +576,11 @@ export async function publish(args = []) {
474
576
  const publishMetadata = {
475
577
  publishedBy: auth?.email || (isLocal ? 'local' : 'cli'),
476
578
  classification: isPropagate ? 'propagate' : 'silent',
579
+ // Git provenance lets the registry serve as a recovery source for
580
+ // the local `dist/publish.json` cache on fresh checkouts, without
581
+ // requiring the cache itself to survive across machines.
582
+ ...(gitSha ? { publishedFromGitSha: gitSha } : {}),
583
+ ...(typeof gitDirty === 'boolean' ? { publishedFromGitDirty: gitDirty } : {}),
477
584
  }
478
585
  if (editAccess) {
479
586
  publishMetadata.editAccess = editAccess
@@ -499,19 +606,13 @@ export async function publish(args = []) {
499
606
  // Local event memory — read by `uniweb deploy` to decide whether a
500
607
  // workspace-local foundation needs republishing. Lives under dist/ which
501
608
  // is gitignored; not part of the upload.
502
- const receiptUrl = publishResult?.url
503
- || (isLocal
504
- ? `file://${registry.getPackagePath(name, version)}/`
505
- : `${registry.apiUrl}/${name}/${version}/`)
506
- const { gitSha, gitDirty } = readGitState(foundationDir)
507
- const receipt = {
508
- schemaVersion: 1,
509
- publishedFromGitSha: gitSha,
510
- publishedFromGitDirty: gitDirty,
511
- url: receiptUrl,
609
+ const receipt = composeReceipt({
610
+ gitSha,
611
+ gitDirty,
612
+ url: deriveReceiptUrl({ publishResult, registry, name, version, isLocal }),
512
613
  publishedAt: new Date().toISOString(),
513
614
  classification: isPropagate ? 'propagate' : 'silent',
514
- }
615
+ })
515
616
  await writeFile(join(distDir, 'publish.json'), JSON.stringify(receipt, null, 2) + '\n')
516
617
 
517
618
  const prefix = getCliPrefix()
@@ -556,6 +657,148 @@ function bumpPatch(version) {
556
657
  return parts.join('.')
557
658
  }
558
659
 
660
+ /**
661
+ * High-confidence auto-derive for non-interactive (CI) first publishes.
662
+ *
663
+ * Diego's principle: never silently take a generic scaffold default like
664
+ * `src` or `site` as the registry id (those are placeholders, not user
665
+ * intent). But when the user has typed a real name elsewhere — most
666
+ * unambiguously the workspace package.json's `name` (set by
667
+ * `uniweb create <name>`) — picking that in CI is the obvious right
668
+ * answer and stopping to ask just breaks the CI run.
669
+ *
670
+ * Auto-derive set is intentionally NARROW:
671
+ * 1. Workspace package.json::name, when it's a clean id and not a
672
+ * generic placeholder.
673
+ *
674
+ * Other suggestion sources from `buildIdSuggestions` (sibling-site
675
+ * name, M-code series) are NOT auto-picked: they're ambiguous in
676
+ * multi-package or multi-foundation workspaces. They remain visible
677
+ * in the CI error message when no high-confidence signal exists, so
678
+ * the user can pick one explicitly via `--name <id>`.
679
+ *
680
+ * Returns the id string, or null when no high-confidence signal is
681
+ * available (caller falls through to the existing error-with-
682
+ * suggestions guidance).
683
+ */
684
+ async function pickAutoDerivedId({ workspaceRoot, foundationDir }) {
685
+ const ID_RE = /^[a-z0-9_-]+$/
686
+ const PLACEHOLDERS = new Set(['src', 'site', 'foundation', 'workspace', 'project'])
687
+ const isHighConfidence = s => typeof s === 'string' && ID_RE.test(s) && !PLACEHOLDERS.has(s)
688
+
689
+ if (!workspaceRoot || workspaceRoot === foundationDir) return null
690
+ try {
691
+ const wsPkg = JSON.parse(await readFile(join(workspaceRoot, 'package.json'), 'utf8'))
692
+ const wsName = typeof wsPkg.name === 'string'
693
+ ? wsPkg.name.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^-+|-+$/g, '')
694
+ : null
695
+ if (isHighConfidence(wsName)) return wsName
696
+ } catch { /* no workspace package.json — skip */ }
697
+ return null
698
+ }
699
+
700
+ /**
701
+ * Build a list of contextual `uniweb.id` suggestions for first-time publishes.
702
+ *
703
+ * The CLI never auto-picks an id (Diego's principle: a bare folder name like
704
+ * "src" is wrong, and silently committing to it would couple the registry
705
+ * id to scaffold noise the user can't easily undo). Instead, suggest names
706
+ * derived from signals the workspace already exposes:
707
+ *
708
+ * - **Sibling site name.** When exactly one site exists in the workspace,
709
+ * the user's mental model is "this foundation is FOR that site" — so
710
+ * the site's name (or "<site>-foundation" if it would collide with the
711
+ * site's own package name) is a natural pick.
712
+ * - **Workspace name.** A workspace package.json often carries a name
713
+ * more meaningful than the foundation folder ("acme-marketing" vs "src").
714
+ * - **Folder name minus `-src`.** Foundations placed under
715
+ * `<name>-src/` strongly suggest `<name>` as the publish id (this
716
+ * is the existing default; preserved here for back-compat).
717
+ * - **Code-based fallback (M1, M2, …).** When the workspace already has
718
+ * other foundations (i.e., the user manages a category of similar
719
+ * foundations across sites/projects), suggest the next code in series.
720
+ *
721
+ * Returns deduplicated `{ id, why }` entries — `why` is shown next to the
722
+ * id in both the CI guidance message and the TTY select prompt so the
723
+ * user can tell at a glance which signal each suggestion comes from.
724
+ *
725
+ * The bare scaffold default `pkg.name === 'src'` is excluded by design.
726
+ * Likewise any non-conforming shape (uppercase, dots, etc.) is filtered
727
+ * out so users only ever see valid candidates.
728
+ */
729
+ async function buildIdSuggestions({ foundationDir, workspaceRoot, pkg }) {
730
+ const ID_RE = /^[a-z0-9_-]+$/
731
+ const sanitize = s => (typeof s === 'string' ? s.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^-+|-+$/g, '') : null)
732
+ const isValid = s => typeof s === 'string' && ID_RE.test(s) && s !== 'src' && s !== 'site'
733
+
734
+ const seen = new Set()
735
+ const out = []
736
+ const push = (id, why) => {
737
+ if (!isValid(id) || seen.has(id)) return
738
+ seen.add(id)
739
+ out.push({ id, why })
740
+ }
741
+
742
+ // 1. Sibling-site suggestion. Only fires when there's exactly one site
743
+ // in the workspace, because that's the unambiguous "for X" case.
744
+ try {
745
+ const sites = await findSites(workspaceRoot)
746
+ if (sites.length === 1) {
747
+ const sitePath = sites[0]
748
+ try {
749
+ const sitePkg = JSON.parse(await readFile(join(workspaceRoot, sitePath, 'package.json'), 'utf8'))
750
+ const siteName = sanitize(sitePkg.name)
751
+ if (siteName) {
752
+ push(siteName, `matches your site "${siteName}"`)
753
+ push(`${siteName}-foundation`, `derived from your site "${siteName}"`)
754
+ }
755
+ } catch { /* missing or malformed site package.json — skip */ }
756
+ }
757
+ } catch { /* findSites can fail in odd workspaces; non-fatal */ }
758
+
759
+ // 2. Workspace name suggestion. The workspace package.json's name is
760
+ // the user's chosen project identity; if it's a clean id, suggest it.
761
+ try {
762
+ if (workspaceRoot && workspaceRoot !== foundationDir) {
763
+ const wsPkg = JSON.parse(await readFile(join(workspaceRoot, 'package.json'), 'utf8'))
764
+ const wsName = sanitize(wsPkg.name)
765
+ if (wsName) push(wsName, `matches your workspace "${wsName}"`)
766
+ }
767
+ } catch { /* no workspace package.json — skip */ }
768
+
769
+ // 3. Folder name minus `-src`. The pre-existing default lives on as a
770
+ // suggestion now rather than the auto-pick.
771
+ if (workspaceRoot && foundationDir !== workspaceRoot) {
772
+ const folderName = foundationDir.replace(workspaceRoot + '/', '').split('/')[0]
773
+ const stripped = sanitize(folderName?.replace(/-src$/, ''))
774
+ if (stripped) push(stripped, `derived from the folder "${folderName}"`)
775
+ }
776
+
777
+ // 4. Code-based fallback. Only suggested when the workspace already has
778
+ // multiple foundations — the case Diego flagged (publishers managing
779
+ // a category like M1, M2, M3 across sites/projects).
780
+ try {
781
+ const foundations = await findFoundations(workspaceRoot)
782
+ if (foundations.length >= 2) {
783
+ // Find the next M-number not already used by a sibling foundation's id.
784
+ const usedCodes = new Set()
785
+ for (const fp of foundations) {
786
+ try {
787
+ const fp_pkg = JSON.parse(await readFile(join(workspaceRoot, fp, 'package.json'), 'utf8'))
788
+ const id = fp_pkg.uniweb?.id
789
+ const m = typeof id === 'string' && id.match(/^m(\d+)$/i)
790
+ if (m) usedCodes.add(parseInt(m[1], 10))
791
+ } catch { /* skip */ }
792
+ }
793
+ let n = 1
794
+ while (usedCodes.has(n)) n++
795
+ push(`m${n}`, `next in your "M-code" series`)
796
+ }
797
+ } catch { /* findFoundations failed — skip */ }
798
+
799
+ return out
800
+ }
801
+
559
802
  function readGitState(dir) {
560
803
  try {
561
804
  const sha = execSync('git rev-parse HEAD', {