uniweb 0.13.17 → 0.14.0

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 (65) hide show
  1. package/README.md +1 -1
  2. package/package.json +7 -7
  3. package/partials/agents.md +51 -0
  4. package/src/backend/client.js +173 -40
  5. package/src/backend/data-bundle.js +15 -3
  6. package/src/backend/foundation-bring-along.js +76 -21
  7. package/src/backend/payment-handoff.js +28 -9
  8. package/src/backend/site-media.js +147 -19
  9. package/src/backend/site-sync.js +362 -40
  10. package/src/commands/add.js +575 -273
  11. package/src/commands/build.js +160 -57
  12. package/src/commands/clone.js +79 -26
  13. package/src/commands/content.js +11 -7
  14. package/src/commands/deploy.js +80 -32
  15. package/src/commands/dev.js +39 -17
  16. package/src/commands/docs.js +44 -16
  17. package/src/commands/doctor.js +338 -82
  18. package/src/commands/export.js +15 -7
  19. package/src/commands/handoff.js +6 -2
  20. package/src/commands/i18n.js +248 -99
  21. package/src/commands/inspect.js +48 -19
  22. package/src/commands/invite.js +9 -3
  23. package/src/commands/org.js +19 -5
  24. package/src/commands/publish.js +229 -60
  25. package/src/commands/pull.js +157 -48
  26. package/src/commands/push.js +144 -42
  27. package/src/commands/refresh.js +37 -10
  28. package/src/commands/register.js +235 -64
  29. package/src/commands/rename.js +126 -46
  30. package/src/commands/runtime.js +74 -24
  31. package/src/commands/status.js +48 -15
  32. package/src/commands/sync.js +7 -2
  33. package/src/commands/template.js +12 -4
  34. package/src/commands/update.js +263 -87
  35. package/src/commands/validate.js +115 -32
  36. package/src/framework-index.json +15 -13
  37. package/src/index.js +298 -145
  38. package/src/templates/fetchers/github.js +7 -7
  39. package/src/templates/fetchers/npm.js +3 -3
  40. package/src/templates/fetchers/release.js +21 -18
  41. package/src/templates/index.js +34 -12
  42. package/src/templates/processor.js +44 -12
  43. package/src/templates/resolver.js +42 -15
  44. package/src/templates/validator.js +14 -7
  45. package/src/utils/asset-upload.js +90 -22
  46. package/src/utils/code-upload.js +62 -37
  47. package/src/utils/config.js +31 -10
  48. package/src/utils/dep-survey.js +33 -7
  49. package/src/utils/destination-prompt.js +27 -17
  50. package/src/utils/git.js +66 -22
  51. package/src/utils/host-prompt.js +4 -3
  52. package/src/utils/install-integrity.js +8 -4
  53. package/src/utils/interactive.js +3 -3
  54. package/src/utils/json-file.js +6 -2
  55. package/src/utils/names.js +15 -6
  56. package/src/utils/placement.js +16 -8
  57. package/src/utils/registry-auth.js +185 -57
  58. package/src/utils/registry-orgs.js +142 -53
  59. package/src/utils/runtime-upload.js +52 -14
  60. package/src/utils/scaffold.js +55 -14
  61. package/src/utils/site-content-refs.js +3 -1
  62. package/src/utils/update-check.js +43 -17
  63. package/src/utils/workspace.js +13 -7
  64. package/src/versions.js +18 -7
  65. package/templates/site/_gitignore +5 -0
