uniweb 0.12.26 → 0.12.28

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.
@@ -1,1201 +1,176 @@
1
1
  /**
2
- * Publish Command
2
+ * uniweb publish — make a SYNCED site's current backend state live (CMS publish).
3
3
  *
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.
4
+ * Three "publish-ish" verbs, three jobs don't conflate them:
5
+ * - `uniweb deploy` hosts the CLI's FILE-BUILT payload (POST /dev/deploy).
6
+ * - `uniweb publish` publishes a SITE that already lives on the backend as a
7
+ * `@uniweb/site-content` entity (synced via `uniweb push`) — POST /dev/site/publish.
8
+ * - `uniweb register` registers a FOUNDATION (+ the data schemas it renders).
9
+ * (Foundation publishing used to be `uniweb publish`; it is now `register`.)
7
10
  *
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.
11
+ * `publish` makes the site's CURRENT backend state live — including edits made
12
+ * through the app since the last push. It does NOT push local files (run
13
+ * `uniweb push` first if you want your local edits live, then `publish`). The two
14
+ * are deliberately separate steps, mirroring the directional sync primitives.
13
15
  *
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).
16
+ * `{uuid}` is the site-content uuid (`site.yml::$uuid`, written by `uniweb push`).
17
+ * A site that was never pushed 404s — push it first, or use `uniweb deploy` for a
18
+ * file-only site.
24
19
  *
25
20
  * Usage:
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
31
- * uniweb publish --edit-access open # Anyone can edit in Studio (default: restricted)
32
- * uniweb publish --dry-run # Show what would be published; no writes
33
- * uniweb publish --propagate # Walk trusting sites' policy waves
21
+ * uniweb publish Publish the synced site's current state
22
+ * uniweb publish --backend <url> Override the backend origin
23
+ * uniweb publish --token <bearer> Auth bearer (skips `uniweb login`)
24
+ * uniweb publish --dry-run Resolve everything; POST nothing
25
+ *
26
+ * Backend: BackendClient POST /dev/site/publish/{uuid}. Origin from
27
+ * --backend/--registry > UNIWEB_REGISTER_URL > default. Auth: --token >
28
+ * UNIWEB_TOKEN > `uniweb login`.
34
29
  */
35
30
 
36
31
  import { existsSync } from 'node:fs'
37
- import { readFile, mkdir } from 'node:fs/promises'
38
- import { resolve, join } from 'node:path'
39
- import { execSync } from 'node:child_process'
32
+ import { readFile } from 'node:fs/promises'
33
+ import { join } from 'node:path'
34
+ import yaml from 'js-yaml'
40
35
 
41
- import { resolveFoundationSrcPath, classifyPackage } from '@uniweb/build'
42
- import { createLocalRegistry, RemoteRegistry } from '../utils/registry.js'
43
- import { ensureAuth, readAuth, writeAuth, decodeJwtPayload } from '../utils/auth.js'
44
- import { getRegistryUrl, getBackendUrl } from '../utils/config.js'
45
- import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
46
- import { findWorkspaceRoot, findFoundations, findSites, promptSelect } from '../utils/workspace.js'
47
- import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
36
+ import { BackendClient } from '../backend/client.js'
37
+ import { resolveSiteDir } from './deploy.js'
38
+ import { readFlagValue } from '../utils/args.js'
48
39
 
49
- // Colors for terminal output
50
- const colors = {
51
- reset: '\x1b[0m',
52
- bright: '\x1b[1m',
53
- dim: '\x1b[2m',
54
- cyan: '\x1b[36m',
55
- green: '\x1b[32m',
56
- yellow: '\x1b[33m',
57
- red: '\x1b[31m',
40
+ const c = {
41
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
42
+ cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m',
58
43
  }
59
-
60
- function success(message) {
61
- console.log(`${colors.green}✓${colors.reset} ${message}`)
44
+ const say = {
45
+ ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
46
+ info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
47
+ warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
48
+ err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
49
+ dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
62
50
  }
63
51
 
64
- function error(message) {
65
- console.error(`${colors.red}✗${colors.reset} ${message}`)
52
+ // Highest installed runtime from the backend's /dev/config list (numeric-aware
53
+ // sort). Mirrors deploy.js's resolver. Null when the list is empty.
54
+ function pickHighestRuntime(installed) {
55
+ if (!Array.isArray(installed) || installed.length === 0) return null
56
+ return [...installed].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true }))[0]
66
57
  }
67
58
 
