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