@@ -1,9 +1,8 @@
1
1
  /**
2
- * Site asset delivery — the asset lane for `uniweb publish` (channel
3
- * framework-backend-f90d). After the link build processes a site's media into
4
- * `dist/assets/`, those bytes are delivered to the backend's content-addressed
5
- * asset store, and the publish step rewrites the content's local refs to durable
6
- * serve URLs:
2
+ * Site asset delivery — the asset lane for `uniweb publish`. After the link
3
+ * build processes a site's media into `dist/assets/`, those bytes are delivered
4
+ * to the backend's content-addressed asset store, and the publish step rewrites
5
+ * the content's local refs to durable serve URLs:
7
6
  *
8
7
  * 1. PLAN — POST {apiBase}/dev/assets with the file list ({ path,
9
8
  * content_type, size, sha256 }). `sha256` is REQUIRED — it is the
@@ -25,8 +24,6 @@
25
24
  * mirrors the foundation code lane (utils/code-upload.js); the one structural
26
25
  * difference is that the backend MINTS the per-asset id, so the plan response is
27
26
  * what the deploy step rewrites content references to.
28
- *
29
- * Contract: kb/framework/build/delivery-lane.md §Assets.
30
27
  */
31
28
 
32
29
  import { createHash } from 'node:crypto'
@@ -62,7 +59,7 @@ export function collectSiteAssets(distDir) {
62
59
  size: st.size,
63
60
  sha256: createHash('sha256').update(bytes).digest('hex'),
64
61
  localUrl: `/assets/${rel}`,
65
- diskPath: full,
62
+ diskPath: full
66
63
  })
67
64
  }
68
65
  }
@@ -96,25 +93,77 @@ export function collectSiteAssets(distDir) {
96
93
  * @param {(msg: string) => void} [opts.onProgress]
97
94
  * @returns {Promise<{ mode: string, uploaded: string[], skipped: string[], failed: Array<{path, status, detail}>, assetsByLocalUrl: Record<string, { id: string, ext: string, serveUrl?: string }> }>}
98
95
  */
99
- export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgress = () => {} }) {
96
+ export async function uploadSiteAssets({
97
+ apiBase,
98
+ token,
99
+ distDir,
100
+ files,
101
+ siteUuid = null,
102
+ onProgress = () => {}
103
+ }) {
100
104
  const list = files || collectSiteAssets(distDir)
101
105
  if (!list.length) {
102
- return { mode: 'none', uploaded: [], skipped: [], failed: [], assetsByLocalUrl: {} }
106
+ return {
107
+ mode: 'none',
108
+ uploaded: [],
109
+ skipped: [],
110
+ failed: [],
111
+ assetsByLocalUrl: {}
112
+ }
103
113
  }
104
114
 
105
115
  const origin = apiBase.replace(/\/$/, '')
106
116
  const planRes = await fetch(`${origin}/dev/assets`, {
107
117
  method: 'POST',
108
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
118
+ headers: {
119
+ 'Content-Type': 'application/json',
120
+ Authorization: `Bearer ${token}`
121
+ },
109
122
  body: JSON.stringify({
110
- files: list.map(({ path, content_type, size, sha256 }) => ({ path, content_type, size, sha256 })),
111
- }),
123
+ // The owner these bytes are charged to. Storage is metered against the
124
+ // owning entity and reclaimed by deleting it, so an upload with no owner is
125
+ // charged and can never be freed — there is nothing to delete. Callers
126
+ // create the site first precisely so this is never null; it stays optional
127
+ // only so a backend predating the field still works.
128
+ //
129
+ // The wire key is `entity`, not `site`, and deliberately so: it names the
130
+ // entity the upload is FOR. On this lane that is always the site-content
131
+ // entity — hence the local `siteUuid`, which is the more informative name
132
+ // here — but the visual app sends an article on the same route. A field
133
+ // called `site` would be a lie for half its callers.
134
+ ...(siteUuid ? { entity: siteUuid } : {}),
135
+ files: list.map(({ path, content_type, size, sha256 }) => ({
136
+ path,
137
+ content_type,
138
+ size,
139
+ sha256
140
+ }))
141
+ })
112
142
  })
