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/publish.js
CHANGED
|
@@ -1,45 +1,63 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* uniweb publish —
|
|
2
|
+
* uniweb publish — the smart Uniweb-hosting flagship (shipping-model.md §3).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* - `uniweb register` registers a FOUNDATION (+ the data schemas it renders).
|
|
9
|
-
* (Foundation publishing used to be `uniweb publish`; it is now `register`.)
|
|
4
|
+
* `uniweb login && uniweb publish` is meant to be the most ergonomic command in
|
|
5
|
+
* the tool: run it, and it does the right thing — talks to the backend,
|
|
6
|
+
* understands the project, and makes the site live on Uniweb hosting (synced +
|
|
7
|
+
* dynamically served). It:
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* 1. resolves WHICH site (your location, or the workspace's one site; multiple
|
|
10
|
+
* → prompt);
|
|
11
|
+
* 2. BRINGS THE FOUNDATION ALONG — if the site's local foundation changed
|
|
12
|
+
* since its last release, releases the new version first (or asks); a
|
|
13
|
+
* published registry ref needs nothing (§4, foundation-bring-along.js);
|
|
14
|
+
* 3. SYNCS — builds the site data (link mode), uploads media + the static-data
|
|
15
|
+
* ball, and pushes content (the same two-lane sync `uniweb push` uses);
|
|
16
|
+
* 4. SETTLES PAYMENT when the backend says go-live needs it — opens a browser
|
|
17
|
+
* to uniweb.app, waits, continues (provider-agnostic; payment-handoff.js);
|
|
18
|
+
* 5. GOES LIVE — POST /dev/site/publish/{uuid}.
|
|
15
19
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* file-only site.
|
|
20
|
+
* Distinct from `uniweb deploy` (third-party hosts) and `uniweb register`
|
|
21
|
+
* (foundation code → catalog). For a self-contained artifact, see `uniweb export`.
|
|
19
22
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* uniweb publish --backend <url> Override the backend origin
|
|
23
|
-
* uniweb publish --token <bearer> Auth bearer (skips `uniweb login`)
|
|
24
|
-
* uniweb publish --dry-run Resolve everything; POST nothing
|
|
23
|
+
* Backend: BackendClient. Origin from --backend/--registry > UNIWEB_REGISTER_URL
|
|
24
|
+
* > default. Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* Usage:
|
|
27
|
+
* uniweb publish Bring the foundation along, sync, and go live
|
|
28
|
+
* uniweb publish --dry-run Resolve everything; POST nothing
|
|
29
|
+
* uniweb publish --yes Skip confirmations (CI); never block on a prompt
|
|
30
|
+
* uniweb publish --no-save Skip the deploy.yml lastDeploy auto-save
|
|
31
|
+
* uniweb publish --backend <url> Override the backend origin
|
|
32
|
+
* uniweb publish --token <bearer> Auth bearer (skips `uniweb login`)
|
|
29
33
|
*/
|
|
30
34
|
|
|
31
|
-
import { existsSync } from 'node:fs'
|
|
35
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
32
36
|
import { readFile } from 'node:fs/promises'
|
|
33
37
|
import { join } from 'node:path'
|
|
38
|
+
import { execSync } from 'node:child_process'
|
|
39
|
+
import { createInterface } from 'node:readline/promises'
|
|
34
40
|
import yaml from 'js-yaml'
|
|
35
41
|
|
|
36
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
loadDeployYml,
|
|
44
|
+
resolveTarget,
|
|
45
|
+
recordLastDeploy,
|
|
46
|
+
assembleDataBall,
|
|
47
|
+
collectBallAssets,
|
|
48
|
+
rewriteBallAssets,
|
|
49
|
+
} from '@uniweb/build/site'
|
|
50
|
+
import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
37
51
|
|
|
38
52
|
import { BackendClient } from '../backend/client.js'
|
|
39
|
-
import { resolveSiteDir } from './deploy.js'
|
|
53
|
+
import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
|
|
40
54
|
import { readFlagValue } from '../utils/args.js'
|
|
41
55
|
import { isNonInteractive } from '../utils/interactive.js'
|
|
42
|
-
import {
|
|
56
|
+
import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
|
|
57
|
+
import { uploadDataBundle } from '../backend/data-bundle.js'
|
|
58
|
+
import { uploadSiteMedia } from '../backend/site-media.js'
|
|
59
|
+
import { bringFoundationAlong } from '../backend/foundation-bring-along.js'
|
|
60
|
+
import { settlePaymentIfNeeded } from '../backend/payment-handoff.js'
|
|
43
61
|
|
|
44
62
|
const c = {
|
|
45
63
|
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
@@ -66,7 +84,7 @@ async function confirm(question, defaultYes = false) {
|
|
|
66
84
|
}
|
|
67
85
|
|
|
68
86
|
// Highest installed runtime from the backend's /dev/config list (numeric-aware
|
|
69
|
-
// sort).
|
|
87
|
+
// sort). Null when the list is empty.
|
|
70
88
|
function pickHighestRuntime(installed) {
|
|
71
89
|
if (!Array.isArray(installed) || installed.length === 0) return null
|
|
72
90
|
return [...installed].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true }))[0]
|
|
@@ -79,10 +97,26 @@ function absolutizeServeUrl(origin, url) {
|
|
|
79
97
|
return `${origin.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
|
|
80
98
|
}
|
|
81
99
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
function readSiteYml(path) {
|
|
101
|
+
if (!existsSync(path)) return {}
|
|
102
|
+
try {
|
|
103
|
+
const parsed = yaml.load(readFileSync(path, 'utf8'))
|
|
104
|
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
|
105
|
+
} catch {
|
|
106
|
+
return {}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Languages from the BUILT site-content.json (config.languages) — the authority
|
|
111
|
+
// after a build. Three accepted shapes: 'en', { value, label }, { code, label }.
|
|
112
|
+
function languagesFromContent(siteContent) {
|
|
113
|
+
const langs = siteContent?.config?.languages
|
|
114
|
+
if (!Array.isArray(langs) || langs.length === 0) return ['en']
|
|
115
|
+
return langs.map((l) => (typeof l === 'string' ? l : l?.value || l?.code)).filter(Boolean)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Languages from site.yml — used only for the dry-run summary (no build yet).
|
|
119
|
+
function languagesFromSiteYml(siteYml) {
|
|
86
120
|
const def = siteYml.defaultLanguage || siteYml.lang || 'en'
|
|
87
121
|
const locales = siteYml.i18n?.locales || siteYml.languages
|
|
88
122
|
if (!Array.isArray(locales) || locales.length === 0) return null
|
|
@@ -90,122 +124,278 @@ function extractLanguages(siteYml) {
|
|
|
90
124
|
return [def, ...norm.filter((l) => l !== def)]
|
|
91
125
|
}
|
|
92
126
|
|
|
127
|
+
// Persist deploy.yml lastDeploy memory (skipped on --no-save / autoSave 'off').
|
|
128
|
+
async function persistLastDeploy(siteDir, opts) {
|
|
129
|
+
if (opts.autoSave === 'off') return
|
|
130
|
+
try {
|
|
131
|
+
const result = await recordLastDeploy(siteDir, opts)
|
|
132
|
+
if (result?.created) say.dim(`Wrote deploy.yml (target: ${opts.targetName})`)
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// The publish itself succeeded — never fail the whole command on a
|
|
135
|
+
// memo-write error. Surface it so the user can fix the file.
|
|
136
|
+
say.dim(`Could not update deploy.yml: ${err.message}`)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
93
140
|
export async function publish(args = []) {
|
|
94
141
|
const dryRun = args.includes('--dry-run')
|
|
95
|
-
const
|
|
96
|
-
const
|
|
142
|
+
const noSave = args.includes('--no-save')
|
|
143
|
+
const asOrg = readFlagValue(args, '--as-org')
|
|
144
|
+
const foundationDir = readFlagValue(args, '--foundation') // optional local foundation for Model schemas
|
|
97
145
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
siteYml = yaml.load(await readFile(siteYmlPath, 'utf8')) || {}
|
|
105
|
-
} catch {
|
|
106
|
-
siteYml = {}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
const uuid = siteYml.$uuid
|
|
110
|
-
if (!uuid) {
|
|
111
|
-
say.err('This site has no $uuid in site.yml — it was never synced to the backend.')
|
|
112
|
-
say.dim('Run `uniweb push` first (publish makes the synced site live), or use `uniweb deploy` for a file-only site.')
|
|
113
|
-
return { exitCode: 1 }
|
|
114
|
-
}
|
|
146
|
+
const siteDir = await resolveSiteDir(args, 'publish')
|
|
147
|
+
const siteYml = readSiteYml(join(siteDir, 'site.yml'))
|
|
148
|
+
// The site's deploy.yml-bound backend (where it was published) feeds the
|
|
149
|
+
// resolution ladder below an explicit --backend / UNIWEB_REGISTER_URL.
|
|
150
|
+
const siteBackend = await resolveSiteBackend(siteDir)
|
|
115
151
|
|
|
116
152
|
const client = new BackendClient({
|
|
117
153
|
originFlag: readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
|
|
154
|
+
siteBackend,
|
|
118
155
|
token: readFlagValue(args, '--token') || undefined,
|
|
119
156
|
args,
|
|
120
157
|
command: 'Publishing',
|
|
121
158
|
})
|
|
122
159
|
|
|
123
|
-
//
|
|
124
|
-
//
|
|
160
|
+
// Capability handshake (cached). Publish ends in a go-live, so the publish
|
|
161
|
+
// lane must be offered.
|
|
125
162
|
const config = await client.discover()
|
|
126
163
|
if (config?.delivery && config.delivery.publish === false) {
|
|
127
164
|
say.err(`Backend at ${client.origin} does not offer the publish lane (delivery.publish=false).`)
|
|
128
165
|
return { exitCode: 1 }
|
|
129
166
|
}
|
|
167
|
+
|
|
168
|
+
// Runtime: an explicit site.yml::runtime pin wins; else the highest installed;
|
|
169
|
+
// else fail closed (better than serving a site with no runtime). A dry-run is
|
|
170
|
+
// a pure preview, so it only WARNS — it stays useful with no backend reachable.
|
|
130
171
|
const installed = Array.isArray(config?.runtime?.installed) ? config.runtime.installed : []
|
|
131
172
|
if (siteYml.runtime && installed.length && !installed.includes(siteYml.runtime)) {
|
|
132
173
|
say.err(`Runtime ${siteYml.runtime} (from site.yml) is not installed on the backend.`)
|
|
133
174
|
say.dim(`Installed: ${installed.join(', ') || '(none)'} — pin one of these in site.yml (\`runtime:\`), or have it installed on the backend.`)
|
|
134
|
-
return { exitCode: 1 }
|
|
175
|
+
if (!dryRun) return { exitCode: 1 }
|
|
135
176
|
}
|
|
136
177
|
const runtimeVersion = siteYml.runtime || pickHighestRuntime(installed)
|
|
137
|
-
if (!runtimeVersion) {
|
|
178
|
+
if (!runtimeVersion && !dryRun) {
|
|
138
179
|
say.err('Could not resolve a runtime version.')
|
|
139
180
|
say.dim('Pin one with `runtime:` in site.yml, or install one on the backend so /dev/config reports it.')
|
|
140
181
|
return { exitCode: 1 }
|
|
141
182
|
}
|
|
142
183
|
|
|
143
|
-
|
|
184
|
+
// deploy.yml target (the Uniweb hosting memory). No --target on publish — it
|
|
185
|
+
// always targets Uniweb hosting; resolveTarget gives us the target name +
|
|
186
|
+
// autoSave for the lastDeploy memo.
|
|
187
|
+
let resolved
|
|
188
|
+
try {
|
|
189
|
+
const deployYml = await loadDeployYml(siteDir)
|
|
190
|
+
// No --target on publish — it always targets Uniweb hosting; resolveTarget
|
|
191
|
+
// returns the uniweb default (fromFile:false) when there's no deploy.yml, so
|
|
192
|
+
// persistLastDeploy scaffolds the file as the "where it's deployed" record.
|
|
193
|
+
resolved = resolveTarget(deployYml, null)
|
|
194
|
+
} catch {
|
|
195
|
+
// Malformed/ambiguous deploy.yml — don't block the publish on the memo.
|
|
196
|
+
resolved = { targetName: 'production', host: 'uniweb', config: {}, autoSave: 'lastDeploy', fromFile: false }
|
|
197
|
+
}
|
|
198
|
+
const autoSave = noSave ? 'off' : (resolved.autoSave || 'lastDeploy')
|
|
144
199
|
|
|
145
200
|
if (dryRun) {
|
|
146
|
-
say.info('Dry run — would
|
|
201
|
+
say.info('Dry run — would bring the foundation along, sync, and go live:')
|
|
147
202
|
say.dim(`Backend : ${client.origin}`)
|
|
148
|
-
say.dim(`
|
|
149
|
-
say.dim(`
|
|
150
|
-
|
|
203
|
+
say.dim(`Runtime : ${runtimeVersion || '(unresolved — needs a backend or a site.yml runtime: pin)'}${runtimeVersion && !siteYml.runtime ? ' (highest installed)' : ''}`)
|
|
204
|
+
say.dim(`site_uuid : ${siteYml.$uuid || '(none — the first push mints it)'}`)
|
|
205
|
+
const langs = languagesFromSiteYml(siteYml)
|
|
206
|
+
if (langs) say.dim(`Languages : ${langs.join(', ')}`)
|
|
207
|
+
await bringFoundationAlong({ client, siteDir, siteYml, args, say, confirm, cliBin: process.argv[1], dryRun: true })
|
|
208
|
+
await settlePaymentIfNeeded({ client, uuid: siteYml.$uuid || null, args, say, dryRun: true })
|
|
151
209
|
return { exitCode: 0 }
|
|
152
210
|
}
|
|
153
211
|
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
212
|
+
// 1. Bring the foundation along — release the local foundation if its code
|
|
213
|
+
// changed (or isn't registered). Never ship a site pointing at stale code.
|
|
214
|
+
let fnd
|
|
215
|
+
try {
|
|
216
|
+
fnd = await bringFoundationAlong({ client, siteDir, siteYml, args, say, confirm, cliBin: process.argv[1] })
|
|
217
|
+
} catch (err) {
|
|
218
|
+
say.err(`Foundation release failed: ${err.message}`)
|
|
219
|
+
say.dim('Fix the foundation, then re-run `uniweb publish`.')
|
|
220
|
+
return { exitCode: 1 }
|
|
221
|
+
}
|
|
222
|
+
if (!fnd.proceed) return { exitCode: 0 }
|
|
223
|
+
|
|
224
|
+
// 2. Build the site data (link mode): dist/site-content.json (+ per-locale),
|
|
225
|
+
// dist/data/*, dist/_search/*, dist/assets/*. Spawn the SAME CLI binary so
|
|
226
|
+
// the inner build can't resolve to a different installed version.
|
|
227
|
+
say.info('Building site…')
|
|
228
|
+
console.log('')
|
|
229
|
+
execSync(`node ${JSON.stringify(process.argv[1])} build --link`, { cwd: siteDir, stdio: 'inherit', env: process.env })
|
|
230
|
+
console.log('')
|
|
231
|
+
|
|
232
|
+
const distDir = join(siteDir, 'dist')
|
|
233
|
+
const contentPath = join(distDir, 'site-content.json')
|
|
234
|
+
if (!existsSync(contentPath)) {
|
|
235
|
+
say.err('Build did not produce dist/site-content.json')
|
|
236
|
+
return { exitCode: 1 }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Non-local @std/registry Model schemas resolve through the backend (same as push).
|
|
240
|
+
const resolveModel = makeModelResolver({ client, offline: false })
|
|
241
|
+
|
|
242
|
+
// 3. Partition collections by schema presence (a first emit reads `schemaless`
|
|
243
|
+
// — collections with no data schema, delivered statically via the ball).
|
|
244
|
+
let probe
|
|
245
|
+
try {
|
|
246
|
+
probe = await emitSyncPackages(siteDir, { ...(foundationDir ? { foundationDir } : {}), resolveModel })
|
|
247
|
+
} catch (err) {
|
|
248
|
+
say.err(`Could not build the sync package: ${err.message}`)
|
|
249
|
+
return { exitCode: 1 }
|
|
250
|
+
}
|
|
251
|
+
const schemalessNames = (probe.schemaless || []).map((col) => col.name)
|
|
252
|
+
const localAssets = probe.localAssets || []
|
|
253
|
+
|
|
254
|
+
// 4. Assemble the static-data ball (schema-less data + search index) BEFORE
|
|
255
|
+
// uploading, since its records can carry local media too.
|
|
256
|
+
let ball = await assembleDataBall(distDir, schemalessNames)
|
|
257
|
+
const ballAssets = collectBallAssets(ball)
|
|
258
|
+
|
|
259
|
+
// 4b. Upload ALL local media (entity refs + ball refs) on one asset lane →
|
|
260
|
+
// the ref→serveUrl map; rewrite the entity content AND the ball with it.
|
|
261
|
+
let assetRewrite = null
|
|
262
|
+
const mediaRefs = [...new Set([...localAssets, ...ballAssets])]
|
|
263
|
+
if (mediaRefs.length) {
|
|
264
|
+
say.info('Uploading media…')
|
|
159
265
|
try {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
266
|
+
const map = await uploadSiteMedia(client, siteDir, mediaRefs, {
|
|
267
|
+
onProgress: (m) => say.dim(` ${m}`),
|
|
268
|
+
warn: (m) => say.dim(`! ${m}`),
|
|
269
|
+
})
|
|
270
|
+
if (Object.keys(map).length) assetRewrite = map
|
|
271
|
+
if (ballAssets.length) ball = rewriteBallAssets(ball, map)
|
|
272
|
+
say.dim(`Media : ${Object.keys(map).length}/${mediaRefs.length} ref(s) → serve URL`)
|
|
273
|
+
} catch (err) {
|
|
274
|
+
say.err(`Media upload failed: ${err.message}`)
|
|
275
|
+
return { exitCode: 1 }
|
|
163
276
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 4c. Upload the (media-rewritten) ball → its content-addressed serve URL.
|
|
280
|
+
let dataBundle
|
|
281
|
+
if (ball) {
|
|
282
|
+
say.info('Uploading data bundle…')
|
|
283
|
+
try {
|
|
284
|
+
dataBundle = await uploadDataBundle(client, ball, { onProgress: (m) => say.dim(` ${m}`) })
|
|
285
|
+
} catch (err) {
|
|
286
|
+
say.err(`Data bundle upload failed: ${err.message}`)
|
|
287
|
+
return { exitCode: 1 }
|
|
172
288
|
}
|
|
289
|
+
say.dim(`Data bundle : ${Object.keys(ball.data).length} data + ${Object.keys(ball.search).length} search file(s)`)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 5. Push the site (content + folder) over the send-only-changed cache —
|
|
293
|
+
// the SAME two-lane submission `uniweb push` uses — stamping
|
|
294
|
+
// info.data_bundle and rewriting local media refs to backend serve URLs.
|
|
295
|
+
const priorHashes = readSyncCache(siteDir)
|
|
296
|
+
// Stamp deploy-derived info on the site-content entity: the data-bundle URL,
|
|
297
|
+
// and the PINNED foundation ref (`@scope/name@version`) from the bring-along.
|
|
298
|
+
// Delivery is version-pinned end-to-end (the gateway serves a foundation only
|
|
299
|
+
// by a concrete version — collab framework-backend-5c3e), so pinning the
|
|
300
|
+
// released version on the wire is required when site.yml uses an unversioned
|
|
301
|
+
// local ref; injectInfo overrides info.foundation. A registry/URL ref → fnd.ref
|
|
302
|
+
// is null → the site.yml ref is forwarded verbatim (already pinned).
|
|
303
|
+
const injectInfo = {
|
|
304
|
+
...(dataBundle ? { data_bundle: dataBundle } : {}),
|
|
305
|
+
...(fnd.ref ? { foundation: fnd.ref } : {}),
|
|
306
|
+
}
|
|
307
|
+
let pkg
|
|
308
|
+
try {
|
|
309
|
+
pkg = await emitSyncPackages(siteDir, {
|
|
310
|
+
...(foundationDir ? { foundationDir } : {}),
|
|
311
|
+
resolveModel,
|
|
312
|
+
priorHashes,
|
|
313
|
+
...(Object.keys(injectInfo).length ? { injectInfo } : {}),
|
|
314
|
+
...(assetRewrite ? { assetRewrite } : {}),
|
|
315
|
+
})
|
|
316
|
+
} catch (err) {
|
|
317
|
+
say.err(`Could not build the sync package: ${err.message}`)
|
|
318
|
+
return { exitCode: 1 }
|
|
319
|
+
}
|
|
320
|
+
for (const w of pkg.warnings) say.dim(`! ${w}`)
|
|
321
|
+
const report = {
|
|
322
|
+
info: (m) => say.info(m),
|
|
323
|
+
note: (m) => say.dim(m),
|
|
324
|
+
error: (m) => say.err(m),
|
|
325
|
+
dim: (s) => `${c.dim}${s}${c.reset}`,
|
|
326
|
+
}
|
|
327
|
+
const pushResult = await pushSyncPackages({ client, siteDir, pkg, asOrg, report })
|
|
328
|
+
if (pushResult.exitCode !== 0) return { exitCode: pushResult.exitCode }
|
|
329
|
+
const siteUuid = pushResult.boundSiteUuid
|
|
330
|
+
if (!siteUuid) {
|
|
331
|
+
say.err('Push did not yield a site uuid — cannot go live.')
|
|
332
|
+
return { exitCode: 1 }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 6. Payment gate — the backend says whether go-live needs payment. Settles
|
|
336
|
+
// via a browser handoff to uniweb.app; degrades to "proceed" when the
|
|
337
|
+
// backend exposes no payment route. The draft is already synced, so a
|
|
338
|
+
// decline leaves a recoverable state (re-run after paying).
|
|
339
|
+
const pay = await settlePaymentIfNeeded({ client, uuid: siteUuid, args, say })
|
|
340
|
+
if (!pay.proceed) {
|
|
341
|
+
say.info('Site synced as a draft but not made live. Re-run `uniweb publish` once payment is complete.')
|
|
342
|
+
return { exitCode: 0 }
|
|
173
343
|
}
|
|
174
344
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
345
|
+
// 7. Go live — make the just-pushed composite live (its current backend state).
|
|
346
|
+
const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
|
|
347
|
+
const languages = languagesFromContent(siteContent)
|
|
348
|
+
say.info(`Publishing to ${c.dim}${client.origin}${c.reset} …`)
|
|
349
|
+
let pubRes
|
|
178
350
|
try {
|
|
179
|
-
|
|
351
|
+
pubRes = await client.publishSite(siteUuid, { runtimeVersion, ...(languages ? { languages } : {}) })
|
|
180
352
|
} catch (err) {
|
|
181
353
|
say.err(`Could not reach the backend at ${client.origin}: ${err.message}`)
|
|
182
354
|
say.dim('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
|
|
183
355
|
return { exitCode: 1 }
|
|
184
356
|
}
|
|
185
|
-
if (!
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
say.dim('Sync it first with `uniweb push`, or use `uniweb deploy` for a file-only site.')
|
|
189
|
-
return { exitCode: 1 }
|
|
190
|
-
}
|
|
191
|
-
say.err(`Publish rejected: HTTP ${res.status} ${res.statusText}`)
|
|
192
|
-
if (res.status === 401 || res.status === 403) {
|
|
357
|
+
if (!pubRes.ok) {
|
|
358
|
+
say.err(`Publish rejected: HTTP ${pubRes.status} ${pubRes.statusText}`)
|
|
359
|
+
if (pubRes.status === 401 || pubRes.status === 403) {
|
|
193
360
|
say.dim("Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>).")
|
|
194
361
|
}
|
|
195
|
-
const body = await
|
|
362
|
+
const body = await pubRes.text().catch(() => '')
|
|
196
363
|
if (body) say.dim(body.slice(0, 800))
|
|
197
364
|
return { exitCode: 1 }
|
|
198
365
|
}
|
|
199
366
|
let result
|
|
200
|
-
try {
|
|
201
|
-
result = await res.json()
|
|
202
|
-
} catch {
|
|
203
|
-
result = {}
|
|
204
|
-
}
|
|
205
|
-
|
|
367
|
+
try { result = await pubRes.json() } catch { result = {} }
|
|
206
368
|
const serveUrl = absolutizeServeUrl(client.origin, result.url)
|
|
369
|
+
|
|
370
|
+
// 8. Persist deploy.yml memory — a record of what went live (and so a re-run
|
|
371
|
+
// reuses the resolved target without re-asking). One identity:
|
|
372
|
+
// site.yml::$uuid. `released` records whether this publish shipped a new
|
|
373
|
+
// foundation version (the bring-along, §4).
|
|
374
|
+
// Record the ref that actually went live: the pinned `@scope/name@version`
|
|
375
|
+
// from the bring-along when present, else the site.yml ref verbatim.
|
|
376
|
+
const siteYmlRef = typeof siteYml.foundation === 'string' ? siteYml.foundation : siteYml.foundation?.ref || null
|
|
377
|
+
const recordedRef = fnd.ref || siteYmlRef
|
|
378
|
+
await persistLastDeploy(siteDir, {
|
|
379
|
+
targetName: resolved.targetName,
|
|
380
|
+
// First publish scaffolds deploy.yml with the backend recorded on the
|
|
381
|
+
// target, binding the site to where it went live (uniweb.app, or a B2B
|
|
382
|
+
// backend). resolveSiteBackend reads it back on later publishes.
|
|
383
|
+
targetConfig: resolved.fromFile ? null : { host: 'uniweb', backend: client.origin },
|
|
384
|
+
autoSave,
|
|
385
|
+
lastDeploy: {
|
|
386
|
+
at: new Date().toISOString(),
|
|
387
|
+
host: 'uniweb',
|
|
388
|
+
backend: client.origin,
|
|
389
|
+
siteUuid,
|
|
390
|
+
url: serveUrl,
|
|
391
|
+
foundation: { ...(recordedRef ? { ref: recordedRef } : {}), released: fnd.released },
|
|
392
|
+
runtime: runtimeVersion,
|
|
393
|
+
locales: Array.isArray(result.locales) ? result.locales : languages,
|
|
394
|
+
},
|
|
395
|
+
})
|
|
396
|
+
|
|
207
397
|
console.log('')
|
|
208
|
-
say.ok(`Published ${c.bold}${
|
|
398
|
+
say.ok(`Published ${c.bold}${siteUuid}${c.reset}${result.status ? ` (${result.status})` : ''}`)
|
|
209
399
|
if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
|
|
210
400
|
if (result.deploy_uuid) say.dim(`deploy: ${result.deploy_uuid}`)
|
|
211
401
|
return { exitCode: 0 }
|
package/src/commands/pull.js
CHANGED
|
@@ -49,7 +49,7 @@ import {
|
|
|
49
49
|
} from '@uniweb/build/uwx'
|
|
50
50
|
import { makeModelResolver } from './push.js'
|
|
51
51
|
import { BackendClient } from '../backend/client.js'
|
|
52
|
-
import { resolveSiteDir as defaultResolveSiteDir } from './deploy.js'
|
|
52
|
+
import { resolveSiteDir as defaultResolveSiteDir, resolveSiteBackend } from './deploy.js'
|
|
53
53
|
|
|
54
54
|
const FOLDER_MODEL = '@uniweb/folder'
|
|
55
55
|
|
|
@@ -179,16 +179,18 @@ export async function pull(args = [], deps = {}) {
|
|
|
179
179
|
const tokenFlag = flagValue(args, '--token')
|
|
180
180
|
const prune = !(args.includes('--no-delete') || args.includes('--no-prune')) // git-like by default
|
|
181
181
|
const noCollections = args.includes('--no-collections') || args.includes('--content-only')
|
|
182
|
+
|
|
183
|
+
const siteDir = await resolveSiteDir(args, 'pull')
|
|
184
|
+
const siteBackend = await resolveSiteBackend(siteDir)
|
|
182
185
|
const client = new BackendClient({
|
|
183
|
-
originFlag: flagValue(args, '--registry'),
|
|
186
|
+
originFlag: flagValue(args, '--backend') || flagValue(args, '--registry'),
|
|
187
|
+
siteBackend,
|
|
184
188
|
token: tokenFlag,
|
|
185
189
|
getToken: deps.getToken,
|
|
186
190
|
fetchImpl: deps.fetch,
|
|
187
191
|
args,
|
|
188
192
|
command: 'Pulling',
|
|
189
193
|
})
|
|
190
|
-
|
|
191
|
-
const siteDir = await resolveSiteDir(args, 'pull')
|
|
192
194
|
// One identity per site: `site.yml::$uuid`. Both lanes (content + folder) are keyed
|
|
193
195
|
// by it — the backend resolves the site's `@uniweb/folder` from this uuid.
|
|
194
196
|
const siteContentUuid = readYamlUuid(join(siteDir, 'site.yml'))
|
|
@@ -216,7 +218,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
216
218
|
res = await doRequest()
|
|
217
219
|
} catch (err) {
|
|
218
220
|
error(`Could not reach the backend at ${client.origin}: ${err.message}`)
|
|
219
|
-
note('Set the origin with --
|
|
221
|
+
note('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
|
|
220
222
|
return null
|
|
221
223
|
}
|
|
222
224
|
if (res.status === 404) {
|
package/src/commands/push.js
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
* Auth: --token > UNIWEB_TOKEN > `uniweb login` session.
|
|
37
37
|
*
|
|
38
38
|
* The two-lane SUBMISSION (POST both lanes, back-fill uuids, persist the
|
|
39
|
-
* send-only-changed cache) lives in `../backend/site-sync.js` so `uniweb
|
|
39
|
+
* send-only-changed cache) lives in `../backend/site-sync.js` so `uniweb publish`
|
|
40
40
|
* (the composite path) reuses the exact same logic. This command owns flag parsing,
|
|
41
41
|
* the emit, and the `-o`/`--dry-run` preview.
|
|
42
42
|
*/
|
|
@@ -45,7 +45,7 @@ import { writeFileSync } from 'node:fs'
|
|
|
45
45
|
import { resolve } from 'node:path'
|
|
46
46
|
import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
47
47
|
import { BackendClient } from '../backend/client.js'
|
|
48
|
-
import { resolveSiteDir } from './deploy.js'
|
|
48
|
+
import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
|
|
49
49
|
import { makeModelResolver, readSyncCache, pushSyncPackages } from '../backend/site-sync.js'
|
|
50
50
|
|
|
51
51
|
// Re-exported for downstream importers (pull.js, push.test.js) that read these
|
|
@@ -77,20 +77,22 @@ export async function push(args = []) {
|
|
|
77
77
|
const asOrg = flagValue(args, '--as-org')
|
|
78
78
|
const foundationDir = flagValue(args, '--foundation')
|
|
79
79
|
const sendAll = args.includes('--all') // bypass the send-only-changed cache
|
|
80
|
+
|
|
81
|
+
const siteDir = await resolveSiteDir(args, 'push')
|
|
82
|
+
const siteBackend = await resolveSiteBackend(siteDir)
|
|
80
83
|
// One front door. The bearer is resolved lazily on first need (a non-local Model
|
|
81
84
|
// read during the build, or the submit). Offline emit (--dry-run / -o) is fully
|
|
82
85
|
// offline: it never submits, and its Model resolver never reads from the backend
|
|
83
86
|
// (the `offline` flag below), so it never authenticates — even when a collection
|
|
84
87
|
// references a Model the local foundation doesn't define.
|
|
85
88
|
const client = new BackendClient({
|
|
86
|
-
originFlag: flagValue(args, '--registry'),
|
|
89
|
+
originFlag: flagValue(args, '--backend') || flagValue(args, '--registry'),
|
|
90
|
+
siteBackend,
|
|
87
91
|
token: tokenFlag,
|
|
88
92
|
args,
|
|
89
93
|
command: 'Syncing',
|
|
90
94
|
})
|
|
91
95
|
|
|
92
|
-
const siteDir = await resolveSiteDir(args, 'push')
|
|
93
|
-
|
|
94
96
|
// Build BOTH directional packages (the producer side). Each carries its own
|
|
95
97
|
// `index` — the per-entity source-file map for back-fill, correlated by submission
|
|
96
98
|
// position. Non-local Models are fetched from the registry on demand. `priorHashes`
|
|
@@ -147,7 +149,7 @@ export async function push(args = []) {
|
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
// Submit both lanes, back-fill the minted uuids, and persist the send-only-changed
|
|
150
|
-
// cache. Shared with `uniweb
|
|
152
|
+
// cache. Shared with `uniweb publish` via ../backend/site-sync.js.
|
|
151
153
|
const result = await pushSyncPackages({
|
|
152
154
|
client,
|
|
153
155
|
siteDir,
|
package/src/commands/register.js
CHANGED
|
@@ -30,10 +30,11 @@
|
|
|
30
30
|
* uniweb register --json Porcelain: ONE compact JSON line on stdout
|
|
31
31
|
* ({ok,scope,origin,entities:[{name,uuid,version,unchanged}]}),
|
|
32
32
|
* all human output to stderr — for scripted callers
|
|
33
|
-
* uniweb register --
|
|
33
|
+
* uniweb register --backend <url> Override the backend origin (alias: --registry)
|
|
34
34
|
* uniweb register --token <bearer> Submit with this bearer; skips `uniweb login`
|
|
35
35
|
*
|
|
36
|
-
* Endpoint resolution: --backend
|
|
36
|
+
* Endpoint resolution: --backend <url> (alias --registry) > UNIWEB_REGISTER_URL >
|
|
37
|
+
* the logged-in session origin > ~/.uniweb/config.json > the default (uniweb.app).
|
|
37
38
|
* Auth (submit only): --token <bearer> > UNIWEB_TOKEN > `uniweb login` session.
|
|
38
39
|
*/
|
|
39
40
|
|
|
@@ -62,7 +63,7 @@ import { resolve, join } from 'node:path'
|
|
|
62
63
|
import { buildRegistryPackage, buildSchemaOnlyPackage } from '@uniweb/build/uwx'
|
|
63
64
|
import { classifyPackage, isSchemasPackage, collectStandaloneSchemas } from '@uniweb/build'
|
|
64
65
|
import { readRegistryAuth } from '../utils/registry-auth.js'
|
|
65
|
-
import { collectDistFiles } from '../utils/code-upload.js'
|
|
66
|
+
import { collectDistFiles, computeFoundationDigest } from '../utils/code-upload.js'
|
|
66
67
|
import { deriveScope } from '../utils/registry-orgs.js'
|
|
67
68
|
import { BackendClient } from '../backend/client.js'
|
|
68
69
|
import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
|
|
@@ -309,12 +310,18 @@ async function runRegister(args = []) {
|
|
|
309
310
|
}
|
|
310
311
|
}
|
|
311
312
|
|
|
313
|
+
// Content digest (foundation path only) — the freshness fingerprint over what
|
|
314
|
+
// register ships (shipping-model.md §4.1). Rides in the foundation-schema
|
|
315
|
+
// entity's info.digest; the backend stores it opaque and returns it so
|
|
316
|
+
// publish/status can detect "code changed since release" with no local state.
|
|
317
|
+
const digest = standalone ? null : computeFoundationDigest(join(targetDir, 'dist'))
|
|
318
|
+
|
|
312
319
|
const exporter = { tool: 'uniweb', version: cliVersion(), instance: 'build' }
|
|
313
320
|
let doc
|
|
314
321
|
try {
|
|
315
322
|
doc = standalone
|
|
316
323
|
? buildSchemaOnlyPackage({ schemas, scope, exporter })
|
|
317
|
-
: buildRegistryPackage({ schema, foundationDir: targetDir, scope, exporter })
|
|
324
|
+
: buildRegistryPackage({ schema, foundationDir: targetDir, scope, exporter, digest })
|
|
318
325
|
} catch (err) {
|
|
319
326
|
error(`Could not assemble the .uwx: ${err.message}`)
|
|
320
327
|
return { exitCode: 2 }
|
|
@@ -330,6 +337,7 @@ async function runRegister(args = []) {
|
|
|
330
337
|
}
|
|
331
338
|
log(` ${colors.dim}data schemas ${standalone ? 'registered' : 'defined'}: ${defined.length ? defined.join(', ') : '(none)'}${colors.reset}`)
|
|
332
339
|
if (scope) log(` ${colors.dim}scope: ${scope} (${scopeSource})${colors.reset}`)
|
|
340
|
+
if (digest) log(` ${colors.dim}digest: ${digest}${colors.reset}`)
|
|
333
341
|
|
|
334
342
|
// Preview paths — no submit, no auth needed.
|
|
335
343
|
if (output) {
|
|
@@ -469,7 +477,7 @@ async function runRegister(args = []) {
|
|
|
469
477
|
...doc.entities.filter((e) => e.model === '@uniweb/foundation-schema').map((e) => e.info?.name ?? e.name),
|
|
470
478
|
].filter(Boolean)
|
|
471
479
|
const entities = names.map((name) => ({ name, ...(minted[name] || { uuid: null, version: null, unchanged: false }) }))
|
|
472
|
-
emitJson({ ok: true, scope: scope || null, origin: client.origin, entities })
|
|
480
|
+
emitJson({ ok: true, scope: scope || null, origin: client.origin, digest: digest || null, entities })
|
|
473
481
|
}
|
|
474
482
|
return { exitCode: 0 }
|
|
475
483
|
}
|
package/src/commands/rename.js
CHANGED
|
@@ -523,8 +523,9 @@ ${colors.bright}What rename extension does:${colors.reset}
|
|
|
523
523
|
• Updates pnpm-workspace.yaml + package.json::workspaces.
|
|
524
524
|
|
|
525
525
|
${colors.bright}What rename does NOT do (any subcommand):${colors.reset}
|
|
526
|
-
• Push to the registry. The
|
|
527
|
-
independent. To
|
|
526
|
+
• Push to the registry. The registered id (package.json::uniweb.id) is
|
|
527
|
+
independent. To register under a new name, set it and run \`${prefix} register\`
|
|
528
|
+
(alias \`${prefix} release\`) — there is no rename flag.
|
|
528
529
|
|
|
529
530
|
${colors.bright}Examples:${colors.reset}
|
|
530
531
|
${prefix} rename foundation src marketing-src
|