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.
- package/README.md +32 -22
- package/package.json +4 -4
- package/src/commands/add.js +88 -12
- package/src/commands/build.js +190 -23
- package/src/commands/deploy.js +343 -18
- 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 +514 -66
- 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
|
@@ -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
|
-
|
|
196
|
-
|
|
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
|
|
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 =
|
|
343
|
+
const version = earlyPkg.version
|
|
221
344
|
|
|
222
345
|
if (!rawName || !version) {
|
|
223
|
-
error('
|
|
224
|
-
console.log(`${colors.dim} Ensure your package.json has "name" and "version" fields
|
|
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
|
-
|
|
270
|
-
|
|
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.
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
//
|
|
447
|
-
|
|
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
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
|
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()
|