113
143
  if (!planRes.ok) {
114
144
  const detail = await planRes.text().catch(() => '')
115
- throw new Error(
145
+ // The plan refuses with an RFC7807 body carrying a machine-readable `reason`.
146
+ // The message keeps its historical shape so anything reading it still works,
147
+ // but the PARSED body has to survive the throw: flattening it to a string
148
+ // here is what made "branch on `reason`" unimplementable and left the old
149
+ // caller no choice but a status regex. Callers read `err.problem`;
150
+ // `describeAssetRefusal` in backend/site-media.js turns it into user-facing
151
+ // lines.
152
+ let problem = null
153
+ if (detail) {
154
+ try {
155
+ const parsed = JSON.parse(detail)
156
+ if (parsed && typeof parsed === 'object') problem = parsed
157
+ } catch {
158
+ /* not a problem document — prose refusal, or an upstream error page */
159
+ }
160
+ }
161
+ const err = new Error(
116
162
  `Asset plan failed: HTTP ${planRes.status} ${planRes.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`
117
163
  )
164
+ err.status = planRes.status
165
+ err.problem = problem
166
+ throw err
118
167
  }
119
168
  const plan = await planRes.json()
120
169
  const mode = plan.mode || 'direct'
@@ -135,7 +184,11 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
135
184
  // ⇒ false (older backend) → falls through to the upload path below.
136
185
  if (up.present) {
137
186
  skipped.push(src.path)
138
- assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, ''), serveUrl: up.serve_url }
187
+ assetsByLocalUrl[src.localUrl] = {
188
+ id: up.id,
189
+ ext: String(up.ext || '').replace(/^\./, ''),
190
+ serveUrl: up.serve_url
191
+ }
139
192
  continue
140
193
  }
141
194
 
@@ -149,7 +202,11 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
149
202
  try {
150
203
  // The plan's url may be origin-relative (direct mode → the backend) or
151
204
  // absolute (presigned → storage); new URL() resolves both.
152
- putRes = await fetch(new URL(up.url, origin), { method: up.method || 'PUT', headers, body: src.bytes ?? readFileSync(src.diskPath) })
205
+ putRes = await fetch(new URL(up.url, origin), {
206
+ method: up.method || 'PUT',
207
+ headers,
208
+ body: src.bytes ?? readFileSync(src.diskPath)
209
+ })
153
210
  } catch (err) {
154
211
  failed.push({ path: src.path, status: 0, detail: err.message })
155
212
  continue
@@ -157,9 +214,17 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
157
214
  if (putRes.ok) {
158
215
  uploaded.push(src.path)
159
216
  // Authoritative id + ext from the plan; mapped only on a successful PUT.
160
- assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, ''), serveUrl: up.serve_url }
217
+ assetsByLocalUrl[src.localUrl] = {
218
+ id: up.id,
219
+ ext: String(up.ext || '').replace(/^\./, ''),
220
+ serveUrl: up.serve_url
221
+ }
161
222
  } else {
162
- failed.push({ path: src.path, status: putRes.status, detail: await putRes.text().catch(() => '') })
223
+ failed.push({
224
+ path: src.path,
225
+ status: putRes.status,
226
+ detail: await putRes.text().catch(() => '')
227
+ })
163
228
  }
164
229
  }
165
230
 
@@ -167,10 +232,13 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
167
232
  }
168
233
 
169
234
  // Build a durable asset serve URL from /dev/config's assetBase. Origin-relative
170
- // (`/gateway/asset/` in dev) → prepend the backend origin; absolute (a prod CDN)
171
- // → used verbatim. Shape: {assetBase}dist/{id}/base.{ext} — basename literally
172
- // `base`, {ext} the source extension the plan echoed.
235
+ // → prepend the backend origin; absolute (a CDN host) → used verbatim. Shape:
236
+ // {assetBase}dist/{id}/base.{ext} — basename literally `base`, {ext} the source
237
+ // extension the plan echoed. The root itself is never assumed: it is whatever
238
+ // discovery returned.
173
239
  export function buildAssetUrl(origin, assetBase, id, ext) {
174
- const base = /^https?:\/\//.test(assetBase) ? assetBase : `${origin}${assetBase}`
240
+ const base = /^https?:\/\//.test(assetBase)
241
+ ? assetBase
242
+ : `${origin}${assetBase}`
175
243
  return `${base.replace(/\/$/, '')}/dist/${id}/base.${ext}`
176
244
  }
