uniweb 0.12.26 → 0.12.28

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.
@@ -4,8 +4,14 @@
4
4
  * registry as one names-only `.uwx` document (uwx-format.md §5).
5
5
  *
6
6
  * `uniweb login && uniweb register`. Distinct from `uniweb publish` (which
7
- * targets the legacy unicloud / uniweb-edge platform) — `register` talks to the
8
- * registry over HTTP at a configurable endpoint.
7
+ * targets the legacy platform) — `register` talks to the registry over HTTP at a
8
+ * configurable endpoint.
9
+ *
10
+ * If the foundation's `dist/` is missing or version-stale (the baked schema
11
+ * version differs from package.json), `register` builds it first — the same
12
+ * build-if-stale `uniweb publish` does, so `register` is a full drop-in.
13
+ * Preview paths (`--dry-run`, `-o`) never write to `dist/`; they require a
14
+ * pre-built foundation.
9
15
  *
10
16
  * Run from a foundation, or from a schemas-only package — a package that exports
11
17
  * schemas (e.g. `@uniweb/schemas`, any `@org/schemas`) or a bare `schemas/*.yml`
@@ -13,10 +19,13 @@
13
19
  * standalone (foundation-less); same flags, `--scope` names them.
14
20
  *
15
21
  * Usage:
16
- * uniweb register Build the .uwx and submit it
22
+ * uniweb register Build the .uwx, submit it, then deliver
23
+ * the foundation's dist/ code (plan +
24
+ * upload — see utils/code-upload.js)
17
25
  * uniweb register --scope @org Publish under @org (resolves @/x -> @org/x).
18
26
  * Default: the package's package.json "uniweb.scope".
19
- * uniweb register --dry-run Print the .uwx; submit nothing
27
+ * uniweb register --schema-only Skip the code delivery (schemas land, no dist upload)
28
+ * uniweb register --dry-run Print the .uwx + the code file plan; submit nothing
20
29
  * uniweb register -o foundation.uwx Write the .uwx to a file; submit nothing
21
30
  * uniweb register --registry <url> Override the submit endpoint
22
31
  * uniweb register --token <bearer> Submit with this bearer; skips `uniweb login`
@@ -25,20 +34,38 @@
25
34
  * Auth (submit only): --token <bearer> > UNIWEB_TOKEN > `uniweb login` session.
26
35
  */
27
36
 
37
+ // DEFERRED foundation-registration capabilities (the legacy `uniweb publish` had
38
+ // these; the new backend doesn't yet — captured here so the design intent isn't
39
+ // lost, and the legacy code could be removed). Implement as `register` flags (or
40
+ // backend-side policy) when the need is real:
41
+ //
42
+ // • ACCESS POLICY — legacy `--edit-access open|restricted`. On the old platform
43
+ // this gated who could act on the foundation in the app. Its meaning is
44
+ // unclear for the new model: there is no editing of a foundation's code or
45
+ // schema, so it is most likely a LICENSING / access-control concern (who may
46
+ // use/reference the foundation), not "editing". Revisit as `--access` (or an
47
+ // org/licensing policy on the backend) when foundation licensing lands.
48
+ //
49
+ // • VERSION PROPAGATION — legacy `--propagate`. Opts a newly-registered version
50
+ // into the registry's version-update walk: trusting sites whose policy allows
51
+ // the jump (e.g. auto-patch) adopt it with no rebuild; default was "silent"
52
+ // (stored, nothing moves). The SAME concept applies to `runtime register`
53
+ // (legacy deploy-runtime had `--propagate` too). Implement once the backend
54
+ // has a version-update/propagation policy; until then every register is silent.
55
+
28
56
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
57
+ import { execSync } from 'node:child_process'
29
58
  import { resolve, join } from 'node:path'
30
59
  import { buildRegistryPackage, buildSchemaOnlyPackage } from '@uniweb/build/uwx'
31
60
  import { classifyPackage, isSchemasPackage, collectStandaloneSchemas } from '@uniweb/build'
32
- import { ensureRegistryAuth, readRegistryAuth } from '../utils/registry-auth.js'
61
+ import { readRegistryAuth } from '../utils/registry-auth.js'
62
+ import { collectDistFiles } from '../utils/code-upload.js'
33
63
  import { deriveScope } from '../utils/registry-orgs.js'
