uniweb 1.0.0 → 1.0.2

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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,537 @@
1
+ /**
2
+ * Deploy Command — ship a site to its resolved target.
3
+ *
4
+ * With a destination already known (a `--host` flag, or a target in
5
+ * deploy.yml) `uniweb deploy` acts on it directly. With NO destination
6
+ * known it runs the WIZARD — "Where should this site go?" — rather than
7
+ * assuming one. Assuming a host was the old behavior and it was wrong;
8
+ * so was the picker that replaced it, which listed every registered
9
+ * adapter when only one could actually be deployed to. The wizard offers
10
+ * only destinations that can be acted on, and says what acting will do.
11
+ *
12
+ * Four outcomes, from the wizard or resolved directly:
13
+ * - THIRD-PARTY host, upload now: build `dist/` in bundle mode and hand
14
+ * it to the adapter's deploy hook (`github-pages`, `cloudflare-pages`,
15
+ * `netlify`, `vercel`, `s3-cloudfront`).
16
+ * - THIRD-PARTY host, set up CI: delegate to `uniweb add ci --host=…`,
17
+ * which scaffolds a workflow so every push deploys. For most free
18
+ * hosts this is the better answer, and the wizard says so.
19
+ * - UNIWEB hosting (`--host=uniweb`, or a `uniweb` target in deploy.yml,
20
+ * or "Uniweb Cloud" in the wizard): DELEGATE to `uniweb publish` — the
21
+ * smart path (sync + dynamic hosting, brings the foundation along). So
22
+ * deploy.yml stays one actionable "where this site deploys" record.
23
+ * - SOMEWHERE ELSE: delegate to `uniweb export` for a self-contained
24
+ * artifact you upload yourself.
25
+ *
26
+ * `uniweb publish` remains the canonical direct verb for Uniweb hosting.
27
+ *
28
+ * Host resolution:
29
+ * 1. --target <name> picks a target from deploy.yml (full config)
30
+ * 2. deploy.yml's `default:` target when no flag is given
31
+ * 3. with no deploy.yml at all, NO host is chosen → the wizard runs
32
+ * (interactive); non-interactive → an actionable error listing the
33
+ * real options
34
+ * 4. --host <name> is a one-off override (does NOT persist to deploy.yml)
35
+ * 5. --host with no value jumps straight to the wizard
36
+ *
37
+ * Usage:
38
+ * uniweb deploy Wizard: pick a destination
39
+ * uniweb deploy --host <name> Build bundle-mode dist/ + hand to the host adapter
40
+ * uniweb deploy --host=uniweb Delegate to `uniweb publish` (Uniweb hosting)
41
+ * uniweb deploy --target <name> Pick a target from deploy.yml
42
+ * uniweb deploy --dry-run Resolve everything; upload nothing
43
+ * uniweb deploy --no-save Do not record this deploy in deploy.yml
44
+ *
45
+ * Escape hatch: UNIWEB_SKIP_BUILD=1 reuses an existing dist/.
46
+ */
47
+
48
+ import { existsSync, readFileSync } from 'node:fs'
49
+ import { resolve, join } from 'node:path'
50
+ import { execSync } from 'node:child_process'
51
+
52
+ import {
53
+ loadDeployYml,
54
+ resolveTarget,
55
+ recordLastDeploy
56
+ } from '@uniweb/build/site'
57
+ import { promptForDestination } from '../utils/destination-prompt.js'
58
+ import { readFlagValue } from '../utils/args.js'
59
+ import { DEFAULT_BACKEND_ORIGIN, getRegistryApiBaseUrl, loggedInOrigin } from '../utils/config.js'
60
+ import { normalizeOrigin } from '../utils/site-identity.js'
61
+ import { parseBoolEnv } from '../utils/env.js'
62
+ import { headProvenance } from '../utils/git.js'
63
+ import { warnIfContentDoesNotConform } from '../utils/conformance.js'
64
+
65
+ import {
66
+ findWorkspaceRoot,
67
+ findSites,
68
+ classifyPackage,
69
+ promptSelect
70
+ } from '../utils/workspace.js'
71
+ import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
72
+
73
+ const c = {
74
+ reset: '\x1b[0m',
75
+ bold: '\x1b[1m',
76
+ dim: '\x1b[2m',
77
+ cyan: '\x1b[36m',
78
+ green: '\x1b[32m',
79
+ yellow: '\x1b[33m',
80
+ red: '\x1b[31m'
81
+ }
82
+ const say = {
83
+ ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
84
+ info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
85
+ warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
86
+ err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
87
+ dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
88
+ }
89
+
90
+ // ─── Main ───────────────────────────────────────────────────
91
+
92
+ export async function deploy(args = []) {
93
+ const dryRun = args.includes('--dry-run')
94
+ const siteDir = await resolveSiteDir(args)
95
+
96
+ // Host dispatch. Resolution order:
97
+ // 1. --target <name> picks a target from deploy.yml
98
+ // 2. deploy.yml's `default:` target when no flag is given
99
+ // 3. with no deploy.yml, NO host is chosen → the wizard decides
100
+ // 4. --host <name> is a one-off override (does not persist on success)
101
+ const targetFromFlag = readFlagValue(args, '--target')
102
+ const hostFromFlag = readFlagValue(args, '--host')
103
+ const noSave = args.includes('--no-save')
104
+
105
+ let deployYml
106
+ try {
107
+ deployYml = await loadDeployYml(siteDir)
108
+ } catch (err) {
109
+ say.err(err.message)
110
+ process.exit(1)
111
+ }
112
+ let resolved
113
+ try {
114
+ resolved = resolveTarget(deployYml, targetFromFlag || null)
115
+ } catch (err) {
116
+ say.err(err.message)
117
+ process.exit(1)
118
+ }
119
+
120
+ // Resolve a PLAN — {kind, host?, action?} — before doing anything. A
121
+ // destination is "known" when the user named it (--host) or deploy.yml
122
+ // records one. Otherwise the wizard asks; we never assume.
123
+ //
124
+ // resolveTarget returns host:'uniweb' with fromFile:false as its
125
+ // no-deploy.yml fallback. That is NOT a chosen destination — treat it
126
+ // as "unknown" so a bare `deploy` in a fresh project opens the wizard
127
+ // instead of silently heading for the paid product.
128
+ let plan
129
+ // `--host` with NO value (readFlagValue → null) is an explicit "ask me",
130
+ // and it must outrank deploy.yml: the user typed the flag precisely to
131
+ // choose something other than what's recorded. Omitting the flag
132
+ // entirely (→ undefined) is the different case where deploy.yml wins.
133
+ const wantsWizard = hostFromFlag === null
134
+ const knownHost = wantsWizard
135
+ ? null
136
+ : hostFromFlag || (resolved.fromFile ? resolved.host : null)
137
+
138
+ if (knownHost === 'uniweb') {
139
+ plan = { kind: 'uniweb' }
140
+ } else if (knownHost) {
141
+ plan = { kind: 'adapter', host: knownHost, action: 'deploy' }
142
+ } else {
143
+ if (isNonInteractive(args)) {
144
+ if (wantsWizard) {
145
+ // They asked to pick, but there's no way to ask. Never silently
146
+ // fall back to deploy.yml — that would deploy somewhere the user
147
+ // was in the middle of overriding.
148
+ const { listAdapters } = await import('@uniweb/build/hosts')
149
+ say.err('`--host` requires a value when running non-interactively.')
150
+ say.dim(`Known adapters: ${listAdapters().join(', ')}.`)
151
+ say.dim('For Uniweb Cloud use `uniweb publish` (or --host=uniweb).')
152
+ process.exit(1)
153
+ }
154
+ say.err('`uniweb deploy` needs a destination.')
155
+ console.log('')
156
+ say.dim(
157
+ '`uniweb publish` Uniweb Cloud (sync + dynamic hosting; brings the foundation along)'
158
+ )
159
+ say.dim(
160
+ '`uniweb deploy --host=…` A third-party host — run `uniweb deploy --help` for the list'
161
+ )
162
+ say.dim('`uniweb add ci --host=…` Set up CI so every push deploys')
163
+ say.dim(
164
+ '`uniweb export` Self-contained dist/ artifact you upload anywhere'
165
+ )
166
+ process.exit(1)
167
+ }
168
+ try {
169
+ plan = await promptForDestination({
170
+ args,
171
+ preselect: resolved.fromFile ? resolved.host : null
172
+ })
173
+ } catch (err) {
174
+ say.err(err.message)
175
+ process.exit(1)
176
+ }
177
+ if (!plan) {
178
+ console.log('\nDeploy cancelled.')
179
+ process.exit(0)
180
+ }
181
+ }
182
+
183
+ // A static host: advisory — warns and ships (utils/conformance.js). Uniweb Cloud is
184
+ // `publish`, below, which refuses content that does not conform: there the backend
185
+ // enforces the schemas, so the check is a gate, not advice (2026-09-24).
186
+ if (plan.kind !== 'uniweb') {
187
+ await warnIfContentDoesNotConform(siteDir, { args, warn: say.warn, dim: say.dim })
188
+ }
189
+
190
+ // Uniweb Cloud is `publish`'s flow — delegate so deploy.yml stays one
191
+ // actionable record and there's a single implementation of go-live.
192
+ if (plan.kind === 'uniweb') {
193
+ // ⭐ `--host=uniweb` IS `publish` *[Diego, 2026-09-21]*: it goes to the backend you are
194
+ // logged in to — deploy follows the login like every other command. A deploy.yml
195
+ // uniweb target's `backend:` records where that target's publishes went; it routes
196
+ // nothing (publish files its record under the target naming its backend).
197
+ const goingTo = getRegistryApiBaseUrl()
198
+ const aimedBy = process.env.UNIWEB_SERVER
199
+ ? 'UNIWEB_SERVER'
200
+ : loggedInOrigin()
201
+ ? 'the backend you are logged in to'
202
+ : 'the default backend — you are not logged in'
203
+ const fromTarget =
204
+ resolved.fromFile &&
205
+ resolved.host === 'uniweb' &&
206
+ // not after the wizard (`--host` with no value): a pick there names no target
207
+ (hostFromFlag === undefined || hostFromFlag === 'uniweb')
208
+ const targetBackend = fromTarget
209
+ ? normalizeOrigin(resolved.config?.backend || DEFAULT_BACKEND_ORIGIN)
210
+ : null
211
+ if (targetBackend && targetBackend !== goingTo) {
212
+ if (targetFromFlag) {
213
+ // ⛔ A refusal, not a route: the user TYPED this destination, and going live
214
+ // somewhere else would contradict it with nothing said. Switching is a login.
215
+ say.err(
216
+ `Target '${resolved.targetName}' is on ${targetBackend}, but this would publish to ${goingTo} (${aimedBy}).`
217
+ )
218
+ say.dim(`To publish there, log in to it first: uniweb login --server ${targetBackend}`)
219
+ process.exit(1)
220
+ }
221
+ say.dim(
222
+ `deploy.yml's default target '${resolved.targetName}' is on ${targetBackend}; publishing to ${goingTo} (${aimedBy}).`
223
+ )
224
+ }
225
+ say.info('Uniweb Cloud → running `uniweb publish`.')
226
+ console.log('')
227
+ // publish ignores deploy's --host/--target; --dry-run/--no-save pass straight
228
+ // through.
229
+ const { publish } = await import('./publish.js')
230
+ // publish runs the conformance gate itself; deploy checked nothing for this host.
231
+ const result = await publish(args)
232
+ process.exit(result?.exitCode ?? 0)
233
+ }
234
+
235
+ if (plan.kind === 'export') {
236
+ // `export` has no --dry-run of its own, so handle it here rather than
237
+ // passing a flag it would ignore — a dry run that actually ran a full
238
+ // build would be a lie.
239
+ if (dryRun) {
240
+ say.info(
241
+ 'Dry run — would run `uniweb export` to build a self-contained dist/.'
242
+ )
243
+ return
244
+ }
245
+ say.info('Building a self-contained artifact → running `uniweb export`.')
246
+ console.log('')
247
+ const { exportSite } = await import('./export.js')
248
+ await exportSite([])
249
+ return
250
+ }
251
+
252
+ // CI setup is `add ci`'s job. Shell out rather than re-implement: add
253
+ // owns site discovery, package-manager detection, the --force guard,
254
+ // and the deploy.yml target write.
255
+ if (plan.action === 'ci') {
256
+ await delegateToAddCi(siteDir, plan.host, dryRun)
257
+ return
258
+ }
259
+
260
+ // Auto-save scope: 'off' from --no-save OR an ad-hoc --host override (we don't
261
+ // want a one-off experiment to rewrite the file). A host picked interactively
262
+ // for a bare `deploy` is NOT an override — we DO want to remember it.
263
+ const hostOverridden = !!hostFromFlag && hostFromFlag !== resolved.host
264
+ // An ad-hoc --host override describes no target, so there is nothing to file a
265
+ // deploy under — the switch goes off without anyone typing it.
266
+ const saveDeploys = !(noSave || hostOverridden) && resolved.saveDeploys !== false
267
+
268
+ await deployStaticHost(siteDir, plan.host, resolved, {
269
+ dryRun,
270
+ saveDeploys,
271
+ hostOverridden
272
+ })
273
+ }
274
+
275
+ // ─── CI delegation ──────────────────────────────────────────
276
+
277
+ async function delegateToAddCi(siteDir, host, dryRun) {
278
+ const workspaceRoot = findWorkspaceRoot(siteDir) || siteDir
279
+
280
+ // Forward the site we already resolved. Without this, `add ci` re-runs
281
+ // its own discovery and asks "which site?" a second time in a
282
+ // multi-site workspace — right after the user answered that in the
283
+ // wizard. `add ci --site` matches on the package name.
284
+ let siteFlag = ''
285
+ try {
286
+ const pkg = JSON.parse(readFileSync(join(siteDir, 'package.json'), 'utf8'))
287
+ if (pkg.name) siteFlag = ` --site ${JSON.stringify(pkg.name)}`
288
+ } catch {
289
+ // No readable package.json — let `add ci` resolve the site itself.
290
+ }
291
+
292
+ const cmd = `add ci --host ${JSON.stringify(host)}${siteFlag}`
293
+ if (dryRun) {
294
+ say.info(`Dry run — would run \`uniweb ${cmd}\` in ${workspaceRoot}`)
295
+ return
296
+ }
297
+ say.info(`Setting up CI → running \`uniweb ${cmd}\`.`)
298
+ console.log('')
299
+ try {
300
+ execSync(`node ${JSON.stringify(process.argv[1])} ${cmd}`, {
301
+ cwd: workspaceRoot,
302
+ stdio: 'inherit'
303
+ })
304
+ } catch {
305
+ // add ci already printed the reason and set the exit code.
306
+ process.exit(1)
307
+ }
308
+ }
309
+
310
+ // ─── Static-host deploy (S3+CloudFront, etc.) ─────────────────
311
+ //
312
+ // Picked when the resolved deploy.yml target (or --host override) names a
313
+ // static-host adapter registered in @uniweb/build/hosts. Always runs
314
+ // `uniweb build` (bundle mode + prerender) first, then hands dist/ to the
315
+ // adapter's deploy hook for upload + invalidation.
316
+
317
+ async function deployStaticHost(
318
+ siteDir,
319
+ hostName,
320
+ resolved,
321
+ { dryRun, saveDeploys, hostOverridden }
322
+ ) {
323
+ let getAdapter
324
+ try {
325
+ ;({ getAdapter } = await import('@uniweb/build/hosts'))
326
+ } catch (err) {
327
+ say.err('Failed to load host adapter registry from @uniweb/build/hosts.')
328
+ say.dim(err.message)
329
+ process.exit(1)
330
+ }
331
+
332
+ let adapter
333
+ try {
334
+ adapter = getAdapter(hostName)
335
+ } catch (err) {
336
+ say.err(err.message)
337
+ say.dim(
338
+ 'Set the host in deploy.yml or pass --host=<name>. See `uniweb deploy --help`.'
339
+ )
340
+ process.exit(1)
341
+ }
342
+
343
+ if (typeof adapter.deploy !== 'function') {
344
+ say.err(`Host adapter '${hostName}' does not implement a deploy step.`)
345
+ say.dim(
346
+ `Build with \`uniweb build --host=${hostName}\` and upload \`dist/\` manually,`
347
+ )
348
+ say.dim(
349
+ `or use a host whose adapter ships a deploy hook (e.g., s3-cloudfront).`
350
+ )
351
+ process.exit(1)
352
+ }
353
+
354
+ const deployConfig = resolved.config || {}
355
+ const distDir = join(siteDir, 'dist')
356
+
357
+ if (dryRun) {
358
+ say.info(
359
+ `Dry run — would deploy via host adapter: ${c.bold}${adapter.name}${c.reset}`
360
+ )
361
+ say.dim(`Site dir : ${siteDir}`)
362
+ say.dim(
363
+ `dist/ : ${existsSync(distDir) ? 'exists (would not rebuild)' : 'missing (would build)'}`
364
+ )
365
+ say.dim(`Target : ${resolved.targetName}`)
366
+ if (adapter.display?.pushWith)
367
+ say.dim(`Uploads by: ${adapter.display.pushWith}`)
368
+ // Echo the resolved target's config as-is. Each adapter reads
369
+ // different keys (bucket/distributionId for s3, projectName for
370
+ // Cloudflare, siteId for Netlify), so listing a fixed set would be
371
+ // wrong for four of the five.
372
+ const configKeys = Object.keys(deployConfig)
373
+ if (configKeys.length) {
374
+ say.dim('Config :')
375
+ for (const key of configKeys.sort()) {
376
+ say.dim(
377
+ ` ${key}: ${typeof deployConfig[key] === 'object' ? JSON.stringify(deployConfig[key]) : deployConfig[key]}`
378
+ )
379
+ }
380
+ } else {
381
+ say.dim(
382
+ 'Config : (none in deploy.yml — the adapter will say what it needs)'
383
+ )
384
+ }
385
+ return
386
+ }
387
+
388
+ // Always rebuild — the static-host flow expects fresh dist/ on every
389
+ // deploy. UNIWEB_SKIP_BUILD env var lets CI / dev loops reuse an
390
+ // existing build.
391
+ const skipBuild = parseBoolEnv('UNIWEB_SKIP_BUILD')
392
+ if (skipBuild) {
393
+ if (!existsSync(distDir)) {
394
+ say.err('UNIWEB_SKIP_BUILD is set but dist/ does not exist.')
395
+ process.exit(1)
396
+ }
397
+ say.info('UNIWEB_SKIP_BUILD set — reusing existing dist/.')
398
+ } else {
399
+ say.info(`Building site (host: ${adapter.name})…`)
400
+ console.log('')
401
+ try {
402
+ execSync(
403
+ `node ${JSON.stringify(process.argv[1])} build --bundle --host ${JSON.stringify(adapter.name)}`,
404
+ { cwd: siteDir, stdio: 'inherit' }
405
+ )
406
+ } catch {
407
+ say.err('Build failed. See output above.')
408
+ process.exit(1)
409
+ }
410
+ if (!existsSync(distDir)) {
411
+ say.err('Build did not produce dist/.')
412
+ process.exit(1)
413
+ }
414
+ console.log('')
415
+ }
416
+
417
+ // Hand off to the adapter. DeployError is the structured shape from
418
+ // @uniweb/build/hosts/s3-cloudfront — translate to user-facing output.
419
+ let deployResult
420
+ try {
421
+ deployResult = await adapter.deploy({
422
+ distDir,
423
+ deployConfig,
424
+ env: process.env,
425
+ log: (m) => console.log(m)
426
+ })
427
+ } catch (err) {
428
+ if (err && err.name === 'DeployError') {
429
+ say.err(err.message)
430
+ if (err.hint) {
431
+ console.log('')
432
+ console.log(err.hint)
433
+ }
434
+ process.exit(1)
435
+ }
436
+ throw err
437
+ }
438
+
439
+ // Record a fresh lastDeploy.<target> entry. Skipped on --no-save and
440
+ // on ad-hoc --host overrides — see saveDeploys gating in deploy().
441
+ const gitStamp = {
442
+ at: new Date().toISOString(),
443
+ git: headProvenance(siteDir)
444
+ }
445
+ await persistLastDeploy(siteDir, {
446
+ targetName: resolved.targetName,
447
+ targetConfig: resolved.fromFile
448
+ ? null
449
+ : { host: hostName, ...deployConfig },
450
+ saveDeploys,
451
+ lastDeploy: {
452
+ at: gitStamp.at,
453
+ host: hostName,
454
+ // Adapters that drive a host CLI get the public URL back from it
455
+ // (wrangler/netlify/vercel print it; github-pages derives it from
456
+ // the git remote). s3-cloudfront can't — the public URL lives in
457
+ // the user's CloudFront/DNS config, which we never see — so the
458
+ // field is simply omitted there rather than guessed.
459
+ ...(deployResult?.url ? { url: deployResult.url } : {}),
460
+ // Same provenance the Uniweb path records: which commit this artifact came
461
+ // from, and whether the tree was clean when it was built.
462
+ ...(gitStamp.git ? { git: gitStamp.git } : {})
463
+ }
464
+ })
465
+ if (hostOverridden && !dryRun) {
466
+ say.dim(
467
+ '--host override active — did not write to deploy.yml. Edit deploy.yml to make this permanent.'
468
+ )
469
+ }
470
+ }
471
+
472
+ // ─── deploy.yml lastDeploy persistence ──────────────────────────
473
+
474
+ async function persistLastDeploy(siteDir, opts) {
475
+ if (opts.saveDeploys === false) return
476
+ try {
477
+ const result = await recordLastDeploy(siteDir, opts)
478
+ if (result?.created) {
479
+ say.dim(`Wrote deploy.yml (target: ${opts.targetName})`)
480
+ }
481
+ } catch (err) {
482
+ // The deploy itself succeeded — never fail the whole command on a
483
+ // memo-write error. Surface it so the user can fix the file.
484
+ say.dim(`Could not update deploy.yml: ${err.message}`)
485
+ }
486
+ }
487
+
488
+ // ─── Resolve site dir ──────────────────────────────────────
489
+
490
+ // Exported so `uniweb export` / `uniweb publish` / `uniweb status` reuse the
491
+ // same site-discovery logic. `verb` is the command being run; it appears in
492
+ // the error messages so the user gets accurate guidance.
493
+ export async function resolveSiteDir(args, verb = 'deploy') {
494
+ const cwd = process.cwd()
495
+ const prefix = getCliPrefix()
496
+
497
+ const type = await classifyPackage(cwd)
498
+ if (type === 'site') return cwd
499
+
500
+ const workspaceRoot = findWorkspaceRoot(cwd)
501
+ if (workspaceRoot) {
502
+ const sites = await findSites(workspaceRoot)
503
+ if (sites.length === 1) return resolve(workspaceRoot, sites[0])
504
+ if (sites.length > 1) {
505
+ if (isNonInteractive(args)) {
506
+ say.err(`Multiple sites found. Specify which one to ${verb}.`)
507
+ console.log('')
508
+ for (const s of sites) {
509
+ console.log(` ${c.cyan}cd ${s} && ${prefix} ${verb}${c.reset}`)
510
+ }
511
+ process.exit(1)
512
+ }
513
+ const choice = await promptSelect('Which site?', sites)
514
+ if (!choice) {
515
+ console.log(
516
+ `\n${verb.charAt(0).toUpperCase() + verb.slice(1)} cancelled.`
517
+ )
518
+ process.exit(0)
519
+ }
520
+ return resolve(workspaceRoot, choice)
521
+ }
522
+ }
523
+
524
+ say.err('No site found in this workspace.')
525
+ if (verb === 'export') {
526
+ say.dim(
527
+ '`export` produces a self-contained dist/ artifact for third-party hosting.'
528
+ )
529
+ } else if (verb === 'deploy') {
530
+ say.dim(
531
+ '`deploy` ships a built site to a third-party host (use `uniweb publish` for Uniweb hosting).'
532
+ )
533
+ } else {
534
+ say.dim(`\`${verb}\` operates on a site.`)
535
+ }
536
+ process.exit(1)
537
+ }