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.
@@ -2,27 +2,35 @@
2
2
  * New-backend org operations for the publish-scope bootstrap — used by
3
3
  * `uniweb register`'s scope resolution and the `uniweb org` command.
4
4
  *
5
- * One resource (Bearer auth, new backend); the verb selects the operation:
6
- * GET /dev/orgs → [{ handle, is_primary }] (your orgs, primary first)
7
- * POST /dev/orgs { handle } → { handle, uuid, is_primary } (creator becomes a
8
- * member, primary iff their first)
5
+ * One resource (Bearer auth, the CLI's /dev lane); the verb selects the op:
9
6
  *
10
- * Handle grammar + reserved scopes are validated client-side for a clean prompt;
11
- * the server is the backstop (409 taken-or-reserved, 422 bad grammar).
7
+ * GET /dev/orgs {
8
+ * account_handle: string|null, // the caller's account handle
9
+ * personal_org_exists: bool, // does @<account_handle> exist as an org
10
+ * orgs: [{ handle, is_primary }] // memberships, primary first;
11
+ * } // handle-less units filtered server-side
12
+ *
13
+ * POST /dev/orgs { handle } → { handle, uuid, is_primary }
14
+ *
15
+ * The PERSONAL org needs no flag: handles live in ONE global namespace across
16
+ * accounts + orgs and only the owner can create the org matching their account
17
+ * handle — so `org.handle === account_handle` is a sound derivation, and the
18
+ * lazy personal claim is just a create. Org creation requires NO second factor
19
+ * on any lane (the 2FA gate lives at escalation points, not here).
20
+ *
21
+ * Failure shapes (branch on STATUS; details are human display, not contract):
22
+ * 422 = handle grammar; 409 = taken / reserved / belongs to another account
23
+ * (one status, server detail says which). Reserved names are SERVER-curated —
24
+ * deliberately not replicated here; the 409 detail carries the answer.
25
+ *
26
+ * Handle grammar (pre-validated for a fast prompt): lowercase alphanumerics +
27
+ * hyphens, 3–39 chars, no leading/trailing hyphen (consecutive hyphens are
28
+ * allowed).
12
29
  */
13
30
 
14
31
  const ORGS_PATH = '/dev/orgs'
15
32
 
16
- // Reserved registry scopes a dev cannot own — the system + standard namespaces
17
- // (uwx-format scope model). Grammatically valid, so the server returns 409
18
- // "taken" for these; we block them earlier for a clearer message.
19
- const RESERVED_HANDLES = new Set(['uniweb', 'std'])
20
-
21
- // Handle grammar (backend): lowercase alphanumerics + internal hyphens, 3–39 chars,
22
- // no leading/trailing hyphen. The server is authoritative on reserved names (409).
23
- const HANDLE_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/
24
- const HANDLE_MIN = 3
25
- const HANDLE_MAX = 39
33
+ const HANDLE_RE = /^[a-z0-9][a-z0-9-]{1,37}[a-z0-9]$/
26
34
 
27
35
  /** Strip a leading `@` and any `/suffix`, returning the bare handle segment. */
28
36
  export function bareHandle(scope) {
@@ -30,34 +38,44 @@ export function bareHandle(scope) {
30
38
  }
31
39
 
32
40
  /**
33
- * Validate a handle client-side. Returns an error string, or null when valid.
34
- * Accepts an optional leading `@` (stripped before checking).
41
+ * Validate a handle's GRAMMAR client-side (reserved names are the server's
42
+ * call a 409 carries the verdict). Returns an error string, or null.
35
43
  */
36
44
  export function validateHandle(handle) {
37
45
  const h = bareHandle(handle)
38
46
  if (!h) return 'A handle is required.'
39
- if (h.length < HANDLE_MIN || h.length > HANDLE_MAX) return `Handle must be ${HANDLE_MIN}–${HANDLE_MAX} characters.`
40
- if (!HANDLE_RE.test(h)) return 'Use lowercase letters, digits, and internal hyphens only (e.g. acme-co).'
41
- if (RESERVED_HANDLES.has(h)) return `@${h} is reserved.`
47
+ if (!HANDLE_RE.test(h)) {
48
+ return 'Use 3–39 lowercase letters, digits, and hyphens (no leading/trailing hyphen).'
49
+ }
42
50
  return null
43
51
  }
44
52
 
45
53
  /**
46
- * List the authenticated account's org memberships (primary first).
47
- * @returns {Promise<Array<{handle: string, is_primary: boolean}>>}
54
+ * The picker read: memberships + the caller's account handle + whether the
55
+ * personal org already exists.
56
+ * @returns {Promise<{account_handle: string|null, personal_org_exists: boolean, orgs: Array<{handle: string, is_primary: boolean}>}>}
48
57
  */
49
- export async function listOrgs({ apiBase, token }) {
58
+ export async function fetchOrgs({ apiBase, token }) {
50
59
  const res = await fetch(`${apiBase.replace(/\/$/, '')}${ORGS_PATH}`, {
51
60
  headers: { Authorization: `Bearer ${token}` },
52
61
  })
53
62
  if (!res.ok) throw new Error(`Could not list your orgs: HTTP ${res.status} ${res.statusText}`)
54
63
  const data = await res.json().catch(() => null)
55
- return Array.isArray(data) ? data : []
64
+ return {
65
+ account_handle: data?.account_handle ?? null,
66
+ personal_org_exists: data?.personal_org_exists === true,
67
+ orgs: Array.isArray(data?.orgs) ? data.orgs : [],
68
+ }
69
+ }
70
+
71
+ /** Back-compat alias: the membership rows only. */
72
+ export async function listOrgs(opts) {
73
+ return (await fetchOrgs(opts)).orgs
56
74
  }
57
75
 
58
76
  /**
59
- * Create an org. The creating account becomes a member (primary iff it's their
60
- * first), so it's immediately publishable.
77
+ * Create an org (the lazy personal claim is exactly this create the
78
+ * owner-exception admits `handle === account_handle` for the owner).
61
79
  * @returns {Promise<{handle: string, uuid?: string, is_primary?: boolean}>}
62
80
  */
63
81
  export async function createOrg({ apiBase, token, handle }) {
@@ -67,56 +85,58 @@ export async function createOrg({ apiBase, token, handle }) {
67
85
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
68
86
  body: JSON.stringify({ handle: h }),
69
87
  })
70
- if (res.status === 409) {
71
- const e = new Error(`@${h} is already taken (or reserved). Pick another.`)
72
- e.status = 409
73
- throw e
74
- }
75
- if (res.status === 422) {
76
- const e = new Error(`@${h} is not a valid handle.`)
77
- e.status = 422
78
- throw e
79
- }
80
- if (!res.ok) {
81
- let detail = ''
82
- try { detail = (await res.text()).slice(0, 200) } catch { /* ignore */ }
83
- const e = new Error(`Could not create @${h}: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`)
84
- e.status = res.status
85
- throw e
86
- }
87
- return res.json()
88
+ if (res.ok) return res.json()
89
+
90
+ // Surface the server's human detail — a 409 distinguishes taken vs
91
+ // reserved vs belongs-to-another-account; a 422 names the grammar rule.
92
+ let detail = ''
93
+ try {
94
+ const body = await res.json()
95
+ detail = body?.detail || ''
96
+ } catch { /* non-JSON body — keep the generic line */ }
97
+ const fallback =
98
+ res.status === 409 ? `@${h} is not available.`
99
+ : res.status === 422 ? `@${h} is not a valid handle.`
100
+ : `Could not create @${h}: HTTP ${res.status}`
101
+ const e = new Error(detail || fallback)
102
+ e.status = res.status
103
+ throw e
88
104
  }
89
105
 
90
106
  /**
91
107
  * Derive the publish scope from login membership when none was supplied:
92
- * 1 org use it (confirm once)
93
- * 0 orgs → offer to create one (proposes the account handle when set, else
94
- * prompts — never username-derived; cold-start per handle-origin (ii))
95
- * N orgs → pick
108
+ * 1 org confirm (1) or pick (N), personal org labeled + first
109
+ * 0 orgs → the first-publish picker (personal default, lazily claimed)
96
110
  * Returns the chosen bare handle (no `@`), or null if cancelled. Persists
97
111
  * nothing — the caller records it. Bails in non-interactive mode.
98
112
  *
99
113
  * @param {Object} p
100
114
  * @param {string} p.apiBase
101
115
  * @param {string} p.token
102
- * @param {string|null} [p.accountHandle] - the account's handle (may be null)
116
+ * @param {string|null} [p.accountHandle] - fallback only; the server's
117
+ * account_handle from the orgs read is authoritative
103
118
  * @param {string[]} [p.args] - argv slice; checked for --non-interactive
104
119
  * @returns {Promise<string|null>}
105
120
  */
106
121
  export async function deriveScope({ apiBase, token, accountHandle = null, args = [] }) {
107
- const orgs = await listOrgs({ apiBase, token })
122
+ const envelope = await fetchOrgs({ apiBase, token })
123
+ const { orgs } = envelope
124
+ const personal = envelope.account_handle || (accountHandle ? bareHandle(accountHandle) : null)
125
+ const personalOrgExists = envelope.personal_org_exists
108
126
  const { isNonInteractive } = await import('./interactive.js')
109
127
  const nonInteractive = isNonInteractive(args)
128
+ const isPersonal = (h) => personal && h === personal
110
129
 
111
130
  if (orgs.length === 1) {
112
131
  const h = orgs[0].handle
132
+ const label = isPersonal(h) ? `your personal org @${h}` : `your org @${h}`
113
133
  if (nonInteractive) {
114
- console.log(`Publishing under your org \x1b[1m@${h}\x1b[0m.`)
134
+ console.log(`Publishing under ${label.replace(`@${h}`, `\x1b[1m@${h}\x1b[0m`)}.`)
115
135
  return h
116
136
  }
117
137
  const prompts = (await import('prompts')).default
118
138
  const { ok } = await prompts({
119
- type: 'confirm', name: 'ok', message: `Publish under your org @${h}?`, initial: true,
139
+ type: 'confirm', name: 'ok', message: `Publish under ${label}?`, initial: true,
120
140
  }, { onCancel: () => { console.log('\nCancelled.'); process.exit(0) } })
121
141
  if (!ok) {
122
142
  console.log('Pass --scope @org, or create another with `uniweb org create <handle>`.')
@@ -126,47 +146,95 @@ export async function deriveScope({ apiBase, token, accountHandle = null, args =
126
146
  }
127
147
 
128
148
  if (orgs.length > 1) {
149
+ // Personal org first; the rest in server order (primary-first).
150
+ const ordered = [...orgs].sort((a, b) => (isPersonal(b.handle) ? 1 : 0) - (isPersonal(a.handle) ? 1 : 0))
129
151
  if (nonInteractive) {
130
- const primary = orgs.find((u) => u.is_primary) || orgs[0]
131
- console.log(`Multiple orgs; using primary \x1b[1m@${primary.handle}\x1b[0m (non-interactive).`)
132
- return primary.handle
152
+ const pick = ordered.find((u) => isPersonal(u.handle)) || orgs.find((u) => u.is_primary) || orgs[0]
153
+ console.log(`Multiple orgs; using \x1b[1m@${pick.handle}\x1b[0m (non-interactive).`)
154
+ return pick.handle
133
155
  }
134
156
  const prompts = (await import('prompts')).default
135
157
  const { choice } = await prompts({
136
158
  type: 'select',
137
159
  name: 'choice',
138
160
  message: 'Publish under which org?',
139
- choices: orgs.map((u) => ({ title: `@${u.handle}${u.is_primary ? ' (primary)' : ''}`, value: u.handle })),
161
+ choices: ordered.map((u) => ({
162
+ title: `@${u.handle}${isPersonal(u.handle) ? ' — your personal org' : u.is_primary ? ' (primary)' : ''}`,
163
+ value: u.handle,
164
+ })),
165
+ initial: 0,
140
166
  }, { onCancel: () => { console.log('\nCancelled.'); process.exit(0) } })
141
167
  return choice || null
142
168
  }
143
169
 
144
- // 0 orgs → offer to create.
170
+ // 0 orgs → the first-publish picker.
145
171
  if (nonInteractive) {
146
172
  console.error('\x1b[31m✗\x1b[0m You have no org to publish under. Create one with `uniweb org create <handle>`, or pass --scope @org.')
147
173
  process.exit(1)
148
174
  }
149
- return offerCreateOrg({ apiBase, token, accountHandle })
175
+ return offerCreateOrg({ apiBase, token, accountHandle: personal, personalOrgExists })
150
176
  }
151
177
 
152
178
  /**
153
- * Cold-start: prompt for a handle (pre-filled with the account handle when it's
154
- * set + valid never the username), create the org, return its handle.
155
- * @returns {Promise<string|null>}
179
+ * Cold-start (0 orgs) the first-publish org choice. Every account handle
180
+ * is a reserved, ready-to-go org handle (globally unique across accounts +
181
+ * orgs; only the owner can claim it), so the default is one keystroke:
182
+ *
183
+ * ? Publish under which org?
184
+ * ❯ @jane — your personal org (created on first publish)
185
+ * A new organization…
186
+ *
187
+ * The personal org is materialized LAZILY here — at first publish, never at
188
+ * signup. Two guards:
189
+ * - no account handle (Service/System accounts — real signups always mint
190
+ * one) → a crisp pointer instead of a prompt;
191
+ * - the personal org exists but the caller is no longer a member
192
+ * (created-then-left) → the lazy claim would 409; don't offer it.
193
+ * Returns the chosen bare handle, or null if cancelled/failed.
156
194
  */
157
- export async function offerCreateOrg({ apiBase, token, accountHandle = null }) {
195
+ export async function offerCreateOrg({ apiBase, token, accountHandle = null, personalOrgExists = false }) {
158
196
  const prompts = (await import('prompts')).default
159
- const suggested = accountHandle && !validateHandle(accountHandle) ? bareHandle(accountHandle) : ''
160
-
161
- console.log("You don't have an org yet — let's create one (it becomes your publish scope).")
162
- const { handle } = await prompts({
163
- type: 'text',
164
- name: 'handle',
165
- message: 'Org handle (e.g. acme):',
166
- initial: suggested,
167
- validate: (v) => validateHandle(v) || true,
197
+ const personal = accountHandle && !validateHandle(accountHandle) ? bareHandle(accountHandle) : null
198
+
199
+ if (!personal) {
200
+ console.error(
201
+ '\x1b[31m✗\x1b[0m This account has no handle (service accounts don\'t get one), so there is no ready-to-go org.\n' +
202
+ ' Log in with a personal account or set a handle in the app — or pass --scope @org / `uniweb org create <handle>`.'
203
+ )
204
+ return null
205
+ }
206
+
207
+ const canClaimPersonal = !personalOrgExists
208
+ const choices = [
209
+ ...(canClaimPersonal
210
+ ? [{ title: `@${personal} — your personal org (created on first publish)`, value: personal }]
211
+ : []),
212
+ { title: 'A new organization…', value: ':new' },
213
+ ]
214
+ if (!canClaimPersonal) {
215
+ console.log(`\x1b[2m@${personal} exists but you're not a member of it — ask its admin, or create another org.\x1b[0m`)
216
+ }
217
+
218
+ const { choice } = await prompts({
219
+ type: 'select',
220
+ name: 'choice',
221
+ message: 'Publish under which org?',
222
+ choices,
223
+ initial: 0,
168
224
  }, { onCancel: () => { console.log('\nCancelled.'); process.exit(0) } })
169
- if (!handle) return null
225
+ if (!choice) return null
226
+
227
+ let handle = choice
228
+ if (choice === ':new') {
229
+ const answer = await prompts({
230
+ type: 'text',
231
+ name: 'handle',
232
+ message: 'Org handle (e.g. acme):',
233
+ validate: (v) => validateHandle(v) || true,
234
+ }, { onCancel: () => { console.log('\nCancelled.'); process.exit(0) } })
235
+ if (!answer.handle) return null
236
+ handle = answer.handle
237
+ }
170
238
 
171
239
  try {
172
240
  const org = await createOrg({ apiBase, token, handle })
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Runtime registration — the framework half of `uniweb runtime register`.
3
+ *
4
+ * Uploads a built `@uniweb/runtime` to the backend so it can serve the runtime
5
+ * version. Mirrors the foundation code lane (utils/code-upload.js): plan →
6
+ * PUT-per-file, one mode-aware auth rule.
7
+ *
8
+ * The runtime is a SYSTEM artifact — registering it requires **@std membership**
9
+ * (a non-@std bearer 403s). Versioned by version alone (no scope/name).
10
+ *
11
+ * A runtime version is ONE unit with two halves, BOTH uploaded here (the backend
12
+ * stages the bytes; where/how it serves them is its decision — we don't assume a
13
+ * serve path, we read `serve_base` back from the plan):
14
+ * - dist/app/** the browser SPA (_importmap/, assets/, index.html,
15
+ * manifest.json) — boots + client-renders a site.
16
+ * - dist/worker-runtime.js + dist/shims/*.js the ssr-edge isolate set (4
17
+ * files): the inlined SSR bundle + its 3 globalThis-bridge
18
+ * shims (react, react/jsx-runtime, @uniweb/core). The
19
+ * isolate can't resolve react without the shims.
20
+ *
21
+ * NOT uploaded: the SSR *orchestrator* (the isolate's `entry.js` boot module). It's
22
+ * a serverless-isolate fetch handler encoding the platform's isolate dispatch
23
+ * protocol — owned by the platform's SSR layer, not a framework artifact. The
24
+ * framework ships the render API the orchestrator imports (worker-runtime.js
25
+ * exports initPrerenderForLocale / renderPage / injectPageContent / hydrateDataStore).
26
+ *
27
+ * Contract — AGREED with the backend (2026-06-14):
28
+ * PLAN POST {apiBase}/dev/runtime
29
+ * { version, files: [{ path, content_type, size, sha256 }] }
30
+ * → { mode, expires_in, serve_base, uploads: [{ path, method, url, headers }] }
31
+ * bearer; @std required (else 403, RFC7807 problem+json, op "runtime-register").
32
+ * UPLOAD PUT each file (direct → bearer; presigned → none; x-uniweb-sha256).
33
+ * MANIFEST LAST — discovery keys a version's existence on manifest.json,
34
+ * so a partial upload never advertises a half-delivered version.
35
+ * SERVE the backend's call (read `serve_base` from the plan; we never construct it).
36
+ */
37
+
38
+ import { createHash } from 'node:crypto'
39
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
40
+ import { join } from 'node:path'
41
+ import { contentTypeFor } from './code-upload.js'
42
+
43
+ const WORKER_RUNTIME = 'worker-runtime.js'
44
+ const SHIMS_DIR = 'shims'
45
+ const MANIFEST = 'manifest.json'
46
+
47
+ function fileEntry(diskPath, path) {
48
+ const bytes = readFileSync(diskPath)
49
+ return {
50
+ path,
51
+ content_type: contentTypeFor(path),
52
+ size: bytes.length,
53
+ sha256: createHash('sha256').update(bytes).digest('hex'),
54
+ diskPath,
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Collect a built runtime's upload set from `distDir` (framework/runtime/dist):
60
+ * everything under `dist/app/**` at the root, plus `dist/worker-runtime.js` and
61
+ * the `dist/shims/*.js` it depends on. Sourcemaps (`*.map`) are excluded (dev-only,
62
+ * not CDN-served). Returns `[]` when `dist/app/` is missing (the runtime isn't
63
+ * built). The worker bundle + shims are collected when present (graceful when not).
64
+ *
65
+ * @param {string} distDir - framework/runtime/dist
66
+ * @returns {Array<{ path, content_type, size, sha256, diskPath }>}
67
+ */
68
+ export function collectRuntimeFiles(distDir) {
69
+ const appDir = join(distDir, 'app')
70
+ if (!existsSync(appDir)) return []
71
+ const files = []
72
+ const walk = (dir, prefix) => {
73
+ for (const name of readdirSync(dir).sort()) {
74
+ const full = join(dir, name)
75
+ const rel = prefix ? `${prefix}/${name}` : name
76
+ const st = statSync(full)
77
+ if (st.isDirectory()) walk(full, rel)
78
+ else if (st.isFile() && !rel.endsWith('.map')) files.push(fileEntry(full, rel))
79
+ }
80
+ }
81
+ walk(appDir, '')
82
+ const worker = join(distDir, WORKER_RUNTIME)
83
+ if (existsSync(worker)) files.push(fileEntry(worker, WORKER_RUNTIME))
84
+ // The SSR isolate's globalThis-bridge shims ride alongside worker-runtime.js,
85
+ // served at shims/*.js. They're part of the ssr-edge artifact SET — the isolate
86
+ // can't resolve `react` without them — so collect the whole dir when present.
87
+ const shimsDir = join(distDir, SHIMS_DIR)
88
+ if (existsSync(shimsDir)) walk(shimsDir, SHIMS_DIR)
89
+ // MANIFEST LAST: the backend keys a version's existence on manifest.json (no
90
+ // server confirm step), so uploading it last means a partial delivery never
91
+ // advertises a half-built version. Reorder regardless of walk order.
92
+ const manifest = files.filter((f) => f.path === MANIFEST)
93
+ if (!manifest.length) return files
94
+ return [...files.filter((f) => f.path !== MANIFEST), ...manifest]
95
+ }
96
+
97
+ /** True when the worker SSR bundle is in the collected set. */
98
+ export function hasWorkerRuntime(files) {
99
+ return files.some((f) => f.path === WORKER_RUNTIME)
100
+ }
101
+
102
+ /** True when any SSR-isolate shim (shims/*.js) is in the collected set. */
103
+ export function hasShims(files) {
104
+ return files.some((f) => f.path.startsWith(`${SHIMS_DIR}/`))
105
+ }
106
+
107
+ /**
108
+ * Plan + upload a built runtime. Throws on a plan-level failure (the caller maps
109
+ * 403 → "@std only"); per-file PUT failures surface in `failed`.
110
+ *
111
+ * @param {object} opts - { apiBase, token, version, distDir, files?, onProgress? }
112
+ * @returns {Promise<{ mode, uploaded: string[], failed: Array, serveBase: string|null }>}
113
+ */
114
+ export async function uploadRuntime({ apiBase, token, version, distDir, files, onProgress = () => {} }) {
115
+ const list = files || collectRuntimeFiles(distDir)
116
+ if (!list.length) return { mode: 'none', uploaded: [], failed: [], serveBase: null }
117
+
118
+ const origin = apiBase.replace(/\/$/, '')
119
+ const planRes = await fetch(`${origin}/dev/runtime`, {
120
+ method: 'POST',
121
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
122
+ body: JSON.stringify({
123
+ version,
124
+ files: list.map(({ path, content_type, size, sha256 }) => ({ path, content_type, size, sha256 })),
125
+ }),
126
+ })
127
+ if (!planRes.ok) {
128
+ const detail = await planRes.text().catch(() => '')
129
+ const err = new Error(`runtime plan rejected: HTTP ${planRes.status}${detail ? ` — ${detail.slice(0, 300)}` : ''}`)
130
+ err.status = planRes.status
131
+ throw err
132
+ }
133
+ const plan = await planRes.json()
134
+ const targets = new Map((plan.uploads || []).map((u) => [u.path, u]))
135
+ // The one mode-aware bit: direct PUTs are bearer-authed backend routes;
136
+ // presigned URLs are self-authorizing and must NOT carry a foreign bearer.
137
+ const authHeaders = plan.mode === 'presigned' ? {} : { Authorization: `Bearer ${token}` }
138
+
139
+ const uploaded = []
140
+ const failed = []
141
+ for (const f of list) {
142
+ const target = targets.get(f.path)
143
+ if (!target) {
144
+ failed.push({ path: f.path, status: 0, detail: 'no upload target in plan' })
145
+ continue
146
+ }
147
+ onProgress(`↑ ${f.path}`)
148
+ let res
149
+ try {
150
+ res = await fetch(new URL(target.url, origin), {
151
+ method: target.method || 'PUT',
152
+ headers: { ...(target.headers || {}), ...authHeaders, 'x-uniweb-sha256': f.sha256 },
153
+ body: readFileSync(f.diskPath),
154
+ })
155
+ } catch (err) {
156
+ failed.push({ path: f.path, status: 0, detail: err.message })
157
+ continue
158
+ }
159
+ if (res.ok) uploaded.push(f.path)
160
+ else failed.push({ path: f.path, status: res.status, detail: (await res.text().catch(() => '')).slice(0, 200) })
161
+ }
162
+ return { mode: plan.mode || 'direct', uploaded, failed, serveBase: plan.serve_base || null }
163
+ }