64
+ import { BackendClient } from '../backend/client.js'
34
65
  import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
35
66
  import { findWorkspaceRoot, findFoundations, promptSelect } from '../utils/workspace.js'
36
67
  import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
37
68
 
38
- // The backend route is `/dev/registry/register`; the host defaults to a local
39
- // server and is overridable via --registry / UNIWEB_REGISTER_URL (full URL).
40
- const DEFAULT_REGISTER_URL = 'http://localhost:8080/dev/registry/register'
41
-
42
69
  const colors = {
43
70
  reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m',
44
71
  red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[36m',
@@ -126,12 +153,53 @@ async function resolveFoundationDir(args) {
126
153
  process.exit(1)
127
154
  }
128
155
 
156
+ /**
157
+ * Does the foundation's `dist/` need a (re)build before we can register it?
158
+ *
159
+ * Mirrors `uniweb publish`'s build-if-stale so `register` is a full drop-in
160
+ * for the foundation-publish flow. Two staleness signals:
161
+ * - MISSING: no `dist/entry.js` (or the legacy `dist/foundation.js`), or no
162
+ * `dist/meta/schema.json` — nothing built yet.
163
+ * - STALE: the version baked into `dist/meta/schema.json::_self.version`
164
+ * differs from `package.json::version` — a version bump without a rebuild,
165
+ * so the artifact encodes the OLD version while the register intends the
166
+ * NEW one (we'd otherwise submit a schema whose version disagrees with the
167
+ * code we deliver).
168
+ *
169
+ * Returns `{ needs: false }` or `{ needs: true, reason }`.
170
+ *
171
+ * @param {string} targetDir the foundation directory
172
+ */
173
+ export function foundationNeedsBuild(targetDir) {
174
+ const distDir = join(targetDir, 'dist')
175
+ const schemaPath = join(distDir, 'meta', 'schema.json')
176
+ // @uniweb/build emits dist/entry.js; older builds emitted dist/foundation.js.
177
+ const hasArtifact = existsSync(join(distDir, 'entry.js')) || existsSync(join(distDir, 'foundation.js'))
178
+ if (!hasArtifact || !existsSync(schemaPath)) return { needs: true, reason: 'no dist/ found' }
179
+ let pkgVersion = null
180
+ try {
181
+ pkgVersion = JSON.parse(readFileSync(join(targetDir, 'package.json'), 'utf8'))?.version || null
182
+ } catch {
183
+ // No readable package.json version — fall through; a present schema with no
184
+ // version to compare is treated as fresh (the submit path validates names).
185
+ }
186
+ try {
187
+ const peek = JSON.parse(readFileSync(schemaPath, 'utf8'))
188
+ if (peek?._self?.version && pkgVersion && peek._self.version !== pkgVersion) {
189
+ return { needs: true, reason: `package.json version (${pkgVersion}) differs from built schema (${peek._self.version})` }
190
+ }
191
+ } catch {
192
+ return { needs: true, reason: 'dist/meta/schema.json could not be parsed' }
193
+ }
194
+ return { needs: false }
195
+ }
196
+
129
197
  export async function register(args = []) {
130
198
  const dryRun = args.includes('--dry-run')
131
199
  const output = flagValue(args, '-o') || flagValue(args, '--output')
132
200
  const scopeFlag = flagValue(args, '--scope')
133
201
  const tokenFlag = flagValue(args, '--token')
134
- const registryUrl = flagValue(args, '--registry') || process.env.UNIWEB_REGISTER_URL || DEFAULT_REGISTER_URL
202
+ const client = new BackendClient({ originFlag: flagValue(args, '--registry'), token: tokenFlag, args, command: 'Registering' })
135
203
 
136
204
  // Target: a schemas-only package (standalone data-schema register) or a
137
205
  // foundation (foundation + the schemas it renders). A schemas package is only
@@ -146,8 +214,6 @@ export async function register(args = []) {
146
214
  let scope = scopeFlag || pkgScope
147
215
  let scopeSource = scopeFlag ? '--scope' : pkgScope ? 'package.json uniweb.scope' : null
148
216
  const isPreview = !!output || dryRun
149
- const apiBase = new URL(registryUrl).origin
150
- let token = null
151
217
 
152
218
  // Each path supplies a different schema source: the standalone path discovers
153
219
  // the package's own schemas; the foundation path reads its built schema.json.
@@ -166,10 +232,29 @@ export async function register(args = []) {
166
232
  return { exitCode: 2 }
167
233
  }
168
234
  } else {
235
+ // Build-if-stale (mirrors `uniweb publish`): a missing or version-stale
236
+ // dist/ gets (re)built before we read its schema. Preview paths
237
+ // (--dry-run / -o) must not write to dist/, so they require a pre-built
238
+ // foundation and say so instead of building.
239
+ const { needs, reason } = foundationNeedsBuild(targetDir)
240
+ if (needs) {
241
+ if (isPreview) {
242
+ error(`No usable build (${reason}).`)
243
+ log(` Build the foundation first: ${colors.bright}uniweb build${colors.reset}`)
244
+ return { exitCode: 2 }
245
+ }
246
+ info(`${reason} — building the foundation first …`)
247
+ try {
248
+ execSync('npx uniweb build --target foundation', { cwd: targetDir, stdio: 'inherit' })
249
+ } catch (err) {
250
+ error(`Build failed: ${err.message}`)
251
+ return { exitCode: 2 }
252
+ }
253
+ }
169
254
  const schemaPath = join(targetDir, 'dist', 'meta', 'schema.json')
170
255
  if (!existsSync(schemaPath)) {
171
- error('No built schema found (dist/meta/schema.json).')
172
- log(` Build the foundation first: ${colors.bright}uniweb build${colors.reset}`)
256
+ error('No built schema found (dist/meta/schema.json) after build.')
257
+ log(` Build the foundation: ${colors.bright}uniweb build${colors.reset}`)
173
258
  return { exitCode: 2 }
174
259
  }
175
260
  try {
@@ -183,9 +268,9 @@ export async function register(args = []) {
183
268
  // No scope for a real submit → derive it from login membership (list → 1 use /
184
269
  // 0 create / N pick), persist to package.json, and reuse the session token.
185
270
  if (!scope && !isPreview) {
186
- token = tokenFlag || (await ensureRegistryAuth({ apiBase, command: 'Registering', args }))
271
+ const token = await client.token()
187
272
  const sess = await readRegistryAuth()
188
- const derived = await deriveScope({ apiBase, token, accountHandle: sess?.handle || null, args })
273
+ const derived = await deriveScope({ apiBase: client.origin, token, accountHandle: sess?.handle || null, args })
189
274
  if (!derived) return { exitCode: 0 }
190
275
  scope = `@${derived}`
191
276
  scopeSource = 'login'
@@ -229,7 +314,15 @@ export async function register(args = []) {
229
314
  log('')
230
315
  log(json)
231
316
  log('')
232
- info(`Dry run — would submit to ${registryUrl}`)
317
+ info(`Dry run — would submit to ${client.origin}`)
318
+ if (!standalone && !args.includes('--schema-only')) {
319
+ const distFiles = collectDistFiles(join(targetDir, 'dist'))
320
+ log('')
321
+ info(`Would then deliver ${distFiles.length} code file(s) (meta/ excluded):`)
322
+ for (const f of distFiles) {
323
+ log(` ${colors.dim}${f.path} ${f.size} bytes ${f.content_type}${colors.reset}`)
324
+ }
325
+ }
233
326
  return { exitCode: 0 }
234
327
  }
235
328
 
@@ -239,36 +332,88 @@ export async function register(args = []) {
239
332
  log(` ${colors.dim}Without a scope, names stay @/… and the registry rejects them.${colors.reset}`)
240
333
  return { exitCode: 2 }
241
334
  }
242
- // Submit auth: reuse the token from the scope bootstrap if it ran, else
243
- // --tokenUNIWEB_TOKEN stored session login (ensureRegistryAuth).
244
- token = token || tokenFlag || (await ensureRegistryAuth({ apiBase, command: 'Registering', args }))
245
- info(`Submitting to ${colors.dim}${registryUrl}${colors.reset} …`)
335
+ // Submit the client carries the bearer (--token UNIWEB_TOKEN stored
336
+ // sessionlogin), resolved lazily on this first authed call.
337
+ info(`Submitting to ${colors.dim}${client.origin}${colors.reset} …`)
246
338
  let res
247
339
  try {
248
- res = await fetch(registryUrl, {
249
- method: 'POST',
250
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
251
- body: json,
252
- })
340
+ res = await client.register(json)
253
341
  } catch (err) {
254
- error(`Could not reach the registry at ${registryUrl}: ${err.message}`)
342
+ error(`Could not reach the registry at ${client.origin}: ${err.message}`)
255
343
  log(` ${colors.dim}Set the endpoint with --registry <url> or UNIWEB_REGISTER_URL.${colors.reset}`)
256
344
  return { exitCode: 2 }
257
345
  }
346
+ let alreadyRegistered = false
258
347
  if (!res.ok) {
259
- error(`Registry rejected the submission: HTTP ${res.status} ${res.statusText}`)
260
- if (res.status === 401 || res.status === 403) {
261
- log(` ${colors.dim}The registry didn't accept your credentials — it may use different ones than \`uniweb login\`.${colors.reset}`)
262
- log(` ${colors.dim}Supply a registry bearer with --token <bearer> (or UNIWEB_TOKEN); an existing one may be wrong or expired.${colors.reset}`)
263
- }
264
348
  const body = await res.text().catch(() => '')
265
- if (body) log(` ${colors.dim}${body.slice(0, 500)}${colors.reset}`)
266
- return { exitCode: 1 }
349
+ // Resume path: a registered version is immutable, so re-running after a
350
+ // partial code delivery hits the duplicate rejection here — a STRUCTURED
351
+ // 409 (problem+json, title "Conflict") — and proceeds to phase 2 (the
352
+ // code-uploads plan authorizes against the REGISTERED version; completed
353
+ // files are idempotent no-ops).
354
+ const isDuplicate = !standalone && res.status === 409
355
+ if (isDuplicate) {
356
+ alreadyRegistered = true
357
+ info(`${colors.dim}Schema for this version is already registered — resuming code delivery.${colors.reset}`)
358
+ } else {
359
+ error(`Registry rejected the submission: HTTP ${res.status} ${res.statusText}`)
360
+ if (res.status === 401 || res.status === 403) {
361
+ log(` ${colors.dim}The registry didn't accept your credentials — it may use different ones than \`uniweb login\`.${colors.reset}`)
362
+ log(` ${colors.dim}Supply a registry bearer with --token <bearer> (or UNIWEB_TOKEN); an existing one may be wrong or expired.${colors.reset}`)
363
+ }
364
+ if (body) log(` ${colors.dim}${body.slice(0, 500)}${colors.reset}`)
365
+ return { exitCode: 1 }
366
+ }
367
+ }
368
+ if (!alreadyRegistered) {
369
+ success(
370
+ standalone
371
+ ? `Registered ${defined.length} data schema(s)${scope ? ` under ${scope}` : ''}`
372
+ : `Registered ${schema._self.name}@${schema._self.version}${defined.length ? ` + ${defined.length} data schema(s)` : ''}`
373
+ )
374
+ }
375
+
376
+ // Phase 2 — deliver the foundation's code (plan → PUT-per-file, entry
377
+ // last; contract: foundation-code-upload.md). Schemas-
378
+ // only packages have no dist; --schema-only skips deliberately.
379
+ if (!standalone && !args.includes('--schema-only')) {
380
+ const distDir = join(targetDir, 'dist')
381
+ // The registry's vocabulary is the SCOPED name (`@org/name`). A scoped
382
+ // package name passes through; a bare one gets the chosen scope — the
383
+ // same resolution the .uwx submission applied.
384
+ const bareName = schema._self.name
385
+ const name = bareName.startsWith('@') ? bareName : `${scope}/${bareName}`
386
+ const version = schema._self.version
387
+ info(`Delivering code for ${colors.bright}${name}@${version}${colors.reset} …`)
388
+ try {
389
+ const result = await client.uploadFoundationCode({
390
+ name,
391
+ version,
392
+ distDir,
393
+ onProgress: (m) => log(` ${colors.dim}${m}${colors.reset}`),
394
+ })
395
+ if (result.failed.length) {
396
+ error(`${result.failed.length} file(s) failed to upload:`)
397
+ for (const f of result.failed) {
398
+ log(` ${colors.red}${f.path}${colors.reset} ${colors.dim}HTTP ${f.status} ${f.detail}${colors.reset}`)
399
+ }
400
+ log(` ${colors.dim}Re-run \`uniweb register\` to resume — completed files are safe no-ops.${colors.reset}`)
401
+ return { exitCode: 1 }
402
+ }
403
+ const where = result.serveBase || 'the registry gateway'
404
+ if (result.verified === true) {
405
+ success(`Code delivered (${result.uploaded.length} files) — entry verified live at ${colors.dim}${where}${colors.reset}`)
406
+ } else if (result.verified === false) {
407
+ error('Code uploaded but the entry verification fetch did not match — investigate before using this version.')
408
+ return { exitCode: 1 }
409
+ } else {
410
+ success(`Code delivered (${result.uploaded.length} files, ${result.mode} mode)`)
411
+ }
412
+ } catch (err) {
413
+ error(`Code delivery failed: ${err.message}`)
414
+ log(` ${colors.dim}The schema registration above succeeded; re-run \`uniweb register\` to deliver the code.${colors.reset}`)
415
+ return { exitCode: 1 }
416
+ }
267
417
  }
268
- success(
269
- standalone
270
- ? `Registered ${defined.length} data schema(s)${scope ? ` under ${scope}` : ''}`
271
- : `Registered ${schema._self.name}@${schema._self.version}${defined.length ? ` + ${defined.length} data schema(s)` : ''}`
272
- )
273
418
  return { exitCode: 0 }
274
419
  }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * uniweb runtime register — upload a built `@uniweb/runtime` to the backend so it
3
+ * can serve the runtime version. The runtime is a SYSTEM artifact: registering it
4
+ * requires **@std membership** (a non-@std bearer 403s). Foundations pin a runtime
5
+ * version (`dist/runtime-pin.json`); that version must be registered, or `uniweb
6
+ * register` of such a foundation fails.
7
+ *
8
+ * Contract AGREED with the backend (2026-06-14): `POST /dev/runtime`, @std-gated,
9
+ * manifest-last. Wire + the two-half artifact set (SPA + ssr-edge isolate, the
10
+ * orchestrator stays platform-owned): utils/runtime-upload.js.
11
+ *
12
+ * Usage:
13
+ * uniweb runtime register From framework/runtime (or --path <dir>)
14
+ * uniweb runtime register --path <dir> The @uniweb/runtime package dir
15
+ * uniweb runtime register --version <v> Override dist/app/manifest.json's version
16
+ * uniweb runtime register --backend <url> Override the backend origin
17
+ * uniweb runtime register --token <bearer> Auth bearer (skips `uniweb login`)
18
+ * uniweb runtime register --dry-run Print the version + file plan; upload nothing
19
+ */
20
+
21
+ import { readFileSync } from 'node:fs'
22
+ import { resolve, join } from 'node:path'
23
+
24
+ import { BackendClient } from '../backend/client.js'
25
+ import { collectRuntimeFiles, hasWorkerRuntime, hasShims } from '../utils/runtime-upload.js'
26
+ import { readFlagValue } from '../utils/args.js'
27
+
28
+ const c = {
29
+ reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
30
+ cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m',
31
+ }
32
+ const say = {
33
+ ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
34
+ info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
35
+ warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
36
+ err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
37
+ dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
38
+ }
39
+
40
+ // The runtime package dir: --path, else the cwd when it IS @uniweb/runtime.
41
+ function resolveRuntimeDir(args) {
42
+ const pathFlag = readFlagValue(args, '--path')
43
+ if (pathFlag) return resolve(pathFlag)
44
+ try {
45
+ if (JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')).name === '@uniweb/runtime') {
46
+ return process.cwd()
47
+ }
48
+ } catch {
49
+ // no readable package.json — fall through
50
+ }
51
+ return null
52
+ }
53
+
54
+ export async function runtime(args = []) {
55
+ const sub = args[0]
56
+ if (sub !== 'register') {
57
+ say.err(sub ? `Unknown subcommand: runtime ${sub}` : 'Usage: uniweb runtime register')
58
+ say.dim('uniweb runtime register — upload the built @uniweb/runtime to the backend (@std only).')
59
+ return { exitCode: sub ? 1 : 0 }
60
+ }
61
+ const rest = args.slice(1)
62
+ const dryRun = rest.includes('--dry-run')
63
+
64
+ const runtimeDir = resolveRuntimeDir(rest)
65
+ if (!runtimeDir) {
66
+ say.err('Not an @uniweb/runtime package.')
67
+ say.dim('Run from framework/runtime, or pass --path <dir>.')
68
+ return { exitCode: 2 }
69
+ }
70
+ const distDir = join(runtimeDir, 'dist')
71
+ const files = collectRuntimeFiles(distDir)
72
+ if (!files.length) {
73
+ say.err('No built runtime found (dist/app/).')
74
+ say.dim('Build it first: `pnpm build` in framework/runtime.')
75
+ return { exitCode: 2 }
76
+ }
77
+
78
+ // Version from the SPA build's manifest (the backend keys the version on it);
79
+ // --version overrides, parity with `uniwebd runtime install --version`.
80
+ let version = readFlagValue(rest, '--version')
81
+ if (!version) {
82
+ try {
83
+ version = JSON.parse(readFileSync(join(distDir, 'app', 'manifest.json'), 'utf8')).version
84
+ } catch (err) {
85
+ say.err(`Could not read dist/app/manifest.json: ${err.message}`)
86
+ return { exitCode: 2 }
87
+ }
88
+ if (!version) {
89
+ say.err('dist/app/manifest.json has no "version" field — rebuild the runtime.')
90
+ return { exitCode: 2 }
91
+ }
92
+ }
93
+ // The ssr-edge artifact is a SET: worker-runtime.js + its shims/*.js. Warn when
94
+ // the set is absent or incomplete (a worker without shims can't resolve react).
95
+ if (!hasWorkerRuntime(files)) {
96
+ say.warn("dist/worker-runtime.js is missing — the SSR isolate bundle won't be uploaded.")
97
+ say.dim('Build it first: `pnpm build:worker` in @uniweb/runtime (after `pnpm build`).')
98
+ } else if (!hasShims(files)) {
99
+ say.warn('dist/worker-runtime.js is present but dist/shims/ is missing — the SSR isolate set is incomplete.')
100
+ say.dim('The isolate resolves react/jsx-runtime/@uniweb/core through those shims; re-run `pnpm build:worker`.')
101
+ }
102
+
103
+ if (dryRun) {
104
+ say.info(`Would register ${c.bold}@uniweb/runtime@${version}${c.reset} (${files.length} files):`)
105
+ for (const f of files) say.dim(`${f.path} ${f.size} bytes ${f.content_type}`)
106
+ return { exitCode: 0 }
107
+ }
108
+
109
+ const client = new BackendClient({
110
+ originFlag: readFlagValue(rest, '--backend') || readFlagValue(rest, '--registry'),
111
+ token: readFlagValue(rest, '--token') || undefined,
112
+ args: rest,
113
+ command: 'Registering the runtime',
114
+ })
115
+
116
+ say.info(`Registering ${c.bold}@uniweb/runtime@${version}${c.reset} → ${c.dim}${client.origin}${c.reset} (${files.length} files)…`)
117
+ let result
118
+ try {
119
+ result = await client.uploadRuntime({ version, distDir, files, onProgress: (m) => say.dim(m) })
120
+ } catch (err) {
121
+ if (err.status === 403) {
122
+ say.err('Not authorized — registering a runtime version requires @std membership.')
123
+ return { exitCode: 1 }
124
+ }
125
+ say.err(`Runtime registration failed: ${err.message}`)
126
+ say.dim('Set the origin with --backend <url>; auth with `uniweb login` or --token <bearer>.')
127
+ return { exitCode: 1 }
128
+ }
129
+ if (result.failed.length) {
130
+ say.err(`${result.failed.length} file(s) failed to upload:`)
131
+ for (const f of result.failed) say.dim(`${f.path} — HTTP ${f.status} ${f.detail}`)
132
+ say.dim('Re-run `uniweb runtime register` to resume — completed files dedupe.')
133
+ return { exitCode: 1 }
134
+ }
135
+ console.log('')
136
+ say.ok(`Registered ${c.bold}@uniweb/runtime@${version}${c.reset} (${result.uploaded.length} files, ${result.mode} mode)`)
137
+ if (result.serveBase) say.dim(`served at ${result.serveBase}`)
138
+ return { exitCode: 0 }
139
+ }
140
+
141
+ export default runtime