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.
@@ -12,16 +12,17 @@
12
12
  */
13
13
 
14
14
  import { existsSync } from 'node:fs'
15
- import { readFile, writeFile } from 'node:fs/promises'
15
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
16
16
  import { resolve, join } from 'node:path'
17
17
  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 = {
@@ -187,13 +188,129 @@ export async function publish(args = []) {
187
188
  process.exit(1)
188
189
  }
189
190
 
190
- // 2. Auto-build if dist/ is missing
191
+ // 2. Auto-build if dist/ is missing OR stale.
192
+ //
193
+ // "Stale" means the schema fingerprint baked into
194
+ // `dist/meta/schema.json::_self.version` doesn't match the user's
195
+ // current `package.json::version`. That happens when the user bumps
196
+ // the version and runs `uniweb publish` without rebuilding — the
197
+ // artifact in dist/ encodes the OLD version, but the publish
198
+ // intends the NEW one. Without rebuilding we'd ship inconsistent
199
+ // bytes (schema says one version, registry record says another).
191
200
  const distDir = join(foundationDir, 'dist')
192
201
  const foundationJs = join(distDir, 'foundation.js')
193
202
  const schemaJson = join(distDir, 'meta', 'schema.json')
194
203
 
195
- if (!existsSync(foundationJs) || !existsSync(schemaJson)) {
196
- console.log(`${colors.yellow}⚠${colors.reset} No build found. Building foundation...`)
204
+ // Pre-read package.json so we can compare its version against the
205
+ // schema before deciding whether to rebuild.
206
+ const pkgPath = join(foundationDir, 'package.json')
207
+ let earlyPkg
208
+ try {
209
+ earlyPkg = JSON.parse(await readFile(pkgPath, 'utf8'))
210
+ } catch (err) {
211
+ error(`Failed to read package.json: ${err.message}`)
212
+ process.exit(1)
213
+ }
214
+
215
+ let needsBuild = !existsSync(foundationJs) || !existsSync(schemaJson)
216
+ let buildReason = needsBuild ? 'no dist/ found' : null
217
+
218
+ if (!needsBuild) {
219
+ try {
220
+ const peekSchema = JSON.parse(await readFile(schemaJson, 'utf8'))
221
+ if (peekSchema?._self?.version && earlyPkg.version && peekSchema._self.version !== earlyPkg.version) {
222
+ needsBuild = true
223
+ buildReason = `package.json::version (${earlyPkg.version}) differs from dist/meta/schema.json::_self.version (${peekSchema._self.version})`
224
+ }
225
+ } catch {
226
+ // Malformed schema → treat as stale.
227
+ needsBuild = true
228
+ buildReason = 'dist/meta/schema.json could not be parsed'
229
+ }
230
+ }
231
+
232
+ // 2b. Pre-flight registry check — runs BEFORE the build so we don't
233
+ // burn vite cycles on a foundation we already know we can't (or
234
+ // don't need to) publish.
235
+ //
236
+ // Two outcomes short-circuit the build:
237
+ //
238
+ // a. The registry already has `<canonicalName>@<version>`
239
+ // published from the CURRENT git sha (per-foundation last
240
+ // commit). The artifact upstream is correct; refresh the
241
+ // local receipt and exit. (Same outcome as the post-build
242
+ // duplicate check, just earlier — saves a build.)
243
+ //
244
+ // b. The registry has the version published from a DIFFERENT
245
+ // sha. The user has unpublished changes against an already-
246
+ // published version → "bump the version" error before any
247
+ // build work. Was the eval skill's pp-03 row.
248
+ //
249
+ // If the pre-flight can't determine the canonical name from
250
+ // pkg.json + flags + auth alone (e.g., needs a TTY prompt for
251
+ // the foundation id), it falls through silently to the existing
252
+ // post-build path. No-build-saved is still the existing behavior.
253
+ if (!isLocal) {
254
+ const preflightName = quickResolveCanonicalName(earlyPkg, { namespaceFlag, nameFlag })
255
+ const preflightVersion = earlyPkg.version
256
+ if (preflightName && preflightVersion) {
257
+ try {
258
+ const auth = await readAuth()
259
+ if (auth?.token) {
260
+ const claims = decodeJwtPayload(auth.token)
261
+ const memberUuid = claims?.memberUuid
262
+ // Empty-scope publishes are server-rewritten to ~<memberUuid>/<id>.
263
+ // Mirror that here so getVersionEntry queries the canonical key.
264
+ const lookupName = preflightName.startsWith('@') || preflightName.startsWith('~')
265
+ ? preflightName
266
+ : memberUuid ? `~${memberUuid}/${preflightName}` : null
267
+ if (lookupName) {
268
+ const registryUrlPre = registryUrl || getRegistryUrl()
269
+ const registryPre = new RemoteRegistry(registryUrlPre, auth.token)
270
+ const existing = await registryPre.getVersionEntry(lookupName, preflightVersion)
271
+ if (existing) {
272
+ const { gitSha } = readGitState(foundationDir)
273
+ 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
+ }
290
+ }
291
+ // 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.
295
+ console.log('')
296
+ error(`${colors.bright}${lookupName}@${preflightVersion}${colors.reset} is already published.`)
297
+ console.log('')
298
+ console.log(` Bump the version in package.json to publish an update:`)
299
+ console.log(` ${colors.dim}"version": "${bumpPatch(preflightVersion)}"${colors.reset}`)
300
+ process.exit(1)
301
+ }
302
+ }
303
+ }
304
+ } catch {
305
+ // Network down, malformed auth, etc. — fall through to the
306
+ // existing post-build flow. No-build-saved is still the same
307
+ // behavior the user got before this pre-flight existed.
308
+ }
309
+ }
310
+ }
311
+
312
+ if (needsBuild) {
313
+ console.log(`${colors.yellow}⚠${colors.reset} ${buildReason}. Building foundation...`)
197
314
  console.log('')
198
315
  execSync('npx uniweb build --target foundation', {
199
316
  cwd: foundationDir,
@@ -207,7 +324,13 @@ export async function publish(args = []) {
207
324
  }
208
325
  }
209
326
 
210
- // 3. Read name and version from meta/schema.json
327
+ // 3. Read name + version from the (now-fresh) schema + package.json.
328
+ //
329
+ // `_self.name` is the build-RESOLVED form — applies `uniweb.id`,
330
+ // scope resolution, etc., that are easier to read off the build
331
+ // output than to redo here. `version` is sourced from package.json
332
+ // directly; the version-skew check above already ensured the
333
+ // schema and package.json agree.
211
334
  let schema
212
335
  try {
213
336
  schema = JSON.parse(await readFile(schemaJson, 'utf8'))
@@ -217,11 +340,12 @@ export async function publish(args = []) {
217
340
  }
218
341
 
219
342
  const rawName = schema._self?.name
220
- const version = schema._self?.version
343
+ const version = earlyPkg.version
221
344
 
222
345
  if (!rawName || !version) {
223
- error('dist/meta/schema.json missing _self.name or _self.version')
224
- console.log(`${colors.dim} Ensure your package.json has "name" and "version" fields.${colors.reset}`)
346
+ error('Foundation missing name or version')
347
+ console.log(`${colors.dim} Ensure your package.json has "name" and "version" fields,${colors.reset}`)
348
+ console.log(`${colors.dim} and that the build has produced dist/meta/schema.json with _self.name.${colors.reset}`)
225
349
  process.exit(1)
226
350
  }
227
351
 
@@ -266,8 +390,9 @@ export async function publish(args = []) {
266
390
  // affects only the registry identity, never the workspace. Most users
267
391
  // benefit from leaving `package.json::name` as the scaffold default
268
392
  // (`src`) and putting the published-as id in `uniweb.id`.
269
- const pkgPath = join(foundationDir, 'package.json')
270
- const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
393
+ // pkgPath was declared earlier (during the rebuild-stale-dist check).
394
+ // Reuse the already-loaded `earlyPkg` rather than re-reading from disk.
395
+ const pkg = earlyPkg
271
396
  const uniwebNamespace = pkg.uniweb?.namespace
272
397
  const uniwebId = pkg.uniweb?.id
273
398
  const orgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
@@ -310,53 +435,106 @@ export async function publish(args = []) {
310
435
  foundationName = uniwebId
311
436
  }
312
437
  if (!foundationName) {
313
- // No id resolvable from any field. Prompt or fail in non-interactive.
438
+ // No id resolvable from any field. Build a set of suggestions
439
+ // contextual to this workspace, then either prompt (TTY) or print
440
+ // them as guidance (CI). The bare `pkg.name` is intentionally NOT
441
+ // a suggestion when it equals the scaffold default `src` — picking
442
+ // that name would couple the registry id to a generic placeholder
443
+ // that future renames couldn't undo.
444
+ const workspaceRoot = findWorkspaceRoot(foundationDir) || foundationDir
445
+ const suggestions = await buildIdSuggestions({ foundationDir, workspaceRoot, pkg })
446
+
314
447
  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
- }
448
+ // CI: when there's a high-confidence signal the workspace
449
+ // package.json's name (the user typed it via `uniweb create
450
+ // <name>`) — auto-derive and persist. This unblocks first-deploy
451
+ // CI flows (pp-01 etc.) where stopping to ask isn't an option.
452
+ // Other suggestion sources (sibling-site name, M-code) are NOT
453
+ // auto-picked because they're ambiguous in multi-package
454
+ // workspaces; they remain available via the error message
455
+ // when no high-confidence signal exists.
456
+ const autoId = await pickAutoDerivedId({ workspaceRoot, foundationDir })
457
+ if (autoId) {
458
+ info(`Auto-deriving ${colors.bright}uniweb.id: "${autoId}"${colors.reset} ${colors.dim}(matches workspace name; persisted to package.json)${colors.reset}`)
459
+ foundationName = autoId
460
+ writeBackId = true
461
+ } else {
462
+ error('Foundation id is required for publishing.')
463
+ console.log('')
464
+ if (suggestions.length > 0) {
465
+ console.log(` ${colors.bright}Suggestions for your workspace:${colors.reset}`)
466
+ for (const { id, why } of suggestions) {
467
+ console.log(` ${colors.cyan}${id}${colors.reset} ${colors.dim}${why}${colors.reset}`)
468
+ }
469
+ console.log('')
470
+ }
471
+ console.log(` ${colors.dim}Use one of:${colors.reset}`)
472
+ const example = suggestions[0]?.id || '<id>'
473
+ console.log(` ${colors.cyan}uniweb publish --name ${example}${colors.reset}`)
474
+ console.log(` ${colors.dim}Add ${colors.reset}"uniweb": { "id": "<your-id>" }${colors.dim} to package.json${colors.reset}`)
475
+ console.log(` ${colors.dim}Or use a scoped name in package.json: ${colors.reset}"name": "@org/<id>"${colors.reset}`)
476
+ process.exit(1)
477
+ }
478
+ } else {
323
479
 
324
480
  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
481
  console.log('')
338
482
  console.log(`${colors.dim}This is the first publish of this foundation. Pick a name${colors.reset}`)
339
483
  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
484
+ console.log('')
485
+
486
+ let chosen
487
+ if (suggestions.length > 0) {
488
+ // Surface contextual suggestions first (sibling site, workspace name,
489
+ // M-code series). Always include "Type a different name…" so the
490
+ // user is never trapped in a list.
491
+ const choices = [
492
+ ...suggestions.map(s => ({ title: s.id, description: s.why, value: s.id })),
493
+ { title: 'Type a different name…', value: '__custom__' },
494
+ ]
495
+ const pickResp = await prompts({
496
+ type: 'select',
497
+ name: 'pick',
498
+ message: 'Foundation name',
499
+ choices,
500
+ initial: 0,
501
+ }, {
502
+ onCancel: () => { console.log(''); console.log('Publish cancelled.'); process.exit(0) },
503
+ })
504
+ if (!pickResp.pick) process.exit(0)
505
+ chosen = pickResp.pick
506
+ } else {
507
+ chosen = '__custom__'
508
+ }
509
+
510
+ if (chosen === '__custom__') {
511
+ const folderName = workspaceRoot === foundationDir
512
+ ? null
513
+ : foundationDir.replace(workspaceRoot + '/', '').split('/')[0]
514
+ const suggestion =
515
+ suggestions[0]?.id ||
516
+ (folderName ? folderName.replace(/-src$/, '') : null) ||
517
+ ''
518
+ const textResp = await prompts({
519
+ type: 'text',
520
+ name: 'id',
521
+ message: 'Foundation name',
522
+ initial: suggestion,
523
+ validate: (v) => {
524
+ if (!v) return 'Required'
525
+ if (!ID_RE.test(v)) return 'Lowercase letters, digits, hyphens, underscores only'
526
+ return true
527
+ },
528
+ }, {
529
+ onCancel: () => { console.log(''); console.log('Publish cancelled.'); process.exit(0) },
530
+ })
531
+ if (!textResp.id) process.exit(0)
532
+ chosen = textResp.id
533
+ }
534
+
535
+ foundationName = chosen
359
536
  writeBackId = true
537
+ }
360
538
  }
361
539
 
362
540
  // Validate the resolved id (may have come from any source).
@@ -443,8 +621,70 @@ export async function publish(args = []) {
443
621
 
444
622
  const registryLabel = isLocal ? 'local registry' : `registry`
445
623
 
446
- // 5. Check for duplicates
447
- if (await registry.exists(name, version)) {
624
+ // Git state read up-front so it can both gate the duplicate check
625
+ // (fresh-checkout no-op vs. true conflict) and ride along in the
626
+ // publish payload.
627
+ const { gitSha, gitDirty } = readGitState(foundationDir)
628
+
629
+ // Compute the canonical name the server stores under. Empty-scope
630
+ // (bare-name) publishes go to the registry as `<name>` but are
631
+ // server-side rewritten to `~<memberUuid>/<name>`. The duplicate
632
+ // check below queries the registry's index, which uses the canonical
633
+ // form as the key — so we have to mirror the rewrite locally.
634
+ // Org / personal-scope publishes skip this (their `name` is already
635
+ // canonical).
636
+ let lookupName = name
637
+ if (!scopeSigil && !isLocal) {
638
+ try {
639
+ const localAuth = await readAuth()
640
+ const claims = decodeJwtPayload(localAuth?.token)
641
+ if (claims?.memberUuid) {
642
+ lookupName = `~${claims.memberUuid}/${foundationName}`
643
+ }
644
+ } catch {
645
+ // No usable auth — fall back to the bare name. The publish call
646
+ // itself will fail later with an auth error if a token is needed.
647
+ }
648
+ }
649
+
650
+ // 5. Check for duplicates. If the registry already has this exact
651
+ // 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).
656
+ const existingEntry = await registry.getVersionEntry(lookupName, version)
657
+ if (existingEntry) {
658
+ 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
686
+ }
687
+ }
448
688
  console.log('')
449
689
  error(`${colors.bright}${name}@${version}${colors.reset} is already published.`)
450
690
  console.log('')
@@ -474,6 +714,11 @@ export async function publish(args = []) {
474
714
  const publishMetadata = {
475
715
  publishedBy: auth?.email || (isLocal ? 'local' : 'cli'),
476
716
  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.
720
+ ...(gitSha ? { publishedFromGitSha: gitSha } : {}),
721
+ ...(typeof gitDirty === 'boolean' ? { publishedFromGitDirty: gitDirty } : {}),
477
722
  }
478
723
  if (editAccess) {
479
724
  publishMetadata.editAccess = editAccess
@@ -499,19 +744,13 @@ export async function publish(args = []) {
499
744
  // Local event memory — read by `uniweb deploy` to decide whether a
500
745
  // workspace-local foundation needs republishing. Lives under dist/ which
501
746
  // 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,
747
+ const receipt = composeReceipt({
748
+ gitSha,
749
+ gitDirty,
750
+ url: deriveReceiptUrl({ publishResult, registry, name, version, isLocal }),
512
751
  publishedAt: new Date().toISOString(),
513
752
  classification: isPropagate ? 'propagate' : 'silent',
514
- }
753
+ })
515
754
  await writeFile(join(distDir, 'publish.json'), JSON.stringify(receipt, null, 2) + '\n')
516
755
 
517
756
  const prefix = getCliPrefix()
@@ -549,6 +788,65 @@ export async function publish(args = []) {
549
788
  * @param {string} version - e.g. "1.0.0"
550
789
  * @returns {string} - e.g. "1.0.1"
551
790
  */
791
+ /**
792
+ * Quickly compute the canonical foundation name from `package.json` +
793
+ * CLI flags alone, without prompting and without reading the build's
794
+ * `dist/meta/schema.json`. Used by the pre-flight registry check so we
795
+ * can short-circuit the build when the registry already has this
796
+ * version published.
797
+ *
798
+ * Returns null when resolution would need a prompt or auto-derive
799
+ * (caller falls through to the existing post-build resolution path,
800
+ * which handles those cases). The returned string is one of:
801
+ * - `@<scope>/<id>` (org scope, full canonical form)
802
+ * - `~<handle>/<id>` (personal alias scope)
803
+ * - `<id>` (bare; caller may prepend `~<memberUuid>/`
804
+ * from the JWT for the actual lookup)
805
+ *
806
+ * The full resolution at line 313+ is the canonical implementation;
807
+ * this helper is a strict subset that mirrors the high-confidence
808
+ * paths only. If they diverge, the helper is the one that should
809
+ * stay conservative (return null on uncertainty).
810
+ */
811
+ function quickResolveCanonicalName(pkg, { namespaceFlag, nameFlag } = {}) {
812
+ if (!pkg) return null
813
+ const orgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
814
+ const personalScopeMatch = (pkg.name || '').match(/^~([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
815
+ const uniwebNamespace = pkg.uniweb?.namespace
816
+ const uniwebId = pkg.uniweb?.id
817
+
818
+ // Scope precedence mirrors the full resolution.
819
+ let scopeSigil = null
820
+ let scopeName = null
821
+ if (namespaceFlag) {
822
+ scopeSigil = '@'
823
+ scopeName = namespaceFlag
824
+ } else if (orgScopeMatch) {
825
+ scopeSigil = '@'
826
+ scopeName = orgScopeMatch[1]
827
+ } else if (personalScopeMatch) {
828
+ scopeSigil = '~'
829
+ scopeName = personalScopeMatch[1]
830
+ } else if (uniwebNamespace) {
831
+ scopeSigil = '@'
832
+ scopeName = uniwebNamespace
833
+ }
834
+
835
+ // Id precedence mirrors the full resolution but stops at "no-prompt"
836
+ // sources. Auto-derive and TTY prompts both happen post-build so the
837
+ // user sees suggestions in context; the pre-flight only fires when
838
+ // the id is already determined.
839
+ let id = null
840
+ if (nameFlag) id = nameFlag
841
+ else if (orgScopeMatch) id = orgScopeMatch[2]
842
+ else if (personalScopeMatch) id = personalScopeMatch[2]
843
+ else if (uniwebId) id = uniwebId
844
+ else return null
845
+
846
+ if (scopeSigil) return `${scopeSigil}${scopeName}/${id}`
847
+ return id
848
+ }
849
+
552
850
  function bumpPatch(version) {
553
851
  const parts = version.split('.')
554
852
  if (parts.length !== 3) return version
@@ -556,13 +854,163 @@ function bumpPatch(version) {
556
854
  return parts.join('.')
557
855
  }
558
856
 
857
+ /**
858
+ * High-confidence auto-derive for non-interactive (CI) first publishes.
859
+ *
860
+ * Diego's principle: never silently take a generic scaffold default like
861
+ * `src` or `site` as the registry id (those are placeholders, not user
862
+ * intent). But when the user has typed a real name elsewhere — most
863
+ * unambiguously the workspace package.json's `name` (set by
864
+ * `uniweb create <name>`) — picking that in CI is the obvious right
865
+ * answer and stopping to ask just breaks the CI run.
866
+ *
867
+ * Auto-derive set is intentionally NARROW:
868
+ * 1. Workspace package.json::name, when it's a clean id and not a
869
+ * generic placeholder.
870
+ *
871
+ * Other suggestion sources from `buildIdSuggestions` (sibling-site
872
+ * name, M-code series) are NOT auto-picked: they're ambiguous in
873
+ * multi-package or multi-foundation workspaces. They remain visible
874
+ * in the CI error message when no high-confidence signal exists, so
875
+ * the user can pick one explicitly via `--name <id>`.
876
+ *
877
+ * Returns the id string, or null when no high-confidence signal is
878
+ * available (caller falls through to the existing error-with-
879
+ * suggestions guidance).
880
+ */
881
+ async function pickAutoDerivedId({ workspaceRoot, foundationDir }) {
882
+ const ID_RE = /^[a-z0-9_-]+$/
883
+ const PLACEHOLDERS = new Set(['src', 'site', 'foundation', 'workspace', 'project'])
884
+ const isHighConfidence = s => typeof s === 'string' && ID_RE.test(s) && !PLACEHOLDERS.has(s)
885
+
886
+ if (!workspaceRoot || workspaceRoot === foundationDir) return null
887
+ try {
888
+ const wsPkg = JSON.parse(await readFile(join(workspaceRoot, 'package.json'), 'utf8'))
889
+ const wsName = typeof wsPkg.name === 'string'
890
+ ? wsPkg.name.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^-+|-+$/g, '')
891
+ : null
892
+ if (isHighConfidence(wsName)) return wsName
893
+ } catch { /* no workspace package.json — skip */ }
894
+ return null
895
+ }
896
+
897
+ /**
898
+ * Build a list of contextual `uniweb.id` suggestions for first-time publishes.
899
+ *
900
+ * The CLI never auto-picks an id (Diego's principle: a bare folder name like
901
+ * "src" is wrong, and silently committing to it would couple the registry
902
+ * id to scaffold noise the user can't easily undo). Instead, suggest names
903
+ * derived from signals the workspace already exposes:
904
+ *
905
+ * - **Sibling site name.** When exactly one site exists in the workspace,
906
+ * the user's mental model is "this foundation is FOR that site" — so
907
+ * the site's name (or "<site>-foundation" if it would collide with the
908
+ * site's own package name) is a natural pick.
909
+ * - **Workspace name.** A workspace package.json often carries a name
910
+ * more meaningful than the foundation folder ("acme-marketing" vs "src").
911
+ * - **Folder name minus `-src`.** Foundations placed under
912
+ * `<name>-src/` strongly suggest `<name>` as the publish id (this
913
+ * is the existing default; preserved here for back-compat).
914
+ * - **Code-based fallback (M1, M2, …).** When the workspace already has
915
+ * other foundations (i.e., the user manages a category of similar
916
+ * foundations across sites/projects), suggest the next code in series.
917
+ *
918
+ * Returns deduplicated `{ id, why }` entries — `why` is shown next to the
919
+ * id in both the CI guidance message and the TTY select prompt so the
920
+ * user can tell at a glance which signal each suggestion comes from.
921
+ *
922
+ * The bare scaffold default `pkg.name === 'src'` is excluded by design.
923
+ * Likewise any non-conforming shape (uppercase, dots, etc.) is filtered
924
+ * out so users only ever see valid candidates.
925
+ */
926
+ async function buildIdSuggestions({ foundationDir, workspaceRoot, pkg }) {
927
+ const ID_RE = /^[a-z0-9_-]+$/
928
+ const sanitize = s => (typeof s === 'string' ? s.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^-+|-+$/g, '') : null)
929
+ const isValid = s => typeof s === 'string' && ID_RE.test(s) && s !== 'src' && s !== 'site'
930
+
931
+ const seen = new Set()
932
+ const out = []
933
+ const push = (id, why) => {
934
+ if (!isValid(id) || seen.has(id)) return
935
+ seen.add(id)
936
+ out.push({ id, why })
937
+ }
938
+
939
+ // 1. Sibling-site suggestion. Only fires when there's exactly one site
940
+ // in the workspace, because that's the unambiguous "for X" case.
941
+ try {
942
+ const sites = await findSites(workspaceRoot)
943
+ if (sites.length === 1) {
944
+ const sitePath = sites[0]
945
+ try {
946
+ const sitePkg = JSON.parse(await readFile(join(workspaceRoot, sitePath, 'package.json'), 'utf8'))
947
+ const siteName = sanitize(sitePkg.name)
948
+ if (siteName) {
949
+ push(siteName, `matches your site "${siteName}"`)
950
+ push(`${siteName}-foundation`, `derived from your site "${siteName}"`)
951
+ }
952
+ } catch { /* missing or malformed site package.json — skip */ }
953
+ }
954
+ } catch { /* findSites can fail in odd workspaces; non-fatal */ }
955
+
956
+ // 2. Workspace name suggestion. The workspace package.json's name is
957
+ // the user's chosen project identity; if it's a clean id, suggest it.
958
+ try {
959
+ if (workspaceRoot && workspaceRoot !== foundationDir) {
960
+ const wsPkg = JSON.parse(await readFile(join(workspaceRoot, 'package.json'), 'utf8'))
961
+ const wsName = sanitize(wsPkg.name)
962
+ if (wsName) push(wsName, `matches your workspace "${wsName}"`)
963
+ }
964
+ } catch { /* no workspace package.json — skip */ }
965
+
966
+ // 3. Folder name minus `-src`. The pre-existing default lives on as a
967
+ // suggestion now rather than the auto-pick.
968
+ if (workspaceRoot && foundationDir !== workspaceRoot) {
969
+ const folderName = foundationDir.replace(workspaceRoot + '/', '').split('/')[0]
970
+ const stripped = sanitize(folderName?.replace(/-src$/, ''))
971
+ if (stripped) push(stripped, `derived from the folder "${folderName}"`)
972
+ }
973
+
974
+ // 4. Code-based fallback. Only suggested when the workspace already has
975
+ // multiple foundations — the case Diego flagged (publishers managing
976
+ // a category like M1, M2, M3 across sites/projects).
977
+ try {
978
+ const foundations = await findFoundations(workspaceRoot)
979
+ if (foundations.length >= 2) {
980
+ // Find the next M-number not already used by a sibling foundation's id.
981
+ const usedCodes = new Set()
982
+ for (const fp of foundations) {
983
+ try {
984
+ const fp_pkg = JSON.parse(await readFile(join(workspaceRoot, fp, 'package.json'), 'utf8'))
985
+ const id = fp_pkg.uniweb?.id
986
+ const m = typeof id === 'string' && id.match(/^m(\d+)$/i)
987
+ if (m) usedCodes.add(parseInt(m[1], 10))
988
+ } catch { /* skip */ }
989
+ }
990
+ let n = 1
991
+ while (usedCodes.has(n)) n++
992
+ push(`m${n}`, `next in your "M-code" series`)
993
+ }
994
+ } catch { /* findFoundations failed — skip */ }
995
+
996
+ return out
997
+ }
998
+
999
+ /**
1000
+ * Per-directory git state. Mirrors `deploy.js::readGitState` exactly —
1001
+ * 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.
1006
+ */
559
1007
  function readGitState(dir) {
560
1008
  try {
561
- const sha = execSync('git rev-parse HEAD', {
1009
+ const sha = execSync('git log -1 --format=%H -- .', {
562
1010
  cwd: dir,
563
1011
  stdio: ['ignore', 'pipe', 'ignore'],
564
1012
  }).toString().trim()
565
- const status = execSync('git status --porcelain', {
1013
+ const status = execSync('git status --porcelain -- .', {
566
1014
  cwd: dir,
567
1015
  stdio: ['ignore', 'pipe', 'ignore'],
568
1016
  }).toString()