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.
- package/README.md +32 -22
- package/package.json +4 -4
- package/src/commands/add.js +88 -12
- package/src/commands/build.js +199 -28
- package/src/commands/deploy.js +318 -16
- package/src/commands/doctor.js +172 -130
- package/src/commands/handoff.js +1 -1
- package/src/commands/invite.js +2 -2
- package/src/commands/publish.js +297 -54
- package/src/commands/rename.js +310 -0
- package/src/framework-index.json +4 -4
- package/src/index.js +14 -5
- package/src/utils/receipt.js +91 -0
- package/src/utils/registry.js +33 -0
- package/templates/workspace/package.json.hbs +2 -4
package/src/commands/publish.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
//
|
|
447
|
-
|
|
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
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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', {
|