@@ -16,9 +16,13 @@
16
16
  * loadable version (practical atomicity — there is no server
17
17
  * confirm step by design).
18
18
  *
19
- * In direct mode the entry is fetched back from the anonymous serve route
20
- * (GET /gateway/foundation/{scope}/{name}/{version}/{path}) and compared
21
- * byte-for-byte the e2e proof that the version is live.
19
+ * When the plan tells us where the version will be readable, the entry is
20
+ * fetched back from that location and compared byte-for-byte — the e2e proof
21
+ * that the version is live. The location is ALWAYS read from the plan's
22
+ * `serve_base`, never derived: where a backend serves uploaded code is its
23
+ * own business, and a producer that reconstructs the path is both coupled to
24
+ * it and wrong wherever a delivery tier mints the URL instead. No
25
+ * `serve_base` simply means "not verifiable from here" — not a fallback guess.
22
26
  *
23
27
  * Rules encoded here (the backend validates too — reject, never repair):
24
28
  * - paths are dist/-relative, '/'-separated, URL-safe verbatim
@@ -59,7 +63,7 @@ const CONTENT_TYPES = {
59
63
  avif: 'image/avif',
60
64
  ico: 'image/x-icon',
61
65
  txt: 'text/plain',
62
- html: 'text/html',
66
+ html: 'text/html'
63
67
  }
64
68
 
65
69
  export function contentTypeFor(path) {
@@ -95,7 +99,7 @@ export function collectDistFiles(distDir) {
95
99
  path: rel,
96
100
  content_type: contentTypeFor(rel),
97
101
  size: st.size,
98
- sha256: createHash('sha256').update(bytes).digest('hex'),
102
+ sha256: createHash('sha256').update(bytes).digest('hex')
99
103
  })
100
104
  }
101
105
  }
@@ -144,25 +148,25 @@ export function computeFoundationDigest(distDir) {
144
148
  // it in explicitly — a schema-only change is still a foundation change.
145
149
  const schemaPath = join(distDir, 'meta', 'schema.json')
146
150
  if (existsSync(schemaPath)) {
147
- hashes.push(createHash('sha256').update(readFileSync(schemaPath)).digest('hex'))
151
+ hashes.push(
152
+ createHash('sha256').update(readFileSync(schemaPath)).digest('hex')
153
+ )
148
154
  }
149
155
  if (!hashes.length) return null
150
156
  hashes.sort()
151
- return 'sha256:' + createHash('sha256').update(hashes.join('\n')).digest('hex')
157
+ return (
158
+ 'sha256:' + createHash('sha256').update(hashes.join('\n')).digest('hex')
159
+ )
152
160
  }
153
161
 
154
- /**
155
- * The gateway serve URL for a file of a registered foundation version.
156
- * Mirrors the backend storage convention: scope WITHOUT the '@'.
157
- * Prefer the plan response's `serve_base` when present.
162
+ /*
163
+ * There is deliberately no serve-URL builder here. A previous version exported
164
+ * `gatewayUrl()`, which reconstructed the backend's serve path from the ref —
165
+ * mirroring its storage convention, scope-without-the-'@' and all. That was
166
+ * wrong twice over: it encoded another service's route layout in a public
167
+ * package, and it can only ever be right on the one deployment shape where the
168
+ * backend also serves the bytes. Read `serve_base` from the upload plan.
158
169
  */
159
- export function gatewayUrl(apiBase, name, version, path) {
160
- const m = /^@([^/]+)\/(.+)$/.exec(name)
161
- const scope = m ? m[1] : ''
162
- const base = m ? m[2] : name
163
- const origin = apiBase.replace(/\/$/, '')
164
- return `${origin}/gateway/foundation/${scope}/${base}/${version}/${path}`
165
- }
166
170
 
