uniweb 0.12.3 → 0.12.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -22
- package/package.json +4 -4
- package/src/commands/add.js +88 -12
- package/src/commands/build.js +190 -23
- package/src/commands/deploy.js +343 -18
- package/src/commands/doctor.js +172 -130
- package/src/commands/handoff.js +1 -1
- package/src/commands/invite.js +2 -2
- package/src/commands/publish.js +514 -66
- package/src/commands/rename.js +310 -0
- package/src/framework-index.json +4 -4
- package/src/index.js +14 -5
- package/src/utils/receipt.js +91 -0
- package/src/utils/registry.js +33 -0
- package/templates/workspace/package.json.hbs +2 -4
package/src/commands/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,
|
|
@@ -164,13 +166,36 @@ const say = {
|
|
|
164
166
|
dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
|
|
165
167
|
}
|
|
166
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Read the git state for `dir`, scoped to that directory's history and
|
|
171
|
+
* working tree — NOT the whole repo's HEAD.
|
|
172
|
+
*
|
|
173
|
+
* `gitSha` : last commit that touched `dir` (`git log -1 -- .`).
|
|
174
|
+
* `gitDirty`: uncommitted changes inside `dir` only (`git status -- .`).
|
|
175
|
+
*
|
|
176
|
+
* Why scope it. In a multi-package monorepo, `git rev-parse HEAD` is
|
|
177
|
+
* the same value for every directory — the repo's current HEAD. That
|
|
178
|
+
* meant editing a SITE then deploying triggered the foundation's
|
|
179
|
+
* staleness check (its receipt's recorded sha didn't match the new
|
|
180
|
+
* repo HEAD), even though the foundation source was unchanged. The
|
|
181
|
+
* receipt's `publishedFromGitSha` field is per-foundation by design;
|
|
182
|
+
* the comparison side has to be too.
|
|
183
|
+
*
|
|
184
|
+
* If the path is outside a git repo, or has no commits touching it
|
|
185
|
+
* yet, the function returns `{ gitSha: null, gitDirty: false }` —
|
|
186
|
+
* same fallback shape as before.
|
|
187
|
+
*/
|
|
167
188
|
function readGitState(dir) {
|
|
168
189
|
try {
|
|
169
|
-
|
|
190
|
+
// `git log -1 --format=%H -- .` returns the SHA of the last
|
|
191
|
+
// commit that touched the cwd path. If no such commit exists
|
|
192
|
+
// yet (path was never committed), output is empty — caller
|
|
193
|
+
// treats null as "no published-from-sha to compare against."
|
|
194
|
+
const sha = execSync('git log -1 --format=%H -- .', {
|
|
170
195
|
cwd: dir,
|
|
171
196
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
172
197
|
}).toString().trim()
|
|
173
|
-
const status = execSync('git status --porcelain', {
|
|
198
|
+
const status = execSync('git status --porcelain -- .', {
|
|
174
199
|
cwd: dir,
|
|
175
200
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
176
201
|
}).toString()
|
|
@@ -193,16 +218,35 @@ function composeFoundationUrl(ref, registryBase) {
|
|
|
193
218
|
* Inspect a workspace-local foundation's `dist/publish.json` (Phase 1 receipt)
|
|
194
219
|
* and decide whether it's stale relative to the current source tree.
|
|
195
220
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
221
|
+
* When the receipt file is missing and a `registry` is provided, attempt
|
|
222
|
+
* to refill it from the registry's index entry for `<name>@<version>`.
|
|
223
|
+
* That makes the receipt pure cache: a fresh clone with a matching
|
|
224
|
+
* upstream artifact resolves without an unnecessary republish. If the
|
|
225
|
+
* registry has no record (or the stored entry lacks git provenance), the
|
|
226
|
+
* inspector returns `stale: true` and the caller's auto-publish path
|
|
227
|
+
* runs as before.
|
|
228
|
+
*
|
|
229
|
+
* Returns `{ stale, reason, receipt, refilled? }`. The caller decides
|
|
230
|
+
* whether to auto-publish (Phase 2 default) or fail (`--no-auto-publish`).
|
|
198
231
|
*/
|
|
199
|
-
async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale }) {
|
|
232
|
+
async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale, registry }) {
|
|
200
233
|
const receiptPath = join(localPath, 'dist', 'publish.json')
|
|
201
234
|
let receipt = null
|
|
235
|
+
let refilled = false
|
|
202
236
|
try {
|
|
203
237
|
receipt = JSON.parse(await readFile(receiptPath, 'utf8'))
|
|
204
238
|
} catch {
|
|
205
|
-
|
|
239
|
+
if (registry) {
|
|
240
|
+
const refill = await tryRefillReceiptFromRegistry({ localPath, registry })
|
|
241
|
+
if (refill) {
|
|
242
|
+
await writeFile(receiptPath, JSON.stringify(refill, null, 2) + '\n')
|
|
243
|
+
receipt = refill
|
|
244
|
+
refilled = true
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (!receipt) {
|
|
248
|
+
return { stale: true, reason: 'no dist/publish.json (foundation has not been published from this checkout)' }
|
|
249
|
+
}
|
|
206
250
|
}
|
|
207
251
|
|
|
208
252
|
const { gitSha, gitDirty } = readGitState(localPath)
|
|
@@ -219,14 +263,94 @@ async function inspectLocalFoundationReceipt(localPath, { dirtyAsStale }) {
|
|
|
219
263
|
if (gitDirty && dirtyAsStale) {
|
|
220
264
|
return { stale: true, reason: 'foundation working tree is dirty', receipt }
|
|
221
265
|
}
|
|
222
|
-
return { stale: false, receipt }
|
|
266
|
+
return { stale: false, receipt, refilled }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* When a receipt is missing, try to reconstruct it from the registry's
|
|
271
|
+
* own record of the publish. We resolve `name@version` from the same
|
|
272
|
+
* package metadata `deriveLocalFoundationRef` uses, then ask the registry
|
|
273
|
+
* for its stored version entry. The entry's `publishedFromGitSha` is the
|
|
274
|
+
* load-bearing field — without it (entries written before git provenance
|
|
275
|
+
* was added) we can't compare against HEAD, so we don't synthesize a
|
|
276
|
+
* misleading "fresh" receipt and let the caller fall through to the
|
|
277
|
+
* republish path.
|
|
278
|
+
*
|
|
279
|
+
* Returns the receipt body, or null when refill is not possible.
|
|
280
|
+
*/
|
|
281
|
+
async function tryRefillReceiptFromRegistry({ localPath, registry }) {
|
|
282
|
+
// Two ways to derive the canonical `<name>@<version>`:
|
|
283
|
+
// 1. `deriveLocalFoundationRef` — works for org-scope foundations
|
|
284
|
+
// (where the namespace is in package.json) and for any foundation
|
|
285
|
+
// where a previous receipt already exists. On the wipe-and-deploy
|
|
286
|
+
// path that fired this code, the receipt is gone, so this only
|
|
287
|
+
// works for org-scope.
|
|
288
|
+
// 2. Empty-scope fallback — if package.json has `uniweb.id` and the
|
|
289
|
+
// user's auth.json carries a `memberUuid` claim, we can synthesize
|
|
290
|
+
// `~<memberUuid>/<id>@<version>` directly. Same shape the server
|
|
291
|
+
// stores under.
|
|
292
|
+
let ref = await deriveLocalFoundationRef(localPath)
|
|
293
|
+
if (!ref) ref = await refFromAuthAndPkg(localPath)
|
|
294
|
+
const split = splitRegistryRef(ref)
|
|
295
|
+
if (!split) return null
|
|
296
|
+
let existingEntry
|
|
297
|
+
try {
|
|
298
|
+
existingEntry = await registry.getVersionEntry(split.name, split.version)
|
|
299
|
+
} catch {
|
|
300
|
+
return null
|
|
301
|
+
}
|
|
302
|
+
if (!existingEntry) return null
|
|
303
|
+
return receiptFromRegistryEntry({
|
|
304
|
+
existingEntry,
|
|
305
|
+
registry,
|
|
306
|
+
name: split.name,
|
|
307
|
+
version: split.version,
|
|
308
|
+
isLocal: false,
|
|
309
|
+
isPropagateDefault: false,
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Last-resort canonical-name derivation for empty-scope foundations.
|
|
315
|
+
* Combines `package.json::uniweb.id` (the foundation's bare name) with
|
|
316
|
+
* the user's `memberUuid` claim from auth.json to produce
|
|
317
|
+
* `~<memberUuid>/<id>@<version>`. Only fires when both inputs are
|
|
318
|
+
* available — otherwise returns null and the caller falls through to
|
|
319
|
+
* the republish path.
|
|
320
|
+
*/
|
|
321
|
+
async function refFromAuthAndPkg(localPath) {
|
|
322
|
+
let pkg
|
|
323
|
+
try {
|
|
324
|
+
pkg = JSON.parse(await readFile(join(localPath, 'package.json'), 'utf8'))
|
|
325
|
+
} catch {
|
|
326
|
+
return null
|
|
327
|
+
}
|
|
328
|
+
const id = pkg?.uniweb?.id
|
|
329
|
+
const version = pkg?.version
|
|
330
|
+
if (!id || !version || !/^[a-z0-9_-]+$/.test(id)) return null
|
|
331
|
+
try {
|
|
332
|
+
const auth = await readAuth()
|
|
333
|
+
const claims = decodeJwtPayload(auth?.token)
|
|
334
|
+
if (claims?.memberUuid) return `~${claims.memberUuid}/${id}@${version}`
|
|
335
|
+
} catch { /* no auth — fall through to null */ }
|
|
336
|
+
return null
|
|
223
337
|
}
|
|
224
338
|
|
|
225
339
|
/**
|
|
226
340
|
* Read a workspace-local foundation's identity (scoped name + version) from
|
|
227
341
|
* its `dist/meta/schema.json` + `package.json`, mirroring `publish.js`'s
|
|
228
|
-
* namespace resolution. Returns the registry ref (`@ns/name@ver`
|
|
229
|
-
* if
|
|
342
|
+
* namespace resolution. Returns the registry ref (`@ns/name@ver` or
|
|
343
|
+
* `~uuid/name@ver`), or null if no shape can be resolved.
|
|
344
|
+
*
|
|
345
|
+
* Resolution order:
|
|
346
|
+
* 1. Org scope from `pkg.uniweb.namespace` or `pkg.name`'s `@org/...` prefix.
|
|
347
|
+
* 2. The receipt at `dist/publish.json`. After a successful publish, the
|
|
348
|
+
* receipt's `url` carries the canonical server-rewritten name —
|
|
349
|
+
* including empty-scope publishes, which the server rewrites to
|
|
350
|
+
* `~<memberUuid>/<name>`. The CLI can't synthesize that locally
|
|
351
|
+
* because the memberUuid lives in the JWT, not in the workspace.
|
|
352
|
+
* 3. null — caller falls through to the helpful "set uniweb.namespace"
|
|
353
|
+
* error message.
|
|
230
354
|
*/
|
|
231
355
|
async function deriveLocalFoundationRef(localPath) {
|
|
232
356
|
let pkg
|
|
@@ -248,14 +372,134 @@ async function deriveLocalFoundationRef(localPath) {
|
|
|
248
372
|
version = version || pkg.version
|
|
249
373
|
if (!rawName || !version) return null
|
|
250
374
|
|
|
375
|
+
// Org-scope path — derived purely from local files.
|
|
251
376
|
const uniwebNamespace = pkg.uniweb?.namespace
|
|
252
377
|
const pkgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\//)
|
|
253
378
|
const selfScopeMatch = rawName.match(/^@([a-z0-9_-]+)\//)
|
|
254
379
|
const namespace = uniwebNamespace || pkgScopeMatch?.[1] || selfScopeMatch?.[1]
|
|
255
|
-
if (
|
|
380
|
+
if (namespace) {
|
|
381
|
+
const bareName = selfScopeMatch ? rawName.slice(selfScopeMatch[0].length) : rawName
|
|
382
|
+
return `@${namespace}/${bareName}@${version}`
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Empty-scope path — read the canonical name from the receipt's URL.
|
|
386
|
+
// The server rewrites bare names to `~<memberUuid>/<name>`; the URL it
|
|
387
|
+
// returns carries that canonical form (e.g.
|
|
388
|
+
// `/foundations/~<uuid>/<name>@<ver>/foundation.js`). Parse it back.
|
|
389
|
+
const fromReceipt = await refFromReceiptUrl(localPath)
|
|
390
|
+
if (fromReceipt) return fromReceipt
|
|
391
|
+
|
|
392
|
+
return null
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Personal-scope namespaces are base58 memberUuids (mixed case; the
|
|
396
|
+
// alphabet is `[A-HJ-NP-Za-km-z1-9]`). Org-scope handles are
|
|
397
|
+
// lowercase-only. Allow both shapes here so the regex captures personal
|
|
398
|
+
// scopes correctly. The bare-name portion (after the `/`) stays
|
|
399
|
+
// lowercase per BARE_NAME_RE.
|
|
400
|
+
const RECEIPT_URL_REF_RE = /\/foundations\/((?:@[a-z0-9_-]+|~[A-Za-z0-9_-]+)\/[a-z0-9_-]+)@([^/]+)\//
|
|
401
|
+
|
|
402
|
+
async function refFromReceiptUrl(localPath) {
|
|
403
|
+
try {
|
|
404
|
+
const receipt = JSON.parse(await readFile(join(localPath, 'dist', 'publish.json'), 'utf8'))
|
|
405
|
+
const m = RECEIPT_URL_REF_RE.exec(receipt?.url || '')
|
|
406
|
+
if (m) return `${m[1]}@${m[2]}`
|
|
407
|
+
} catch {
|
|
408
|
+
// No receipt, malformed JSON, or URL doesn't carry the canonical
|
|
409
|
+
// shape — fall through to null.
|
|
410
|
+
}
|
|
411
|
+
return null
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Resolve the deploy mode for this site.
|
|
416
|
+
*
|
|
417
|
+
* Returns `'link'` or `'bundle'`, optionally prompting the user when
|
|
418
|
+
* neither inference nor an explicit flag yields an answer. Mode choice
|
|
419
|
+
* is persisted server-side after the first successful deploy; switching
|
|
420
|
+
* modes later requires deleting the site and redeploying fresh.
|
|
421
|
+
*
|
|
422
|
+
* Ladder (first match wins):
|
|
423
|
+
* 1. `--link` or `--bundle` flag passed explicitly.
|
|
424
|
+
* 2. `site.yml::foundation` is a registry ref (`@org/x@ver` or
|
|
425
|
+
* `~uuid/x@ver`) or full HTTPS URL → infer link. The user already
|
|
426
|
+
* declared "load this foundation by URL at runtime."
|
|
427
|
+
* 3. `site.yml::foundation` is a workspace-local sibling AND that
|
|
428
|
+
* foundation has a publish receipt (`dist/publish.json` with a
|
|
429
|
+
* registry URL) → infer link. The user has already done the work
|
|
430
|
+
* to make the foundation loadable from the registry.
|
|
431
|
+
* 4. Otherwise → ask (TTY prompt) or error (CI).
|
|
432
|
+
*
|
|
433
|
+
* @param {Object} ctx
|
|
434
|
+
* @param {string} ctx.foundationRef - the raw value of site.yml::foundation (string or normalized object).
|
|
435
|
+
* @param {string} ctx.siteDir - absolute path to the site dir (for resolving local foundation siblings).
|
|
436
|
+
* @param {boolean} ctx.linkFlag
|
|
437
|
+
* @param {boolean} ctx.bundleFlag
|
|
438
|
+
* @returns {Promise<{ mode: 'link'|'bundle', source: 'flag'|'inferred-registry-ref'|'inferred-published-local'|'asked' }>}
|
|
439
|
+
*/
|
|
440
|
+
async function resolveDeployMode({ foundationRef, siteDir, linkFlag, bundleFlag }) {
|
|
441
|
+
// 1. Explicit flag wins.
|
|
442
|
+
if (linkFlag) return { mode: 'link', source: 'flag' }
|
|
443
|
+
if (bundleFlag) return { mode: 'bundle', source: 'flag' }
|
|
444
|
+
|
|
445
|
+
// 2. Registry ref or URL in site.yml → link mode is the only sensible choice.
|
|
446
|
+
if (typeof foundationRef === 'string') {
|
|
447
|
+
if (foundationRef.startsWith('http://') || foundationRef.startsWith('https://')) {
|
|
448
|
+
return { mode: 'link', source: 'inferred-registry-ref' }
|
|
449
|
+
}
|
|
450
|
+
if (/^@[a-z0-9_-]+\/[a-z0-9_-]+@/.test(foundationRef)) {
|
|
451
|
+
return { mode: 'link', source: 'inferred-registry-ref' }
|
|
452
|
+
}
|
|
453
|
+
if (/^~[A-Za-z0-9_-]+\/[a-z0-9_-]+@/.test(foundationRef)) {
|
|
454
|
+
return { mode: 'link', source: 'inferred-registry-ref' }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// 3. Workspace-local foundation with an existing publish receipt → infer link.
|
|
459
|
+
// The receipt being there means the user has already published this
|
|
460
|
+
// foundation at least once, so they've shown intent to ship via the
|
|
461
|
+
// registry. We don't validate the receipt's freshness here — that's
|
|
462
|
+
// the existing inspectLocalFoundationReceipt path's job, which runs
|
|
463
|
+
// later in the deploy flow.
|
|
464
|
+
if (typeof foundationRef === 'string') {
|
|
465
|
+
const detected = detectFoundationType(foundationRef, siteDir)
|
|
466
|
+
if (detected.type === 'local') {
|
|
467
|
+
const receiptPath = join(detected.path, 'dist', 'publish.json')
|
|
468
|
+
if (existsSync(receiptPath)) {
|
|
469
|
+
return { mode: 'link', source: 'inferred-published-local' }
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
256
473
|
|
|
257
|
-
|
|
258
|
-
|
|
474
|
+
// 4. Ambiguous — local foundation never published, or unknown shape. Ask.
|
|
475
|
+
if (isNonInteractive(process.argv)) {
|
|
476
|
+
say.err('First deploy of this site needs an explicit mode.')
|
|
477
|
+
console.log('')
|
|
478
|
+
console.log(' Pick one and re-run:')
|
|
479
|
+
console.log(` ${c.cyan}uniweb deploy --link${c.reset} ${c.dim}Uniweb-edge hosting (data only; worker generates HTML)${c.reset}`)
|
|
480
|
+
console.log(` ${c.cyan}uniweb deploy --bundle${c.reset} ${c.dim}Static-host artifact (vite build; for non-Uniweb hosts)${c.reset}`)
|
|
481
|
+
console.log('')
|
|
482
|
+
console.log(` ${c.dim}Mode is persisted after the first deploy and can't be changed in place.${c.reset}`)
|
|
483
|
+
process.exit(1)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const prompts = (await import('prompts')).default
|
|
487
|
+
console.log('')
|
|
488
|
+
console.log(`${c.dim}First deploy of this site. Pick a deployment mode:${c.reset}`)
|
|
489
|
+
const resp = await prompts({
|
|
490
|
+
type: 'select',
|
|
491
|
+
name: 'mode',
|
|
492
|
+
message: 'Deployment mode',
|
|
493
|
+
choices: [
|
|
494
|
+
{ title: 'Link mode (Uniweb-edge hosting)', description: 'Data only; worker generates HTML at request time', value: 'link' },
|
|
495
|
+
{ title: 'Bundle mode (static-host artifact)', description: 'vite-built; deploy to Netlify, Vercel, GitHub Pages, etc.', value: 'bundle' },
|
|
496
|
+
],
|
|
497
|
+
initial: 0,
|
|
498
|
+
}, {
|
|
499
|
+
onCancel: () => { console.log(''); console.log('Deploy cancelled.'); process.exit(0) },
|
|
500
|
+
})
|
|
501
|
+
if (!resp.mode) process.exit(0)
|
|
502
|
+
return { mode: resp.mode, source: 'asked' }
|
|
259
503
|
}
|
|
260
504
|
|
|
261
505
|
// ─── Main ───────────────────────────────────────────────────
|
|
@@ -276,6 +520,16 @@ export async function deploy(args = []) {
|
|
|
276
520
|
// its `dist/publish.json` receipt is missing/stale. These flags opt out.
|
|
277
521
|
const autoPublishFoundation = !args.includes('--no-auto-publish')
|
|
278
522
|
const treatDirtyAsStale = !args.includes('--no-dirty-as-stale')
|
|
523
|
+
// Site mode — `--link` ships data only (Uniweb-edge hosting); `--bundle`
|
|
524
|
+
// ships a vite-built static-host artifact. Mutually exclusive. Resolution
|
|
525
|
+
// ladder runs further below: explicit flag → registry-ref / URL infer →
|
|
526
|
+
// published-local-foundation infer → ask-or-error.
|
|
527
|
+
const linkFlag = args.includes('--link')
|
|
528
|
+
const bundleFlag = args.includes('--bundle')
|
|
529
|
+
if (linkFlag && bundleFlag) {
|
|
530
|
+
say.err('Cannot pass both --link and --bundle.')
|
|
531
|
+
process.exit(1)
|
|
532
|
+
}
|
|
279
533
|
|
|
280
534
|
const siteDir = await resolveSiteDir(args)
|
|
281
535
|
const backendUrl = getBackendUrl()
|
|
@@ -308,6 +562,29 @@ export async function deploy(args = []) {
|
|
|
308
562
|
say.dim('Foundation policy: exact (pinned)')
|
|
309
563
|
}
|
|
310
564
|
|
|
565
|
+
// Resolve deploy mode (link vs bundle) BEFORE the foundation
|
|
566
|
+
// staleness check, because mode selection feeds into how we run
|
|
567
|
+
// the site build later. The resolver may prompt the user on first
|
|
568
|
+
// deploy; subsequent deploys infer or use the explicit flag.
|
|
569
|
+
// TODO: when PHP authorize starts returning a persisted mode for
|
|
570
|
+
// this site, reconcile it with the resolved mode here and reject
|
|
571
|
+
// any mismatch ("delete site and redeploy fresh to change modes").
|
|
572
|
+
const { mode: deployMode, source: modeSource } = await resolveDeployMode({
|
|
573
|
+
foundationRef: foundation,
|
|
574
|
+
siteDir,
|
|
575
|
+
linkFlag,
|
|
576
|
+
bundleFlag,
|
|
577
|
+
})
|
|
578
|
+
if (modeSource === 'inferred-registry-ref') {
|
|
579
|
+
say.dim('Deploy mode: link (inferred — foundation is a registry ref)')
|
|
580
|
+
} else if (modeSource === 'inferred-published-local') {
|
|
581
|
+
say.dim('Deploy mode: link (inferred — local foundation has a publish receipt)')
|
|
582
|
+
} else if (modeSource === 'asked') {
|
|
583
|
+
say.dim(`Deploy mode: ${deployMode} (selected)`)
|
|
584
|
+
} else {
|
|
585
|
+
say.dim(`Deploy mode: ${deployMode}`)
|
|
586
|
+
}
|
|
587
|
+
|
|
311
588
|
// Phase 2: resolve workspace-local `file:` foundation refs.
|
|
312
589
|
//
|
|
313
590
|
// The object form of `foundation:` already requires a registry ref
|
|
@@ -325,9 +602,17 @@ export async function deploy(args = []) {
|
|
|
325
602
|
const localPath = detected.path
|
|
326
603
|
const relPath = relative(siteDir, localPath) || localPath
|
|
327
604
|
|
|
605
|
+
// Pass a RemoteRegistry into the inspector so it can refill a missing
|
|
606
|
+
// `dist/publish.json` from the registry's index (fresh-clone case).
|
|
607
|
+
// No auth needed — `getVersionEntry` reads the public listing.
|
|
608
|
+
const refillRegistry = new RemoteRegistry(workerUrl)
|
|
328
609
|
const inspection = await inspectLocalFoundationReceipt(localPath, {
|
|
329
610
|
dirtyAsStale: treatDirtyAsStale,
|
|
611
|
+
registry: refillRegistry,
|
|
330
612
|
})
|
|
613
|
+
if (inspection.refilled) {
|
|
614
|
+
say.dim(`Foundation receipt at ${relPath} refilled from registry.`)
|
|
615
|
+
}
|
|
331
616
|
|
|
332
617
|
if (inspection.stale && !autoPublishFoundation) {
|
|
333
618
|
say.err(`Local foundation at ${relPath} is stale: ${inspection.reason}.`)
|
|
@@ -338,7 +623,16 @@ export async function deploy(args = []) {
|
|
|
338
623
|
say.info(`Foundation at ${relPath} is stale (${inspection.reason}). Auto-publishing…`)
|
|
339
624
|
console.log('')
|
|
340
625
|
try {
|
|
341
|
-
|
|
626
|
+
// Spawn the SAME CLI binary that's currently running, not via
|
|
627
|
+
// `npx uniweb` — npx resolves through node_modules and could
|
|
628
|
+
// pick up a stale npm-published version that doesn't share
|
|
629
|
+
// this CLI's behavior (e.g. doesn't recognize new flags).
|
|
630
|
+
// Using `process.argv[1]` keeps the outer/inner CLI version
|
|
631
|
+
// identical, eliminating the skew.
|
|
632
|
+
execSync(`node ${JSON.stringify(process.argv[1])} publish`, {
|
|
633
|
+
cwd: localPath,
|
|
634
|
+
stdio: 'inherit',
|
|
635
|
+
})
|
|
342
636
|
} catch {
|
|
343
637
|
say.err(`Auto-publish of foundation at ${relPath} failed. See output above.`)
|
|
344
638
|
process.exit(1)
|
|
@@ -394,10 +688,19 @@ export async function deploy(args = []) {
|
|
|
394
688
|
//
|
|
395
689
|
// For workspace-local foundations (Phase 2 resolution above),
|
|
396
690
|
// 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
|
-
|
|
691
|
+
// registry ref instead of site.yml's literal value. In bundle mode
|
|
692
|
+
// the build produces a runtime-mode bundle pointing at the just-
|
|
693
|
+
// published foundation rather than embedding the local source.
|
|
694
|
+
// Link mode doesn't run vite at all, so the env var is harmless
|
|
695
|
+
// there but still passed through for consistency.
|
|
696
|
+
//
|
|
697
|
+
// Spawn the SAME CLI binary that's currently running rather than
|
|
698
|
+
// `npx uniweb build` — npx walks node_modules and would resolve to
|
|
699
|
+
// whatever version is installed there (which might be older than
|
|
700
|
+
// the deploy CLI and silently ignore --link). `process.argv[1]`
|
|
701
|
+
// pins the inner build to the outer's exact version.
|
|
702
|
+
const buildModeFlag = deployMode === 'link' ? '--link' : '--bundle'
|
|
703
|
+
execSync(`node ${JSON.stringify(process.argv[1])} build ${buildModeFlag}`, {
|
|
401
704
|
cwd: siteDir,
|
|
402
705
|
stdio: 'inherit',
|
|
403
706
|
env: foundationBuildOverride
|
|
@@ -484,6 +787,13 @@ export async function deploy(args = []) {
|
|
|
484
787
|
// User-forced review (`uniweb deploy --review`). PHP refuses to
|
|
485
788
|
// fast-path even when nothing else has drifted.
|
|
486
789
|
forceReview: forceReview || undefined,
|
|
790
|
+
// Deploy mode for this site. On first deploy PHP should persist
|
|
791
|
+
// it to the site row; on subsequent deploys PHP should return the
|
|
792
|
+
// persisted value back so the CLI can detect mode mismatches and
|
|
793
|
+
// refuse with "delete and redeploy fresh" rather than silently
|
|
794
|
+
// reshape the storage layout. PHP versions that pre-date mode
|
|
795
|
+
// persistence ignore this field — back-compat is built in.
|
|
796
|
+
mode: deployMode,
|
|
487
797
|
}
|
|
488
798
|
let authRes
|
|
489
799
|
try {
|
|
@@ -504,6 +814,21 @@ export async function deploy(args = []) {
|
|
|
504
814
|
}
|
|
505
815
|
}
|
|
506
816
|
|
|
817
|
+
// Mode lock — once a site has a persisted mode on the server,
|
|
818
|
+
// deploying with a different mode would silently reshape its R2
|
|
819
|
+
// storage layout. Refuse and direct the user to start fresh.
|
|
820
|
+
// Until PHP starts returning `persistedMode`, this check is a
|
|
821
|
+
// no-op (forward-compatible).
|
|
822
|
+
if (authRes.persistedMode && authRes.persistedMode !== deployMode) {
|
|
823
|
+
say.err(`Deploy mode mismatch: this site is configured for ${authRes.persistedMode}, but this deploy resolved to ${deployMode}.`)
|
|
824
|
+
console.log('')
|
|
825
|
+
console.log(` ${c.dim}Mode is locked after the first deploy. To switch:${c.reset}`)
|
|
826
|
+
console.log(` 1. Delete the site (manage.uniweb.app or the dashboard)`)
|
|
827
|
+
console.log(` 2. Remove ${c.cyan}site.id${c.reset} and ${c.cyan}site.handle${c.reset} from site.yml`)
|
|
828
|
+
console.log(` 3. Re-run ${c.cyan}uniweb deploy --${deployMode}${c.reset}`)
|
|
829
|
+
process.exit(1)
|
|
830
|
+
}
|
|
831
|
+
|
|
507
832
|
if (authRes.needsReview) {
|
|
508
833
|
const flowLabel = authRes.intent === 'create' ? 'site creation' : 'review'
|
|
509
834
|
// openBrowser returns a hint about whether a GUI was available. On
|