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.
@@ -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
+ }
@@ -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
- // ── Platform URLs ──────────────────────────────────────────────
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 URL overrides.
37
- * Platform developers use this to point CLI to local servers.
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 {{ backendUrl?: string, registryUrl?: string }}
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 PHP backend URL.
64
- *
65
- * Priority: env var > ~/.uniweb/config.json > production default
66
- * @returns {string}
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 (the env users already set for
92
- * register) > ~/.uniweb/config.json registryApiUrl > local default.
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'