167
171
  /**
168
172
  * Deliver a foundation's code: plan, upload (entry last), verify (direct
@@ -185,17 +189,26 @@ export async function uploadFoundationCode({
185
189
  version,
186
190
  distDir,
187
191
  files,
188
- onProgress = () => {},
192
+ onProgress = () => {}
189
193
  }) {
190
194
  const list = files || collectDistFiles(distDir)
191
195
  if (!list.length) {
192
- return { mode: 'none', uploaded: [], failed: [], verified: null, serveBase: null }
196
+ return {
197
+ mode: 'none',
198
+ uploaded: [],
199
+ failed: [],
200
+ verified: null,
201
+ serveBase: null
202
+ }
193
203
  }
194
204
 
195
205
  const origin = apiBase.replace(/\/$/, '')
196
206
  const planRes = await fetch(`${origin}/dev/registry/code-uploads`, {
197
207
  method: 'POST',
198
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
208
+ headers: {
209
+ 'Content-Type': 'application/json',
210
+ Authorization: `Bearer ${token}`
211
+ },
199
212
  body: JSON.stringify({
200
213
  name,
201
214
  version,
@@ -205,9 +218,9 @@ export async function uploadFoundationCode({
205
218
  size,
206
219
  // Optional integrity hint (ignored by the v1 backend; flows so a
207
220
  // future checksum-bearing presign needs no CLI change).
208
- sha256,
209
- })),
210
- }),
221
+ sha256
222
+ }))
223
+ })
211
224
  })
212
225
  if (!planRes.ok) {
213
226
  const body = await planRes.text().catch(() => '')
@@ -223,14 +236,19 @@ export async function uploadFoundationCode({
223
236
  // The ONE mode-aware bit: direct-mode PUTs are bearer-authed backend
224
237
  // routes; presigned URLs are self-authorizing and must NOT carry a
225
238
  // bearer (foreign auth headers can break signed-request validation).
226
- const authHeaders = plan.mode === 'direct' ? { Authorization: `Bearer ${token}` } : {}
239
+ const authHeaders =
240
+ plan.mode === 'direct' ? { Authorization: `Bearer ${token}` } : {}
227
241
 
228
242
  const uploaded = []
229
243
  const failed = []
230
244
  for (const file of uploadOrder(list)) {
231
245
  const target = targets.get(file.path)
232
246
  if (!target) {
233
- failed.push({ path: file.path, status: 0, detail: 'no upload target in plan' })
247
+ failed.push({
248
+ path: file.path,
249
+ status: 0,
250
+ detail: 'no upload target in plan'
251
+ })
234
252
  continue
235
253
  }
236
254
  const bytes = readFileSync(join(distDir, file.path))
@@ -239,8 +257,12 @@ export async function uploadFoundationCode({
239
257
  method: target.method || 'PUT',
240
258
  // x-uniweb-sha256: optional integrity guard — direct mode verifies
241
259
  // the received bytes and 400s on mismatch (corruption-in-flight).
242
- headers: { ...(target.headers || {}), ...authHeaders, 'x-uniweb-sha256': file.sha256 },
243
- body: bytes,
260
+ headers: {
261
+ ...(target.headers || {}),
262
+ ...authHeaders,
263
+ 'x-uniweb-sha256': file.sha256
264
+ },
265
+ body: bytes
244
266
  })
245
267
  if (res.ok) {
246
268
  uploaded.push(file.path)
@@ -249,7 +271,7 @@ export async function uploadFoundationCode({
249
271
  failed.push({
250
272
  path: file.path,
251
273
  status: res.status,
252
- detail: (await res.text().catch(() => '')).slice(0, 200),
274
+ detail: (await res.text().catch(() => '')).slice(0, 200)
253
275
  })
254
276
  }
255
277
  } catch (err) {
@@ -257,17 +279,20 @@ export async function uploadFoundationCode({
257
279
  }
258
280
  }
259
281
 
260
- // Direct mode: prove the version is live — fetch the entry back and
261
- // compare bytes (the channel's e2e proof, made a default).
282
+ // Prove the version is live — fetch the entry back and compare bytes.
283
+ // Gated on `serveBase`, NOT on the plan's mode: the question is only ever
284
+ // "did the backend tell us where to look", and inferring a location from the
285
+ // mode is the coupling this lane used to carry. A plan without a serve_base
286
+ // (a delivery tier owns the URL) leaves `verified` null.
262
287
  let verified = null
263
288
  const entry = list.find((f) => f.path === ENTRY_PATH)
264
- if (plan.mode === 'direct' && entry && !failed.length) {
289
+ if (serveBase && entry && !failed.length) {
265
290
  try {
266
- // serve_base is origin-relative in direct mode — resolve against the
267
- // registry origin before fetching.
268
- const url = serveBase
269
- ? new URL(`${serveBase.replace(/\/$/, '')}/${ENTRY_PATH}`, origin).toString()
270
- : gatewayUrl(origin, name, version, ENTRY_PATH)
291
+ // serve_base may be origin-relative — resolve against the backend origin.
292
+ const url = new URL(
293
+ `${serveBase.replace(/\/$/, '')}/${ENTRY_PATH}`,
294
+ origin
295
+ ).toString()
271
296
  const res = await fetch(url)
272
297
  if (res.ok) {
273
298
  const served = Buffer.from(await res.arrayBuffer())
@@ -83,15 +83,27 @@ function readSessionOrigin() {
83
83
  export function getRegistryApiBaseUrl() {
84
84
  const fromEnv = process.env.UNIWEB_REGISTER_URL
85
85
  if (fromEnv) {
86
- try { return new URL(fromEnv).origin } catch { /* fall through */ }
86
+ try {
87
+ return new URL(fromEnv).origin
88
+ } catch {
89
+ /* fall through */
90
+ }
87
91
  }
