uniweb 0.12.35 → 0.12.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/partials/agents.md +11 -11
- package/src/backend/client.js +78 -24
- package/src/backend/foundation-bring-along.js +229 -0
- package/src/backend/payment-handoff.js +105 -0
- package/src/backend/site-sync.js +2 -2
- package/src/commands/build.js +39 -35
- package/src/commands/clone.js +3 -3
- package/src/commands/deploy.js +95 -424
- package/src/commands/export.js +5 -3
- package/src/commands/publish.js +285 -95
- package/src/commands/pull.js +7 -5
- package/src/commands/push.js +8 -6
- package/src/commands/register.js +13 -5
- package/src/commands/rename.js +3 -2
- package/src/commands/runtime.js +1 -1
- package/src/commands/status.js +24 -5
- package/src/framework-index.json +5 -5
- package/src/index.js +63 -48
- package/src/utils/asset-upload.js +3 -3
- package/src/utils/code-upload.js +43 -3
- package/src/utils/config.js +30 -5
- package/src/utils/registry-auth.js +84 -33
package/src/commands/deploy.js
CHANGED
|
@@ -1,67 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deploy Command
|
|
2
|
+
* Deploy Command — ship a site to its resolved target.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* `uniweb deploy` resolves WHERE a site goes from deploy.yml (+ `--host` /
|
|
5
|
+
* `--target`) and ships it there:
|
|
6
|
+
* - THIRD-PARTY host (`s3-cloudfront`, `cloudflare-pages`, `github-pages`,
|
|
7
|
+
* `generic-static`, …): build `dist/` in bundle mode and hand it to the
|
|
8
|
+
* host adapter for upload + invalidation.
|
|
9
|
+
* - UNIWEB hosting target (an explicit `--host=uniweb`, or a `uniweb` target
|
|
10
|
+
* in deploy.yml): DELEGATE to `uniweb publish` — the smart path (sync +
|
|
11
|
+
* dynamic hosting, brings the foundation along). So deploy.yml stays one
|
|
12
|
+
* actionable "where this site deploys" record, uniweb included.
|
|
6
13
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
14
|
+
* `uniweb publish` is the canonical direct verb for Uniweb hosting (reach for it
|
|
15
|
+
* by default); `uniweb export` writes a self-contained artifact you upload
|
|
16
|
+
* yourself.
|
|
10
17
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* Uniweb-host flow (deployToUniwebBackend) — the composite deploy = build → ball →
|
|
20
|
-
* media → push → publish:
|
|
21
|
-
* 1. Resolve the site dir + deploy.yml target; discover() the backend (GET
|
|
22
|
-
* /dev/config) and resolve the runtime (`site.yml::runtime` if pinned, else the
|
|
23
|
-
* backend's highest installed; fail closed if neither resolves).
|
|
24
|
-
* 2. Build the site data (link mode): site-content.json (+ per-locale variants),
|
|
25
|
-
* collection data, search indexes, processed assets.
|
|
26
|
-
* 3. Partition collections by schema presence: schema-less → the static-data ball;
|
|
27
|
-
* schema-backed → typed folder entities on the push lane.
|
|
28
|
-
* 4. Upload the site's local media (entity refs + the ball's refs, one deduped set) →
|
|
29
|
-
* each site-root ref's backend serve URL; rewrite the ball with it, then upload the
|
|
30
|
-
* rewritten ball (content-addressed → `info.data_bundle`).
|
|
31
|
-
* 5. Push — the SAME two-lane sync `uniweb push` uses (site-content with
|
|
32
|
-
* `info.data_bundle` stamped + media refs rewritten, then the folder + records) —
|
|
33
|
-
* over the send-only-changed cache; the backend mints/round-trips the site uuid.
|
|
34
|
-
* 6. Publish — make the just-pushed composite live; the backend returns the serve URL.
|
|
18
|
+
* Host resolution:
|
|
19
|
+
* 1. --target <name> picks a target from deploy.yml (full config)
|
|
20
|
+
* 2. deploy.yml's `default:` target when no flag is given
|
|
21
|
+
* 3. with no deploy.yml at all, NO host is chosen → deploy prompts for a
|
|
22
|
+
* third-party adapter (interactive) rather than assuming Uniweb;
|
|
23
|
+
* non-interactive → an actionable error pointing at `publish` / `--host`
|
|
24
|
+
* 4. --host <name> is a one-off override (does NOT persist to deploy.yml)
|
|
35
25
|
*
|
|
36
26
|
* Usage:
|
|
37
|
-
* uniweb deploy
|
|
38
|
-
* uniweb deploy --
|
|
39
|
-
* uniweb deploy --target <name> Pick a target from deploy.yml
|
|
40
|
-
* uniweb deploy --
|
|
27
|
+
* uniweb deploy --host <name> Build bundle-mode dist/ + hand to the host adapter
|
|
28
|
+
* uniweb deploy --host=uniweb Delegate to `uniweb publish` (Uniweb hosting)
|
|
29
|
+
* uniweb deploy --target <name> Pick a target from deploy.yml
|
|
30
|
+
* uniweb deploy --dry-run Resolve everything; upload nothing
|
|
41
31
|
* uniweb deploy --no-save Skip the deploy.yml lastDeploy auto-save
|
|
42
|
-
* uniweb deploy --backend <url> Override the backend origin
|
|
43
32
|
*
|
|
44
|
-
*
|
|
45
|
-
* > the default. Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
|
|
46
|
-
*
|
|
47
|
-
* Escape hatch: UNIWEB_SKIP_BUILD=1 reuses an existing dist/ (static-host flow).
|
|
33
|
+
* Escape hatch: UNIWEB_SKIP_BUILD=1 reuses an existing dist/.
|
|
48
34
|
*/
|
|
49
35
|
|
|
50
36
|
import { existsSync } from 'node:fs'
|
|
51
|
-
import { readFile } from 'node:fs/promises'
|
|
52
37
|
import { resolve, join } from 'node:path'
|
|
53
38
|
import { execSync } from 'node:child_process'
|
|
54
|
-
import yaml from 'js-yaml'
|
|
55
39
|
|
|
56
|
-
import { loadDeployYml, resolveTarget, recordLastDeploy
|
|
40
|
+
import { loadDeployYml, resolveTarget, recordLastDeploy } from '@uniweb/build/site'
|
|
57
41
|
import { promptForHost } from '../utils/host-prompt.js'
|
|
58
42
|
import { readFlagValue } from '../utils/args.js'
|
|
59
43
|
import { parseBoolEnv } from '../utils/env.js'
|
|
60
|
-
import { BackendClient } from '../backend/client.js'
|
|
61
|
-
import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
62
|
-
import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
|
|
63
|
-
import { uploadDataBundle } from '../backend/data-bundle.js'
|
|
64
|
-
import { uploadSiteMedia } from '../backend/site-media.js'
|
|
65
44
|
|
|
66
45
|
import {
|
|
67
46
|
findWorkspaceRoot,
|
|
@@ -71,69 +50,6 @@ import {
|
|
|
71
50
|
} from '../utils/workspace.js'
|
|
72
51
|
import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
|
|
73
52
|
|
|
74
|
-
const FOUNDATION_POLICIES = new Set(['exact', 'auto-patch', 'auto-minor'])
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Parse the `foundation:` field from site.yml into a normalized shape.
|
|
78
|
-
*
|
|
79
|
-
* Accepts:
|
|
80
|
-
* - string: '@uniweb/votiverse@0.1.1'
|
|
81
|
-
* - object: { ref: '@uniweb/votiverse@0.1.1', policy?: ..., pinned?: true }
|
|
82
|
-
*
|
|
83
|
-
* Returns one of:
|
|
84
|
-
* - { error: 'description of what's wrong' }
|
|
85
|
-
* - { normalized, policy?, pinned } where `normalized` is whichever
|
|
86
|
-
* shape we received (string or { ref, policy?, pinned? }) — the Worker
|
|
87
|
-
* accepts both. `policy`/`pinned` are also returned individually so
|
|
88
|
-
* the CLI can print friendly diagnostics.
|
|
89
|
-
*
|
|
90
|
-
* Validation rules (mirrors publish.js::parseFoundationConfig):
|
|
91
|
-
* - `policy` must be one of 'exact', 'auto-patch', 'auto-minor'
|
|
92
|
-
* - `pinned: true` + `policy: not-exact` is rejected as conflicting
|
|
93
|
-
*/
|
|
94
|
-
function parseSiteFoundation(input) {
|
|
95
|
-
if (typeof input === 'string') {
|
|
96
|
-
return { normalized: input, policy: null, pinned: false }
|
|
97
|
-
}
|
|
98
|
-
if (!input || typeof input !== 'object') {
|
|
99
|
-
return { error: 'foundation must be a string or object' }
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Object form must carry `ref`; everything else is metadata.
|
|
103
|
-
if (!input.ref || typeof input.ref !== 'string') {
|
|
104
|
-
return { error: 'foundation.ref is required when using object form' }
|
|
105
|
-
}
|
|
106
|
-
if (!/^@[a-z0-9_-]+\/[a-z0-9_-]+@.+$/.test(input.ref)) {
|
|
107
|
-
return {
|
|
108
|
-
error: `foundation.ref does not match @namespace/name@version: '${input.ref}'`,
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
let policy = null
|
|
113
|
-
if (input.policy != null) {
|
|
114
|
-
if (!FOUNDATION_POLICIES.has(input.policy)) {
|
|
115
|
-
return {
|
|
116
|
-
error: `foundation.policy must be one of 'exact', 'auto-patch', 'auto-minor' (got '${input.policy}')`,
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
policy = input.policy
|
|
120
|
-
}
|
|
121
|
-
const pinned = input.pinned === true
|
|
122
|
-
|
|
123
|
-
if (pinned && policy && policy !== 'exact') {
|
|
124
|
-
return {
|
|
125
|
-
error: `foundation: 'pinned: true' conflicts with policy '${policy}'. ` +
|
|
126
|
-
`Use either 'pinned: true' or 'policy: \"exact\"' (they're equivalent), or drop one.`,
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return {
|
|
131
|
-
normalized: { ref: input.ref, ...(policy ? { policy } : {}), ...(pinned ? { pinned: true } : {}) },
|
|
132
|
-
policy: pinned ? 'exact' : policy,
|
|
133
|
-
pinned,
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
53
|
const c = {
|
|
138
54
|
reset: '\x1b[0m',
|
|
139
55
|
bold: '\x1b[1m',
|
|
@@ -156,24 +72,12 @@ const say = {
|
|
|
156
72
|
export async function deploy(args = []) {
|
|
157
73
|
const dryRun = args.includes('--dry-run')
|
|
158
74
|
const siteDir = await resolveSiteDir(args)
|
|
159
|
-
// Read site.yml — declares the foundation (required) and optionally the
|
|
160
|
-
// site.id / site.handle from prior deploys.
|
|
161
|
-
const siteYmlPath = join(siteDir, 'site.yml')
|
|
162
|
-
const siteYml = await readSiteYml(siteYmlPath)
|
|
163
75
|
|
|
164
|
-
// Host dispatch.
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// 2. deploy.yml's `default:` target is used when no flag is given
|
|
170
|
-
// 3. With no deploy.yml at all, the implicit default is host: 'uniweb'
|
|
171
|
-
// 4. --host <name> is a one-off override of the resolved target's host
|
|
172
|
-
// and does NOT persist on success (see saveDeployTarget below).
|
|
173
|
-
//
|
|
174
|
-
// The default flow (`uniweb`) requires a `foundation:` declaration;
|
|
175
|
-
// static-host deploys don't, so this branch comes BEFORE the foundation
|
|
176
|
-
// check.
|
|
76
|
+
// Host dispatch. Resolution order:
|
|
77
|
+
// 1. --target <name> picks a target from deploy.yml
|
|
78
|
+
// 2. deploy.yml's `default:` target when no flag is given
|
|
79
|
+
// 3. with no deploy.yml, the implicit default is host: 'uniweb'
|
|
80
|
+
// 4. --host <name> is a one-off override (does not persist on success)
|
|
177
81
|
const targetFromFlag = readFlagValue(args, '--target')
|
|
178
82
|
let hostFromFlag = readFlagValue(args, '--host')
|
|
179
83
|
const noSave = args.includes('--no-save')
|
|
@@ -202,291 +106,61 @@ export async function deploy(args = []) {
|
|
|
202
106
|
process.exit(1)
|
|
203
107
|
}
|
|
204
108
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
// (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
process.exit(1)
|
|
231
|
-
}
|
|
232
|
-
// `foundation` is the on-the-wire shape we forward to PHP authorize +
|
|
233
|
-
// Worker publish. PHP only inspects the namespace via the ref string;
|
|
234
|
-
// it doesn't care about policy/pinned, so the object form passes through.
|
|
235
|
-
// The Worker (publish.js::parseFoundationConfig) handles both shapes.
|
|
236
|
-
let foundation = fnd.normalized
|
|
237
|
-
if (fnd.policy && fnd.policy !== 'auto-patch') {
|
|
238
|
-
say.dim(`Foundation policy: ${fnd.policy}${fnd.pinned ? ' (pinned)' : ''}`)
|
|
239
|
-
} else if (fnd.pinned) {
|
|
240
|
-
say.dim('Foundation policy: exact (pinned)')
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Uniweb hosting → the new backend's /dev/deploy delivery lane (BackendClient):
|
|
244
|
-
// one authed POST, no PHP authorize, no Worker publish, no JWT. Backend chosen by
|
|
245
|
-
// origin only; capabilities + installed runtimes discovered via GET /dev/config.
|
|
246
|
-
// Foundation/runtime resolution, payload assembly, the POST, and the deploy.yml
|
|
247
|
-
// uuid round-trip all live in deployToUniwebBackend. The legacy PHP-authorize +
|
|
248
|
-
// Worker-publish flow below is retired by this routing (excised on cutover).
|
|
249
|
-
await deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRun, resolved, deployYml, autoSave })
|
|
250
|
-
return
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// ─── Uniweb-backend deploy (the /dev/deploy delivery lane) ────────────────
|
|
254
|
-
//
|
|
255
|
-
// Hosts a file-built site on the Uniweb backend through BackendClient: one authed
|
|
256
|
-
// POST /dev/deploy carrying the deploy payload `build-site-data.js` produces. The
|
|
257
|
-
// login bearer authorizes (the account IS the authorization) — no PHP authorize,
|
|
258
|
-
// no Worker publish, no JWT, no asset-presign dance. Backend is chosen by origin
|
|
259
|
-
// only (--backend/--registry > UNIWEB_REGISTER_URL > default); everything else is
|
|
260
|
-
// discovered via GET /dev/config (capabilities + installed runtimes). Replaces the
|
|
261
|
-
// legacy PHP+Worker flow in deploy() above.
|
|
262
|
-
|
|
263
|
-
async function deployToUniwebBackend(siteDir, siteYml, { foundation, args, dryRun, resolved, deployYml, autoSave }) {
|
|
264
|
-
const client = new BackendClient({
|
|
265
|
-
originFlag: readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
|
|
266
|
-
token: readFlagValue(args, '--token'),
|
|
267
|
-
args,
|
|
268
|
-
command: 'Deploying',
|
|
269
|
-
})
|
|
270
|
-
|
|
271
|
-
const foundationDir = readFlagValue(args, '--foundation') // optional local foundation for Model schemas
|
|
272
|
-
const asOrg = readFlagValue(args, '--as-org')
|
|
273
|
-
|
|
274
|
-
// Anonymous capability handshake (cached). The composite deploy ends in a publish,
|
|
275
|
-
// so confirm that lane is offered (the push/sync lanes are the backend's baseline).
|
|
276
|
-
const config = await client.discover()
|
|
277
|
-
if (config?.delivery && config.delivery.publish === false) {
|
|
278
|
-
say.err(`Backend at ${client.origin} does not offer the publish lane (delivery.publish=false).`)
|
|
279
|
-
process.exit(1)
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Runtime resolution: an explicit site.yml::runtime pin wins; else the highest
|
|
283
|
-
// version the backend reports installed; else fail closed with a clear
|
|
284
|
-
// precondition error (better than serving a site with no runtime).
|
|
285
|
-
const installed = Array.isArray(config?.runtime?.installed) ? config.runtime.installed : []
|
|
286
|
-
if (siteYml.runtime && installed.length && !installed.includes(siteYml.runtime)) {
|
|
287
|
-
say.err(`Runtime ${siteYml.runtime} (from site.yml) is not installed on the backend.`)
|
|
288
|
-
say.dim(`Installed: ${installed.join(', ') || '(none)'} — pin one of these in site.yml (\`runtime:\`), or have it installed on the backend.`)
|
|
289
|
-
process.exit(1)
|
|
290
|
-
}
|
|
291
|
-
const runtimeVersion = siteYml.runtime || pickHighestRuntime(installed)
|
|
292
|
-
if (!runtimeVersion) {
|
|
293
|
-
say.err('Could not resolve a runtime version.')
|
|
294
|
-
say.dim('Pin one with `runtime:` in site.yml, or install one on the backend so /dev/config reports it.')
|
|
295
|
-
process.exit(1)
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
if (dryRun) {
|
|
299
|
-
say.info('Dry run — would deploy to the Uniweb backend as a composite (ball → push → publish):')
|
|
300
|
-
say.dim(`Backend : ${client.origin}`)
|
|
301
|
-
say.dim(`Foundation : ${typeof foundation === 'string' ? foundation : foundation.ref}`)
|
|
302
|
-
say.dim(`Runtime : ${runtimeVersion}${siteYml.runtime ? '' : ' (highest installed)'}`)
|
|
303
|
-
say.dim(`site_uuid : ${siteYml.$uuid || '(none — the first push mints it)'}`)
|
|
304
|
-
return
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Build (link mode): emits dist/data/*, dist/_search/*, dist/assets/*, and
|
|
308
|
-
// dist/site-content.json. Spawn the SAME CLI binary so the inner build can't resolve
|
|
309
|
-
// to a different installed version.
|
|
310
|
-
say.info('Building site…')
|
|
311
|
-
console.log('')
|
|
312
|
-
execSync(`node ${JSON.stringify(process.argv[1])} build --link`, {
|
|
313
|
-
cwd: siteDir,
|
|
314
|
-
stdio: 'inherit',
|
|
315
|
-
env: process.env,
|
|
316
|
-
})
|
|
317
|
-
console.log('')
|
|
318
|
-
|
|
319
|
-
const distDir = join(siteDir, 'dist')
|
|
320
|
-
const contentPath = join(distDir, 'site-content.json')
|
|
321
|
-
if (!existsSync(contentPath)) {
|
|
322
|
-
say.err('Build did not produce dist/site-content.json')
|
|
323
|
-
process.exit(1)
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Non-local @std/registry Model schemas resolve through the backend (same as push).
|
|
327
|
-
const resolveModel = makeModelResolver({ client, offline: false })
|
|
328
|
-
|
|
329
|
-
// 1. Partition the collections by schema presence. A first emit reads `schemaless`
|
|
330
|
-
// — the collections with no data schema, delivered statically via the ball. Its
|
|
331
|
-
// packages are discarded (deploy is not a hot path; the cheap clarity beats a
|
|
332
|
-
// schemaless-only fast path, a later optimization).
|
|
333
|
-
let probe
|
|
334
|
-
try {
|
|
335
|
-
probe = await emitSyncPackages(siteDir, { ...(foundationDir ? { foundationDir } : {}), resolveModel })
|
|
336
|
-
} catch (err) {
|
|
337
|
-
say.err(`Could not build the sync package: ${err.message}`)
|
|
338
|
-
process.exit(1)
|
|
339
|
-
}
|
|
340
|
-
const schemalessNames = (probe.schemaless || []).map((col) => col.name)
|
|
341
|
-
const localAssets = probe.localAssets || [] // entity-content site-root media refs
|
|
342
|
-
|
|
343
|
-
// 2. Assemble the static-data ball (schema-less collection data + the search index) —
|
|
344
|
-
// BEFORE uploading it, because its schema-less records can carry local media too,
|
|
345
|
-
// which we upload + rewrite to serve URLs exactly like entity content (the backend
|
|
346
|
-
// serves a serve_url in the ball identically — it unwraps the ball verbatim).
|
|
347
|
-
let ball = await assembleDataBall(distDir, schemalessNames)
|
|
348
|
-
const ballAssets = collectBallAssets(ball)
|
|
349
|
-
|
|
350
|
-
// 2b. Upload ALL local media (entity refs + ball refs) on one asset lane → the
|
|
351
|
-
// ref→serveUrl map. The same map rewrites the entity content (assetRewrite, real
|
|
352
|
-
// emit below) AND the ball (here, before it's uploaded). Co-located refs were
|
|
353
|
-
// warned + skipped by the producer; a missing file is skipped here (warned).
|
|
354
|
-
let assetRewrite = null
|
|
355
|
-
const mediaRefs = [...new Set([...localAssets, ...ballAssets])]
|
|
356
|
-
if (mediaRefs.length) {
|
|
357
|
-
say.info('Uploading media…')
|
|
358
|
-
try {
|
|
359
|
-
const map = await uploadSiteMedia(client, siteDir, mediaRefs, {
|
|
360
|
-
onProgress: (m) => say.dim(` ${m}`),
|
|
361
|
-
warn: (m) => say.dim(`! ${m}`),
|
|
362
|
-
})
|
|
363
|
-
if (Object.keys(map).length) assetRewrite = map
|
|
364
|
-
if (ballAssets.length) ball = rewriteBallAssets(ball, map) // swap the ball's local refs → serve URLs
|
|
365
|
-
say.dim(`Media : ${Object.keys(map).length}/${mediaRefs.length} ref(s) → serve URL`)
|
|
366
|
-
} catch (err) {
|
|
367
|
-
say.err(`Media upload failed: ${err.message}`)
|
|
109
|
+
let host = hostFromFlag || resolved.host
|
|
110
|
+
|
|
111
|
+
// A Uniweb-hosting target is `publish`'s flow. When the user EXPLICITLY chose
|
|
112
|
+
// uniweb (a `--host=uniweb`, or a `uniweb` target in deploy.yml), DELEGATE to
|
|
113
|
+
// `uniweb publish` so deploy.yml stays one actionable record. When NO host was
|
|
114
|
+
// chosen (the implicit default with no deploy.yml), don't assume uniweb:
|
|
115
|
+
// prompt for a third-party adapter (interactive) or point at publish / --host
|
|
116
|
+
// (non-interactive). promptForHost lists only third-party adapters.
|
|
117
|
+
if (host === 'uniweb') {
|
|
118
|
+
const explicitUniweb = hostFromFlag === 'uniweb' || (resolved.fromFile && resolved.host === 'uniweb')
|
|
119
|
+
if (explicitUniweb) {
|
|
120
|
+
say.info('Uniweb hosting target → running `uniweb publish`.')
|
|
121
|
+
console.log('')
|
|
122
|
+
// publish ignores deploy's --host/--target; --dry-run/--no-save/--backend
|
|
123
|
+
// /--token pass straight through.
|
|
124
|
+
const { publish } = await import('./publish.js')
|
|
125
|
+
const result = await publish(args)
|
|
126
|
+
process.exit(result?.exitCode ?? 0)
|
|
127
|
+
}
|
|
128
|
+
if (isNonInteractive(args)) {
|
|
129
|
+
say.err('`uniweb deploy` needs a host. For Uniweb hosting use `uniweb publish`; for a third-party host pass `--host=<adapter>`.')
|
|
130
|
+
console.log('')
|
|
131
|
+
say.dim('`uniweb publish` Uniweb hosting (sync + dynamic hosting; brings the foundation along)')
|
|
132
|
+
say.dim('`uniweb deploy --host=…` Third-party host (s3-cloudfront, cloudflare-pages, github-pages, generic-static)')
|
|
133
|
+
say.dim('`uniweb export` Self-contained dist/ artifact you upload anywhere')
|
|
368
134
|
process.exit(1)
|
|
369
135
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
// 2c. Upload the (media-rewritten) ball. `data_bundle` is its content-addressed serve
|
|
373
|
-
// URL; omitted when there is nothing static to deliver.
|
|
374
|
-
let dataBundle
|
|
375
|
-
if (ball) {
|
|
376
|
-
say.info('Uploading data bundle…')
|
|
136
|
+
say.info('`uniweb deploy` ships to a third-party host. (For Uniweb hosting, run `uniweb publish`.)')
|
|
377
137
|
try {
|
|
378
|
-
|
|
138
|
+
host = await promptForHost({ args })
|
|
379
139
|
} catch (err) {
|
|
380
|
-
say.err(
|
|
140
|
+
say.err(err.message)
|
|
381
141
|
process.exit(1)
|
|
382
142
|
}
|
|
383
|
-
say.dim(`Data bundle : ${Object.keys(ball.data).length} data + ${Object.keys(ball.search).length} search file(s)`)
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// 3. Push the site (content + folder) over the send-only-changed cache — the SAME
|
|
387
|
-
// two-lane submission `uniweb push` uses — stamping info.data_bundle on the
|
|
388
|
-
// site-content entity and rewriting local media refs to their backend serve URLs.
|
|
389
|
-
const priorHashes = readSyncCache(siteDir)
|
|
390
|
-
let pkg
|
|
391
|
-
try {
|
|
392
|
-
pkg = await emitSyncPackages(siteDir, {
|
|
393
|
-
...(foundationDir ? { foundationDir } : {}),
|
|
394
|
-
resolveModel,
|
|
395
|
-
priorHashes,
|
|
396
|
-
...(dataBundle ? { injectInfo: { data_bundle: dataBundle } } : {}),
|
|
397
|
-
...(assetRewrite ? { assetRewrite } : {}),
|
|
398
|
-
})
|
|
399
|
-
} catch (err) {
|
|
400
|
-
say.err(`Could not build the sync package: ${err.message}`)
|
|
401
|
-
process.exit(1)
|
|
402
|
-
}
|
|
403
|
-
for (const w of pkg.warnings) say.dim(`! ${w}`)
|
|
404
|
-
const report = {
|
|
405
|
-
info: (m) => say.info(m),
|
|
406
|
-
note: (m) => say.dim(m),
|
|
407
|
-
error: (m) => say.err(m),
|
|
408
|
-
dim: (s) => `${c.dim}${s}${c.reset}`,
|
|
409
|
-
}
|
|
410
|
-
const pushResult = await pushSyncPackages({ client, siteDir, pkg, asOrg, report })
|
|
411
|
-
if (pushResult.exitCode !== 0) process.exit(pushResult.exitCode)
|
|
412
|
-
const siteUuid = pushResult.boundSiteUuid
|
|
413
|
-
if (!siteUuid) {
|
|
414
|
-
say.err('Push did not yield a site uuid — cannot publish.')
|
|
415
|
-
process.exit(1)
|
|
416
143
|
}
|
|
417
144
|
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
try {
|
|
424
|
-
pubRes = await client.publishSite(siteUuid, { runtimeVersion, ...(languages ? { languages } : {}) })
|
|
425
|
-
} catch (err) {
|
|
426
|
-
say.err(`Could not reach the backend at ${client.origin}: ${err.message}`)
|
|
427
|
-
say.dim('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
|
|
428
|
-
process.exit(1)
|
|
429
|
-
}
|
|
430
|
-
if (!pubRes.ok) {
|
|
431
|
-
say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
|
|
432
|
-
if (pubRes.status === 401 || pubRes.status === 403) {
|
|
433
|
-
say.dim("Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>).")
|
|
434
|
-
}
|
|
435
|
-
const body = await pubRes.text().catch(() => '')
|
|
436
|
-
if (body) say.dim(body.slice(0, 800))
|
|
437
|
-
process.exit(1)
|
|
438
|
-
}
|
|
439
|
-
let result
|
|
440
|
-
try { result = await pubRes.json() } catch { result = {} }
|
|
441
|
-
const serveUrl = absolutizeServeUrl(client.origin, result.url)
|
|
145
|
+
// Auto-save scope: 'off' from --no-save OR an ad-hoc --host override (we don't
|
|
146
|
+
// want a one-off experiment to rewrite the file). A host picked interactively
|
|
147
|
+
// for a bare `deploy` is NOT an override — we DO want to remember it.
|
|
148
|
+
const hostOverridden = !!hostFromFlag && hostFromFlag !== resolved.host
|
|
149
|
+
const autoSave = noSave || hostOverridden ? 'off' : resolved.autoSave
|
|
442
150
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
await persistLastDeploy(siteDir, {
|
|
446
|
-
targetName: resolved.targetName,
|
|
447
|
-
targetConfig: resolved.fromFile ? null : { host: 'uniweb' },
|
|
151
|
+
await deployStaticHost(siteDir, host, resolved, {
|
|
152
|
+
dryRun,
|
|
448
153
|
autoSave,
|
|
449
|
-
|
|
450
|
-
at: new Date().toISOString(),
|
|
451
|
-
host: 'uniweb',
|
|
452
|
-
backend: client.origin,
|
|
453
|
-
siteUuid,
|
|
454
|
-
url: serveUrl,
|
|
455
|
-
foundation: { ref: typeof foundation === 'string' ? foundation : foundation?.ref },
|
|
456
|
-
runtime: runtimeVersion,
|
|
457
|
-
locales: Array.isArray(result.locales) ? result.locales : languages,
|
|
458
|
-
},
|
|
154
|
+
hostOverridden,
|
|
459
155
|
})
|
|
460
|
-
|
|
461
|
-
console.log('')
|
|
462
|
-
say.ok(`Deployed ${c.bold}${siteUuid}${c.reset}`)
|
|
463
|
-
if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// Pick the highest runtime from the backend's installed list. localeCompare with
|
|
467
|
-
// numeric ordering puts '0.8.16' above '0.8.9' and orders the synthetic dev tags
|
|
468
|
-
// deterministically. Null when the list is empty.
|
|
469
|
-
function pickHighestRuntime(installed) {
|
|
470
|
-
if (!Array.isArray(installed) || installed.length === 0) return null
|
|
471
|
-
return [...installed].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true }))[0]
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// The deploy response `url` is the serve path. When origin-relative (the self-serve
|
|
475
|
-
// default, e.g. /gateway/site/<uuid>/) prefix the BackendClient origin so the printed
|
|
476
|
-
// link is clickable; absolute URLs pass through unchanged.
|
|
477
|
-
function absolutizeServeUrl(origin, url) {
|
|
478
|
-
if (!url || typeof url !== 'string') return null
|
|
479
|
-
if (/^https?:\/\//.test(url)) return url
|
|
480
|
-
return `${origin.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
|
|
481
156
|
}
|
|
482
157
|
|
|
483
158
|
// ─── Static-host deploy (S3+CloudFront, etc.) ─────────────────
|
|
484
159
|
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
// for upload + invalidation.
|
|
160
|
+
// Picked when the resolved deploy.yml target (or --host override) names a
|
|
161
|
+
// static-host adapter registered in @uniweb/build/hosts. Always runs
|
|
162
|
+
// `uniweb build` (bundle mode + prerender) first, then hands dist/ to the
|
|
163
|
+
// adapter's deploy hook for upload + invalidation.
|
|
490
164
|
|
|
491
165
|
async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave, hostOverridden }) {
|
|
492
166
|
let getAdapter
|
|
@@ -531,7 +205,7 @@ async function deployStaticHost(siteDir, hostName, resolved, { dryRun, autoSave,
|
|
|
531
205
|
|
|
532
206
|
// Always rebuild — the static-host flow expects fresh dist/ on every
|
|
533
207
|
// deploy. UNIWEB_SKIP_BUILD env var lets CI / dev loops reuse an
|
|
534
|
-
// existing build
|
|
208
|
+
// existing build.
|
|
535
209
|
const skipBuild = parseBoolEnv('UNIWEB_SKIP_BUILD')
|
|
536
210
|
if (skipBuild) {
|
|
537
211
|
if (!existsSync(distDir)) {
|
|
@@ -613,25 +287,11 @@ async function persistLastDeploy(siteDir, opts) {
|
|
|
613
287
|
}
|
|
614
288
|
}
|
|
615
289
|
|
|
616
|
-
// ─── site
|
|
617
|
-
|
|
618
|
-
async function readSiteYml(path) {
|
|
619
|
-
if (!existsSync(path)) return {}
|
|
620
|
-
try {
|
|
621
|
-
const parsed = yaml.load(await readFile(path, 'utf8'))
|
|
622
|
-
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
623
|
-
} catch (err) {
|
|
624
|
-
say.err(`Could not parse ${path}: ${err.message}`)
|
|
625
|
-
process.exit(1)
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// ─── Resolve site dir + runtime ────────────────────────────
|
|
290
|
+
// ─── Resolve site dir ──────────────────────────────────────
|
|
630
291
|
|
|
631
|
-
// Exported so `uniweb export`
|
|
632
|
-
// site-discovery logic
|
|
633
|
-
//
|
|
634
|
-
// so the user gets accurate guidance.
|
|
292
|
+
// Exported so `uniweb export` / `uniweb publish` / `uniweb status` reuse the
|
|
293
|
+
// same site-discovery logic. `verb` is the command being run; it appears in
|
|
294
|
+
// the error messages so the user gets accurate guidance.
|
|
635
295
|
export async function resolveSiteDir(args, verb = 'deploy') {
|
|
636
296
|
const cwd = process.cwd()
|
|
637
297
|
const prefix = getCliPrefix()
|
|
@@ -664,17 +324,28 @@ export async function resolveSiteDir(args, verb = 'deploy') {
|
|
|
664
324
|
say.err('No site found in this workspace.')
|
|
665
325
|
if (verb === 'export') {
|
|
666
326
|
say.dim('`export` produces a self-contained dist/ artifact for third-party hosting.')
|
|
327
|
+
} else if (verb === 'deploy') {
|
|
328
|
+
say.dim('`deploy` ships a built site to a third-party host (use `uniweb publish` for Uniweb hosting).')
|
|
667
329
|
} else {
|
|
668
|
-
say.dim(
|
|
330
|
+
say.dim(`\`${verb}\` operates on a site.`)
|
|
669
331
|
}
|
|
670
332
|
process.exit(1)
|
|
671
333
|
}
|
|
672
334
|
|
|
673
|
-
//
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
335
|
+
// The site's deploy.yml-bound backend origin (for the resolved `uniweb` target),
|
|
336
|
+
// or null when there's no deploy.yml, the target isn't Uniweb hosting, or no
|
|
337
|
+
// backend was recorded. Site verbs pass this to BackendClient as `siteBackend`
|
|
338
|
+
// so a site stays bound to the backend it publishes to — deploy.yml is the
|
|
339
|
+
// record of *where* a site is deployed (the 98% case is uniweb.app, but a B2B
|
|
340
|
+
// university backend is just a `backend:` on the target). Sits below --backend
|
|
341
|
+
// and UNIWEB_REGISTER_URL but above the logged-in session in the resolution
|
|
342
|
+
// ladder (see resolveBackendOrigin). Best-effort: any read/parse error → null.
|
|
343
|
+
export async function resolveSiteBackend(siteDir) {
|
|
344
|
+
try {
|
|
345
|
+
const deployYml = await loadDeployYml(siteDir)
|
|
346
|
+
const resolved = resolveTarget(deployYml, null)
|
|
347
|
+
return resolved.host === 'uniweb' ? (resolved.config?.backend || null) : null
|
|
348
|
+
} catch {
|
|
349
|
+
return null
|
|
350
|
+
}
|
|
680
351
|
}
|
package/src/commands/export.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Produces a self-contained, vite-built site artifact in `dist/` for
|
|
5
5
|
* hosting on a third-party CDN (Netlify, Vercel, GitHub Pages, S3 +
|
|
6
|
-
* CloudFront, etc.). Does NOT upload anywhere —
|
|
6
|
+
* CloudFront, etc.). Does NOT upload anywhere — `uniweb deploy --host`
|
|
7
|
+
* uploads to a third-party host; `uniweb publish` ships to Uniweb hosting.
|
|
7
8
|
*
|
|
8
9
|
* The `dist/` output bundles the runtime + foundation + content into
|
|
9
10
|
* concatenated packaging, with a vite-built `index.html` + `entry.js` +
|
|
@@ -12,7 +13,8 @@
|
|
|
12
13
|
* Internally this is `uniweb build --bundle` plus user guidance for the
|
|
13
14
|
* upload step. The `--link` / `--bundle` flag pair is internal-only
|
|
14
15
|
* vocabulary now (Phase 2 of the CLI ergonomics overhaul); users see
|
|
15
|
-
* `uniweb
|
|
16
|
+
* `uniweb publish` (Uniweb hosting), `uniweb deploy --host` (third-party),
|
|
17
|
+
* and `uniweb export` (self-contained artifact).
|
|
16
18
|
*
|
|
17
19
|
* Usage:
|
|
18
20
|
* uniweb export Produce dist/ for static hosting
|
|
@@ -102,5 +104,5 @@ export async function exportSite(args = []) {
|
|
|
102
104
|
console.log(` ${c.dim}Vercel:${c.reset} ${c.cyan}vercel --prod${c.reset}`)
|
|
103
105
|
console.log(` ${c.dim}S3:${c.reset} ${c.cyan}aws s3 sync dist/ s3://your-bucket/${c.reset}`)
|
|
104
106
|
console.log('')
|
|
105
|
-
console.log(` ${c.dim}For Uniweb
|
|
107
|
+
console.log(` ${c.dim}For Uniweb hosting instead, use ${c.reset}${c.cyan}uniweb publish${c.reset}${c.dim}.${c.reset}`)
|
|
106
108
|
}
|