uniweb 0.13.17 → 0.14.1
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 +1 -1
- package/package.json +7 -7
- package/partials/agents.md +51 -0
- package/src/backend/client.js +173 -40
- package/src/backend/data-bundle.js +15 -3
- package/src/backend/foundation-bring-along.js +76 -21
- package/src/backend/payment-handoff.js +28 -9
- package/src/backend/site-media.js +147 -19
- package/src/backend/site-sync.js +362 -40
- package/src/commands/add.js +575 -273
- package/src/commands/build.js +160 -57
- package/src/commands/clone.js +79 -26
- package/src/commands/content.js +11 -7
- package/src/commands/deploy.js +80 -32
- package/src/commands/dev.js +39 -17
- package/src/commands/docs.js +44 -16
- package/src/commands/doctor.js +338 -82
- package/src/commands/export.js +15 -7
- package/src/commands/handoff.js +6 -2
- package/src/commands/i18n.js +248 -99
- package/src/commands/inspect.js +48 -19
- package/src/commands/invite.js +9 -3
- package/src/commands/org.js +19 -5
- package/src/commands/publish.js +229 -60
- package/src/commands/pull.js +157 -48
- package/src/commands/push.js +144 -42
- package/src/commands/refresh.js +37 -10
- package/src/commands/register.js +235 -64
- package/src/commands/rename.js +126 -46
- package/src/commands/runtime.js +74 -24
- package/src/commands/status.js +48 -15
- package/src/commands/sync.js +7 -2
- package/src/commands/template.js +12 -4
- package/src/commands/update.js +263 -87
- package/src/commands/validate.js +115 -32
- package/src/framework-index.json +15 -13
- package/src/index.js +298 -145
- package/src/templates/fetchers/github.js +7 -7
- package/src/templates/fetchers/npm.js +3 -3
- package/src/templates/fetchers/release.js +21 -18
- package/src/templates/index.js +34 -12
- package/src/templates/processor.js +44 -12
- package/src/templates/resolver.js +42 -15
- package/src/templates/validator.js +14 -7
- package/src/utils/asset-upload.js +90 -22
- package/src/utils/code-upload.js +62 -37
- package/src/utils/config.js +31 -10
- package/src/utils/dep-survey.js +33 -7
- package/src/utils/destination-prompt.js +27 -17
- package/src/utils/git.js +66 -22
- package/src/utils/host-prompt.js +4 -3
- package/src/utils/install-integrity.js +8 -4
- package/src/utils/interactive.js +3 -3
- package/src/utils/json-file.js +6 -2
- package/src/utils/names.js +15 -6
- package/src/utils/placement.js +16 -8
- package/src/utils/registry-auth.js +185 -57
- package/src/utils/registry-orgs.js +142 -53
- package/src/utils/runtime-upload.js +52 -14
- package/src/utils/scaffold.js +55 -14
- package/src/utils/site-content-refs.js +3 -1
- package/src/utils/update-check.js +43 -17
- package/src/utils/workspace.js +13 -7
- package/src/versions.js +18 -7
- package/templates/site/_gitignore +5 -0
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Site asset delivery — the asset lane for `uniweb publish
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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({
|
|
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 {
|
|
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: {
|
|
118
|
+
headers: {
|
|
119
|
+
'Content-Type': 'application/json',
|
|
120
|
+
Authorization: `Bearer ${token}`
|
|
121
|
+
},
|
|
109
122
|
body: JSON.stringify({
|
|
110
|
-
|
|
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
|
-
|
|
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] = {
|
|
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), {
|
|
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] = {
|
|
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({
|
|
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
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
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)
|
|
240
|
+
const base = /^https?:\/\//.test(assetBase)
|
|
241
|
+
? assetBase
|
|
242
|
+
: `${origin}${assetBase}`
|
|
175
243
|
return `${base.replace(/\/$/, '')}/dist/${id}/base.${ext}`
|
|
176
244
|
}
|
package/src/utils/code-upload.js
CHANGED
|
@@ -16,9 +16,13 @@
|
|
|
16
16
|
* loadable version (practical atomicity — there is no server
|
|
17
17
|
* confirm step by design).
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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(
|
|
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
|
|
157
|
+
return (
|
|
158
|
+
'sha256:' + createHash('sha256').update(hashes.join('\n')).digest('hex')
|
|
159
|
+
)
|
|
152
160
|
}
|
|
153
161
|
|
|
154
|
-
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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 {
|
|
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: {
|
|
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 =
|
|
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({
|
|
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: {
|
|
243
|
-
|
|
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
|
-
//
|
|
261
|
-
//
|
|
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 (
|
|
289
|
+
if (serveBase && entry && !failed.length) {
|
|
265
290
|
try {
|
|
266
|
-
// serve_base
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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())
|
package/src/utils/config.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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(
|
|
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(
|
|
280
|
-
|
|
281
|
-
|
|
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
|
}
|
package/src/utils/dep-survey.js
CHANGED
|
@@ -25,7 +25,12 @@ import { getWorkspacePackages } from './workspace.js'
|
|
|
25
25
|
* @returns {string}
|
|
26
26
|
*/
|
|
27
27
|
export function stripVersionRange(spec) {
|
|
28
|
-
return (
|
|
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 {
|
|
84
|
+
try {
|
|
85
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
86
|
+
} catch {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
80
89
|
|
|
81
|
-
for (const sectionName of [
|
|
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) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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(
|
|
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 || ''}
|
|
61
|
-
|
|
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
|
-
|
|
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({
|
|
115
|
+
export async function promptForDestination({
|
|
116
|
+
args = [],
|
|
117
|
+
preselect = null
|
|
118
|
+
} = {}) {
|
|
114
119
|
if (isNonInteractive(args)) {
|
|
115
|
-
throw new Error(
|
|
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(
|
|
123
|
-
|
|
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)
|