68
- function info(message) {
69
- console.log(`${colors.cyan}→${colors.reset} ${message}`)
59
+ // Origin-relative serve path → clickable absolute URL (self-serve default).
60
+ function absolutizeServeUrl(origin, url) {
61
+ if (!url || typeof url !== 'string') return null
62
+ if (/^https?:\/\//.test(url)) return url
63
+ return `${origin.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
70
64
  }
71
65
 
72
- /**
73
- * Resolve the foundation directory to publish.
74
- *
75
- * Priority:
76
- * 1. In a foundation directory → use it
77
- * 2. At workspace root, one foundation → use it
78
- * 3. At workspace root, multiple prompt (or error if non-interactive)
79
- * 4. No foundation educational error
80
- *
81
- * @param {string[]} args
82
- * @returns {Promise<string>} Absolute path to the foundation directory
83
- */
84
- async function resolveFoundationDir(args) {
85
- const cwd = process.cwd()
86
- const prefix = getCliPrefix()
87
-
88
- // Check if current directory is a foundation
89
- const type = classifyPackage(cwd)
90
- if (type === 'foundation') {
91
- return cwd
92
- }
93
-
94
- // Check workspace
95
- const workspaceRoot = findWorkspaceRoot(cwd)
96
- if (workspaceRoot) {
97
- const foundations = await findFoundations(workspaceRoot)
98
-
99
- if (foundations.length === 1) {
100
- return resolve(workspaceRoot, foundations[0])
101
- }
102
-
103
- if (foundations.length > 1) {
104
- if (isNonInteractive(args)) {
105
- error('Multiple foundations found. Specify which one to publish.')
106
- console.log('')
107
- for (const f of foundations) {
108
- console.log(` ${colors.cyan}cd ${f} && ${prefix} publish${colors.reset}`)
109
- }
110
- process.exit(1)
111
- }
112
-
113
- const choice = await promptSelect('Which foundation?', foundations)
114
- if (!choice) {
115
- console.log('\nPublish cancelled.')
116
- process.exit(0)
117
- }
118
- return resolve(workspaceRoot, choice)
119
- }
120
- }
121
-
122
- // No foundation found — educational error
123
- error('No foundation found in this workspace.')
124
- console.log('')
125
- console.log(` ${colors.dim}\`publish\` registers your foundation so clients you invite can${colors.reset}`)
126
- console.log(` ${colors.dim}create and manage their own sites with it.${colors.reset}`)
127
- console.log('')
128
- console.log(` ${colors.dim}To publish, run this command from a foundation directory, or from a${colors.reset}`)
129
- console.log(` ${colors.dim}workspace root that contains a foundation.${colors.reset}`)
130
- process.exit(1)
66
+ // Locale set from site.yml — source/default first, then declared locales.
67
+ // Tolerant of the shapes site.yml uses (i18n.locales, languages[]). Null when
68
+ // single-locale, so the body omits `languages` and the backend defaults.
69
+ function extractLanguages(siteYml) {
70
+ const def = siteYml.defaultLanguage || siteYml.lang || 'en'
71
+ const locales = siteYml.i18n?.locales || siteYml.languages
72
+ if (!Array.isArray(locales) || locales.length === 0) return null
73
+ const norm = locales.map((l) => (typeof l === 'string' ? l : l?.value || l?.code)).filter(Boolean)
74
+ return [def, ...norm.filter((l) => l !== def)]
131
75
  }
132
76
 
133
- /**
134
- * Parse --registry <url> from args.
135
- * @param {string[]} args
136
- * @returns {string|null}
137
- */
138
- function parseRegistryUrl(args) {
139
- const idx = args.indexOf('--registry')
140
- if (idx === -1 || !args[idx + 1]) return null
141
- return args[idx + 1]
142
- }
143
-
144
- /**
145
- * Parse --namespace <handle> from args.
146
- * @param {string[]} args
147
- * @returns {string|null}
148
- */
149
- function parseNamespace(args) {
150
- const idx = args.indexOf('--namespace')
151
- if (idx === -1 || !args[idx + 1]) return null
152
- return args[idx + 1]
153
- }
154
-
155
- /**
156
- * Parse --name <id> from args.
157
- * The publish-time "id" — the bare-name segment in the registry name.
158
- * Distinct from `package.json::name` (a workspace concern). Persisted
159
- * to `package.json::uniweb.id` after the first successful publish so
160
- * it doesn't need to be supplied again.
161
- * @param {string[]} args
162
- * @returns {string|null}
163
- */
164
- function parseName(args) {
165
- const idx = args.indexOf('--name')
166
- if (idx === -1 || !args[idx + 1]) return null
167
- return args[idx + 1]
168
- }
169
-
170
- /**
171
- * Parse --edit-access <policy> from args.
172
- * @param {string[]} args
173
- * @returns {'open'|'restricted'|null}
174
- */
175
- function parseEditAccess(args) {
176
- const idx = args.indexOf('--edit-access')
177
- if (idx === -1 || !args[idx + 1]) return null
178
- const value = args[idx + 1]
179
- if (value !== 'open' && value !== 'restricted') {
180
- error(`Invalid --edit-access value: "${value}". Must be "open" or "restricted".`)
181
- process.exit(1)
182
- }
183
- return value
184
- }
185
-
186
- /**
187
- * Main publish command handler
188
- */
189
77
  export async function publish(args = []) {
190
- const isLocal = args.includes('--local')
191
- const isDryRun = args.includes('--dry-run')
192
- // --propagate opts the new version into the registry's version-update
193
- // walk: trusting sites whose policy permits the jump pick it up
194
- // automatically via gated rollout. Without --propagate (default
195
- // 'silent'), the artifact is stored but no site moves until republish
196
- // or manual refresh.
197
- const isPropagate = args.includes('--propagate')
198
- // --catalog confirms the user understands they're publishing to the
199
- // public catalog. Phase 3 of the CLI ergonomics overhaul: in
200
- // interactive mode, missing --catalog triggers a confirmation prompt;
201
- // in non-interactive mode, it's required (otherwise fatal). Skipped
202
- // entirely for --local (local mock) and --dry-run (no writes).
203
- const isCatalog = args.includes('--catalog')
204
- const registryUrl = parseRegistryUrl(args)
205
- const editAccess = parseEditAccess(args)
206
- const namespaceFlag = parseNamespace(args)
207
- const nameFlag = parseName(args)
208
-
209
- // 1. Resolve foundation directory
210
- const foundationDir = await resolveFoundationDir(args)
211
-
212
- // Verify it's actually a foundation (canonical classifier checks
213
- // package.json::main, then main.js, then legacy foundation.js).
214
- if (classifyPackage(foundationDir) !== 'foundation') {
215
- error(`Not a foundation directory: ${foundationDir}`)
216
- process.exit(1)
217
- }
218
-
219
- // 2. Auto-build if dist/ is missing OR stale.
220
- //
221
- // "Stale" means the schema fingerprint baked into
222
- // `dist/meta/schema.json::_self.version` doesn't match the user's
223
- // current `package.json::version`. That happens when the user bumps
224
- // the version and runs `uniweb publish` without rebuilding — the
225
- // artifact in dist/ encodes the OLD version, but the publish
226
- // intends the NEW one. Without rebuilding we'd ship inconsistent
227
- // bytes (schema says one version, registry record says another).
228
- const distDir = join(foundationDir, 'dist')
229
- // @uniweb/build@0.14.0+ emits dist/entry.js (Phase 5 of CDN migration);
230
- // older builds emitted dist/foundation.js. Accept either so a single CLI
231
- // works against both old and new foundations during the rollout window.
232
- const entryJs = join(distDir, 'entry.js')
233
- const foundationJs = join(distDir, 'foundation.js')
234
- const hasMainArtifact = () => existsSync(entryJs) || existsSync(foundationJs)
235
- const schemaJson = join(distDir, 'meta', 'schema.json')
236
-
237
- // Pre-read package.json so we can compare its version against the
238
- // schema before deciding whether to rebuild.
239
- const pkgPath = join(foundationDir, 'package.json')
240
- let earlyPkg
241
- try {
242
- earlyPkg = JSON.parse(await readFile(pkgPath, 'utf8'))
243
- } catch (err) {
244
- error(`Failed to read package.json: ${err.message}`)
245
- process.exit(1)
246
- }
247
-
248
- // 1b. Phase 4e: catalog-publish gate.
249
- //
250
- // `uniweb publish` is for cataloging a foundation as a product —
251
- // deliberate `@org/{name}` name, version-pinnable, discoverable.
252
- // Site-bound foundations go through `uniweb deploy` instead, which
253
- // uploads them to `sites/{siteId}/_src/...` automatically.
254
- //
255
- // The gate rejects two shapes:
256
- // (a) No explicit name at all — running `uniweb publish` from a
257
- // fresh scaffold would otherwise register `src` (or whatever
258
- // the workspace name is) as a catalog entry.
259
- // (b) `~user/...` or personal-UUID scopes — Phase 4e retired the
260
- // personal scope; site-bound foundations use deploy, catalog
261
- // uses `@org/`. There is no "personal catalog" any more.
262
- //
263
- // `--local` skips the gate (local mock registry, no public consequences).
264
- const hasExplicitName = !!(
265
- nameFlag ||
266
- namespaceFlag ||
267
- /^[@~]/.test(earlyPkg.name || '') ||
268
- earlyPkg.uniweb?.id ||
269
- earlyPkg.uniweb?.namespace
270
- )
271
- if (!hasExplicitName && !isLocal) {
272
- error('uniweb publish needs a deliberate foundation name.')
273
- console.log('')
274
- console.log(` ${colors.bright}If this foundation only powers one site, use ${colors.cyan}uniweb deploy${colors.reset}${colors.bright} instead.${colors.reset}`)
275
- console.log(` ${colors.dim}Deploy uploads your foundation alongside the site's assets — no name ceremony.${colors.reset}`)
276
- console.log('')
277
- console.log(` ${colors.bright}If you're cataloging this foundation as a product, name it explicitly:${colors.reset}`)
278
- console.log(` ${colors.cyan}uniweb publish @your-org/foundation-name${colors.reset}`)
279
- console.log('')
280
- console.log(` ${colors.dim}For local development, ${colors.reset}${colors.cyan}--local${colors.reset}${colors.dim} skips this gate.${colors.reset}`)
281
- process.exit(1)
282
- }
283
-
284
- // 1b'. Phase 4e: reject `~`-scoped names. Site-bound foundations don't
285
- // go through publish at all.
286
- if (!isLocal) {
287
- const candidateName = nameFlag || earlyPkg.name || earlyPkg.uniweb?.id || ''
288
- const candidateNamespace = namespaceFlag || earlyPkg.uniweb?.namespace || ''
289
- if (candidateName.startsWith('~') || candidateNamespace.startsWith('~')) {
290
- error('uniweb publish is for cataloged foundations only.')
291
- console.log('')
292
- console.log(` ${colors.dim}The personal-UUID scope (${colors.reset}~uuid/name${colors.dim}) is no longer accepted.${colors.reset}`)
293
- 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}`)
294
- console.log('')
295
- console.log(` ${colors.bright}For a catalog product, use an org scope:${colors.reset}`)
296
- console.log(` ${colors.cyan}uniweb publish @your-org/foundation-name${colors.reset}`)
297
- console.log('')
298
- 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}`)
299
- process.exit(1)
300
- }
301
- }
302
-
303
- // 1c. Phase 3 catalog confirmation gate.
304
- //
305
- // Cataloging a foundation has consequences (visible in the catalog,
306
- // other developers may pin to versions, propagation system tracks
307
- // it). Require explicit confirmation:
308
- // - Interactive: prompt unless --catalog passed.
309
- // - Non-interactive: fatal unless --catalog passed.
310
- // - Skipped for --local and --dry-run (no public consequences).
311
- if (hasExplicitName && !isLocal && !isDryRun && !isCatalog) {
312
- if (isNonInteractive(process.argv)) {
313
- error('uniweb publish to the catalog needs --catalog confirmation.')
314
- console.log('')
315
- console.log(` ${colors.dim}Catalog publishes are public — other developers can pin to your versions.${colors.reset}`)
316
- console.log(` ${colors.dim}Pass ${colors.reset}${colors.cyan}--catalog${colors.reset}${colors.dim} to confirm:${colors.reset}`)
317
- console.log(` ${colors.cyan}uniweb publish ${colors.reset}${colors.dim}<args>${colors.reset} ${colors.cyan}--catalog${colors.reset}`)
318
- console.log('')
319
- console.log(` ${colors.dim}For site-bound foundations, use ${colors.reset}${colors.cyan}uniweb deploy${colors.reset}${colors.dim} instead.${colors.reset}`)
320
- process.exit(1)
321
- }
322
-
323
- const prompts = (await import('prompts')).default
324
- console.log('')
325
- console.log(`${colors.dim}You're publishing this foundation to the public catalog.${colors.reset}`)
326
- console.log(`${colors.dim}Other developers will be able to find it and pin to its versions.${colors.reset}`)
327
- console.log(`${colors.dim}For site-bound foundations, ${colors.reset}${colors.cyan}uniweb deploy${colors.reset}${colors.dim} is the right command.${colors.reset}`)
328
- console.log('')
329
- const confirm = await prompts({
330
- type: 'confirm',
331
- name: 'go',
332
- message: 'Continue with catalog publish?',
333
- initial: false,
334
- }, {
335
- onCancel: () => { console.log(''); console.log('Publish cancelled.'); process.exit(0) },
336
- })
337
- if (!confirm.go) {
338
- console.log('')
339
- console.log(`${colors.dim}Cancelled. Use ${colors.reset}${colors.cyan}uniweb deploy${colors.reset}${colors.dim} for site-bound foundations.${colors.reset}`)
340
- process.exit(0)
341
- }
342
- }
343
-
344
- let needsBuild = !hasMainArtifact() || !existsSync(schemaJson)
345
- let buildReason = needsBuild ? 'no dist/ found' : null
346
-
347
- if (!needsBuild) {
78
+ const dryRun = args.includes('--dry-run')
79
+ const siteDir = await resolveSiteDir(args, 'publish')
80
+
81
+ // The site-content uuid lives in site.yml::$uuid (written by `uniweb push`).
82
+ // No uuid the site was never synced; publish has nothing to make live.
83
+ const siteYmlPath = join(siteDir, 'site.yml')
84
+ let siteYml = {}
85
+ if (existsSync(siteYmlPath)) {
348
86
  try {
349
- const peekSchema = JSON.parse(await readFile(schemaJson, 'utf8'))
350
- if (peekSchema?._self?.version && earlyPkg.version && peekSchema._self.version !== earlyPkg.version) {
351
- needsBuild = true
352
- buildReason = `package.json::version (${earlyPkg.version}) differs from dist/meta/schema.json::_self.version (${peekSchema._self.version})`
353
- }
87
+ siteYml = yaml.load(await readFile(siteYmlPath, 'utf8')) || {}
354
88
  } catch {
355
- // Malformed schema → treat as stale.
356
- needsBuild = true
357
- buildReason = 'dist/meta/schema.json could not be parsed'
358
- }
359
- }
360
-
361
- // --dry-run gate. Must come BEFORE the pre-flight registry check (which
362
- // may persist `uniweb.id` to package.json on the matching-sha path) and
363
- // BEFORE the build (which writes to dist/). Earlier the dry-run check
364
- // sat after both, which violated the zero-writes contract.
365
- if (isDryRun) {
366
- const previewName = quickResolveCanonicalName(earlyPkg, { namespaceFlag, nameFlag })
367
- || earlyPkg.name
368
- || '(unresolved)'
369
- const target = isLocal ? 'local registry' : `remote registry (${registryUrl || getRegistryUrl()})`
370
- console.log('')
371
- info(`Would publish ${colors.bright}${previewName}@${earlyPkg.version}${colors.reset} to ${target}`)
372
- if (needsBuild) {
373
- console.log(` ${colors.dim}Would build first: ${buildReason}${colors.reset}`)
374
- } else {
375
- console.log(` ${colors.dim}Source: ${distDir}${colors.reset}`)
376
- }
377
- return
378
- }
379
-
380
- // 2b. Pre-flight registry check — runs BEFORE the build so we don't
381
- // burn vite cycles on a foundation we already know we can't (or
382
- // don't need to) publish.
383
- //
384
- // Two outcomes short-circuit the build:
385
- //
386
- // a. The registry already has `<canonicalName>@<version>`
387
- // published from the CURRENT git sha (per-foundation last
388
- // commit). The artifact upstream is correct; refresh the
389
- // local receipt and exit. (Same outcome as the post-build
390
- // duplicate check, just earlier — saves a build.)
391
- //
392
- // b. The registry has the version published from a DIFFERENT
393
- // sha. The user has unpublished changes against an already-
394
- // published version → "bump the version" error before any
395
- // build work. Was the eval skill's pp-03 row.
396
- //
397
- // If the pre-flight can't determine the canonical name from
398
- // pkg.json + flags + auth alone (e.g., needs a TTY prompt for
399
- // the foundation id), it falls through silently to the existing
400
- // post-build path. No-build-saved is still the existing behavior.
401
- if (!isLocal) {
402
- const preflightName = quickResolveCanonicalName(earlyPkg, { namespaceFlag, nameFlag })
403
- const preflightVersion = earlyPkg.version
404
- if (preflightName && preflightVersion) {
405
- try {
406
- const auth = await readAuth()
407
- if (auth?.token) {
408
- const claims = decodeJwtPayload(auth.token)
409
- const memberUuid = claims?.memberUuid
410
- // Empty-scope publishes are server-rewritten to ~<memberUuid>/<id>.
411
- // Mirror that here so getVersionEntry queries the canonical key.
412
- const lookupName = preflightName.startsWith('@') || preflightName.startsWith('~')
413
- ? preflightName
414
- : memberUuid ? `~${memberUuid}/${preflightName}` : null
415
- if (lookupName) {
416
- const registryUrlPre = registryUrl || getRegistryUrl()
417
- const registryPre = new RemoteRegistry(registryUrlPre, auth.token)
418
- const existing = await registryPre.getVersionEntry(lookupName, preflightVersion)
419
- if (existing) {
420
- const { gitSha } = readGitState(foundationDir)
421
- if (gitSha && existing.publishedFromGitSha === gitSha) {
422
- // Already published from this exact source — nothing to do.
423
- console.log('')
424
- success(`${colors.bright}${lookupName}@${preflightVersion}${colors.reset} already published from ${gitSha.slice(0, 7)}.`)
425
- return
426
- }
427
- // Sha mismatch (or no provenance recorded for the existing
428
- // entry). Clean error before any build work.
429
- console.log('')
430
- error(`Foundation source has changed since the last publish, but ${colors.bright}${lookupName}@${preflightVersion}${colors.reset} is already published.`)
431
- console.log('')
432
- console.log(` Bump ${colors.cyan}package.json::version${colors.reset} to publish an update:`)
433
- console.log(` ${colors.dim}"version": "${bumpPatch(preflightVersion)}"${colors.reset}`)
434
- process.exit(1)
435
- }
436
- }
437
- }
438
- } catch {
439
- // Network down, malformed auth, etc. — fall through to the
440
- // existing post-build flow. No-build-saved is still the same
441
- // behavior the user got before this pre-flight existed.
442
- }
443
- }
444
- }
445
-
446
- if (needsBuild) {
447
- console.log(`${colors.yellow}⚠${colors.reset} ${buildReason}. Building foundation...`)
448
- console.log('')
449
- execSync('npx uniweb build --target foundation', {
450
- cwd: foundationDir,
451
- stdio: 'inherit',
452
- })
453
- console.log('')
454
-
455
- if (!hasMainArtifact() || !existsSync(schemaJson)) {
456
- error('Build did not produce dist/entry.js (or legacy dist/foundation.js) and dist/meta/schema.json')
457
- process.exit(1)
89
+ siteYml = {}
458
90
  }
459
91
  }
460
-
461
- // 3. Read name + version from the (now-fresh) schema + package.json.
462
- //
463
- // `_self.name` is the build-RESOLVED form applies `uniweb.id`,
464
- // scope resolution, etc., that are easier to read off the build
465
- // output than to redo here. `version` is sourced from package.json
466
- // directly; the version-skew check above already ensured the
467
- // schema and package.json agree.
468
- let schema
469
- try {
470
- schema = JSON.parse(await readFile(schemaJson, 'utf8'))
471
- } catch (err) {
472
- error(`Failed to read dist/meta/schema.json: ${err.message}`)
473
- process.exit(1)
474
- }
475
-
476
- const rawName = schema._self?.name
477
- const version = earlyPkg.version
478
-
479
- if (!rawName || !version) {
480
- error('Foundation missing name or version')
481
- console.log(`${colors.dim} Ensure your package.json has "name" and "version" fields,${colors.reset}`)
482
- console.log(`${colors.dim} and that the build has produced dist/meta/schema.json with _self.name.${colors.reset}`)
483
- process.exit(1)
484
- }
485
-
486
- // 3b. Resolve scope and foundation id.
487
- //
488
- // The publish-time identity is two pieces: a SCOPE (org `@`, personal `~`,
489
- // or empty → server-resolved personal) and an ID (the bare name segment).
490
- // They live in different places and get different defaults.
491
- //
492
- // Scope priority:
493
- // 1. --namespace <handle> CLI flag → forces `@<handle>` org scope
494
- // 2. Sigil in `package.json::name`:
495
- // - `@org/x` → `@org`
496
- // - `~user/x` → `~user` (personal alias)
497
- // 3. `package.json::uniweb.namespace` → legacy explicit org field
498
- // 4. (none) → empty scope; server attaches
499
- // the publisher's personal
500
- // scope at upload time
501
- //
502
- // ID priority (the bare name segment):
503
- // 1. --name <id> CLI flag → override
504
- // 2. Sigil-stripped `package.json::name` → @org/<id> or ~user/<id>
505
- // 3. `package.json::uniweb.id` → persisted publish-id
506
- // 4. Interactive prompt → and write back to
507
- // `package.json::uniweb.id`
508
- // so future publishes don't
509
- // re-prompt.
510
- // 5. Non-interactive without a usable id → fail with guidance.
511
- //
512
- // Note: a bare `package.json::name` (e.g. the scaffold default `src`)
513
- // is intentionally NOT used as a fallback id. The workspace name is for
514
- // pnpm linking and the file: dependency in site/package.json — using it
515
- // as the publish id would couple the registry identity to the workspace,
516
- // exactly what `uniweb.id` exists to prevent. Users who want their
517
- // workspace name to be the publish id pass `--name <pkg-name>` once;
518
- // it persists.
519
- //
520
- // Why two storage locations for an ID? `package.json::name` is a
521
- // workspace concern — pnpm uses it to link packages, sites reference
522
- // it via `file:` deps and `site.yml::foundation`. Renaming it cascades
523
- // through several files. `uniweb.id` is publish-only — changing it
524
- // affects only the registry identity, never the workspace. Most users
525
- // benefit from leaving `package.json::name` as the scaffold default
526
- // (`src`) and putting the published-as id in `uniweb.id`.
527
- // pkgPath was declared earlier (during the rebuild-stale-dist check).
528
- // Reuse the already-loaded `earlyPkg` rather than re-reading from disk.
529
- const pkg = earlyPkg
530
- const uniwebNamespace = pkg.uniweb?.namespace
531
- const uniwebId = pkg.uniweb?.id
532
- const orgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
533
- const personalScopeMatch = (pkg.name || '').match(/^~([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
534
-
535
- // Resolve the SCOPE.
536
- let scopeSigil = null
537
- let scopeName = null
538
- if (namespaceFlag) {
539
- scopeSigil = '@'
540
- scopeName = namespaceFlag
541
- } else if (orgScopeMatch) {
542
- scopeSigil = '@'
543
- scopeName = orgScopeMatch[1]
544
- } else if (personalScopeMatch) {
545
- scopeSigil = '~'
546
- scopeName = personalScopeMatch[1]
547
- } else if (uniwebNamespace) {
548
- scopeSigil = '@'
549
- scopeName = uniwebNamespace
550
- }
551
-
552
- // Resolve the ID.
553
- const ID_RE = /^[a-z0-9_-]+$/
554
- let foundationName = null
555
- let writeBackId = false
556
- if (nameFlag) {
557
- foundationName = nameFlag
558
- // Persist the flag's value when it differs from what's already in
559
- // `uniweb.id`. This makes rename a one-shot:
560
- // $ uniweb publish --name new-name
561
- // From here on, `uniweb publish` (no flag) keeps using `new-name`.
562
- // No-op when --name matches the existing id.
563
- if (nameFlag !== uniwebId) writeBackId = true
564
- } else if (orgScopeMatch) {
565
- foundationName = orgScopeMatch[2]
566
- } else if (personalScopeMatch) {
567
- foundationName = personalScopeMatch[2]
568
- } else if (uniwebId) {
569
- foundationName = uniwebId
570
- }
571
- if (!foundationName) {
572
- // No id resolvable from any field. Build a set of suggestions
573
- // contextual to this workspace, then either prompt (TTY) or print
574
- // them as guidance (CI). The bare `pkg.name` is intentionally NOT
575
- // a suggestion when it equals the scaffold default `src` — picking
576
- // that name would couple the registry id to a generic placeholder
577
- // that future renames couldn't undo.
578
- const workspaceRoot = findWorkspaceRoot(foundationDir) || foundationDir
579
- const suggestions = await buildIdSuggestions({ foundationDir, workspaceRoot, pkg })
580
-
581
- if (isNonInteractive(process.argv)) {
582
- // CI: when there's a high-confidence signal — the workspace
583
- // package.json's name (the user typed it via `uniweb create
584
- // <name>`) — auto-derive and persist. This unblocks first-deploy
585
- // CI flows (pp-01 etc.) where stopping to ask isn't an option.
586
- // Other suggestion sources (sibling-site name, M-code) are NOT
587
- // auto-picked because they're ambiguous in multi-package
588
- // workspaces; they remain available via the error message
589
- // when no high-confidence signal exists.
590
- const autoId = await pickAutoDerivedId({ workspaceRoot, foundationDir })
591
- if (autoId) {
592
- info(`Auto-deriving ${colors.bright}uniweb.id: "${autoId}"${colors.reset} ${colors.dim}(matches workspace name; persisted to package.json)${colors.reset}`)
593
- foundationName = autoId
594
- writeBackId = true
595
- } else {
596
- error('Foundation id is required for publishing.')
597
- console.log('')
598
- if (suggestions.length > 0) {
599
- console.log(` ${colors.bright}Suggestions for your workspace:${colors.reset}`)
600
- for (const { id, why } of suggestions) {
601
- console.log(` ${colors.cyan}${id}${colors.reset} ${colors.dim}${why}${colors.reset}`)
602
- }
603
- console.log('')
604
- }
605
- console.log(` ${colors.dim}Use one of:${colors.reset}`)
606
- const example = suggestions[0]?.id || '<id>'
607
- console.log(` ${colors.cyan}uniweb publish --name ${example}${colors.reset}`)
608
- console.log(` ${colors.dim}Add ${colors.reset}"uniweb": { "id": "<your-id>" }${colors.dim} to package.json${colors.reset}`)
609
- console.log(` ${colors.dim}Or use a scoped name in package.json: ${colors.reset}"name": "@org/<id>"${colors.reset}`)
610
- process.exit(1)
611
- }
612
- } else {
613
-
614
- const prompts = (await import('prompts')).default
615
- console.log('')
616
- console.log(`${colors.dim}This is the first publish of this foundation. Pick a name${colors.reset}`)
617
- console.log(`${colors.dim}for the registry — what your foundation will be known as.${colors.reset}`)
618
- console.log('')
619
-
620
- let chosen
621
- if (suggestions.length > 0) {
622
- // Surface contextual suggestions first (sibling site, workspace name,
623
- // M-code series). Always include "Type a different name…" so the
624
- // user is never trapped in a list.
625
- const choices = [
626
- ...suggestions.map(s => ({ title: s.id, description: s.why, value: s.id })),
627
- { title: 'Type a different name…', value: '__custom__' },
628
- ]
629
- const pickResp = await prompts({
630
- type: 'select',
631
- name: 'pick',
632
- message: 'Foundation name',
633
- choices,
634
- initial: 0,
635
- }, {
636
- onCancel: () => { console.log(''); console.log('Publish cancelled.'); process.exit(0) },
637
- })
638
- if (!pickResp.pick) process.exit(0)
639
- chosen = pickResp.pick
640
- } else {
641
- chosen = '__custom__'
642
- }
643
-
644
- if (chosen === '__custom__') {
645
- const folderName = workspaceRoot === foundationDir
646
- ? null
647
- : foundationDir.replace(workspaceRoot + '/', '').split('/')[0]
648
- const suggestion =
649
- suggestions[0]?.id ||
650
- (folderName ? folderName.replace(/-src$/, '') : null) ||
651
- ''
652
- const textResp = await prompts({
653
- type: 'text',
654
- name: 'id',
655
- message: 'Foundation name',
656
- initial: suggestion,
657
- validate: (v) => {
658
- if (!v) return 'Required'
659
- if (!ID_RE.test(v)) return 'Lowercase letters, digits, hyphens, underscores only'
660
- return true
661
- },
662
- }, {
663
- onCancel: () => { console.log(''); console.log('Publish cancelled.'); process.exit(0) },
664
- })
665
- if (!textResp.id) process.exit(0)
666
- chosen = textResp.id
667
- }
668
-
669
- foundationName = chosen
670
- writeBackId = true
671
- }
672
- }
673
-
674
- // Validate the resolved id (may have come from any source).
675
- if (!ID_RE.test(foundationName)) {
676
- error(`Invalid foundation name: "${foundationName}"`)
677
- console.log(` ${colors.dim}Names must be lowercase letters, digits, hyphens, or underscores.${colors.reset}`)
678
- process.exit(1)
679
- }
680
-
681
- // Persist the id so future publishes don't re-prompt.
682
- if (writeBackId) {
683
- pkg.uniweb = pkg.uniweb || {}
684
- pkg.uniweb.id = foundationName
685
- await writeJsonPreservingStyleAsync(pkgPath, pkg)
686
- info(`Wrote ${colors.cyan}uniweb.id: "${foundationName}"${colors.reset} to ${colors.dim}package.json${colors.reset}`)
687
- }
688
-
689
- // The registry name. Three cases:
690
- //
691
- // 1. Explicit scope (`@org/x` or `~user/x`) → `<sigil><name>/<base>`.
692
- // 2. Empty-scope, --local → synthesize a
693
- // personal-scope form `~<loginName-or-sub-or-'me'>/<base>` so the
694
- // local index mirrors what production will write. This is the
695
- // local mock's stand-in for the server-side memberId resolution.
696
- // 3. Empty-scope, remote → send the bare
697
- // name. The Worker attaches the personal scope server-side
698
- // (anchoring to the `sub` claim), and the publish response
699
- // carries the canonical URL back to the CLI for the receipt.
700
- let name
701
- if (scopeSigil) {
702
- name = `${scopeSigil}${scopeName}/${foundationName}`
703
- } else if (isLocal) {
704
- const localAuth = await readAuth()
705
- const personalSeed = localAuth?.loginName || localAuth?.sub || 'me'
706
- name = `~${personalSeed}/${foundationName}`
707
- } else {
708
- name = foundationName
92
+ const uuid = siteYml.$uuid
93
+ if (!uuid) {
94
+ say.err('This site has no $uuid in site.yml — it was never synced to the backend.')
95
+ say.dim('Run `uniweb push` first (publish makes the synced site live), or use `uniweb deploy` for a file-only site.')
96
+ return { exitCode: 1 }
709
97
  }
710
98
 
711
- // 3c. Phase 4f: org-claim flow.
712
- //
713
- // `uniweb publish @handle/foo` against a handle the user doesn't own
714
- // yet drops into the org-claim flow instead of failing. Three cases:
715
- // (a) JWT has no `namespaces` claim at all → token predates org
716
- // support; tell the user to `uniweb login` again.
717
- // (b) Handle is already in `namespaces` → proceed.
718
- // (c) Handle is NOT in `namespaces` → call POST /api/orgs/{handle}.
719
- // Confirm-and-claim if available; hard-fail if taken; refresh
720
- // the cached token on success and proceed with publish.
721
- //
722
- // Skipped for `--local` (no auth, no org system).
723
- const claimOrgFlag = args.includes('--claim-org')
724
- if (!isLocal && scopeSigil === '@') {
725
- const auth = await readAuth()
726
- if (!Array.isArray(auth?.namespaces)) {
727
- // Old token, predates org support.
728
- error('Your authentication token doesn\'t carry organization claims.')
729
- console.log('')
730
- console.log(` ${colors.dim}Run ${colors.reset}${colors.cyan}uniweb login${colors.reset}${colors.dim} to refresh your session, then retry.${colors.reset}`)
731
- process.exit(1)
732
- }
733
- if (!auth.namespaces.includes(scopeName)) {
734
- // Need to claim. Confirm interactively unless --claim-org was passed.
735
- if (isNonInteractive(process.argv) && !claimOrgFlag) {
736
- error(`You don't own ${colors.bright}@${scopeName}${colors.reset} yet.`)
737
- console.log('')
738
- console.log(` ${colors.dim}In CI, pass ${colors.reset}${colors.cyan}--claim-org${colors.reset}${colors.dim} to claim available handles automatically.${colors.reset}`)
739
- console.log(` ${colors.dim}Interactive mode prompts for confirmation.${colors.reset}`)
740
- process.exit(1)
741
- }
742
-
743
- if (!claimOrgFlag) {
744
- const prompts = (await import('prompts')).default
745
- console.log('')
746
- console.log(`${colors.dim}You don't own ${colors.reset}${colors.bright}@${scopeName}${colors.reset}${colors.dim} yet.${colors.reset}`)
747
- console.log(`${colors.dim}Org handles are global and permanent — only the claiming account can publish under them.${colors.reset}`)
748
- console.log('')
749
- const confirm = await prompts({
750
- type: 'confirm',
751
- name: 'go',
752
- message: `Claim @${scopeName} for your account?`,
753
- initial: false,
754
- }, {
755
- onCancel: () => { console.log(''); console.log('Publish cancelled.'); process.exit(0) },
756
- })
757
- if (!confirm.go) {
758
- console.log('')
759
- console.log(`${colors.dim}Cancelled. Publish under a handle you already own, or pick a different one.${colors.reset}`)
760
- process.exit(0)
761
- }
762
- }
99
+ const client = new BackendClient({
100
+ originFlag: readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
101
+ token: readFlagValue(args, '--token') || undefined,
102
+ args,
103
+ command: 'Publishing',
104
+ })
763
105
 
764
- // Org claim hits the PHP backend (auth/identity is PHP's domain),
765
- // not the worker. In local dev unicloud serves both on one port, so
766
- // tests work; in production these are different hosts.
767
- const claimed = await claimOrgHandle({
768
- handle: scopeName,
769
- token: auth.token,
770
- backendUrl: getBackendUrl(),
771
- })
772
- if (claimed.taken) {
773
- error(`@${scopeName} is already claimed by another account.`)
774
- console.log('')
775
- console.log(` ${colors.dim}Pick a different handle. Org names are global and exclusive.${colors.reset}`)
776
- process.exit(1)
777
- }
778
- // Swap the cached token for the refreshed one (now carries the new
779
- // namespace claim). Subsequent publish calls in this run see it via
780
- // a fresh `readAuth()` and the worker accepts the upload.
781
- await writeAuth({
782
- token: claimed.token,
783
- email: auth.email,
784
- expiresAt: auth.expiresAt,
785
- })
786
- if (claimed.created) {
787
- success(`Claimed ${colors.bright}@${scopeName}${colors.reset} for your account.`)
788
- } else {
789
- info(`Refreshed your token; ${colors.bright}@${scopeName}${colors.reset} is yours.`)
790
- }
791
- console.log('')
792
- }
106
+ // Discover + resolve the runtime exactly like deploy: explicit site.yml::runtime,
107
+ // else the highest installed (the /dev/config source). Fail closed otherwise.
108
+ const config = await client.discover()
109
+ if (config?.delivery && config.delivery.publish === false) {
110
+ say.err(`Backend at ${client.origin} does not offer the publish lane (delivery.publish=false).`)
111
+ return { exitCode: 1 }
793
112
  }
794
-
795
- // 4. Create registry (local or remote)
796
- const isRemote = !isLocal
797
- let registry
798
-
799
- if (isLocal) {
800
- registry = createLocalRegistry(foundationDir)
801
- } else {
802
- // Remote publish — ensure authenticated (inline login if needed)
803
- const token = await ensureAuth({ command: 'Publishing', args })
804
-
805
- const url = registryUrl || getRegistryUrl()
806
- registry = new RemoteRegistry(url, token)
113
+ const installed = Array.isArray(config?.runtime?.installed) ? config.runtime.installed : []
114
+ if (siteYml.runtime && installed.length && !installed.includes(siteYml.runtime)) {
115
+ say.err(`Runtime ${siteYml.runtime} (from site.yml) is not installed on the backend.`)
116
+ say.dim(`Installed: ${installed.join(', ') || '(none)'} — pin one of these in site.yml (\`runtime:\`), or have it installed on the backend.`)
117
+ return { exitCode: 1 }
807
118
  }
808
-
809
- const registryLabel = isLocal ? 'local registry' : `registry`
810
-
811
- // Git state read up-front so it can both gate the duplicate check
812
- // (fresh-checkout no-op vs. true conflict) and ride along in the
813
- // publish payload.
814
- const { gitSha, gitDirty } = readGitState(foundationDir)
815
-
816
- // Compute the canonical name the server stores under. Empty-scope
817
- // (bare-name) publishes go to the registry as `<name>` but are
818
- // server-side rewritten to `~<memberUuid>/<name>`. The duplicate
819
- // check below queries the registry's index, which uses the canonical
820
- // form as the key — so we have to mirror the rewrite locally.
821
- // Org / personal-scope publishes skip this (their `name` is already
822
- // canonical).
823
- let lookupName = name
824
- if (!scopeSigil && !isLocal) {
825
- try {
826
- const localAuth = await readAuth()
827
- const claims = decodeJwtPayload(localAuth?.token)
828
- if (claims?.memberUuid) {
829
- lookupName = `~${claims.memberUuid}/${foundationName}`
830
- }
831
- } catch {
832
- // No usable auth — fall back to the bare name. The publish call
833
- // itself will fail later with an auth error if a token is needed.
834
- }
119
+ const runtimeVersion = siteYml.runtime || pickHighestRuntime(installed)
120
+ if (!runtimeVersion) {
121
+ say.err('Could not resolve a runtime version.')
122
+ say.dim('Pin one with `runtime:` in site.yml, or install one on the backend so /dev/config reports it.')
123
+ return { exitCode: 1 }
835
124
  }
836
125
 
837
- // 5. Check for duplicates. If the registry already has this exact
838
- // version recorded as published from the current commit, treat it
839
- // as a fresh-checkout no-op — the artifact upstream is already
840
- // correct; there's nothing to upload.
841
- const existingEntry = await registry.getVersionEntry(lookupName, version)
842
- if (existingEntry) {
843
- if (gitSha && existingEntry.publishedFromGitSha === gitSha) {
844
- // Persist uniweb.id BEFORE the early return when an auto-derive
845
- // or prompt-resolved id was set in this run. Without this, the
846
- // next run wouldn't know the id and would have to re-derive
847
- // from scratch — which means the pre-flight registry check at
848
- // the top of publish() can't fire either (it relies on a
849
- // resolvable id from pkg.json alone). Persisting here closes
850
- // that loop so future deploys hit the pre-flight bail and skip
851
- // the build entirely.
852
- if (writeBackId) {
853
- pkg.uniweb = pkg.uniweb || {}
854
- pkg.uniweb.id = foundationName
855
- await writeJsonPreservingStyleAsync(pkgPath, pkg)
856
- info(`Wrote ${colors.cyan}uniweb.id: "${foundationName}"${colors.reset} to ${colors.dim}package.json${colors.reset}`)
857
- }
858
- console.log('')
859
- success(`${colors.bright}${lookupName}@${version}${colors.reset} already published from ${gitSha.slice(0, 7)}.`)
860
- return
861
- }
862
- console.log('')
863
- error(`Foundation source has changed since the last publish, but ${colors.bright}${name}@${version}${colors.reset} is already published.`)
864
- console.log('')
865
- console.log(` Bump ${colors.cyan}package.json::version${colors.reset} to publish an update:`)
866
- console.log(` ${colors.dim}"version": "${bumpPatch(version)}"${colors.reset}`)
867
- process.exit(1)
868
- }
869
-
870
- // 6. Publish
871
- info(`Publishing ${colors.bright}${name}@${version}${colors.reset} to ${registryLabel}...`)
126
+ const languages = extractLanguages(siteYml)
872
127
 
873
- // Resolve the publisher's identity
874
- const auth = isLocal ? null : await readAuth()
875
- const publishMetadata = {
876
- publishedBy: auth?.email || (isLocal ? 'local' : 'cli'),
877
- classification: isPropagate ? 'propagate' : 'silent',
878
- // Git provenance lets `uniweb deploy` decide whether a workspace-local
879
- // foundation needs republishing — see deploy.js's staleness check.
880
- ...(gitSha ? { publishedFromGitSha: gitSha } : {}),
881
- ...(typeof gitDirty === 'boolean' ? { publishedFromGitDirty: gitDirty } : {}),
882
- }
883
- if (editAccess) {
884
- publishMetadata.editAccess = editAccess
128
+ if (dryRun) {
129
+ say.info('Dry run would publish the synced site (its current backend state):')
130
+ say.dim(`Backend : ${client.origin}`)
131
+ say.dim(`Site uuid : ${uuid}`)
132
+ say.dim(`Runtime : ${runtimeVersion}${siteYml.runtime ? '' : ' (highest installed)'}`)
133
+ if (languages) say.dim(`Languages : ${languages.join(', ')}`)
134
+ return { exitCode: 0 }
885
135
  }
886
136
 
137
+ say.info(`Publishing the synced site to ${c.dim}${client.origin}${c.reset} …`)
138
+ say.dim('Publishes the CURRENT backend state (incl. app-side edits) — run `uniweb push` first to include local edits.')
139
+ let res
887
140
  try {
888
- await registry.publish(name, version, distDir, publishMetadata)
141
+ res = await client.publishSite(uuid, { runtimeVersion, ...(languages ? { languages } : {}) })
889
142
  } catch (err) {
890
- if (err.code === 'CONFLICT') {
891
- error(`${colors.bright}${name}@${version}${colors.reset} already exists on the registry.`)
892
- console.log(` Bump the version in foundation.js to publish an update.`)
893
- process.exit(1)
894
- }
895
- if (err.code === 'UNAUTHORIZED') {
896
- error('Authentication failed.')
897
- console.log(` Run ${colors.cyan}${getCliPrefix()} login${colors.reset} to refresh your credentials.`)
898
- process.exit(1)
899
- }
900
- throw err
143
+ say.err(`Could not reach the backend at ${client.origin}: ${err.message}`)
144
+ say.dim('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
145
+ return { exitCode: 1 }
901
146
  }
902
-
903
- const prefix = getCliPrefix()
904
- const isExtension = schema._self?.role === 'extension'
905
- console.log('')
906
- success(`Published ${colors.bright}${name}@${version}${colors.reset}${isExtension ? ' (extension)' : ''}`)
907
- if (editAccess) {
908
- console.log(` ${colors.dim}Edit access: ${editAccess}${colors.reset}`)
909
- }
910
-
911
- // Cross-promotion: working with clients (remote only), deploy (if workspace has a site)
912
- if (isRemote) {
913
- console.log('')
914
- if (isExtension) {
915
- console.log(` ${colors.bright}Authorize a client to use this extension:${colors.reset}`)
916
- console.log(` ${colors.bright}${prefix} invite <email>${colors.reset} Client adds this extension to their site`)
917
- } else {
918
- console.log(` ${colors.bright}Working with clients:${colors.reset}`)
919
- console.log(` ${colors.bright}${prefix} invite <email>${colors.reset} Client creates their own site with your foundation`)
920
- console.log(` ${colors.bright}${prefix} handoff <email>${colors.reset} Create a web or local site and hand it off to a client`)
921
- }
922
- }
923
- const workspaceRoot = findWorkspaceRoot(foundationDir)
924
- if (workspaceRoot) {
925
- const sites = await findSites(workspaceRoot)
926
- if (sites.length > 0) {
927
- console.log('')
928
- console.log(` ${colors.dim}Tip: Run \`${prefix} deploy\` for a conventional static bundle deployment.${colors.reset}`)
929
- }
930
- }
931
- }
932
-
933
- /**
934
- * Bump the patch version of a semver string.
935
- * @param {string} version - e.g. "1.0.0"
936
- * @returns {string} - e.g. "1.0.1"
937
- */
938
- /**
939
- * Quickly compute the canonical foundation name from `package.json` +
940
- * CLI flags alone, without prompting and without reading the build's
941
- * `dist/meta/schema.json`. Used by the pre-flight registry check so we
942
- * can short-circuit the build when the registry already has this
943
- * version published.
944
- *
945
- * Returns null when resolution would need a prompt or auto-derive
946
- * (caller falls through to the existing post-build resolution path,
947
- * which handles those cases). The returned string is one of:
948
- * - `@<scope>/<id>` (org scope, full canonical form)
949
- * - `~<handle>/<id>` (personal alias scope)
950
- * - `<id>` (bare; caller may prepend `~<memberUuid>/`
951
- * from the JWT for the actual lookup)
952
- *
953
- * The full resolution at line 313+ is the canonical implementation;
954
- * this helper is a strict subset that mirrors the high-confidence
955
- * paths only. If they diverge, the helper is the one that should
956
- * stay conservative (return null on uncertainty).
957
- */
958
- function quickResolveCanonicalName(pkg, { namespaceFlag, nameFlag } = {}) {
959
- if (!pkg) return null
960
- const orgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
961
- const personalScopeMatch = (pkg.name || '').match(/^~([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
962
- const uniwebNamespace = pkg.uniweb?.namespace
963
- const uniwebId = pkg.uniweb?.id
964
-
965
- // Scope precedence mirrors the full resolution.
966
- let scopeSigil = null
967
- let scopeName = null
968
- if (namespaceFlag) {
969
- scopeSigil = '@'
970
- scopeName = namespaceFlag
971
- } else if (orgScopeMatch) {
972
- scopeSigil = '@'
973
- scopeName = orgScopeMatch[1]
974
- } else if (personalScopeMatch) {
975
- scopeSigil = '~'
976
- scopeName = personalScopeMatch[1]
977
- } else if (uniwebNamespace) {
978
- scopeSigil = '@'
979
- scopeName = uniwebNamespace
980
- }
981
-
982
- // Id precedence mirrors the full resolution but stops at "no-prompt"
983
- // sources. Auto-derive and TTY prompts both happen post-build so the
984
- // user sees suggestions in context; the pre-flight only fires when
985
- // the id is already determined.
986
- let id = null
987
- if (nameFlag) id = nameFlag
988
- else if (orgScopeMatch) id = orgScopeMatch[2]
989
- else if (personalScopeMatch) id = personalScopeMatch[2]
990
- else if (uniwebId) id = uniwebId
991
- else return null
992
-
993
- if (scopeSigil) return `${scopeSigil}${scopeName}/${id}`
994
- return id
995
- }
996
-
997
- function bumpPatch(version) {
998
- const parts = version.split('.')
999
- if (parts.length !== 3) return version
1000
- parts[2] = String(Number(parts[2]) + 1)
1001
- return parts.join('.')
1002
- }
1003
-
1004
- /**
1005
- * High-confidence auto-derive for non-interactive (CI) first publishes.
1006
- *
1007
- * Diego's principle: never silently take a generic scaffold default like
1008
- * `src` or `site` as the registry id (those are placeholders, not user
1009
- * intent). But when the user has typed a real name elsewhere — most
1010
- * unambiguously the workspace package.json's `name` (set by
1011
- * `uniweb create <name>`) — picking that in CI is the obvious right
1012
- * answer and stopping to ask just breaks the CI run.
1013
- *
1014
- * Auto-derive set is intentionally NARROW:
1015
- * 1. Workspace package.json::name, when it's a clean id and not a
1016
- * generic placeholder.
1017
- *
1018
- * Other suggestion sources from `buildIdSuggestions` (sibling-site
1019
- * name, M-code series) are NOT auto-picked: they're ambiguous in
1020
- * multi-package or multi-foundation workspaces. They remain visible
1021
- * in the CI error message when no high-confidence signal exists, so
1022
- * the user can pick one explicitly via `--name <id>`.
1023
- *
1024
- * Returns the id string, or null when no high-confidence signal is
1025
- * available (caller falls through to the existing error-with-
1026
- * suggestions guidance).
1027
- */
1028
- async function pickAutoDerivedId({ workspaceRoot, foundationDir }) {
1029
- const ID_RE = /^[a-z0-9_-]+$/
1030
- const PLACEHOLDERS = new Set(['src', 'site', 'foundation', 'workspace', 'project'])
1031
- const isHighConfidence = s => typeof s === 'string' && ID_RE.test(s) && !PLACEHOLDERS.has(s)
1032
-
1033
- if (!workspaceRoot || workspaceRoot === foundationDir) return null
1034
- try {
1035
- const wsPkg = JSON.parse(await readFile(join(workspaceRoot, 'package.json'), 'utf8'))
1036
- const wsName = typeof wsPkg.name === 'string'
1037
- ? wsPkg.name.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^-+|-+$/g, '')
1038
- : null
1039
- if (isHighConfidence(wsName)) return wsName
1040
- } catch { /* no workspace package.json — skip */ }
1041
- return null
1042
- }
1043
-
1044
- /**
1045
- * Build a list of contextual `uniweb.id` suggestions for first-time publishes.
1046
- *
1047
- * The CLI never auto-picks an id (Diego's principle: a bare folder name like
1048
- * "src" is wrong, and silently committing to it would couple the registry
1049
- * id to scaffold noise the user can't easily undo). Instead, suggest names
1050
- * derived from signals the workspace already exposes:
1051
- *
1052
- * - **Sibling site name.** When exactly one site exists in the workspace,
1053
- * the user's mental model is "this foundation is FOR that site" — so
1054
- * the site's name (or "<site>-foundation" if it would collide with the
1055
- * site's own package name) is a natural pick.
1056
- * - **Workspace name.** A workspace package.json often carries a name
1057
- * more meaningful than the foundation folder ("acme-marketing" vs "src").
1058
- * - **Folder name minus `-src`.** Foundations placed under
1059
- * `<name>-src/` strongly suggest `<name>` as the publish id (this
1060
- * is the existing default; preserved here for back-compat).
1061
- * - **Code-based fallback (M1, M2, …).** When the workspace already has
1062
- * other foundations (i.e., the user manages a category of similar
1063
- * foundations across sites/projects), suggest the next code in series.
1064
- *
1065
- * Returns deduplicated `{ id, why }` entries — `why` is shown next to the
1066
- * id in both the CI guidance message and the TTY select prompt so the
1067
- * user can tell at a glance which signal each suggestion comes from.
1068
- *
1069
- * The bare scaffold default `pkg.name === 'src'` is excluded by design.
1070
- * Likewise any non-conforming shape (uppercase, dots, etc.) is filtered
1071
- * out so users only ever see valid candidates.
1072
- */
1073
- async function buildIdSuggestions({ foundationDir, workspaceRoot, pkg }) {
1074
- const ID_RE = /^[a-z0-9_-]+$/
1075
- const sanitize = s => (typeof s === 'string' ? s.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^-+|-+$/g, '') : null)
1076
- const isValid = s => typeof s === 'string' && ID_RE.test(s) && s !== 'src' && s !== 'site'
1077
-
1078
- const seen = new Set()
1079
- const out = []
1080
- const push = (id, why) => {
1081
- if (!isValid(id) || seen.has(id)) return
1082
- seen.add(id)
1083
- out.push({ id, why })
1084
- }
1085
-
1086
- // 1. Sibling-site suggestion. Only fires when there's exactly one site
1087
- // in the workspace, because that's the unambiguous "for X" case.
1088
- try {
1089
- const sites = await findSites(workspaceRoot)
1090
- if (sites.length === 1) {
1091
- const sitePath = sites[0]
1092
- try {
1093
- const sitePkg = JSON.parse(await readFile(join(workspaceRoot, sitePath, 'package.json'), 'utf8'))
1094
- const siteName = sanitize(sitePkg.name)
1095
- if (siteName) {
1096
- push(siteName, `matches your site "${siteName}"`)
1097
- push(`${siteName}-foundation`, `derived from your site "${siteName}"`)
1098
- }
1099
- } catch { /* missing or malformed site package.json — skip */ }
147
+ if (!res.ok) {
148
+ if (res.status === 404) {
149
+ say.err(`Site ${uuid} not found on the backend (404).`)
150
+ say.dim('Sync it first with `uniweb push`, or use `uniweb deploy` for a file-only site.')
151
+ return { exitCode: 1 }
1100
152
  }
1101
- } catch { /* findSites can fail in odd workspaces; non-fatal */ }
1102
-
1103
- // 2. Workspace name suggestion. The workspace package.json's name is
1104
- // the user's chosen project identity; if it's a clean id, suggest it.
1105
- try {
1106
- if (workspaceRoot && workspaceRoot !== foundationDir) {
1107
- const wsPkg = JSON.parse(await readFile(join(workspaceRoot, 'package.json'), 'utf8'))
1108
- const wsName = sanitize(wsPkg.name)
1109
- if (wsName) push(wsName, `matches your workspace "${wsName}"`)
153
+ say.err(`Publish rejected: HTTP ${res.status} ${res.statusText}`)
154
+ if (res.status === 401 || res.status === 403) {
155
+ say.dim("Credentials weren't accepted run `uniweb login` (or pass --token <bearer>).")
1110
156
  }
1111
- } catch { /* no workspace package.json skip */ }
1112
-
1113
- // 3. Folder name minus `-src`. The pre-existing default lives on as a
1114
- // suggestion now rather than the auto-pick.
1115
- if (workspaceRoot && foundationDir !== workspaceRoot) {
1116
- const folderName = foundationDir.replace(workspaceRoot + '/', '').split('/')[0]
1117
- const stripped = sanitize(folderName?.replace(/-src$/, ''))
1118
- if (stripped) push(stripped, `derived from the folder "${folderName}"`)
157
+ const body = await res.text().catch(() => '')
158
+ if (body) say.dim(body.slice(0, 800))
159
+ return { exitCode: 1 }
1119
160
  }
1120
-
1121
- // 4. Code-based fallback. Only suggested when the workspace already has
1122
- // multiple foundations — the case Diego flagged (publishers managing
1123
- // a category like M1, M2, M3 across sites/projects).
1124
- try {
1125
- const foundations = await findFoundations(workspaceRoot)
1126
- if (foundations.length >= 2) {
1127
- // Find the next M-number not already used by a sibling foundation's id.
1128
- const usedCodes = new Set()
1129
- for (const fp of foundations) {
1130
- try {
1131
- const fp_pkg = JSON.parse(await readFile(join(workspaceRoot, fp, 'package.json'), 'utf8'))
1132
- const id = fp_pkg.uniweb?.id
1133
- const m = typeof id === 'string' && id.match(/^m(\d+)$/i)
1134
- if (m) usedCodes.add(parseInt(m[1], 10))
1135
- } catch { /* skip */ }
1136
- }
1137
- let n = 1
1138
- while (usedCodes.has(n)) n++
1139
- push(`m${n}`, `next in your "M-code" series`)
1140
- }
1141
- } catch { /* findFoundations failed — skip */ }
1142
-
1143
- return out
1144
- }
1145
-
1146
- /**
1147
- * Per-directory git state. Mirrors `deploy.js::readGitState` exactly —
1148
- * scopes the sha + dirty check to `dir` rather than reading the whole
1149
- * repo's HEAD. Publish records this in registry metadata; deploy
1150
- * compares against it for staleness. Both sides must read the same
1151
- * shape or the staleness check drifts.
1152
- */
1153
- function readGitState(dir) {
161
+ let result
1154
162
  try {
1155
- const sha = execSync('git log -1 --format=%H -- .', {
1156
- cwd: dir,
1157
- stdio: ['ignore', 'pipe', 'ignore'],
1158
- }).toString().trim()
1159
- const status = execSync('git status --porcelain -- .', {
1160
- cwd: dir,
1161
- stdio: ['ignore', 'pipe', 'ignore'],
1162
- }).toString()
1163
- return { gitSha: sha || null, gitDirty: status.length > 0 }
163
+ result = await res.json()
1164
164
  } catch {
1165
- return { gitSha: null, gitDirty: false }
165
+ result = {}
1166
166
  }
1167
- }
1168
167
 
1169
- /**
1170
- * POST /api/orgs/{handle} — claim an `@handle` for the calling user.
1171
- *
1172
- * Returns one of:
1173
- * { created: true, token: '<refreshed JWT>' } — handle was free
1174
- * { created: false, token: '<refreshed JWT>' } — user already owned it
1175
- * { taken: true } — claimed by someone else
1176
- *
1177
- * Other failures throw.
1178
- */
1179
- async function claimOrgHandle({ handle, token, backendUrl }) {
1180
- const url = `${backendUrl.replace(/\/$/, '')}/api/orgs/${encodeURIComponent(handle)}`
1181
- const res = await fetch(url, {
1182
- method: 'POST',
1183
- headers: {
1184
- 'Content-Type': 'application/json',
1185
- Authorization: `Bearer ${token}`,
1186
- },
1187
- })
1188
- if (res.status === 409) return { taken: true }
1189
- if (!res.ok) {
1190
- let detail = `HTTP ${res.status}`
1191
- try {
1192
- const j = await res.json()
1193
- detail = j.error || detail
1194
- } catch { /* non-JSON body */ }
1195
- throw new Error(`Org claim failed: ${detail}`)
1196
- }
1197
- const body = await res.json()
1198
- return { created: !!body.created, token: body.token }
168
+ const serveUrl = absolutizeServeUrl(client.origin, result.url)
169
+ console.log('')
170
+ say.ok(`Published ${c.bold}${uuid}${c.reset}${result.status ? ` (${result.status})` : ''}`)
171
+ if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
172
+ if (result.deploy_uuid) say.dim(`deploy: ${result.deploy_uuid}`)
173
+ return { exitCode: 0 }
1199
174
  }
1200
175
 
1201
176
  export default publish