uniweb 0.12.34 → 0.12.36
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 +7 -7
- package/package.json +2 -2
- package/partials/agents.md +28 -17
- package/src/backend/client.js +103 -13
- package/src/backend/foundation-bring-along.js +229 -0
- package/src/backend/payment-handoff.js +105 -0
- package/src/backend/site-sync.js +24 -3
- 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 +304 -76
- 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 +8 -5
- package/src/commands/runtime.js +1 -1
- package/src/commands/status.js +193 -0
- package/src/framework-index.json +4 -4
- package/src/index.js +71 -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/runtime.js
CHANGED
|
@@ -76,7 +76,7 @@ export async function runtime(args = []) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
// Version from the SPA build's manifest (the backend keys the version on it);
|
|
79
|
-
// --version overrides, parity with
|
|
79
|
+
// --version overrides, parity with the backend's runtime install --version.
|
|
80
80
|
let version = readFlagValue(rest, '--version')
|
|
81
81
|
if (!version) {
|
|
82
82
|
try {
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uniweb status — show how a site's local files compare to the Uniweb backend:
|
|
3
|
+
* its sync identity, unpushed content changes, and the foundation it references.
|
|
4
|
+
*
|
|
5
|
+
* LOCAL + OFFLINE by default: it builds the sync packages with an OFFLINE Model
|
|
6
|
+
* resolver and diffs them against the send-only-changed cache (the same diff
|
|
7
|
+
* `uniweb push` runs) — no auth, no backend round-trip.
|
|
8
|
+
*
|
|
9
|
+
* `--remote` adds the backend signals (may prompt for login, like `git fetch`):
|
|
10
|
+
* - whether the synced draft differs from what's live (publish needed), and
|
|
11
|
+
* - whether a newer foundation version is registered than the site pins.
|
|
12
|
+
* Those use ASSUMED endpoints (see kb shipping-verbs-and-freshness.md §6.5); until
|
|
13
|
+
* the backend exposes them, `--remote` degrades silently to the local view.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* uniweb status Sync identity + unpushed content + foundation ref (local)
|
|
17
|
+
* uniweb status --remote Also: draft-vs-live + a newer-registered-foundation check
|
|
18
|
+
* uniweb status --json One JSON line (adds a `remote` object under --remote)
|
|
19
|
+
*
|
|
20
|
+
* Run from a site, or a workspace with one site.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import yaml from 'js-yaml'
|
|
26
|
+
|
|
27
|
+
import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
|
|
28
|
+
import { probeUnpushed } from '../backend/site-sync.js'
|
|
29
|
+
import { BackendClient } from '../backend/client.js'
|
|
30
|
+
import { readFlagValue } from '../utils/args.js'
|
|
31
|
+
import { resolveLocalFoundation } from '../backend/foundation-bring-along.js'
|
|
32
|
+
import { computeFoundationDigest } from '../utils/code-upload.js'
|
|
33
|
+
|
|
34
|
+
const c = {
|
|
35
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
36
|
+
cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m',
|
|
37
|
+
}
|
|
38
|
+
const say = {
|
|
39
|
+
ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
|
|
40
|
+
info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
|
|
41
|
+
warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
|
|
42
|
+
dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readSiteYml(siteDir) {
|
|
46
|
+
const p = join(siteDir, 'site.yml')
|
|
47
|
+
if (!existsSync(p)) return {}
|
|
48
|
+
try {
|
|
49
|
+
return yaml.load(readFileSync(p, 'utf8')) || {}
|
|
50
|
+
} catch {
|
|
51
|
+
return {}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function foundationRef(siteYml) {
|
|
56
|
+
const f = siteYml.foundation
|
|
57
|
+
if (!f) return null
|
|
58
|
+
return typeof f === 'string' ? f : f.ref || null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A versioned registry ref `@org/name@1.2.3` → its scoped name `@org/name` and
|
|
62
|
+
// pinned version `1.2.3`. A bare/local/unversioned ref → nulls.
|
|
63
|
+
function splitFoundationRef(fnd) {
|
|
64
|
+
if (!fnd || fnd[0] !== '@') return { scope: null, version: null }
|
|
65
|
+
const at = fnd.lastIndexOf('@')
|
|
66
|
+
return at > 0 ? { scope: fnd.slice(0, at), version: fnd.slice(at + 1) } : { scope: null, version: null }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function status(args = []) {
|
|
70
|
+
const jsonMode = args.includes('--json')
|
|
71
|
+
const remote = args.includes('--remote')
|
|
72
|
+
const siteDir = await resolveSiteDir(args, 'status')
|
|
73
|
+
const siteYml = readSiteYml(siteDir)
|
|
74
|
+
const uuid = siteYml.$uuid || null
|
|
75
|
+
const fnd = foundationRef(siteYml)
|
|
76
|
+
const { scope: fndScope, version: fndVersion } = splitFoundationRef(fnd)
|
|
77
|
+
|
|
78
|
+
// Local content diff — builds the sync packages, never authenticates.
|
|
79
|
+
let probe = null
|
|
80
|
+
let probeErr = null
|
|
81
|
+
try {
|
|
82
|
+
probe = await probeUnpushed(siteDir)
|
|
83
|
+
} catch (err) {
|
|
84
|
+
probeErr = err.message
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Remote signals — opt-in (`--remote`). May prompt for login. Degrades to null
|
|
88
|
+
// on 404 / any failure, so a backend without the endpoints just shows local.
|
|
89
|
+
let site = null
|
|
90
|
+
let fdnLatest = null
|
|
91
|
+
let foundationFresh = null // true/false when both digests are known; else null
|
|
92
|
+
if (remote) {
|
|
93
|
+
try {
|
|
94
|
+
const client = new BackendClient({
|
|
95
|
+
originFlag: readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
|
|
96
|
+
siteBackend: await resolveSiteBackend(siteDir),
|
|
97
|
+
token: readFlagValue(args, '--token') || undefined,
|
|
98
|
+
args,
|
|
99
|
+
command: 'Status',
|
|
100
|
+
})
|
|
101
|
+
if (uuid) site = await client.siteStatus(uuid)
|
|
102
|
+
// Foundation freshness: prefer the LOCAL foundation's scoped name (so a
|
|
103
|
+
// local-foundation site can be checked too); fall back to a scoped
|
|
104
|
+
// site.yml ref. The digest compare is read-only — it never builds, so it
|
|
105
|
+
// only fires when the local foundation is already built (dist present).
|
|
106
|
+
const local = resolveLocalFoundation(siteDir, siteYml)
|
|
107
|
+
const lookupName = local?.scopedName || fndScope
|
|
108
|
+
if (lookupName) fdnLatest = await client.readFoundationLatest(lookupName)
|
|
109
|
+
if (local?.dir && fdnLatest?.digest) {
|
|
110
|
+
const localDigest = computeFoundationDigest(join(local.dir, 'dist'))
|
|
111
|
+
if (localDigest) foundationFresh = localDigest === fdnLatest.digest
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// degrade silently
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (jsonMode) {
|
|
119
|
+
console.log(
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
synced: Boolean(uuid),
|
|
122
|
+
uuid,
|
|
123
|
+
foundation: fnd,
|
|
124
|
+
changed: probe ? probe.changed : null,
|
|
125
|
+
unchanged: probe ? probe.unchanged : null,
|
|
126
|
+
...(probeErr ? { error: probeErr } : {}),
|
|
127
|
+
...(remote ? { remote: { site, foundation_latest: fdnLatest?.latest_version ?? null, foundation_fresh: foundationFresh } } : {}),
|
|
128
|
+
})
|
|
129
|
+
)
|
|
130
|
+
return { exitCode: 0 }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
console.log('')
|
|
134
|
+
|
|
135
|
+
// Sync identity
|
|
136
|
+
if (uuid) {
|
|
137
|
+
say.ok(`Synced — site-content ${c.bold}${uuid}${c.reset}`)
|
|
138
|
+
} else {
|
|
139
|
+
say.warn('Not synced — this site has never been pushed to a backend.')
|
|
140
|
+
say.dim('Run `uniweb push` to create it, or `uniweb publish` to sync and go live in one step.')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Content
|
|
144
|
+
if (probeErr) {
|
|
145
|
+
say.warn(`Couldn't compute content changes: ${probeErr}`)
|
|
146
|
+
say.dim('A build error or an unresolved data Model can block the offline diff.')
|
|
147
|
+
} else if (!uuid) {
|
|
148
|
+
const n = probe.changed
|
|
149
|
+
say.info(`${n} content ${n === 1 ? 'entity' : 'entities'} ready to push.`)
|
|
150
|
+
} else if (probe.changed === 0) {
|
|
151
|
+
say.ok('Content is in sync with the last push.')
|
|
152
|
+
} else {
|
|
153
|
+
const n = probe.changed
|
|
154
|
+
say.info(
|
|
155
|
+
`${c.bold}${n}${c.reset} content ${n === 1 ? 'entity' : 'entities'} not pushed` +
|
|
156
|
+
(probe.unchanged ? ` (${probe.unchanged} unchanged)` : '') +
|
|
157
|
+
'.'
|
|
158
|
+
)
|
|
159
|
+
say.dim('Run `uniweb publish` to sync and go live (or `uniweb push` to sync only).')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Foundation
|
|
163
|
+
if (fnd) say.dim(`Foundation: ${fnd}`)
|
|
164
|
+
|
|
165
|
+
// Remote signals
|
|
166
|
+
if (remote) {
|
|
167
|
+
if (site) {
|
|
168
|
+
if (site.draft_dirty) {
|
|
169
|
+
say.info('Synced draft has changes not yet live — run `uniweb publish` to go live.')
|
|
170
|
+
} else if (site.published) {
|
|
171
|
+
say.ok('Live with the latest synced content.')
|
|
172
|
+
} else {
|
|
173
|
+
say.info('Synced but not published yet — run `uniweb publish` to go live.')
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (fdnLatest?.latest_version && fndVersion && fdnLatest.latest_version !== fndVersion) {
|
|
177
|
+
say.info(`A newer foundation version (${fdnLatest.latest_version}) is registered than the site pins (${fndVersion}).`)
|
|
178
|
+
}
|
|
179
|
+
if (foundationFresh === false) {
|
|
180
|
+
say.info('Local foundation differs from the registered version — `uniweb register` (or `uniweb publish`) to release the change.')
|
|
181
|
+
} else if (foundationFresh === true) {
|
|
182
|
+
say.ok('Local foundation matches the registered version.')
|
|
183
|
+
}
|
|
184
|
+
if (!site && !fdnLatest) {
|
|
185
|
+
say.dim('(No remote signals — the backend may not expose them yet.)')
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
console.log('')
|
|
190
|
+
return { exitCode: 0 }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export default status
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-06-
|
|
3
|
+
"generatedAt": "2026-06-25T01:51:21.593Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.14.
|
|
6
|
+
"version": "0.14.19",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"deps": []
|
|
21
21
|
},
|
|
22
22
|
"@uniweb/content-writer": {
|
|
23
|
-
"version": "0.2.
|
|
23
|
+
"version": "0.2.6",
|
|
24
24
|
"path": "framework/content-writer",
|
|
25
25
|
"deps": []
|
|
26
26
|
},
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"deps": []
|
|
100
100
|
},
|
|
101
101
|
"@uniweb/unipress": {
|
|
102
|
-
"version": "0.4.
|
|
102
|
+
"version": "0.4.25",
|
|
103
103
|
"path": "framework/unipress",
|
|
104
104
|
"deps": [
|
|
105
105
|
"@uniweb/build",
|
package/src/index.js
CHANGED
|
@@ -606,8 +606,10 @@ async function main() {
|
|
|
606
606
|
process.exit(result?.exitCode ?? 0)
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
-
// Handle register command (dynamic import — depends on @uniweb/build)
|
|
610
|
-
|
|
609
|
+
// Handle register command (dynamic import — depends on @uniweb/build).
|
|
610
|
+
// `release` is a code-only synonym: `register` reads naturally the first
|
|
611
|
+
// time, `release` for updates (shipping-model.md §6.1). Same act, same code.
|
|
612
|
+
if (command === 'register' || command === 'release') {
|
|
611
613
|
const { register } = await importProjectCommand('./commands/register.js')
|
|
612
614
|
const result = await register(args.slice(1))
|
|
613
615
|
process.exit(result?.exitCode ?? 0)
|
|
@@ -627,6 +629,13 @@ async function main() {
|
|
|
627
629
|
process.exit(result?.exitCode ?? 0)
|
|
628
630
|
}
|
|
629
631
|
|
|
632
|
+
// Handle status command (dynamic import — offline emit via @uniweb/build)
|
|
633
|
+
if (command === 'status') {
|
|
634
|
+
const { status } = await importProjectCommand('./commands/status.js')
|
|
635
|
+
const result = await status(args.slice(1))
|
|
636
|
+
process.exit(result?.exitCode ?? 0)
|
|
637
|
+
}
|
|
638
|
+
|
|
630
639
|
// Handle clone command (global — bootstraps a new project from a backend site;
|
|
631
640
|
// STANDALONE, so a global `uniweb clone` runs here instead of delegating to a
|
|
632
641
|
// project-local CLI that doesn't exist yet. clone.js avoids any static
|
|
@@ -663,15 +672,16 @@ async function main() {
|
|
|
663
672
|
return
|
|
664
673
|
}
|
|
665
674
|
|
|
666
|
-
// Handle publish command —
|
|
667
|
-
//
|
|
675
|
+
// Handle publish command — the smart Uniweb-hosting path: bring the
|
|
676
|
+
// foundation along, sync, settle payment, go live. Distinct from `deploy`
|
|
677
|
+
// (third-party hosts) and `register`/`release` (foundation code → catalog).
|
|
668
678
|
if (command === 'publish') {
|
|
669
679
|
const { publish } = await importProjectCommand('./commands/publish.js')
|
|
670
680
|
const result = await publish(args.slice(1))
|
|
671
681
|
process.exit(result?.exitCode ?? 0)
|
|
672
682
|
}
|
|
673
683
|
|
|
674
|
-
// Handle deploy command (dynamic import —
|
|
684
|
+
// Handle deploy command — third-party hosts only (dynamic import — @uniweb/build)
|
|
675
685
|
if (command === 'deploy') {
|
|
676
686
|
const { deploy } = await importProjectCommand('./commands/deploy.js')
|
|
677
687
|
await deploy(args.slice(1))
|
|
@@ -1079,18 +1089,26 @@ async function main() {
|
|
|
1079
1089
|
* without loading @uniweb/build or any project context.
|
|
1080
1090
|
*/
|
|
1081
1091
|
function printCommandHelp(command) {
|
|
1092
|
+
// `release` is a synonym of `register` (shipping-model.md §6.1) — show the
|
|
1093
|
+
// same help block.
|
|
1094
|
+
if (command === 'release') command = 'register'
|
|
1082
1095
|
const blocks = {
|
|
1083
1096
|
deploy: `
|
|
1084
|
-
${colors.cyan}${colors.bright}uniweb deploy${colors.reset} ${colors.dim}—
|
|
1097
|
+
${colors.cyan}${colors.bright}uniweb deploy${colors.reset} ${colors.dim}— Ship a site to its resolved target${colors.reset}
|
|
1085
1098
|
|
|
1086
1099
|
${colors.bright}Usage:${colors.reset}
|
|
1087
|
-
uniweb deploy [options]
|
|
1100
|
+
uniweb deploy --host <name> [options]
|
|
1088
1101
|
|
|
1089
|
-
|
|
1090
|
-
|
|
1102
|
+
Ships a site to its resolved target. A THIRD-PARTY host builds dist/ (bundle
|
|
1103
|
+
mode) and hands it to a host adapter for upload + invalidation. A UNIWEB target
|
|
1104
|
+
(\`--host=uniweb\`, or a \`uniweb\` target in deploy.yml) delegates to
|
|
1105
|
+
${colors.cyan}uniweb publish${colors.reset} (sync + dynamic serving; brings the foundation along) — the
|
|
1106
|
+
canonical direct verb for Uniweb hosting. With no host chosen, deploy prompts for
|
|
1107
|
+
a third-party adapter. For a self-contained dist/ you upload yourself, use
|
|
1108
|
+
${colors.cyan}uniweb export${colors.reset}.
|
|
1091
1109
|
|
|
1092
1110
|
${colors.bright}Hosts:${colors.reset}
|
|
1093
|
-
uniweb Uniweb hosting (
|
|
1111
|
+
uniweb Uniweb hosting (delegates to \`uniweb publish\`)
|
|
1094
1112
|
cloudflare-pages Cloudflare Pages (build artifact + adapter postBuild)
|
|
1095
1113
|
netlify Netlify (alias of cloudflare-pages adapter)
|
|
1096
1114
|
vercel Vercel (build-only — deploy via \`npx vercel\`)
|
|
@@ -1099,43 +1117,40 @@ ${colors.bright}Hosts:${colors.reset}
|
|
|
1099
1117
|
generic-static Plain static-host build, no host-specific helpers
|
|
1100
1118
|
|
|
1101
1119
|
${colors.bright}Options:${colors.reset}
|
|
1120
|
+
--host <name> The host to ship to (no value → interactive third-party picker, TTY only)
|
|
1102
1121
|
--target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
|
|
1103
|
-
--
|
|
1104
|
-
--host No value → interactive picker (TTY only)
|
|
1105
|
-
--dry-run Resolve site.yml + foundation/runtime; print summary; no writes
|
|
1106
|
-
--no-auto-publish Don't auto-publish workspace-local foundation as part of deploy
|
|
1122
|
+
--dry-run Resolve the target + adapter; print summary; upload nothing
|
|
1107
1123
|
--no-save Skip the auto-save of lastDeploy in deploy.yml
|
|
1108
|
-
--backend <url> Override the default backend origin (\$UNIWEB_REGISTER_URL or built-in)
|
|
1109
1124
|
--non-interactive Fail with usage info instead of prompting
|
|
1110
1125
|
|
|
1111
|
-
${colors.bright}Auth:${colors.reset}
|
|
1112
|
-
\`host: uniweb\` requires authentication. Run \`uniweb login\` first, set
|
|
1113
|
-
\`UNIWEB_TOKEN=<bearer>\` env var, or use a static-host adapter that
|
|
1114
|
-
doesn't need a Uniweb account. CI / agents / piped stdin auto-detect
|
|
1115
|
-
non-interactive mode and bail with an actionable error instead of
|
|
1116
|
-
hanging on a browser callback.
|
|
1117
|
-
|
|
1118
1126
|
${colors.bright}Examples:${colors.reset}
|
|
1119
|
-
uniweb deploy
|
|
1120
|
-
uniweb deploy --
|
|
1121
|
-
uniweb deploy --host=
|
|
1122
|
-
uniweb deploy --target=preview #
|
|
1127
|
+
uniweb deploy --host=cloudflare-pages # Build + upload to Cloudflare Pages
|
|
1128
|
+
uniweb deploy --host=s3-cloudfront # Build + upload + invalidate
|
|
1129
|
+
uniweb deploy --host=uniweb # → delegates to \`uniweb publish\`
|
|
1130
|
+
uniweb deploy --target=preview # Named target from deploy.yml
|
|
1123
1131
|
`,
|
|
1124
1132
|
publish: `
|
|
1125
|
-
${colors.cyan}${colors.bright}uniweb publish${colors.reset} ${colors.dim}— Publish a
|
|
1133
|
+
${colors.cyan}${colors.bright}uniweb publish${colors.reset} ${colors.dim}— Publish a site to Uniweb hosting (the smart path)${colors.reset}
|
|
1126
1134
|
|
|
1127
1135
|
${colors.bright}Usage:${colors.reset}
|
|
1128
1136
|
uniweb publish [options]
|
|
1129
1137
|
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1138
|
+
The most ergonomic command in the tool: \`uniweb login && uniweb publish\` reads
|
|
1139
|
+
your project and makes the site live on Uniweb hosting. It resolves which site,
|
|
1140
|
+
BRINGS THE FOUNDATION ALONG (releases the local foundation when its code
|
|
1141
|
+
changed), syncs content, settles payment when go-live needs it (a browser
|
|
1142
|
+
handoff), and goes live. A published-registry foundation needs no release; an
|
|
1143
|
+
already-paid site opens no browser.
|
|
1144
|
+
|
|
1145
|
+
For a third-party host use \`uniweb deploy --host=<name>\`; to register a
|
|
1146
|
+
FOUNDATION on its own use \`uniweb register\` (alias \`uniweb release\`).
|
|
1134
1147
|
|
|
1135
1148
|
${colors.bright}Options:${colors.reset}
|
|
1149
|
+
--dry-run Resolve everything; release/sync/POST nothing
|
|
1150
|
+
--yes Skip confirmations (CI); never block on a prompt
|
|
1151
|
+
--no-save Skip the deploy.yml lastDeploy auto-save
|
|
1136
1152
|
--backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
|
|
1137
1153
|
--token <bearer> Auth bearer (skips \`uniweb login\`)
|
|
1138
|
-
--dry-run Resolve everything; POST nothing
|
|
1139
1154
|
`,
|
|
1140
1155
|
create: `
|
|
1141
1156
|
${colors.cyan}${colors.bright}uniweb create${colors.reset} ${colors.dim}— Create a new project${colors.reset}
|
|
@@ -1269,15 +1284,18 @@ ${colors.bright}Options:${colors.reset}
|
|
|
1269
1284
|
Exit codes: 0 clean (or warn-only), 1 violations under --strict, 2 setup error.
|
|
1270
1285
|
`,
|
|
1271
1286
|
register: `
|
|
1272
|
-
${colors.cyan}${colors.bright}uniweb register${colors.reset} ${colors.dim}— Register a foundation + its data schemas with the backend registry${colors.reset}
|
|
1287
|
+
${colors.cyan}${colors.bright}uniweb register${colors.reset} ${colors.dim}— Register (release) a foundation + its data schemas with the backend registry${colors.reset}
|
|
1273
1288
|
|
|
1274
1289
|
${colors.bright}Usage:${colors.reset}
|
|
1275
1290
|
uniweb register [options]
|
|
1291
|
+
uniweb release [options] ${colors.dim}(synonym — reads naturally for updates)${colors.reset}
|
|
1276
1292
|
|
|
1277
1293
|
Builds one \`.uwx\` document and submits it to the registry over HTTP. Run
|
|
1278
|
-
\`uniweb login\` first (or pass \`--token\`). \`register\`
|
|
1279
|
-
schemas); \`uniweb publish\` makes a synced SITE
|
|
1280
|
-
|
|
1294
|
+
\`uniweb login\` first (or pass \`--token\`). \`register\`/\`release\` are for
|
|
1295
|
+
FOUNDATIONS (and schemas) — code only; \`uniweb publish\` makes a synced SITE
|
|
1296
|
+
live (and brings its foundation along); \`uniweb deploy\` hosts on a third-party
|
|
1297
|
+
host. \`register\` and \`release\` are the same act: \`register\` reads naturally
|
|
1298
|
+
the first time, \`release\` for updates.
|
|
1281
1299
|
|
|
1282
1300
|
Auto-detects what you run it in:
|
|
1283
1301
|
• a foundation the foundation + the data schemas it defines/renders
|
|
@@ -1293,7 +1311,7 @@ ${colors.bright}Options:${colors.reset}
|
|
|
1293
1311
|
--scope @org Publish under @org (resolves @/x -> @org/x); default: package.json uniweb.scope
|
|
1294
1312
|
--dry-run Print the .uwx; submit nothing
|
|
1295
1313
|
-o, --output <f> Write the .uwx to a file; submit nothing
|
|
1296
|
-
--
|
|
1314
|
+
--backend <url> Backend origin (alias: --registry; default: \$UNIWEB_REGISTER_URL or a local URL)
|
|
1297
1315
|
--token <bearer> Submit with this bearer; skips \`uniweb login\` (or set UNIWEB_TOKEN)
|
|
1298
1316
|
--non-interactive Fail with usage info instead of prompting
|
|
1299
1317
|
|
|
@@ -1463,13 +1481,15 @@ ${colors.bright}Commands:${colors.reset}
|
|
|
1463
1481
|
rename <type> Rename a foundation, site, or extension across the workspace
|
|
1464
1482
|
dev Start a dev server for a site
|
|
1465
1483
|
build Build the current project
|
|
1466
|
-
|
|
1484
|
+
publish Publish a site to Uniweb hosting (smart: foundation + sync + go live)
|
|
1485
|
+
deploy Deploy a site to a third-party host (--host=<adapter>)
|
|
1467
1486
|
export Export a self-contained site for third-party hosting
|
|
1468
|
-
publish Publish a synced site (make its backend state live)
|
|
1469
1487
|
register Register a foundation + its data schemas with the backend registry
|
|
1488
|
+
release Release a foundation version (synonym of register)
|
|
1470
1489
|
runtime register Register an @uniweb/runtime version to the backend (@std only)
|
|
1471
1490
|
push Push a site's content to the backend
|
|
1472
1491
|
pull Pull a site's content from the backend
|
|
1492
|
+
status Show a site's sync state (unpushed content, foundation)
|
|
1473
1493
|
inspect <path> Inspect parsed content shape of a markdown file or folder
|
|
1474
1494
|
docs Generate component documentation
|
|
1475
1495
|
doctor Diagnose project configuration issues
|
|
@@ -1498,21 +1518,24 @@ ${colors.bright}Global Options:${colors.reset}
|
|
|
1498
1518
|
Auto-detected when CI=true or no TTY (pipes, agents)
|
|
1499
1519
|
|
|
1500
1520
|
${colors.bright}Publish Options:${colors.reset}
|
|
1521
|
+
--dry-run Resolve everything; release/sync/POST nothing
|
|
1522
|
+
--yes Skip confirmations (CI); never block on a prompt
|
|
1523
|
+
--no-save Skip the deploy.yml lastDeploy auto-save
|
|
1501
1524
|
--backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
|
|
1502
1525
|
--token <bearer> Auth bearer (skips \`uniweb login\`)
|
|
1503
|
-
--dry-run Resolve everything; POST nothing
|
|
1504
1526
|
|
|
1505
|
-
uniweb publish
|
|
1506
|
-
|
|
1527
|
+
uniweb publish is the smart Uniweb-hosting path: it brings the site's
|
|
1528
|
+
foundation along, syncs, and goes live. To register a foundation on its own
|
|
1529
|
+
use \`uniweb register\` (alias \`uniweb release\`); for a third-party host use
|
|
1530
|
+
\`uniweb deploy --host=<name>\`.
|
|
1507
1531
|
|
|
1508
1532
|
${colors.bright}Deploy Options:${colors.reset}
|
|
1533
|
+
--host <name> The host to ship to (no value → interactive third-party
|
|
1534
|
+
picker, TTY only). Third-party: cloudflare-pages, netlify,
|
|
1535
|
+
vercel, github-pages, s3-cloudfront, generic-static.
|
|
1536
|
+
\`--host=uniweb\` delegates to \`uniweb publish\`.
|
|
1509
1537
|
--target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
|
|
1510
|
-
--
|
|
1511
|
-
Without a value, opens an interactive picker (TTY only).
|
|
1512
|
-
Hosts: uniweb, cloudflare-pages, netlify, vercel,
|
|
1513
|
-
github-pages, s3-cloudfront, generic-static.
|
|
1514
|
-
--dry-run Resolve site.yml + foundation/runtime; print summary; no writes
|
|
1515
|
-
--no-auto-publish Don't auto-publish workspace-local foundation as part of deploy
|
|
1538
|
+
--dry-run Resolve the target + adapter; print summary; upload nothing
|
|
1516
1539
|
--no-save Skip the auto-save of lastDeploy in deploy.yml
|
|
1517
1540
|
|
|
1518
1541
|
${colors.bright}Dev Options:${colors.reset}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Site asset delivery — the asset lane for `uniweb
|
|
2
|
+
* Site asset delivery — the asset lane for `uniweb publish` (channel
|
|
3
3
|
* framework-backend-f90d). After the link build processes a site's media into
|
|
4
4
|
* `dist/assets/`, those bytes are delivered to the backend's content-addressed
|
|
5
|
-
* asset store, and the
|
|
5
|
+
* asset store, and the publish step rewrites the content's local refs to durable
|
|
6
6
|
* serve URLs:
|
|
7
7
|
*
|
|
8
8
|
* 1. PLAN — POST {apiBase}/dev/assets with the file list ({ path,
|
|
@@ -147,7 +147,7 @@ export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgr
|
|
|
147
147
|
onProgress(`↑ ${src.path}`)
|
|
148
148
|
let putRes
|
|
149
149
|
try {
|
|
150
|
-
// The plan's url may be origin-relative (direct mode →
|
|
150
|
+
// The plan's url may be origin-relative (direct mode → the backend) or
|
|
151
151
|
// absolute (presigned → storage); new URL() resolves both.
|
|
152
152
|
putRes = await fetch(new URL(up.url, origin), { method: up.method || 'PUT', headers, body: src.bytes ?? readFileSync(src.diskPath) })
|
|
153
153
|
} catch (err) {
|
package/src/utils/code-upload.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 1. PLAN — POST {apiBase}/dev/registry/code-uploads with the file list
|
|
9
9
|
* ({ path, content_type, size, sha256? }). The response carries
|
|
10
10
|
* one upload target per file ({ path, method, url, headers })
|
|
11
|
-
* plus mode: 'direct' (dev — URLs point back at
|
|
11
|
+
* plus mode: 'direct' (dev — URLs point back at the backend) or
|
|
12
12
|
* 'presigned' (prod — storage PUTs; bytes never transit the
|
|
13
13
|
* backend). The CLI never branches on the mode.
|
|
14
14
|
* 2. UPLOAD — PUT each file's raw bytes to its URL with the given headers.
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { createHash } from 'node:crypto'
|
|
38
|
-
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
38
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
39
39
|
import { join } from 'node:path'
|
|
40
40
|
|
|
41
41
|
// Extension → declared content type. Extension-honest by construction (Vite
|
|
@@ -111,6 +111,46 @@ export function uploadOrder(files) {
|
|
|
111
111
|
return [...rest, ...entry]
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The deterministic content digest of a built foundation — the freshness
|
|
116
|
+
* fingerprint `register` records and `publish`/`status` later compare against
|
|
117
|
+
* (shipping-model.md §4.1). It answers one question — "did the foundation change
|
|
118
|
+
* since it was released?" — with NO local state, so it has to be reproducible:
|
|
119
|
+
* the same source, built on any machine, must yield the same digest.
|
|
120
|
+
*
|
|
121
|
+
* Algorithm (§4.1): `sha256` over the newline-joined, SORTED list of the
|
|
122
|
+
* per-file `sha256` (hex) of exactly what `register` ships for a foundation —
|
|
123
|
+
* - the code-upload set (`collectDistFiles`: entry + chunks + assets, with
|
|
124
|
+
* `meta/**` and `*.map` already excluded), and
|
|
125
|
+
* - the schema source (`meta/schema.json`) — NOT uploaded as a file, and not
|
|
126
|
+
* sent verbatim: register submits its *content* as the foundation-schema
|
|
127
|
+
* ENTITY in the `.uwx`. It's read here only to fold the schema into the
|
|
128
|
+
* local freshness fingerprint (so a schema edit shows as "changed").
|
|
129
|
+
* Hashes are SORTED (so the digest is order-independent) and taken over file
|
|
130
|
+
* CONTENTS, not names, so content-hashed chunk filenames don't perturb it.
|
|
131
|
+
* Returns null when there's nothing to hash (no usable dist), so callers skip
|
|
132
|
+
* cleanly rather than fingerprinting emptiness.
|
|
133
|
+
*
|
|
134
|
+
* The result is OPAQUE to the backend — it stores the string and returns it
|
|
135
|
+
* verbatim; the framework owns the algorithm end-to-end and can change it
|
|
136
|
+
* freely (a digest only ever compares against one the same CLI computed).
|
|
137
|
+
*
|
|
138
|
+
* @param {string} distDir - the built `dist/` directory
|
|
139
|
+
* @returns {string|null} `sha256:<hex>` or null when there's nothing to hash
|
|
140
|
+
*/
|
|
141
|
+
export function computeFoundationDigest(distDir) {
|
|
142
|
+
const hashes = collectDistFiles(distDir).map((f) => f.sha256)
|
|
143
|
+
// The schema rides in the register `.uwx`, not the code-upload set, so fold
|
|
144
|
+
// it in explicitly — a schema-only change is still a foundation change.
|
|
145
|
+
const schemaPath = join(distDir, 'meta', 'schema.json')
|
|
146
|
+
if (existsSync(schemaPath)) {
|
|
147
|
+
hashes.push(createHash('sha256').update(readFileSync(schemaPath)).digest('hex'))
|
|
148
|
+
}
|
|
149
|
+
if (!hashes.length) return null
|
|
150
|
+
hashes.sort()
|
|
151
|
+
return 'sha256:' + createHash('sha256').update(hashes.join('\n')).digest('hex')
|
|
152
|
+
}
|
|
153
|
+
|
|
114
154
|
/**
|
|
115
155
|
* The gateway serve URL for a file of a registered foundation version.
|
|
116
156
|
* Mirrors the backend storage convention: scope WITHOUT the '@'.
|
|
@@ -180,7 +220,7 @@ export async function uploadFoundationCode({
|
|
|
180
220
|
const plan = await planRes.json()
|
|
181
221
|
const targets = new Map((plan.uploads || []).map((u) => [u.path, u]))
|
|
182
222
|
const serveBase = plan.serve_base || null
|
|
183
|
-
// The ONE mode-aware bit: direct-mode PUTs are bearer-authed
|
|
223
|
+
// The ONE mode-aware bit: direct-mode PUTs are bearer-authed backend
|
|
184
224
|
// routes; presigned URLs are self-authorizing and must NOT carry a
|
|
185
225
|
// bearer (foreign auth headers can break signed-request validation).
|
|
186
226
|
const authHeaders = plan.mode === 'direct' ? { Authorization: `Bearer ${token}` } : {}
|
package/src/utils/config.js
CHANGED
|
@@ -49,14 +49,35 @@ function readCliConfig() {
|
|
|
49
49
|
return _cliConfig
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The origin the LAST `uniweb login` authenticated against — persisted on the
|
|
54
|
+
* session record so subsequent verbs default to the backend you logged into
|
|
55
|
+
* (no `--backend` per command). Sync read; null when there's no session or it
|
|
56
|
+
* carries no origin (older sessions). Read directly (not via registry-auth.js)
|
|
57
|
+
* to keep this module off the optional-peer / import-cycle path.
|
|
58
|
+
* @returns {string|null}
|
|
59
|
+
*/
|
|
60
|
+
function readSessionOrigin() {
|
|
61
|
+
try {
|
|
62
|
+
const p = join(homedir(), '.uniweb', 'registry-auth.json')
|
|
63
|
+
if (!existsSync(p)) return null
|
|
64
|
+
const o = JSON.parse(readFileSync(p, 'utf8'))?.origin
|
|
65
|
+
return o || null
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
52
71
|
/**
|
|
53
72
|
* Get the backend's API base origin. `register` POSTs to
|
|
54
73
|
* {origin}/dev/registry/register, `login` to {origin}/dev/auth/login, etc.
|
|
55
|
-
* (BackendClient.resolveBackendOrigin layers the --backend
|
|
56
|
-
* top of this.)
|
|
74
|
+
* (BackendClient.resolveBackendOrigin layers the --backend flag and a site's
|
|
75
|
+
* deploy.yml backend on top of this.)
|
|
57
76
|
*
|
|
58
|
-
* Priority: UNIWEB_REGISTER_URL's origin >
|
|
59
|
-
* >
|
|
77
|
+
* Priority: UNIWEB_REGISTER_URL's origin > the logged-in session origin >
|
|
78
|
+
* ~/.uniweb/config.json registryApiUrl > the default (uniweb.app). Local dev
|
|
79
|
+
* points at a local backend EXPLICITLY (--backend / UNIWEB_REGISTER_URL /
|
|
80
|
+
* `uniweb login --backend …`), rather than the default being localhost.
|
|
60
81
|
* @returns {string}
|
|
61
82
|
*/
|
|
62
83
|
export function getRegistryApiBaseUrl() {
|
|
@@ -64,11 +85,15 @@ export function getRegistryApiBaseUrl() {
|
|
|
64
85
|
if (fromEnv) {
|
|
65
86
|
try { return new URL(fromEnv).origin } catch { /* fall through */ }
|
|
66
87
|
}
|
|
88
|
+
const fromSession = readSessionOrigin()
|
|
89
|
+
if (fromSession) {
|
|
90
|
+
try { return new URL(fromSession).origin } catch { return fromSession }
|
|
91
|
+
}
|
|
67
92
|
const fromCfg = readCliConfig().registryApiUrl
|
|
68
93
|
if (fromCfg) {
|
|
69
94
|
try { return new URL(fromCfg).origin } catch { return fromCfg }
|
|
70
95
|
}
|
|
71
|
-
return '
|
|
96
|
+
return 'https://uniweb.app'
|
|
72
97
|
}
|
|
73
98
|
|
|
74
99
|
/**
|