88
92
  const fromSession = readSessionOrigin()
89
93
  if (fromSession) {
90
- try { return new URL(fromSession).origin } catch { return fromSession }
94
+ try {
95
+ return new URL(fromSession).origin
96
+ } catch {
97
+ return fromSession
98
+ }
91
99
  }
92
100
  const fromCfg = readCliConfig().registryApiUrl
93
101
  if (fromCfg) {
94
- try { return new URL(fromCfg).origin } catch { return fromCfg }
102
+ try {
103
+ return new URL(fromCfg).origin
104
+ } catch {
105
+ return fromCfg
106
+ }
95
107
  }
96
108
  return 'https://uniweb.app'
97
109
  }
@@ -199,7 +211,7 @@ export async function writeRootPackageJson(rootDir, pkg) {
199
211
  */
200
212
  export function computeRootScripts(sites, pm = 'pnpm') {
201
213
  const scripts = {
202
- build: 'uniweb build',
214
+ build: 'uniweb build'
203
215
  }
204
216
 
205
217
  if (sites.length === 0) {
@@ -218,7 +230,11 @@ export function computeRootScripts(sites, pm = 'pnpm') {
218
230
  // Subsequent sites get qualified dev:{name}/preview:{name}
219
231
  for (let i = 1; i < sites.length; i++) {
220
232
  scripts[`dev:${sites[i].name}`] = `uniweb dev ${sites[i].name}`
221
- scripts[`preview:${sites[i].name}`] = filterCmd(pm, sites[i].name, 'preview')
233
+ scripts[`preview:${sites[i].name}`] = filterCmd(
234
+ pm,
235
+ sites[i].name,
236
+ 'preview'
237
+ )
222
238
  }
223
239
  }
224
240
 
@@ -262,8 +278,8 @@ export async function resolveGlob(rootDir, pattern) {
262
278
  const { readdirSync } = await import('node:fs')
263
279
  const entries = readdirSync(fullPath, { withFileTypes: true })
264
280
  return entries
265
- .filter(e => e.isDirectory() && !e.name.startsWith('.'))
266
- .map(e => join(baseDir, e.name))
281
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
282
+ .map((e) => join(baseDir, e.name))
267
283
  } catch {
268
284
  return []
269
285
  }
@@ -276,9 +292,14 @@ export async function resolveGlob(rootDir, pattern) {
276
292
  try {
277
293
  const entries = readdirSync(rootDir, { withFileTypes: true })
278
294
  return entries
279
- .filter(e => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
280
- .filter(e => existsSync(join(rootDir, e.name, suffix)))
281
- .map(e => join(e.name, suffix))
295
+ .filter(
296
+ (e) =>
297
+ e.isDirectory() &&
298
+ !e.name.startsWith('.') &&
299
+ e.name !== 'node_modules'
300
+ )
301
+ .filter((e) => existsSync(join(rootDir, e.name, suffix)))
302
+ .map((e) => join(e.name, suffix))
282
303
  } catch {
283
304
  return []
284
305
  }
@@ -25,7 +25,12 @@ import { getWorkspacePackages } from './workspace.js'
25
25
  * @returns {string}
26
26
  */
27
27
  export function stripVersionRange(spec) {
28
- return (spec || '').replace(/^[\^~>=<\s]+/, '').trim().split(/\s+/)[0] || ''
28
+ return (
29
+ (spec || '')
30
+ .replace(/^[\^~>=<\s]+/, '')
31
+ .trim()
32
+ .split(/\s+/)[0] || ''
33
+ )
29
34
  }
30
35
 
31
36
  /**
@@ -76,9 +81,17 @@ export async function surveyWorkspaceDeps(workspaceDir) {
76
81
  const pkgPath = join(pkgDir, 'package.json')
77
82
  if (!existsSync(pkgPath)) continue
78
83
  let pkg
79
- try { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) } catch { continue }
84
+ try {
85
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
86
+ } catch {
87
+ continue
88
+ }
80
89
 
81
- for (const sectionName of ['dependencies', 'devDependencies', 'peerDependencies']) {
90
+ for (const sectionName of [
91
+ 'dependencies',
92
+ 'devDependencies',
93
+ 'peerDependencies'
94
+ ]) {
82
95
  const section = pkg[sectionName]
83
96
  if (!section) continue
84
97
  for (const [name, current] of Object.entries(section)) {
@@ -87,10 +100,23 @@ export async function surveyWorkspaceDeps(workspaceDir) {
87
100
  if (!target) continue
88
101
  const cmp = compareSemver(target, current)
89
102
  let status
90
- if (cmp > 0) { status = 'behind'; anyDrift = true }
91
- else if (cmp < 0) { status = 'ahead'; anyAhead = true }
92
- else { status = 'aligned' }
93
- rows.push({ relDir: relDir || '(root)', section: sectionName, name, current, target, status })
103
+ if (cmp > 0) {
104
+ status = 'behind'
105
+ anyDrift = true
106
+ } else if (cmp < 0) {
107
+ status = 'ahead'
108
+ anyAhead = true
109
+ } else {
110
+ status = 'aligned'
111
+ }
112
+ rows.push({
113
+ relDir: relDir || '(root)',
114
+ section: sectionName,
115
+ name,
116
+ current,
117
+ target,
118
+ status
119
+ })
94
120
  }
95
121
  }
96
122
  }
@@ -30,14 +30,14 @@ export const UNIWEB_DESTINATION = {
30
30
  value: { kind: 'uniweb' },
31
31
  title: 'Uniweb Cloud · paid, dynamic + visual editing',
32
32
  description:
33
- 'Sync + dynamic SSR, visual editing for content authors, foundation propagation. Runs `uniweb publish`.',
33
+ 'Sync + dynamic SSR, visual editing for content authors, foundation propagation. Runs `uniweb publish`.'
34
34
  }
35
35
 
36
36
  export const EXPORT_DESTINATION = {
37
37
  value: { kind: 'export' },
38
38
  title: 'Somewhere else · export a folder',
39
39
  description:
40
- 'Builds a self-contained dist/ you upload yourself. Runs `uniweb export`.',
40
+ 'Builds a self-contained dist/ you upload yourself. Runs `uniweb export`.'
41
41
  }
42
42
 
43
43
  /**
@@ -49,16 +49,20 @@ export async function buildDestinationChoices() {
49
49
  const { listAdapters, getAdapter } = await import('@uniweb/build/hosts')
50
50
 
51
51
  const adapters = listAdapters()
52
- .map(name => getAdapter(name))
53
- .filter(a => a.display?.wizard !== false)
52
+ .map((name) => getAdapter(name))
53
+ .filter((a) => a.display?.wizard !== false)
54
54
  // Never offer a door that doesn't open.
55
- .filter(a => typeof a.deploy === 'function' || typeof a.initCi === 'function')
55
+ .filter(
56
+ (a) => typeof a.deploy === 'function' || typeof a.initCi === 'function'
57
+ )
56
58
  .sort((a, b) => (a.display?.order ?? 999) - (b.display?.order ?? 999))
57
59
 
58
- const choices = adapters.map(a => ({
60
+ const choices = adapters.map((a) => ({
59
61
  value: { kind: 'adapter', host: a.name },
60
- title: `${a.display?.title || a.name} · ${a.display?.qualifier || ''}`.trim().replace(/ ·\s*$/, ''),
61
- description: a.display?.summary || '',
62
+ title: `${a.display?.title || a.name} · ${a.display?.qualifier || ''}`
63
+ .trim()
64
+ .replace(/ ·\s*$/, ''),
65
+ description: a.display?.summary || ''
62
66
  }))
63
67
 
64
68
  return [...choices, UNIWEB_DESTINATION, EXPORT_DESTINATION]
@@ -87,15 +91,13 @@ async function promptForAction(adapter) {
87
91
  {
88
92
  value: 'ci',
89
93
  title: 'Set it up to deploy on every push · recommended',
90
- description:
91
- `Writes a GitHub Actions workflow. One-time setup — after this, pushing to the default branch deploys.${previews}`,
94
+ description: `Writes a GitHub Actions workflow. One-time setup — after this, pushing to the default branch deploys.${previews}`
92
95
  },
93
96
  {
94
97
  value: 'deploy',
95
98
  title: 'Upload from this machine now',
96
- description:
97
- `Builds dist/ here and pushes it with ${adapter.display?.pushWith || 'the host CLI'}.`,
98
- },
99
+ description: `Builds dist/ here and pushes it with ${adapter.display?.pushWith || 'the host CLI'}.`
100
+ }
99
101
  ])
100
102
  }
101
103
 
@@ -110,17 +112,25 @@ async function promptForAction(adapter) {
110
112
  * null when the user cancels.
111
113
  * @throws {Error} When non-interactive — the caller prints the guidance.
112
114
  */
113
- export async function promptForDestination({ args = [], preselect = null } = {}) {
115
+ export async function promptForDestination({
116
+ args = [],
117
+ preselect = null
118
+ } = {}) {
114
119
  if (isNonInteractive(args)) {
115
- throw new Error('Cannot prompt for a destination when running non-interactively.')
120
+ throw new Error(
121
+ 'Cannot prompt for a destination when running non-interactively.'
122
+ )
116
123
  }
117
124
 
118
125
  let choices = await buildDestinationChoices()
119
126
 
120
127
  // Float the remembered target so Enter does the obvious thing.
121
128
  if (preselect) {
122
- const idx = choices.findIndex(c => c.value.kind === 'adapter' && c.value.host === preselect)
123
- if (idx > 0) choices = [choices[idx], ...choices.filter((_, i) => i !== idx)]
129
+ const idx = choices.findIndex(
130
+ (c) => c.value.kind === 'adapter' && c.value.host === preselect
131
+ )
132
+ if (idx > 0)
133
+ choices = [choices[idx], ...choices.filter((_, i) => i !== idx)]
124
134
  }
125
135
 
126
136
  const picked = await promptSelect('Where should this site go?', choices)