uniweb 0.12.26 → 0.12.27
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 +6 -6
- package/partials/agents.md +3 -0
- package/src/backend/client.js +339 -0
- package/src/commands/clone.js +18 -32
- package/src/commands/deploy.js +218 -1783
- package/src/commands/handoff.js +9 -246
- package/src/commands/invite.js +10 -318
- package/src/commands/org.js +6 -8
- package/src/commands/publish.js +128 -1153
- package/src/commands/pull.js +22 -36
- package/src/commands/push.js +43 -101
- package/src/commands/register.js +184 -39
- package/src/commands/runtime.js +141 -0
- package/src/commands/template.js +13 -221
- package/src/framework-index.json +17 -6
- package/src/index.js +74 -100
- package/src/utils/asset-upload.js +162 -0
- package/src/utils/code-upload.js +245 -0
- package/src/utils/config.js +11 -44
- package/src/utils/registry-auth.js +35 -1
- package/src/utils/registry-orgs.js +141 -73
- package/src/utils/runtime-upload.js +163 -0
- package/src/commands/login.js +0 -230
- package/src/utils/auth.js +0 -212
- package/src/utils/registry.js +0 -466
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site asset delivery — the asset lane for `uniweb deploy` (channel
|
|
3
|
+
* framework-backend-f90d). After the link build processes a site's media into
|
|
4
|
+
* `dist/assets/`, those bytes are delivered to the backend's content-addressed
|
|
5
|
+
* asset store, and the deploy step rewrites the content's local refs to durable
|
|
6
|
+
* serve URLs:
|
|
7
|
+
*
|
|
8
|
+
* 1. PLAN — POST {apiBase}/dev/assets with the file list ({ path,
|
|
9
|
+
* content_type, size, sha256 }). `sha256` is REQUIRED — it is the
|
|
10
|
+
* content address. The response carries one entry per file
|
|
11
|
+
* ({ path, id, ext, present?, method, url, headers }) plus mode:
|
|
12
|
+
* 'direct' (dev — URLs point back at the backend) or 'presigned'
|
|
13
|
+
* (prod — storage PUTs). `id` is the lowercase-hex sha256 of the
|
|
14
|
+
* bytes; we READ it from the response (never depend on id == sha256)
|
|
15
|
+
* so the client stays correct if the derivation ever changes.
|
|
16
|
+
* 2. UPLOAD — PUT each NEW file's raw bytes to its URL with the given headers.
|
|
17
|
+
* Order is irrelevant (unlike code-upload, there is no "entry").
|
|
18
|
+
* Skip-list: an entry the plan marks `present: true` is already in
|
|
19
|
+
* the content-addressed store, so we SKIP the PUT but still record
|
|
20
|
+
* its id+ext for the rewrite. Absent `present` ⇒ false — an older
|
|
21
|
+
* backend without the flag uploads everything (today's behavior).
|
|
22
|
+
*
|
|
23
|
+
* Assets are GLOBAL + content-addressed: identical bytes → same id → dedup
|
|
24
|
+
* across sites and idempotent re-deploys (a re-PUT is a cheap no-op). This
|
|
25
|
+
* mirrors the foundation code lane (utils/code-upload.js); the one structural
|
|
26
|
+
* difference is that the backend MINTS the per-asset id, so the plan response is
|
|
27
|
+
* what the deploy step rewrites content references to.
|
|
28
|
+
*
|
|
29
|
+
* Contract: kb/framework/build/delivery-lane.md §Assets.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHash } from 'node:crypto'
|
|
33
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
34
|
+
import { join } from 'node:path'
|
|
35
|
+
import { contentTypeFor } from './code-upload.js'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Walk a built site's `dist/assets/` and produce the upload file list. `path` is
|
|
39
|
+
* POSIX-relative to distDir (e.g. `assets/hero-ab12cd34.webp`); `localUrl` is how
|
|
40
|
+
* the built content references the asset (`/assets/...`) — the rewrite key.
|
|
41
|
+
* Returns `[]` when there is no `assets/` dir (an image-free site).
|
|
42
|
+
*
|
|
43
|
+
* @param {string} distDir - the site's built dist/ directory
|
|
44
|
+
* @returns {Array<{ path: string, content_type: string, size: number, sha256: string, localUrl: string, diskPath: string }>}
|
|
45
|
+
*/
|
|
46
|
+
export function collectSiteAssets(distDir) {
|
|
47
|
+
const assetsDir = join(distDir, 'assets')
|
|
48
|
+
if (!existsSync(assetsDir)) return []
|
|
49
|
+
const files = []
|
|
50
|
+
const walk = (dir, prefix) => {
|
|
51
|
+
for (const name of readdirSync(dir).sort()) {
|
|
52
|
+
const full = join(dir, name)
|
|
53
|
+
const rel = prefix ? `${prefix}/${name}` : name
|
|
54
|
+
const st = statSync(full)
|
|
55
|
+
if (st.isDirectory()) {
|
|
56
|
+
walk(full, rel)
|
|
57
|
+
} else if (st.isFile()) {
|
|
58
|
+
const bytes = readFileSync(full)
|
|
59
|
+
files.push({
|
|
60
|
+
path: `assets/${rel}`,
|
|
61
|
+
content_type: contentTypeFor(name),
|
|
62
|
+
size: st.size,
|
|
63
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
64
|
+
localUrl: `/assets/${rel}`,
|
|
65
|
+
diskPath: full,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
walk(assetsDir, '')
|
|
71
|
+
return files
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Deliver a site's assets: plan, then PUT each NEW file. Returns the rewrite map
|
|
76
|
+
* (`localUrl → { id, ext }`) — populated for files that uploaded successfully OR
|
|
77
|
+
* that the plan reports already `present`, so a partial failure never injects a
|
|
78
|
+
* broken serve URL into content. Throws only on a plan-level failure; per-file
|
|
79
|
+
* PUT failures surface in `failed`.
|
|
80
|
+
*
|
|
81
|
+
* Skip-list (content-addressed dedup): a plan entry with `present: true` is
|
|
82
|
+
* already in the global store — we don't re-PUT it, but we DO record its id+ext
|
|
83
|
+
* so the rewrite covers every asset. A backend that omits the flag (absent ⇒
|
|
84
|
+
* false) uploads everything, exactly as before.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} opts
|
|
87
|
+
* @param {string} opts.apiBase - backend origin (e.g. http://localhost:8080)
|
|
88
|
+
* @param {string} opts.token - bearer (same session as deploy/register)
|
|
89
|
+
* @param {string} opts.distDir - the site's built dist/ directory
|
|
90
|
+
* @param {Array} [opts.files] - pre-collected list (default: collectSiteAssets)
|
|
91
|
+
* @param {(msg: string) => void} [opts.onProgress]
|
|
92
|
+
* @returns {Promise<{ mode: string, uploaded: string[], skipped: string[], failed: Array<{path, status, detail}>, assetsByLocalUrl: Record<string, { id: string, ext: string }> }>}
|
|
93
|
+
*/
|
|
94
|
+
export async function uploadSiteAssets({ apiBase, token, distDir, files, onProgress = () => {} }) {
|
|
95
|
+
const list = files || collectSiteAssets(distDir)
|
|
96
|
+
if (!list.length) {
|
|
97
|
+
return { mode: 'none', uploaded: [], skipped: [], failed: [], assetsByLocalUrl: {} }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const origin = apiBase.replace(/\/$/, '')
|
|
101
|
+
const planRes = await fetch(`${origin}/dev/assets`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
104
|
+
body: JSON.stringify({
|
|
105
|
+
files: list.map(({ path, content_type, size, sha256 }) => ({ path, content_type, size, sha256 })),
|
|
106
|
+
}),
|
|
107
|
+
})
|
|
108
|
+
if (!planRes.ok) {
|
|
109
|
+
const detail = await planRes.text().catch(() => '')
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Asset plan failed: HTTP ${planRes.status} ${planRes.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
const plan = await planRes.json()
|
|
115
|
+
const mode = plan.mode || 'direct'
|
|
116
|
+
const uploads = Array.isArray(plan.uploads) ? plan.uploads : []
|
|
117
|
+
const byPath = new Map(list.map((f) => [f.path, f]))
|
|
118
|
+
|
|
119
|
+
const uploaded = []
|
|
120
|
+
const skipped = []
|
|
121
|
+
const failed = []
|
|
122
|
+
const assetsByLocalUrl = {}
|
|
123
|
+
|
|
124
|
+
for (const up of uploads) {
|
|
125
|
+
const src = byPath.get(up.path)
|
|
126
|
+
if (!src) continue // backend echoed a path we didn't send — ignore
|
|
127
|
+
|
|
128
|
+
// Skip-list: the bytes are already in the content-addressed store. No PUT,
|
|
129
|
+
// but still record id+ext so the rewrite covers this asset. `present` absent
|
|
130
|
+
// ⇒ false (older backend) → falls through to the upload path below.
|
|
131
|
+
if (up.present) {
|
|
132
|
+
skipped.push(src.path)
|
|
133
|
+
assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, '') }
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const headers = { ...(up.headers || {}), 'x-uniweb-sha256': src.sha256 }
|
|
138
|
+
// Direct-mode PUTs are authed requests to the backend; presigned URLs are
|
|
139
|
+
// self-signed, so a foreign auth header can break the SigV4 target. This is
|
|
140
|
+
// the only mode-aware line in the client (same rule as code-upload).
|
|
141
|
+
if (mode !== 'presigned') headers.Authorization = `Bearer ${token}`
|
|
142
|
+
onProgress(`↑ ${src.path}`)
|
|
143
|
+
let putRes
|
|
144
|
+
try {
|
|
145
|
+
// The plan's url may be origin-relative (direct mode → uniwebd) or
|
|
146
|
+
// absolute (presigned → storage); new URL() resolves both.
|
|
147
|
+
putRes = await fetch(new URL(up.url, origin), { method: up.method || 'PUT', headers, body: readFileSync(src.diskPath) })
|
|
148
|
+
} catch (err) {
|
|
149
|
+
failed.push({ path: src.path, status: 0, detail: err.message })
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
if (putRes.ok) {
|
|
153
|
+
uploaded.push(src.path)
|
|
154
|
+
// Authoritative id + ext from the plan; mapped only on a successful PUT.
|
|
155
|
+
assetsByLocalUrl[src.localUrl] = { id: up.id, ext: String(up.ext || '').replace(/^\./, '') }
|
|
156
|
+
} else {
|
|
157
|
+
failed.push({ path: src.path, status: putRes.status, detail: await putRes.text().catch(() => '') })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { mode, uploaded, skipped, failed, assetsByLocalUrl }
|
|
162
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foundation code delivery — the phase-2 client of `uniweb register`.
|
|
3
|
+
*
|
|
4
|
+
* After a successful schema registration, the foundation's built `dist/`
|
|
5
|
+
* bytes are delivered to the registry in two steps (contract:
|
|
6
|
+
* foundation-code-upload.md, beside uwx-format.md):
|
|
7
|
+
*
|
|
8
|
+
* 1. PLAN — POST {apiBase}/dev/registry/code-uploads with the file list
|
|
9
|
+
* ({ path, content_type, size, sha256? }). The response carries
|
|
10
|
+
* one upload target per file ({ path, method, url, headers })
|
|
11
|
+
* plus mode: 'direct' (dev — URLs point back at uniwebd) or
|
|
12
|
+
* 'presigned' (prod — storage PUTs; bytes never transit the
|
|
13
|
+
* backend). The CLI never branches on the mode.
|
|
14
|
+
* 2. UPLOAD — PUT each file's raw bytes to its URL with the given headers.
|
|
15
|
+
* The ENTRY uploads LAST: a partial upload never yields a
|
|
16
|
+
* loadable version (practical atomicity — there is no server
|
|
17
|
+
* confirm step by design).
|
|
18
|
+
*
|
|
19
|
+
* In direct mode the entry is fetched back from the anonymous serve route
|
|
20
|
+
* (GET /gateway/foundation/{scope}/{name}/{version}/{path}) and compared
|
|
21
|
+
* byte-for-byte — the e2e proof that the version is live.
|
|
22
|
+
*
|
|
23
|
+
* Rules encoded here (the backend validates too — reject, never repair):
|
|
24
|
+
* - paths are dist/-relative, '/'-separated, URL-safe verbatim
|
|
25
|
+
* - `meta/**` is EXCLUDED from the upload set: schema custody is the
|
|
26
|
+
* registry's entity store, and the gateway serves anonymously while
|
|
27
|
+
* schemas are authenticated content everywhere else (no public catalog)
|
|
28
|
+
* - `*.map` sourcemaps are EXCLUDED: they are dev-only debugging artifacts,
|
|
29
|
+
* not part of the CDN-served runtime. (A Shiki-heavy foundation emits a
|
|
30
|
+
* map per highlighted-grammar chunk, which is what pushes a build past the
|
|
31
|
+
* plan step's per-version file cap — the cap is an abuse guard, the maps
|
|
32
|
+
* simply don't belong on the CDN.)
|
|
33
|
+
* - a registered version is immutable, code included — changed bytes mean
|
|
34
|
+
* a new version (re-PUTting identical bytes is a safe no-op)
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { createHash } from 'node:crypto'
|
|
38
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
39
|
+
import { join } from 'node:path'
|
|
40
|
+
|
|
41
|
+
// Extension → declared content type. Extension-honest by construction (Vite
|
|
42
|
+
// output); anything unknown ships as octet-stream.
|
|
43
|
+
const CONTENT_TYPES = {
|
|
44
|
+
js: 'text/javascript',
|
|
45
|
+
mjs: 'text/javascript',
|
|
46
|
+
css: 'text/css',
|
|
47
|
+
map: 'application/json',
|
|
48
|
+
json: 'application/json',
|
|
49
|
+
wasm: 'application/wasm',
|
|
50
|
+
woff2: 'font/woff2',
|
|
51
|
+
woff: 'font/woff',
|
|
52
|
+
ttf: 'font/ttf',
|
|
53
|
+
png: 'image/png',
|
|
54
|
+
jpg: 'image/jpeg',
|
|
55
|
+
jpeg: 'image/jpeg',
|
|
56
|
+
gif: 'image/gif',
|
|
57
|
+
svg: 'image/svg+xml',
|
|
58
|
+
webp: 'image/webp',
|
|
59
|
+
avif: 'image/avif',
|
|
60
|
+
ico: 'image/x-icon',
|
|
61
|
+
txt: 'text/plain',
|
|
62
|
+
html: 'text/html',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function contentTypeFor(path) {
|
|
66
|
+
const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase()
|
|
67
|
+
return CONTENT_TYPES[ext] || 'application/octet-stream'
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The dist-root entry file — uploaded last, verified after. */
|
|
71
|
+
export const ENTRY_PATH = 'entry.js'
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Walk a built dist/ and produce the upload file list.
|
|
75
|
+
* Excludes `meta/**` and `*.map` sourcemaps (see header). Paths are
|
|
76
|
+
* POSIX-relative to distDir.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} distDir
|
|
79
|
+
* @returns {Array<{ path: string, content_type: string, size: number, sha256: string }>}
|
|
80
|
+
*/
|
|
81
|
+
export function collectDistFiles(distDir) {
|
|
82
|
+
const files = []
|
|
83
|
+
const walk = (dir, prefix) => {
|
|
84
|
+
for (const name of readdirSync(dir).sort()) {
|
|
85
|
+
const full = join(dir, name)
|
|
86
|
+
const rel = prefix ? `${prefix}/${name}` : name
|
|
87
|
+
const st = statSync(full)
|
|
88
|
+
if (st.isDirectory()) {
|
|
89
|
+
if (rel === 'meta') continue // schema custody + no-public-catalog
|
|
90
|
+
walk(full, rel)
|
|
91
|
+
} else if (st.isFile()) {
|
|
92
|
+
if (rel.endsWith('.map')) continue // sourcemaps: dev-only, not CDN-served
|
|
93
|
+
const bytes = readFileSync(full)
|
|
94
|
+
files.push({
|
|
95
|
+
path: rel,
|
|
96
|
+
content_type: contentTypeFor(rel),
|
|
97
|
+
size: st.size,
|
|
98
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
walk(distDir, '')
|
|
104
|
+
return files
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Entry-last upload order (practical atomicity — see header). */
|
|
108
|
+
export function uploadOrder(files) {
|
|
109
|
+
const entry = files.filter((f) => f.path === ENTRY_PATH)
|
|
110
|
+
const rest = files.filter((f) => f.path !== ENTRY_PATH)
|
|
111
|
+
return [...rest, ...entry]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The gateway serve URL for a file of a registered foundation version.
|
|
116
|
+
* Mirrors the backend storage convention: scope WITHOUT the '@'.
|
|
117
|
+
* Prefer the plan response's `serve_base` when present.
|
|
118
|
+
*/
|
|
119
|
+
export function gatewayUrl(apiBase, name, version, path) {
|
|
120
|
+
const m = /^@([^/]+)\/(.+)$/.exec(name)
|
|
121
|
+
const scope = m ? m[1] : ''
|
|
122
|
+
const base = m ? m[2] : name
|
|
123
|
+
const origin = apiBase.replace(/\/$/, '')
|
|
124
|
+
return `${origin}/gateway/foundation/${scope}/${base}/${version}/${path}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Deliver a foundation's code: plan, upload (entry last), verify (direct
|
|
129
|
+
* mode). Returns a result object; throws only on plan-level failures.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} opts
|
|
132
|
+
* @param {string} opts.apiBase - registry origin (e.g. http://localhost:8080)
|
|
133
|
+
* @param {string} opts.token - bearer (same session as register)
|
|
134
|
+
* @param {string} opts.name - '@scope/name'
|
|
135
|
+
* @param {string} opts.version - the registered semver
|
|
136
|
+
* @param {string} opts.distDir - the built dist/ directory
|
|
137
|
+
* @param {Array} [opts.files] - pre-collected file list (default: collect)
|
|
138
|
+
* @param {(msg: string) => void} [opts.onProgress]
|
|
139
|
+
* @returns {Promise<{ mode: string, uploaded: string[], failed: Array<{path, status, detail}>, verified: boolean|null, serveBase: string|null }>}
|
|
140
|
+
*/
|
|
141
|
+
export async function uploadFoundationCode({
|
|
142
|
+
apiBase,
|
|
143
|
+
token,
|
|
144
|
+
name,
|
|
145
|
+
version,
|
|
146
|
+
distDir,
|
|
147
|
+
files,
|
|
148
|
+
onProgress = () => {},
|
|
149
|
+
}) {
|
|
150
|
+
const list = files || collectDistFiles(distDir)
|
|
151
|
+
if (!list.length) {
|
|
152
|
+
return { mode: 'none', uploaded: [], failed: [], verified: null, serveBase: null }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const origin = apiBase.replace(/\/$/, '')
|
|
156
|
+
const planRes = await fetch(`${origin}/dev/registry/code-uploads`, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
159
|
+
body: JSON.stringify({
|
|
160
|
+
name,
|
|
161
|
+
version,
|
|
162
|
+
files: list.map(({ path, content_type, size, sha256 }) => ({
|
|
163
|
+
path,
|
|
164
|
+
content_type,
|
|
165
|
+
size,
|
|
166
|
+
// Optional integrity hint (ignored by the v1 backend; flows so a
|
|
167
|
+
// future checksum-bearing presign needs no CLI change).
|
|
168
|
+
sha256,
|
|
169
|
+
})),
|
|
170
|
+
}),
|
|
171
|
+
})
|
|
172
|
+
if (!planRes.ok) {
|
|
173
|
+
const body = await planRes.text().catch(() => '')
|
|
174
|
+
const err = new Error(
|
|
175
|
+
`code-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
|
|
176
|
+
)
|
|
177
|
+
err.status = planRes.status
|
|
178
|
+
throw err
|
|
179
|
+
}
|
|
180
|
+
const plan = await planRes.json()
|
|
181
|
+
const targets = new Map((plan.uploads || []).map((u) => [u.path, u]))
|
|
182
|
+
const serveBase = plan.serve_base || null
|
|
183
|
+
// The ONE mode-aware bit: direct-mode PUTs are bearer-authed uniwebd
|
|
184
|
+
// routes; presigned URLs are self-authorizing and must NOT carry a
|
|
185
|
+
// bearer (foreign auth headers can break signed-request validation).
|
|
186
|
+
const authHeaders = plan.mode === 'direct' ? { Authorization: `Bearer ${token}` } : {}
|
|
187
|
+
|
|
188
|
+
const uploaded = []
|
|
189
|
+
const failed = []
|
|
190
|
+
for (const file of uploadOrder(list)) {
|
|
191
|
+
const target = targets.get(file.path)
|
|
192
|
+
if (!target) {
|
|
193
|
+
failed.push({ path: file.path, status: 0, detail: 'no upload target in plan' })
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
const bytes = readFileSync(join(distDir, file.path))
|
|
197
|
+
try {
|
|
198
|
+
const res = await fetch(new URL(target.url, origin), {
|
|
199
|
+
method: target.method || 'PUT',
|
|
200
|
+
// x-uniweb-sha256: optional integrity guard — direct mode verifies
|
|
201
|
+
// the received bytes and 400s on mismatch (corruption-in-flight).
|
|
202
|
+
headers: { ...(target.headers || {}), ...authHeaders, 'x-uniweb-sha256': file.sha256 },
|
|
203
|
+
body: bytes,
|
|
204
|
+
})
|
|
205
|
+
if (res.ok) {
|
|
206
|
+
uploaded.push(file.path)
|
|
207
|
+
onProgress(`${file.path} (${file.size} bytes)`)
|
|
208
|
+
} else {
|
|
209
|
+
failed.push({
|
|
210
|
+
path: file.path,
|
|
211
|
+
status: res.status,
|
|
212
|
+
detail: (await res.text().catch(() => '')).slice(0, 200),
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
} catch (err) {
|
|
216
|
+
failed.push({ path: file.path, status: 0, detail: err.message })
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Direct mode: prove the version is live — fetch the entry back and
|
|
221
|
+
// compare bytes (the channel's e2e proof, made a default).
|
|
222
|
+
let verified = null
|
|
223
|
+
const entry = list.find((f) => f.path === ENTRY_PATH)
|
|
224
|
+
if (plan.mode === 'direct' && entry && !failed.length) {
|
|
225
|
+
try {
|
|
226
|
+
// serve_base is origin-relative in direct mode — resolve against the
|
|
227
|
+
// registry origin before fetching.
|
|
228
|
+
const url = serveBase
|
|
229
|
+
? new URL(`${serveBase.replace(/\/$/, '')}/${ENTRY_PATH}`, origin).toString()
|
|
230
|
+
: gatewayUrl(origin, name, version, ENTRY_PATH)
|
|
231
|
+
const res = await fetch(url)
|
|
232
|
+
if (res.ok) {
|
|
233
|
+
const served = Buffer.from(await res.arrayBuffer())
|
|
234
|
+
const local = readFileSync(join(distDir, ENTRY_PATH))
|
|
235
|
+
verified = served.equals(local)
|
|
236
|
+
} else {
|
|
237
|
+
verified = false
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
verified = false
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { mode: plan.mode || 'direct', uploaded, failed, verified, serveBase }
|
|
245
|
+
}
|
package/src/utils/config.js
CHANGED
|
@@ -22,24 +22,14 @@ import yaml from 'js-yaml'
|
|
|
22
22
|
import { filterCmd } from './pm.js'
|
|
23
23
|
import { writeJsonPreservingStyleAsync } from './json-file.js'
|
|
24
24
|
|
|
25
|
-
// ──
|
|
26
|
-
|
|
27
|
-
// Production defaults — regular users get these out of the box.
|
|
28
|
-
// REGISTRY hosts platform operations (publish, foundations, runtime, admin):
|
|
29
|
-
// moved to hosting.uniweb.app in the CDN migration (Phase 4c, 2026-05-04).
|
|
30
|
-
// BACKEND hosts the PHP user-facing surface (login, account, orgs, billing,
|
|
31
|
-
// publish-authorize).
|
|
32
|
-
const PRODUCTION_BACKEND_URL = 'https://www.uniweb.app'
|
|
33
|
-
const PRODUCTION_REGISTRY_URL = 'https://hosting.uniweb.app'
|
|
25
|
+
// ── Backend origin ─────────────────────────────────────────────
|
|
34
26
|
|
|
35
27
|
/**
|
|
36
|
-
* Read ~/.uniweb/config.json for persistent
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* Example ~/.uniweb/config.json:
|
|
40
|
-
* { "backendUrl": "http://127.0.0.1:8002", "registryUrl": "http://localhost:4001" }
|
|
28
|
+
* Read ~/.uniweb/config.json for a persistent backend-origin override (e.g.
|
|
29
|
+
* `{ "registryApiUrl": "http://localhost:8081" }`) — consumed by
|
|
30
|
+
* getRegistryApiBaseUrl() below.
|
|
41
31
|
*
|
|
42
|
-
* @returns {{
|
|
32
|
+
* @returns {{ registryApiUrl?: string }}
|
|
43
33
|
*/
|
|
44
34
|
let _cliConfig = undefined
|
|
45
35
|
function readCliConfig() {
|
|
@@ -60,36 +50,13 @@ function readCliConfig() {
|
|
|
60
50
|
}
|
|
61
51
|
|
|
62
52
|
/**
|
|
63
|
-
* Get the
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*/
|
|
68
|
-
export function getBackendUrl() {
|
|
69
|
-
return process.env.UNIWEB_BACKEND_URL
|
|
70
|
-
|| readCliConfig().backendUrl
|
|
71
|
-
|| PRODUCTION_BACKEND_URL
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Get the registry API URL (Cloudflare Worker or local unicloud).
|
|
76
|
-
*
|
|
77
|
-
* Priority: env var > ~/.uniweb/config.json > production default
|
|
78
|
-
* @returns {string}
|
|
79
|
-
*/
|
|
80
|
-
export function getRegistryUrl() {
|
|
81
|
-
return process.env.UNIWEB_REGISTRY_URL
|
|
82
|
-
|| readCliConfig().registryUrl
|
|
83
|
-
|| PRODUCTION_REGISTRY_URL
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Get the new registry backend's API base origin — DISTINCT from the
|
|
88
|
-
* legacy PHP getBackendUrl(). `register` POSTs to {origin}/dev/registry/register
|
|
89
|
-
* and the new-backend `login` to {origin}/dev/auth/login.
|
|
53
|
+
* Get the backend's API base origin. `register` POSTs to
|
|
54
|
+
* {origin}/dev/registry/register, `login` to {origin}/dev/auth/login, etc.
|
|
55
|
+
* (BackendClient.resolveBackendOrigin layers the --backend/--registry flag on
|
|
56
|
+
* top of this.)
|
|
90
57
|
*
|
|
91
|
-
* Priority: UNIWEB_REGISTER_URL's origin
|
|
92
|
-
*
|
|
58
|
+
* Priority: UNIWEB_REGISTER_URL's origin > ~/.uniweb/config.json registryApiUrl
|
|
59
|
+
* > local default.
|
|
93
60
|
* @returns {string}
|
|
94
61
|
*/
|
|
95
62
|
export function getRegistryApiBaseUrl() {
|
|
@@ -24,12 +24,23 @@
|
|
|
24
24
|
import { existsSync } from 'node:fs'
|
|
25
25
|
import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises'
|
|
26
26
|
import { join } from 'node:path'
|
|
27
|
+
import { homedir } from 'node:os'
|
|
27
28
|
import { createServer } from 'node:http'
|
|
28
29
|
import { randomBytes } from 'node:crypto'
|
|
29
|
-
import { getAuthDir, isExpired } from './auth.js'
|
|
30
30
|
|
|
31
31
|
const LOGIN_PATH = '/dev/auth/login'
|
|
32
32
|
|
|
33
|
+
/** The shared ~/.uniweb credential directory. */
|
|
34
|
+
export function getAuthDir() {
|
|
35
|
+
return join(homedir(), '.uniweb')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True when a stored session's `expiresAt` is in the past (absent → never expires). */
|
|
39
|
+
export function isExpired(auth) {
|
|
40
|
+
if (!auth?.expiresAt) return false
|
|
41
|
+
return new Date(auth.expiresAt) < new Date()
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
/**
|
|
34
45
|
* Path to the register-scoped credential file (~/.uniweb/registry-auth.json).
|
|
35
46
|
* Reuses the legacy auth dir (the ~/.uniweb home is shared infrastructure);
|
|
@@ -334,6 +345,29 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
|
|
|
334
345
|
const { isNonInteractive } = await import('./interactive.js')
|
|
335
346
|
const nonInteractive = isNonInteractive(args)
|
|
336
347
|
|
|
348
|
+
// `--token <bearer>` seeds + verifies a session non-interactively (verified
|
|
349
|
+
// against /dev/auth/me before it's stored, so an invalid token fails loudly
|
|
350
|
+
// instead of poisoning the session file). Distinct from the per-command
|
|
351
|
+
// `--token` (ephemeral, never stored) and from UNIWEB_TOKEN env.
|
|
352
|
+
const { readFlagValue } = await import('./args.js')
|
|
353
|
+
const tokenFlag = readFlagValue(args, '--token')
|
|
354
|
+
if (tokenFlag) {
|
|
355
|
+
let account
|
|
356
|
+
try {
|
|
357
|
+
account = await fetchMe({ apiBase, token: tokenFlag })
|
|
358
|
+
} catch (err) {
|
|
359
|
+
console.error(`\x1b[31m✗\x1b[0m Token rejected by ${apiBase}: ${err.message}`)
|
|
360
|
+
process.exit(1)
|
|
361
|
+
}
|
|
362
|
+
const record = { token: tokenFlag }
|
|
363
|
+
if (account?.uuid) record.uuid = account.uuid
|
|
364
|
+
if (account?.username) record.username = account.username
|
|
365
|
+
if (account?.handle) record.handle = account.handle
|
|
366
|
+
await writeRegistryAuth(record)
|
|
367
|
+
console.log(`\x1b[32m✓\x1b[0m Logged in${account?.username ? ` as \x1b[1m${account.username}\x1b[0m` : ''}${apiBase ? ` (${apiBase})` : ''}`)
|
|
368
|
+
return record
|
|
369
|
+
}
|
|
370
|
+
|
|
337
371
|
let method = args.includes('--browser') ? 'browser'
|
|
338
372
|
: args.includes('--password') ? 'password'
|
|
339
373
|
: args.includes('--token-paste') ? 'token-paste'
|