uniweb 0.12.2 → 0.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -22
- package/package.json +4 -4
- package/src/commands/add.js +88 -12
- package/src/commands/build.js +199 -28
- package/src/commands/deploy.js +318 -16
- package/src/commands/doctor.js +172 -130
- package/src/commands/handoff.js +1 -1
- package/src/commands/invite.js +2 -2
- package/src/commands/publish.js +297 -54
- package/src/commands/rename.js +310 -0
- package/src/framework-index.json +4 -4
- package/src/index.js +14 -5
- package/src/utils/receipt.js +91 -0
- package/src/utils/registry.js +33 -0
- package/templates/workspace/package.json.hbs +2 -4
package/src/commands/deploy.js
CHANGED
|
@@ -44,8 +44,10 @@ import yaml from 'js-yaml'
|
|
|
44
44
|
|
|
45
45
|
import { detectFoundationType } from '@uniweb/build'
|
|
46
46
|
|
|
47
|
-
import { ensureAuth } from '../utils/auth.js'
|
|
47
|
+
import { ensureAuth, readAuth, decodeJwtPayload } from '../utils/auth.js'
|
|
48
48
|
import { getBackendUrl, getRegistryUrl } from '../utils/config.js'
|
|
49
|
+
import { RemoteRegistry } from '../utils/registry.js'
|
|
50
|
+
import { receiptFromRegistryEntry, splitRegistryRef } from '../utils/receipt.js'
|
|
49
51
|
import {
|
|
50
52
|
findWorkspaceRoot,
|
|
51
53
|
findSites,
|
|
@@ -193,16 +195,35 @@ function composeFoundationUrl(ref, registryBase) {
|
|
|
193
195
|
* Inspect a workspace-local foundation's `dist/publish.json` (Phase 1 receipt)
|
|
194
196
|
* and decide whether it's stale relative to the current source tree.
|
|
195
197
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
+
* When the receipt file is missing and a `registry` is provided, attempt
|
|
199
|
+
* to refill it from the registry's index entry for `<name>@<version>`.
|
|
200
|
+
* That makes the receipt pure cache: a fresh clone with a matching
|
|
201
|
+
* upstream artifact resolves without an unnecessary republish. If the
|
|
202
|
+
* registry has no record (or the stored entry lacks git provenance), the
|
|
203
|
+
* inspector returns `stale: true` and the caller's auto-publish path
|
|
204
|
+
* runs as before.
|
|
205
|
+
*
|
|
206
|
+
* Returns `{ stale, reason, receipt, refilled? }`. The caller decides
|
|
207
|
+
* whether to auto-publish (Phase 2 default) or fail (`--no-auto-publish`).
|
|
198
208
|
*/
|
|
199
|
-
async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale }) {
|
|
209
|
+
async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale, registry }) {
|
|
200
210
|
const receiptPath = join(localPath, 'dist', 'publish.json')
|
|
201
211
|
let receipt = null
|
|
212
|
+
let refilled = false
|
|
202
213
|
try {
|
|
203
214
|
receipt = JSON.parse(await readFile(receiptPath, 'utf8'))
|
|
204
215
|
} catch {
|
|
205
|
-
|
|
216
|
+
if (registry) {
|
|
217
|
+
const refill = await tryRefillReceiptFromRegistry({ localPath, registry })
|
|
218
|
+
if (refill) {
|
|
219
|
+
await writeFile(receiptPath, JSON.stringify(refill, null, 2) + '\n')
|
|
220
|
+
receipt = refill
|
|
221
|
+
refilled = true
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (!receipt) {
|
|
225
|
+
return { stale: true, reason: 'no dist/publish.json (foundation has not been published from this checkout)' }
|
|
226
|
+
}
|
|
206
227
|
}
|
|
207
228
|
|
|
208
229
|
const { gitSha, gitDirty } = readGitState(localPath)
|
|
@@ -219,14 +240,94 @@ async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale }) {
|
|
|
219
240
|
if (gitDirty && dirtyAsStale) {
|
|
220
241
|
return { stale: true, reason: 'foundation working tree is dirty', receipt }
|
|
221
242
|
}
|
|
222
|
-
return { stale: false, receipt }
|
|
243
|
+
return { stale: false, receipt, refilled }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* When a receipt is missing, try to reconstruct it from the registry's
|
|
248
|
+
* own record of the publish. We resolve `name@version` from the same
|
|
249
|
+
* package metadata `deriveLocalFoundationRef` uses, then ask the registry
|
|
250
|
+
* for its stored version entry. The entry's `publishedFromGitSha` is the
|
|
251
|
+
* load-bearing field — without it (entries written before git provenance
|
|
252
|
+
* was added) we can't compare against HEAD, so we don't synthesize a
|
|
253
|
+
* misleading "fresh" receipt and let the caller fall through to the
|
|
254
|
+
* republish path.
|
|
255
|
+
*
|
|
256
|
+
* Returns the receipt body, or null when refill is not possible.
|
|
257
|
+
*/
|
|
258
|
+
async function tryRefillReceiptFromRegistry({ localPath, registry }) {
|
|
259
|
+
// Two ways to derive the canonical `<name>@<version>`:
|
|
260
|
+
// 1. `deriveLocalFoundationRef` — works for org-scope foundations
|
|
261
|
+
// (where the namespace is in package.json) and for any foundation
|
|
262
|
+
// where a previous receipt already exists. On the wipe-and-deploy
|
|
263
|
+
// path that fired this code, the receipt is gone, so this only
|
|
264
|
+
// works for org-scope.
|
|
265
|
+
// 2. Empty-scope fallback — if package.json has `uniweb.id` and the
|
|
266
|
+
// user's auth.json carries a `memberUuid` claim, we can synthesize
|
|
267
|
+
// `~<memberUuid>/<id>@<version>` directly. Same shape the server
|
|
268
|
+
// stores under.
|
|
269
|
+
let ref = await deriveLocalFoundationRef(localPath)
|
|
270
|
+
if (!ref) ref = await refFromAuthAndPkg(localPath)
|
|
271
|
+
const split = splitRegistryRef(ref)
|
|
272
|
+
if (!split) return null
|
|
273
|
+
let existingEntry
|
|
274
|
+
try {
|
|
275
|
+
existingEntry = await registry.getVersionEntry(split.name, split.version)
|
|
276
|
+
} catch {
|
|
277
|
+
return null
|
|
278
|
+
}
|
|
279
|
+
if (!existingEntry) return null
|
|
280
|
+
return receiptFromRegistryEntry({
|
|
281
|
+
existingEntry,
|
|
282
|
+
registry,
|
|
283
|
+
name: split.name,
|
|
284
|
+
version: split.version,
|
|
285
|
+
isLocal: false,
|
|
286
|
+
isPropagateDefault: false,
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Last-resort canonical-name derivation for empty-scope foundations.
|
|
292
|
+
* Combines `package.json::uniweb.id` (the foundation's bare name) with
|
|
293
|
+
* the user's `memberUuid` claim from auth.json to produce
|
|
294
|
+
* `~<memberUuid>/<id>@<version>`. Only fires when both inputs are
|
|
295
|
+
* available — otherwise returns null and the caller falls through to
|
|
296
|
+
* the republish path.
|
|
297
|
+
*/
|
|
298
|
+
async function refFromAuthAndPkg(localPath) {
|
|
299
|
+
let pkg
|
|
300
|
+
try {
|
|
301
|
+
pkg = JSON.parse(await readFile(join(localPath, 'package.json'), 'utf8'))
|
|
302
|
+
} catch {
|
|
303
|
+
return null
|
|
304
|
+
}
|
|
305
|
+
const id = pkg?.uniweb?.id
|
|
306
|
+
const version = pkg?.version
|
|
307
|
+
if (!id || !version || !/^[a-z0-9_-]+$/.test(id)) return null
|
|
308
|
+
try {
|
|
309
|
+
const auth = await readAuth()
|
|
310
|
+
const claims = decodeJwtPayload(auth?.token)
|
|
311
|
+
if (claims?.memberUuid) return `~${claims.memberUuid}/${id}@${version}`
|
|
312
|
+
} catch { /* no auth — fall through to null */ }
|
|
313
|
+
return null
|
|
223
314
|
}
|
|
224
315
|
|
|
225
316
|
/**
|
|
226
317
|
* Read a workspace-local foundation's identity (scoped name + version) from
|
|
227
318
|
* its `dist/meta/schema.json` + `package.json`, mirroring `publish.js`'s
|
|
228
|
-
* namespace resolution. Returns the registry ref (`@ns/name@ver`
|
|
229
|
-
* if
|
|
319
|
+
* namespace resolution. Returns the registry ref (`@ns/name@ver` or
|
|
320
|
+
* `~uuid/name@ver`), or null if no shape can be resolved.
|
|
321
|
+
*
|
|
322
|
+
* Resolution order:
|
|
323
|
+
* 1. Org scope from `pkg.uniweb.namespace` or `pkg.name`'s `@org/...` prefix.
|
|
324
|
+
* 2. The receipt at `dist/publish.json`. After a successful publish, the
|
|
325
|
+
* receipt's `url` carries the canonical server-rewritten name —
|
|
326
|
+
* including empty-scope publishes, which the server rewrites to
|
|
327
|
+
* `~<memberUuid>/<name>`. The CLI can't synthesize that locally
|
|
328
|
+
* because the memberUuid lives in the JWT, not in the workspace.
|
|
329
|
+
* 3. null — caller falls through to the helpful "set uniweb.namespace"
|
|
330
|
+
* error message.
|
|
230
331
|
*/
|
|
231
332
|
async function deriveLocalFoundationRef(localPath) {
|
|
232
333
|
let pkg
|
|
@@ -248,14 +349,134 @@ async function deriveLocalFoundationRef(localPath) {
|
|
|
248
349
|
version = version || pkg.version
|
|
249
350
|
if (!rawName || !version) return null
|
|
250
351
|
|
|
352
|
+
// Org-scope path — derived purely from local files.
|
|
251
353
|
const uniwebNamespace = pkg.uniweb?.namespace
|
|
252
354
|
const pkgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\//)
|
|
253
355
|
const selfScopeMatch = rawName.match(/^@([a-z0-9_-]+)\//)
|
|
254
356
|
const namespace = uniwebNamespace || pkgScopeMatch?.[1] || selfScopeMatch?.[1]
|
|
255
|
-
if (
|
|
357
|
+
if (namespace) {
|
|
358
|
+
const bareName = selfScopeMatch ? rawName.slice(selfScopeMatch[0].length) : rawName
|
|
359
|
+
return `@${namespace}/${bareName}@${version}`
|
|
360
|
+
}
|
|
256
361
|
|
|
257
|
-
|
|
258
|
-
|
|
362
|
+
// Empty-scope path — read the canonical name from the receipt's URL.
|
|
363
|
+
// The server rewrites bare names to `~<memberUuid>/<name>`; the URL it
|
|
364
|
+
// returns carries that canonical form (e.g.
|
|
365
|
+
// `/foundations/~<uuid>/<name>@<ver>/foundation.js`). Parse it back.
|
|
366
|
+
const fromReceipt = await refFromReceiptUrl(localPath)
|
|
367
|
+
if (fromReceipt) return fromReceipt
|
|
368
|
+
|
|
369
|
+
return null
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Personal-scope namespaces are base58 memberUuids (mixed case; the
|
|
373
|
+
// alphabet is `[A-HJ-NP-Za-km-z1-9]`). Org-scope handles are
|
|
374
|
+
// lowercase-only. Allow both shapes here so the regex captures personal
|
|
375
|
+
// scopes correctly. The bare-name portion (after the `/`) stays
|
|
376
|
+
// lowercase per BARE_NAME_RE.
|
|
377
|
+
const RECEIPT_URL_REF_RE = /\/foundations\/((?:@[a-z0-9_-]+|~[A-Za-z0-9_-]+)\/[a-z0-9_-]+)@([^/]+)\//
|
|
378
|
+
|
|
379
|
+
async function refFromReceiptUrl(localPath) {
|
|
380
|
+
try {
|
|
381
|
+
const receipt = JSON.parse(await readFile(join(localPath, 'dist', 'publish.json'), 'utf8'))
|
|
382
|
+
const m = RECEIPT_URL_REF_RE.exec(receipt?.url || '')
|
|
383
|
+
if (m) return `${m[1]}@${m[2]}`
|
|
384
|
+
} catch {
|
|
385
|
+
// No receipt, malformed JSON, or URL doesn't carry the canonical
|
|
386
|
+
// shape — fall through to null.
|
|
387
|
+
}
|
|
388
|
+
return null
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Resolve the deploy mode for this site.
|
|
393
|
+
*
|
|
394
|
+
* Returns `'link'` or `'bundle'`, optionally prompting the user when
|
|
395
|
+
* neither inference nor an explicit flag yields an answer. Mode choice
|
|
396
|
+
* is persisted server-side after the first successful deploy; switching
|
|
397
|
+
* modes later requires deleting the site and redeploying fresh.
|
|
398
|
+
*
|
|
399
|
+
* Ladder (first match wins):
|
|
400
|
+
* 1. `--link` or `--bundle` flag passed explicitly.
|
|
401
|
+
* 2. `site.yml::foundation` is a registry ref (`@org/x@ver` or
|
|
402
|
+
* `~uuid/x@ver`) or full HTTPS URL → infer link. The user already
|
|
403
|
+
* declared "load this foundation by URL at runtime."
|
|
404
|
+
* 3. `site.yml::foundation` is a workspace-local sibling AND that
|
|
405
|
+
* foundation has a publish receipt (`dist/publish.json` with a
|
|
406
|
+
* registry URL) → infer link. The user has already done the work
|
|
407
|
+
* to make the foundation loadable from the registry.
|
|
408
|
+
* 4. Otherwise → ask (TTY prompt) or error (CI).
|
|
409
|
+
*
|
|
410
|
+
* @param {Object} ctx
|
|
411
|
+
* @param {string} ctx.foundationRef - the raw value of site.yml::foundation (string or normalized object).
|
|
412
|
+
* @param {string} ctx.siteDir - absolute path to the site dir (for resolving local foundation siblings).
|
|
413
|
+
* @param {boolean} ctx.linkFlag
|
|
414
|
+
* @param {boolean} ctx.bundleFlag
|
|
415
|
+
* @returns {Promise<{ mode: 'link'|'bundle', source: 'flag'|'inferred-registry-ref'|'inferred-published-local'|'asked' }>}
|
|
416
|
+
*/
|
|
417
|
+
async function resolveDeployMode({ foundationRef, siteDir, linkFlag, bundleFlag }) {
|
|
418
|
+
// 1. Explicit flag wins.
|
|
419
|
+
if (linkFlag) return { mode: 'link', source: 'flag' }
|
|
420
|
+
if (bundleFlag) return { mode: 'bundle', source: 'flag' }
|
|
421
|
+
|
|
422
|
+
// 2. Registry ref or URL in site.yml → link mode is the only sensible choice.
|
|
423
|
+
if (typeof foundationRef === 'string') {
|
|
424
|
+
if (foundationRef.startsWith('http://') || foundationRef.startsWith('https://')) {
|
|
425
|
+
return { mode: 'link', source: 'inferred-registry-ref' }
|
|
426
|
+
}
|
|
427
|
+
if (/^@[a-z0-9_-]+\/[a-z0-9_-]+@/.test(foundationRef)) {
|
|
428
|
+
return { mode: 'link', source: 'inferred-registry-ref' }
|
|
429
|
+
}
|
|
430
|
+
if (/^~[A-Za-z0-9_-]+\/[a-z0-9_-]+@/.test(foundationRef)) {
|
|
431
|
+
return { mode: 'link', source: 'inferred-registry-ref' }
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// 3. Workspace-local foundation with an existing publish receipt → infer link.
|
|
436
|
+
// The receipt being there means the user has already published this
|
|
437
|
+
// foundation at least once, so they've shown intent to ship via the
|
|
438
|
+
// registry. We don't validate the receipt's freshness here — that's
|
|
439
|
+
// the existing inspectLocalFoundationReceipt path's job, which runs
|
|
440
|
+
// later in the deploy flow.
|
|
441
|
+
if (typeof foundationRef === 'string') {
|
|
442
|
+
const detected = detectFoundationType(foundationRef, siteDir)
|
|
443
|
+
if (detected.type === 'local') {
|
|
444
|
+
const receiptPath = join(detected.path, 'dist', 'publish.json')
|
|
445
|
+
if (existsSync(receiptPath)) {
|
|
446
|
+
return { mode: 'link', source: 'inferred-published-local' }
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// 4. Ambiguous — local foundation never published, or unknown shape. Ask.
|
|
452
|
+
if (isNonInteractive(process.argv)) {
|
|
453
|
+
say.err('First deploy of this site needs an explicit mode.')
|
|
454
|
+
console.log('')
|
|
455
|
+
console.log(' Pick one and re-run:')
|
|
456
|
+
console.log(` ${c.cyan}uniweb deploy --link${c.reset} ${c.dim}Uniweb-edge hosting (data only; worker generates HTML)${c.reset}`)
|
|
457
|
+
console.log(` ${c.cyan}uniweb deploy --bundle${c.reset} ${c.dim}Static-host artifact (vite build; for non-Uniweb hosts)${c.reset}`)
|
|
458
|
+
console.log('')
|
|
459
|
+
console.log(` ${c.dim}Mode is persisted after the first deploy and can't be changed in place.${c.reset}`)
|
|
460
|
+
process.exit(1)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const prompts = (await import('prompts')).default
|
|
464
|
+
console.log('')
|
|
465
|
+
console.log(`${c.dim}First deploy of this site. Pick a deployment mode:${c.reset}`)
|
|
466
|
+
const resp = await prompts({
|
|
467
|
+
type: 'select',
|
|
468
|
+
name: 'mode',
|
|
469
|
+
message: 'Deployment mode',
|
|
470
|
+
choices: [
|
|
471
|
+
{ title: 'Link mode (Uniweb-edge hosting)', description: 'Data only; worker generates HTML at request time', value: 'link' },
|
|
472
|
+
{ title: 'Bundle mode (static-host artifact)', description: 'vite-built; deploy to Netlify, Vercel, GitHub Pages, etc.', value: 'bundle' },
|
|
473
|
+
],
|
|
474
|
+
initial: 0,
|
|
475
|
+
}, {
|
|
476
|
+
onCancel: () => { console.log(''); console.log('Deploy cancelled.'); process.exit(0) },
|
|
477
|
+
})
|
|
478
|
+
if (!resp.mode) process.exit(0)
|
|
479
|
+
return { mode: resp.mode, source: 'asked' }
|
|
259
480
|
}
|
|
260
481
|
|
|
261
482
|
// ─── Main ───────────────────────────────────────────────────
|
|
@@ -276,6 +497,16 @@ export async function deploy(args = []) {
|
|
|
276
497
|
// its `dist/publish.json` receipt is missing/stale. These flags opt out.
|
|
277
498
|
const autoPublishFoundation = !args.includes('--no-auto-publish')
|
|
278
499
|
const treatDirtyAsStale = !args.includes('--no-dirty-as-stale')
|
|
500
|
+
// Site mode — `--link` ships data only (Uniweb-edge hosting); `--bundle`
|
|
501
|
+
// ships a vite-built static-host artifact. Mutually exclusive. Resolution
|
|
502
|
+
// ladder runs further below: explicit flag → registry-ref / URL infer →
|
|
503
|
+
// published-local-foundation infer → ask-or-error.
|
|
504
|
+
const linkFlag = args.includes('--link')
|
|
505
|
+
const bundleFlag = args.includes('--bundle')
|
|
506
|
+
if (linkFlag && bundleFlag) {
|
|
507
|
+
say.err('Cannot pass both --link and --bundle.')
|
|
508
|
+
process.exit(1)
|
|
509
|
+
}
|
|
279
510
|
|
|
280
511
|
const siteDir = await resolveSiteDir(args)
|
|
281
512
|
const backendUrl = getBackendUrl()
|
|
@@ -308,6 +539,29 @@ export async function deploy(args = []) {
|
|
|
308
539
|
say.dim('Foundation policy: exact (pinned)')
|
|
309
540
|
}
|
|
310
541
|
|
|
542
|
+
// Resolve deploy mode (link vs bundle) BEFORE the foundation
|
|
543
|
+
// staleness check, because mode selection feeds into how we run
|
|
544
|
+
// the site build later. The resolver may prompt the user on first
|
|
545
|
+
// deploy; subsequent deploys infer or use the explicit flag.
|
|
546
|
+
// TODO: when PHP authorize starts returning a persisted mode for
|
|
547
|
+
// this site, reconcile it with the resolved mode here and reject
|
|
548
|
+
// any mismatch ("delete site and redeploy fresh to change modes").
|
|
549
|
+
const { mode: deployMode, source: modeSource } = await resolveDeployMode({
|
|
550
|
+
foundationRef: foundation,
|
|
551
|
+
siteDir,
|
|
552
|
+
linkFlag,
|
|
553
|
+
bundleFlag,
|
|
554
|
+
})
|
|
555
|
+
if (modeSource === 'inferred-registry-ref') {
|
|
556
|
+
say.dim('Deploy mode: link (inferred — foundation is a registry ref)')
|
|
557
|
+
} else if (modeSource === 'inferred-published-local') {
|
|
558
|
+
say.dim('Deploy mode: link (inferred — local foundation has a publish receipt)')
|
|
559
|
+
} else if (modeSource === 'asked') {
|
|
560
|
+
say.dim(`Deploy mode: ${deployMode} (selected)`)
|
|
561
|
+
} else {
|
|
562
|
+
say.dim(`Deploy mode: ${deployMode}`)
|
|
563
|
+
}
|
|
564
|
+
|
|
311
565
|
// Phase 2: resolve workspace-local `file:` foundation refs.
|
|
312
566
|
//
|
|
313
567
|
// The object form of `foundation:` already requires a registry ref
|
|
@@ -325,9 +579,17 @@ export async function deploy(args = []) {
|
|
|
325
579
|
const localPath = detected.path
|
|
326
580
|
const relPath = relative(siteDir, localPath) || localPath
|
|
327
581
|
|
|
582
|
+
// Pass a RemoteRegistry into the inspector so it can refill a missing
|
|
583
|
+
// `dist/publish.json` from the registry's index (fresh-clone case).
|
|
584
|
+
// No auth needed — `getVersionEntry` reads the public listing.
|
|
585
|
+
const refillRegistry = new RemoteRegistry(workerUrl)
|
|
328
586
|
const inspection = await inspectLocalFoundationReceipt(localPath, {
|
|
329
587
|
dirtyAsStale: treatDirtyAsStale,
|
|
588
|
+
registry: refillRegistry,
|
|
330
589
|
})
|
|
590
|
+
if (inspection.refilled) {
|
|
591
|
+
say.dim(`Foundation receipt at ${relPath} refilled from registry.`)
|
|
592
|
+
}
|
|
331
593
|
|
|
332
594
|
if (inspection.stale && !autoPublishFoundation) {
|
|
333
595
|
say.err(`Local foundation at ${relPath} is stale: ${inspection.reason}.`)
|
|
@@ -338,7 +600,16 @@ export async function deploy(args = []) {
|
|
|
338
600
|
say.info(`Foundation at ${relPath} is stale (${inspection.reason}). Auto-publishing…`)
|
|
339
601
|
console.log('')
|
|
340
602
|
try {
|
|
341
|
-
|
|
603
|
+
// Spawn the SAME CLI binary that's currently running, not via
|
|
604
|
+
// `npx uniweb` — npx resolves through node_modules and could
|
|
605
|
+
// pick up a stale npm-published version that doesn't share
|
|
606
|
+
// this CLI's behavior (e.g. doesn't recognize new flags).
|
|
607
|
+
// Using `process.argv[1]` keeps the outer/inner CLI version
|
|
608
|
+
// identical, eliminating the skew.
|
|
609
|
+
execSync(`node ${JSON.stringify(process.argv[1])} publish`, {
|
|
610
|
+
cwd: localPath,
|
|
611
|
+
stdio: 'inherit',
|
|
612
|
+
})
|
|
342
613
|
} catch {
|
|
343
614
|
say.err(`Auto-publish of foundation at ${relPath} failed. See output above.`)
|
|
344
615
|
process.exit(1)
|
|
@@ -394,10 +665,19 @@ export async function deploy(args = []) {
|
|
|
394
665
|
//
|
|
395
666
|
// For workspace-local foundations (Phase 2 resolution above),
|
|
396
667
|
// UNIWEB_FOUNDATION_REF tells defineSiteConfig to use the resolved
|
|
397
|
-
// registry ref instead of site.yml's literal value
|
|
398
|
-
// produces a runtime-mode bundle pointing at the just-
|
|
399
|
-
// foundation rather than embedding the local source.
|
|
400
|
-
|
|
668
|
+
// registry ref instead of site.yml's literal value. In bundle mode
|
|
669
|
+
// the build produces a runtime-mode bundle pointing at the just-
|
|
670
|
+
// published foundation rather than embedding the local source.
|
|
671
|
+
// Link mode doesn't run vite at all, so the env var is harmless
|
|
672
|
+
// there but still passed through for consistency.
|
|
673
|
+
//
|
|
674
|
+
// Spawn the SAME CLI binary that's currently running rather than
|
|
675
|
+
// `npx uniweb build` — npx walks node_modules and would resolve to
|
|
676
|
+
// whatever version is installed there (which might be older than
|
|
677
|
+
// the deploy CLI and silently ignore --link). `process.argv[1]`
|
|
678
|
+
// pins the inner build to the outer's exact version.
|
|
679
|
+
const buildModeFlag = deployMode === 'link' ? '--link' : '--bundle'
|
|
680
|
+
execSync(`node ${JSON.stringify(process.argv[1])} build ${buildModeFlag}`, {
|
|
401
681
|
cwd: siteDir,
|
|
402
682
|
stdio: 'inherit',
|
|
403
683
|
env: foundationBuildOverride
|
|
@@ -484,6 +764,13 @@ export async function deploy(args = []) {
|
|
|
484
764
|
// User-forced review (`uniweb deploy --review`). PHP refuses to
|
|
485
765
|
// fast-path even when nothing else has drifted.
|
|
486
766
|
forceReview: forceReview || undefined,
|
|
767
|
+
// Deploy mode for this site. On first deploy PHP should persist
|
|
768
|
+
// it to the site row; on subsequent deploys PHP should return the
|
|
769
|
+
// persisted value back so the CLI can detect mode mismatches and
|
|
770
|
+
// refuse with "delete and redeploy fresh" rather than silently
|
|
771
|
+
// reshape the storage layout. PHP versions that pre-date mode
|
|
772
|
+
// persistence ignore this field — back-compat is built in.
|
|
773
|
+
mode: deployMode,
|
|
487
774
|
}
|
|
488
775
|
let authRes
|
|
489
776
|
try {
|
|
@@ -504,6 +791,21 @@ export async function deploy(args = []) {
|
|
|
504
791
|
}
|
|
505
792
|
}
|
|
506
793
|
|
|
794
|
+
// Mode lock — once a site has a persisted mode on the server,
|
|
795
|
+
// deploying with a different mode would silently reshape its R2
|
|
796
|
+
// storage layout. Refuse and direct the user to start fresh.
|
|
797
|
+
// Until PHP starts returning `persistedMode`, this check is a
|
|
798
|
+
// no-op (forward-compatible).
|
|
799
|
+
if (authRes.persistedMode && authRes.persistedMode !== deployMode) {
|
|
800
|
+
say.err(`Deploy mode mismatch: this site is configured for ${authRes.persistedMode}, but this deploy resolved to ${deployMode}.`)
|
|
801
|
+
console.log('')
|
|
802
|
+
console.log(` ${c.dim}Mode is locked after the first deploy. To switch:${c.reset}`)
|
|
803
|
+
console.log(` 1. Delete the site (manage.uniweb.app or the dashboard)`)
|
|
804
|
+
console.log(` 2. Remove ${c.cyan}site.id${c.reset} and ${c.cyan}site.handle${c.reset} from site.yml`)
|
|
805
|
+
console.log(` 3. Re-run ${c.cyan}uniweb deploy --${deployMode}${c.reset}`)
|
|
806
|
+
process.exit(1)
|
|
807
|
+
}
|
|
808
|
+
|
|
507
809
|
if (authRes.needsReview) {
|
|
508
810
|
const flowLabel = authRes.intent === 'create' ? 'site creation' : 'review'
|
|
509
811
|
// openBrowser returns a hint about whether a GUI was available. On
|