uniweb 0.37.0 → 0.38.0
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 +7 -7
- package/src/framework-index.json +9 -9
- package/src/templates/fetchers/github.js +7 -18
- package/src/templates/fetchers/npm.js +7 -18
- package/src/templates/fetchers/release.js +7 -19
- package/src/utils/asset-upload.js +12 -2
- package/src/utils/code-upload.js +12 -2
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/registry-auth.js +33 -13
- package/src/utils/registry-orgs.js +29 -9
- package/src/utils/site-data-upload.js +12 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,15 +41,15 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/core": "^0.
|
|
45
|
-
"@uniweb/kit": "^0.15.
|
|
46
|
-
"@uniweb/
|
|
47
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/core": "^0.17.0",
|
|
45
|
+
"@uniweb/kit": "^0.15.4",
|
|
46
|
+
"@uniweb/semantic-parser": "^1.4.0",
|
|
47
|
+
"@uniweb/runtime": "^0.13.6"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "^0.33.0",
|
|
51
50
|
"@uniweb/content-reader": "^1.2.4",
|
|
52
|
-
"@uniweb/semantic-parser": "^1.4.0"
|
|
51
|
+
"@uniweb/semantic-parser": "^1.4.0",
|
|
52
|
+
"@uniweb/build": "^0.34.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/src/framework-index.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"generatedAt": "2026-09-02T18:47:18.047Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
|
-
"version": "0.2.
|
|
6
|
+
"version": "0.2.3",
|
|
7
7
|
"path": "framework/api",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/core"
|
|
10
10
|
]
|
|
11
11
|
},
|
|
12
12
|
"@uniweb/build": {
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.34.0",
|
|
14
14
|
"path": "framework/build",
|
|
15
15
|
"deps": [
|
|
16
16
|
"@uniweb/content-reader",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"deps": []
|
|
35
35
|
},
|
|
36
36
|
"@uniweb/core": {
|
|
37
|
-
"version": "0.
|
|
37
|
+
"version": "0.17.0",
|
|
38
38
|
"path": "framework/core",
|
|
39
39
|
"deps": [
|
|
40
40
|
"@uniweb/semantic-parser",
|
|
@@ -47,14 +47,14 @@
|
|
|
47
47
|
"deps": []
|
|
48
48
|
},
|
|
49
49
|
"@uniweb/icons": {
|
|
50
|
-
"version": "0.4.
|
|
50
|
+
"version": "0.4.8",
|
|
51
51
|
"path": "framework/icons",
|
|
52
52
|
"deps": [
|
|
53
53
|
"@uniweb/core"
|
|
54
54
|
]
|
|
55
55
|
},
|
|
56
56
|
"@uniweb/kit": {
|
|
57
|
-
"version": "0.15.
|
|
57
|
+
"version": "0.15.4",
|
|
58
58
|
"path": "framework/kit",
|
|
59
59
|
"deps": [
|
|
60
60
|
"@uniweb/core",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"deps": []
|
|
74
74
|
},
|
|
75
75
|
"@uniweb/projections": {
|
|
76
|
-
"version": "0.5.
|
|
76
|
+
"version": "0.5.5",
|
|
77
77
|
"path": "framework/projections",
|
|
78
78
|
"deps": [
|
|
79
79
|
"@uniweb/content-writer",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
]
|
|
82
82
|
},
|
|
83
83
|
"@uniweb/runtime": {
|
|
84
|
-
"version": "0.13.
|
|
84
|
+
"version": "0.13.6",
|
|
85
85
|
"path": "framework/runtime",
|
|
86
86
|
"deps": [
|
|
87
87
|
"@uniweb/core",
|
|
@@ -119,7 +119,7 @@
|
|
|
119
119
|
"deps": []
|
|
120
120
|
},
|
|
121
121
|
"@uniweb/unipress": {
|
|
122
|
-
"version": "0.
|
|
122
|
+
"version": "0.9.0",
|
|
123
123
|
"path": "framework/unipress",
|
|
124
124
|
"deps": [
|
|
125
125
|
"@uniweb/build",
|
|
@@ -8,6 +8,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|
|
8
8
|
import { tmpdir } from 'node:os'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import * as tar from 'tar'
|
|
11
|
+
import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Fetch a template from a GitHub repository
|
|
@@ -78,21 +79,9 @@ export async function fetchGitHubTemplate(owner, repo, options = {}) {
|
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const response = await fetch(url, {
|
|
88
|
-
...options,
|
|
89
|
-
signal: AbortSignal.timeout(60000) // 60s timeout for GitHub (can be slow)
|
|
90
|
-
})
|
|
91
|
-
return response
|
|
92
|
-
} catch (err) {
|
|
93
|
-
if (attempt === maxRetries) throw err
|
|
94
|
-
const delay = Math.min(1000 * Math.pow(2, attempt), 10000)
|
|
95
|
-
await new Promise((r) => setTimeout(r, delay))
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
82
|
+
// Retry + timeout live in one place now (`../../utils/fetch-retry.js`).
|
|
83
|
+
// This file carried its own copy, as did the other two fetchers — three
|
|
84
|
+
// byte-identical implementations, and none reachable from the upload paths
|
|
85
|
+
// that had no retry at all. The wrapper keeps this caller's own timeout.
|
|
86
|
+
const fetchWithRetry = (url, options = {}, maxRetries = 3) =>
|
|
87
|
+
sharedFetchWithRetry(url, { ...options }, { retries: maxRetries, timeoutMs: 60000 })
|
|
@@ -8,6 +8,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|
|
8
8
|
import { tmpdir } from 'node:os'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import * as tar from 'tar'
|
|
11
|
+
import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.js'
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Fetch a template from npm registry
|
|
@@ -76,21 +77,9 @@ export async function fetchNpmTemplate(packageName, options = {}) {
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const response = await fetch(url, {
|
|
86
|
-
...options,
|
|
87
|
-
signal: AbortSignal.timeout(30000) // 30s timeout
|
|
88
|
-
})
|
|
89
|
-
return response
|
|
90
|
-
} catch (err) {
|
|
91
|
-
if (attempt === maxRetries) throw err
|
|
92
|
-
const delay = Math.min(1000 * Math.pow(2, attempt), 10000)
|
|
93
|
-
await new Promise((r) => setTimeout(r, delay))
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
80
|
+
// Retry + timeout live in one place now (`../../utils/fetch-retry.js`).
|
|
81
|
+
// This file carried its own copy, as did the other two fetchers — three
|
|
82
|
+
// byte-identical implementations, and none reachable from the upload paths
|
|
83
|
+
// that had no retry at all. The wrapper keeps this caller's own timeout.
|
|
84
|
+
const fetchWithRetry = (url, options = {}, maxRetries = 3) =>
|
|
85
|
+
sharedFetchWithRetry(url, { ...options }, { retries: maxRetries, timeoutMs: 30000 })
|
|
@@ -8,6 +8,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
|
|
|
8
8
|
import { tmpdir } from 'node:os'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import * as tar from 'tar'
|
|
11
|
+
import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.js'
|
|
11
12
|
|
|
12
13
|
// GitHub repository for official templates
|
|
13
14
|
const TEMPLATES_REPO = 'uniweb/templates'
|
|
@@ -222,22 +223,9 @@ async function handleGitHubError(response) {
|
|
|
222
223
|
throw new Error(`GitHub API error: ${response.status}`)
|
|
223
224
|
}
|
|
224
225
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const response = await fetch(url, {
|
|
232
|
-
...options,
|
|
233
|
-
redirect: 'follow',
|
|
234
|
-
signal: AbortSignal.timeout(60000) // 60s timeout
|
|
235
|
-
})
|
|
236
|
-
return response
|
|
237
|
-
} catch (err) {
|
|
238
|
-
if (attempt === maxRetries) throw err
|
|
239
|
-
const delay = Math.min(1000 * Math.pow(2, attempt), 10000)
|
|
240
|
-
await new Promise((r) => setTimeout(r, delay))
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
226
|
+
// Retry + timeout live in one place now (`../../utils/fetch-retry.js`).
|
|
227
|
+
// This file carried its own copy, as did the other two fetchers — three
|
|
228
|
+
// byte-identical implementations, and none reachable from the upload paths
|
|
229
|
+
// that had no retry at all. The wrapper keeps this caller's own timeout.
|
|
230
|
+
const fetchWithRetry = (url, options = {}, maxRetries = 3) =>
|
|
231
|
+
sharedFetchWithRetry(url, { redirect: 'follow', ...options }, { retries: maxRetries, timeoutMs: 60000 })
|
|
@@ -30,6 +30,7 @@ import { createHash } from 'node:crypto'
|
|
|
30
30
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
31
31
|
import { join } from 'node:path'
|
|
32
32
|
import { contentTypeFor } from './code-upload.js'
|
|
33
|
+
import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* Walk a built site's `dist/assets/` and produce the upload file list. `path` is
|
|
@@ -202,11 +203,20 @@ export async function uploadSiteAssets({
|
|
|
202
203
|
try {
|
|
203
204
|
// The plan's url may be origin-relative (direct mode → the backend) or
|
|
204
205
|
// absolute (presigned → storage); new URL() resolves both.
|
|
205
|
-
|
|
206
|
+
// ⭐ **Retried.** A single connection-level failure used to fail the whole
|
|
207
|
+
// verb after every other file had already gone up. The PUT is idempotent
|
|
208
|
+
// here — same bytes, same target, verified by `x-uniweb-sha256` on receipt
|
|
209
|
+
// — so repeating it is safe by construction, which is why this opts in to
|
|
210
|
+
// `retryOnStatus` rather than inheriting it.
|
|
211
|
+
//
|
|
212
|
+
// ⚠️ 120s per attempt, not the helper's 30s default: this bounds the time
|
|
213
|
+
// to send a whole BODY, and aborting a legitimately slow upload only to
|
|
214
|
+
// retry it is worse than not timing out at all.
|
|
215
|
+
putRes = await fetchWithRetry(new URL(up.url, origin), {
|
|
206
216
|
method: up.method || 'PUT',
|
|
207
217
|
headers,
|
|
208
218
|
body: src.bytes ?? readFileSync(src.diskPath)
|
|
209
|
-
})
|
|
219
|
+
}, { timeoutMs: 120_000, retryOnStatus: isTransientStatus })
|
|
210
220
|
} catch (err) {
|
|
211
221
|
failed.push({ path: src.path, status: 0, detail: err.message })
|
|
212
222
|
continue
|
package/src/utils/code-upload.js
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
import { createHash } from 'node:crypto'
|
|
42
42
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
43
43
|
import { join } from 'node:path'
|
|
44
|
+
import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
|
|
44
45
|
|
|
45
46
|
// Extension → declared content type. Extension-honest by construction (Vite
|
|
46
47
|
// output); anything unknown ships as octet-stream.
|
|
@@ -287,7 +288,16 @@ export async function uploadFoundationCode({
|
|
|
287
288
|
}
|
|
288
289
|
const bytes = readFileSync(join(distDir, file.path))
|
|
289
290
|
try {
|
|
290
|
-
|
|
291
|
+
// ⭐ **Retried.** A single connection-level failure used to fail the whole
|
|
292
|
+
// verb after every other file had already gone up. The PUT is idempotent
|
|
293
|
+
// here — same bytes, same target, verified by `x-uniweb-sha256` on receipt
|
|
294
|
+
// — so repeating it is safe by construction, which is why this opts in to
|
|
295
|
+
// `retryOnStatus` rather than inheriting it.
|
|
296
|
+
//
|
|
297
|
+
// ⚠️ 120s per attempt, not the helper's 30s default: this bounds the time
|
|
298
|
+
// to send a whole BODY, and aborting a legitimately slow upload only to
|
|
299
|
+
// retry it is worse than not timing out at all.
|
|
300
|
+
const res = await fetchWithRetry(new URL(target.url, origin), {
|
|
291
301
|
method: target.method || 'PUT',
|
|
292
302
|
// x-uniweb-sha256: optional integrity guard — direct mode verifies
|
|
293
303
|
// the received bytes and 400s on mismatch (corruption-in-flight).
|
|
@@ -297,7 +307,7 @@ export async function uploadFoundationCode({
|
|
|
297
307
|
'x-uniweb-sha256': file.sha256
|
|
298
308
|
},
|
|
299
309
|
body: bytes
|
|
300
|
-
})
|
|
310
|
+
}, { timeoutMs: 120_000, retryOnStatus: isTransientStatus })
|
|
301
311
|
if (res.ok) {
|
|
302
312
|
uploaded.push(file.path)
|
|
303
313
|
onProgress(`${file.path} (${file.size} bytes)`)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fetch` with bounded retry and a timeout. **The one implementation.**
|
|
3
|
+
*
|
|
4
|
+
* ## Why this file exists
|
|
5
|
+
*
|
|
6
|
+
* There were three, byte-identical apart from a timeout constant — one each in
|
|
7
|
+
* `templates/fetchers/{npm,release,github}.js` — and **none of them was reachable
|
|
8
|
+
* from the code that needed it most.** The three upload paths (`code-upload`,
|
|
9
|
+
* `asset-upload`, `site-data-upload`) had no retry at all, so a single
|
|
10
|
+
* connection-level failure on any one file failed the whole `register` / `push`
|
|
11
|
+
* / `publish` after every other file had already gone up.
|
|
12
|
+
*
|
|
13
|
+
* ⭐ **Measured by the `flows` lane, 2026-09-02:** roughly one red per full suite
|
|
14
|
+
* run, landing on a *different flow each time* and passing on re-run every time —
|
|
15
|
+
* the signature of an unretried transient, with the network as the variable
|
|
16
|
+
* rather than any flow. Their symptom was recorded as "not diagnosed, may be the
|
|
17
|
+
* manor, the CDN, or the connection"; it was none of those. It was us.
|
|
18
|
+
*
|
|
19
|
+
* ## ⚠️ What a retry does NOT do
|
|
20
|
+
*
|
|
21
|
+
* It masks whatever drops the connection. That is the right trade for a genuine
|
|
22
|
+
* transient and the wrong one for a systematic fault: if the failure rate ever
|
|
23
|
+
* climbs, a retry turns a fast red into a slow one and hides the cause. **Do not
|
|
24
|
+
* read a green run as evidence the network is healthy.**
|
|
25
|
+
*
|
|
26
|
+
* ## Retrying a PUT is safe HERE, and not in general
|
|
27
|
+
*
|
|
28
|
+
* These uploads are idempotent by construction — the same bytes to the same
|
|
29
|
+
* target, with an `x-uniweb-sha256` integrity guard the far side verifies. That
|
|
30
|
+
* is why `retryOnStatus` is **opt-in** rather than the default: a caller has to
|
|
31
|
+
* assert its own idempotence, because this helper cannot know it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const DEFAULT_RETRIES = 3
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
36
|
+
const MAX_BACKOFF_MS = 10_000
|
|
37
|
+
|
|
38
|
+
/** Exponential backoff, capped. Attempt is 1-based. */
|
|
39
|
+
const backoffMs = (attempt) => Math.min(1000 * Math.pow(2, attempt), MAX_BACKOFF_MS)
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string|URL} url
|
|
43
|
+
* @param {RequestInit} [options] - passed to `fetch`; `signal` is supplied here.
|
|
44
|
+
* @param {Object} [config]
|
|
45
|
+
* @param {number} [config.retries=3] - total attempts, not extra ones.
|
|
46
|
+
* @param {number} [config.timeoutMs=30000] - per ATTEMPT, not for the whole call.
|
|
47
|
+
* ⚠️ For an upload this bounds the time to send a whole body, so a large file
|
|
48
|
+
* on a slow link needs a generous value — too short and a legitimately slow
|
|
49
|
+
* upload is aborted and then retried, which is worse than no timeout at all.
|
|
50
|
+
* @param {(status: number) => boolean} [config.retryOnStatus] - opt-in. Without
|
|
51
|
+
* it only a THROWN fetch is retried (a connection-level failure, which is what
|
|
52
|
+
* surfaces as `status: 0` in the upload paths). A caller passing this asserts
|
|
53
|
+
* its request is safe to repeat.
|
|
54
|
+
* @param {(info: {attempt: number, of: number, reason: string, delayMs: number}) => void} [config.onRetry]
|
|
55
|
+
* Called before each wait. ⭐ Worth passing: a retry that no one can see turns
|
|
56
|
+
* a visible failure into an invisible slowdown, which is its own defect.
|
|
57
|
+
* @returns {Promise<Response>}
|
|
58
|
+
*/
|
|
59
|
+
export async function fetchWithRetry(url, options = {}, config = {}) {
|
|
60
|
+
const {
|
|
61
|
+
retries = DEFAULT_RETRIES,
|
|
62
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
63
|
+
retryOnStatus = null,
|
|
64
|
+
onRetry = null,
|
|
65
|
+
} = config
|
|
66
|
+
|
|
67
|
+
let lastErr = null
|
|
68
|
+
|
|
69
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(url, {
|
|
72
|
+
...options,
|
|
73
|
+
signal: options.signal ?? AbortSignal.timeout(timeoutMs),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
if (attempt < retries && retryOnStatus && retryOnStatus(res.status)) {
|
|
77
|
+
const delayMs = backoffMs(attempt)
|
|
78
|
+
onRetry?.({ attempt, of: retries, reason: `HTTP ${res.status}`, delayMs })
|
|
79
|
+
await new Promise((r) => setTimeout(r, delayMs))
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return res
|
|
84
|
+
} catch (err) {
|
|
85
|
+
lastErr = err
|
|
86
|
+
// The last attempt throws to the caller, which keeps this a drop-in for
|
|
87
|
+
// the three template fetchers whose contract was "throws like fetch".
|
|
88
|
+
if (attempt === retries) throw err
|
|
89
|
+
const delayMs = backoffMs(attempt)
|
|
90
|
+
onRetry?.({ attempt, of: retries, reason: err.message, delayMs })
|
|
91
|
+
await new Promise((r) => setTimeout(r, delayMs))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* c8 ignore next 2 -- unreachable: the loop returns or throws on the last attempt */
|
|
96
|
+
throw lastErr
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The statuses worth repeating an idempotent request for: a server that failed
|
|
101
|
+
* to answer this time, or asked us to slow down.
|
|
102
|
+
*
|
|
103
|
+
* ⛔ Never a 4xx other than 429 — a rejection does not improve on repetition,
|
|
104
|
+
* and retrying one turns a clear error into a delayed clear error.
|
|
105
|
+
*
|
|
106
|
+
* @param {number} status
|
|
107
|
+
* @returns {boolean}
|
|
108
|
+
*/
|
|
109
|
+
export const isTransientStatus = (status) => status === 429 || status >= 500
|
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⛔ **HUMAN OUTPUT IN THIS FILE GOES TO STDERR, NEVER STDOUT.**
|
|
3
|
+
*
|
|
4
|
+
* `stdout` is the CLI's DATA channel. `uniweb register --json` promises a single
|
|
5
|
+
* parseable JSON line there, and `register` diverts its own output accordingly —
|
|
6
|
+
* but it calls into this module, which wrote prose to stdout directly, so the
|
|
7
|
+
* promise broke for any caller that piped it.
|
|
8
|
+
*
|
|
9
|
+
* ⭐ Measured by the `flows` lane at `uniweb@0.37.1`: two stray lines ahead of
|
|
10
|
+
* the JSON on the cold path — down from ~35 before the delegated builder's
|
|
11
|
+
* stdout was redirected to fd 2, which is why the two defects share one symptom
|
|
12
|
+
* and only one of them was fixed. Every line here is prose, a prompt, or a
|
|
13
|
+
* cancellation notice; none of it is anything a machine reads. On stderr it is
|
|
14
|
+
* equally visible to a human and invisible to a pipe.
|
|
15
|
+
*
|
|
16
|
+
* ⚖️ This is not a `--json` special case. A utility that cannot see the flag
|
|
17
|
+
* should not be choosing the stream at all — which is exactly how it got the
|
|
18
|
+
* choice wrong. `cli/test/porcelain-stdout.test.js` walks `register`'s import
|
|
19
|
+
* graph and fails on a new `console.log`.
|
|
20
|
+
*/
|
|
1
21
|
/**
|
|
2
22
|
* Registry (new-backend) credential storage + login.
|
|
3
23
|
*
|
|
@@ -212,11 +232,11 @@ export async function ensureRegistryAuth({
|
|
|
212
232
|
}
|
|
213
233
|
|
|
214
234
|
if (stored && isExpired(stored)) {
|
|
215
|
-
console.
|
|
235
|
+
console.error(
|
|
216
236
|
`\x1b[33mSession expired.\x1b[0m ${command} requires a Uniweb account.\n`
|
|
217
237
|
)
|
|
218
238
|
} else {
|
|
219
|
-
console.
|
|
239
|
+
console.error(`${command} requires a Uniweb account.\n`)
|
|
220
240
|
}
|
|
221
241
|
|
|
222
242
|
// Interactive: hand off to the multi-method login picker, reuse its session.
|
|
@@ -273,7 +293,7 @@ async function loginViaPassword({ apiBase, nonInteractive }) {
|
|
|
273
293
|
],
|
|
274
294
|
{
|
|
275
295
|
onCancel: () => {
|
|
276
|
-
console.
|
|
296
|
+
console.error('\nLogin cancelled.')
|
|
277
297
|
process.exit(0)
|
|
278
298
|
}
|
|
279
299
|
}
|
|
@@ -300,7 +320,7 @@ async function loginViaTokenPaste({ apiBase, nonInteractive }) {
|
|
|
300
320
|
},
|
|
301
321
|
{
|
|
302
322
|
onCancel: () => {
|
|
303
|
-
console.
|
|
323
|
+
console.error('\nLogin cancelled.')
|
|
304
324
|
process.exit(0)
|
|
305
325
|
}
|
|
306
326
|
}
|
|
@@ -398,14 +418,14 @@ export async function awaitBrowserCallback({
|
|
|
398
418
|
const { port } = server.address()
|
|
399
419
|
const redirectUri = `http://127.0.0.1:${port}/callback`
|
|
400
420
|
const url = buildUrl(redirectUri)
|
|
401
|
-
console.
|
|
402
|
-
console.
|
|
421
|
+
console.error(`\x1b[36m→\x1b[0m ${openingLabel}`)
|
|
422
|
+
console.error(` \x1b[2m${url}\x1b[0m`)
|
|
403
423
|
const opened = await openBrowser(url)
|
|
404
424
|
if (!opened)
|
|
405
|
-
console.
|
|
425
|
+
console.error(
|
|
406
426
|
'\x1b[33m⚠\x1b[0m Could not open a browser automatically — open the URL above.'
|
|
407
427
|
)
|
|
408
|
-
console.
|
|
428
|
+
console.error(
|
|
409
429
|
`\x1b[2m${waitingLabel || `Waiting (${Math.round(timeoutMs / 1000)}s)…`}\x1b[0m`
|
|
410
430
|
)
|
|
411
431
|
})
|
|
@@ -528,10 +548,10 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
|
|
|
528
548
|
existing.username ||
|
|
529
549
|
existing.handle ||
|
|
530
550
|
(existing.uuid ? `account ${existing.uuid}` : '')
|
|
531
|
-
console.
|
|
551
|
+
console.error(
|
|
532
552
|
`Already logged in${who ? ` as \x1b[1m${who}\x1b[0m` : ''}${apiBase ? ` (${apiBase})` : ''}.`
|
|
533
553
|
)
|
|
534
|
-
console.
|
|
554
|
+
console.error('\x1b[2mContinuing will replace the existing session.\x1b[0m\n')
|
|
535
555
|
}
|
|
536
556
|
|
|
537
557
|
const { isNonInteractive } = await import('./interactive.js')
|
|
@@ -558,7 +578,7 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
|
|
|
558
578
|
if (account?.username) record.username = account.username
|
|
559
579
|
if (account?.handle) record.handle = account.handle
|
|
560
580
|
await writeRegistryAuth(record)
|
|
561
|
-
console.
|
|
581
|
+
console.error(
|
|
562
582
|
`\x1b[32m✓\x1b[0m Logged in${account?.username ? ` as \x1b[1m${account.username}\x1b[0m` : ''}${apiBase ? ` (${apiBase})` : ''}`
|
|
563
583
|
)
|
|
564
584
|
return record
|
|
@@ -604,7 +624,7 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
|
|
|
604
624
|
},
|
|
605
625
|
{
|
|
606
626
|
onCancel: () => {
|
|
607
|
-
console.
|
|
627
|
+
console.error('\nLogin cancelled.')
|
|
608
628
|
process.exit(0)
|
|
609
629
|
}
|
|
610
630
|
}
|
|
@@ -626,7 +646,7 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
|
|
|
626
646
|
}
|
|
627
647
|
|
|
628
648
|
if (record?.token) {
|
|
629
|
-
console.
|
|
649
|
+
console.error(
|
|
630
650
|
`\x1b[32m✓\x1b[0m Logged in${record.username ? ` as \x1b[1m${record.username}\x1b[0m` : ''}${apiBase ? ` (${apiBase})` : ''}`
|
|
631
651
|
)
|
|
632
652
|
}
|
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⛔ **HUMAN OUTPUT IN THIS FILE GOES TO STDERR, NEVER STDOUT.**
|
|
3
|
+
*
|
|
4
|
+
* `stdout` is the CLI's DATA channel. `uniweb register --json` promises a single
|
|
5
|
+
* parseable JSON line there, and `register` diverts its own output accordingly —
|
|
6
|
+
* but it calls into this module, which wrote prose to stdout directly, so the
|
|
7
|
+
* promise broke for any caller that piped it.
|
|
8
|
+
*
|
|
9
|
+
* ⭐ Measured by the `flows` lane at `uniweb@0.37.1`: two stray lines ahead of
|
|
10
|
+
* the JSON on the cold path — down from ~35 before the delegated builder's
|
|
11
|
+
* stdout was redirected to fd 2, which is why the two defects share one symptom
|
|
12
|
+
* and only one of them was fixed. Every line here is prose, a prompt, or a
|
|
13
|
+
* cancellation notice; none of it is anything a machine reads. On stderr it is
|
|
14
|
+
* equally visible to a human and invisible to a pipe.
|
|
15
|
+
*
|
|
16
|
+
* ⚖️ This is not a `--json` special case. A utility that cannot see the flag
|
|
17
|
+
* should not be choosing the stream at all — which is exactly how it got the
|
|
18
|
+
* choice wrong. `cli/test/porcelain-stdout.test.js` walks `register`'s import
|
|
19
|
+
* graph and fails on a new `console.log`.
|
|
20
|
+
*/
|
|
1
21
|
/**
|
|
2
22
|
* New-backend org operations for the publish-scope bootstrap — used by
|
|
3
23
|
* `uniweb register`'s scope resolution and the `uniweb org` command.
|
|
@@ -150,7 +170,7 @@ export async function deriveScope({
|
|
|
150
170
|
const h = orgs[0].handle
|
|
151
171
|
const label = isPersonal(h) ? `your personal org @${h}` : `your org @${h}`
|
|
152
172
|
if (nonInteractive) {
|
|
153
|
-
console.
|
|
173
|
+
console.error(
|
|
154
174
|
`Publishing under ${label.replace(`@${h}`, `\x1b[1m@${h}\x1b[0m`)}.`
|
|
155
175
|
)
|
|
156
176
|
return h
|
|
@@ -165,13 +185,13 @@ export async function deriveScope({
|
|
|
165
185
|
},
|
|
166
186
|
{
|
|
167
187
|
onCancel: () => {
|
|
168
|
-
console.
|
|
188
|
+
console.error('\nCancelled.')
|
|
169
189
|
process.exit(0)
|
|
170
190
|
}
|
|
171
191
|
}
|
|
172
192
|
)
|
|
173
193
|
if (!ok) {
|
|
174
|
-
console.
|
|
194
|
+
console.error(
|
|
175
195
|
'Pass --scope @org, or create another with `uniweb org create <handle>`.'
|
|
176
196
|
)
|
|
177
197
|
return null
|
|
@@ -189,7 +209,7 @@ export async function deriveScope({
|
|
|
189
209
|
ordered.find((u) => isPersonal(u.handle)) ||
|
|
190
210
|
orgs.find((u) => u.is_primary) ||
|
|
191
211
|
orgs[0]
|
|
192
|
-
console.
|
|
212
|
+
console.error(
|
|
193
213
|
`Multiple orgs; using \x1b[1m@${pick.handle}\x1b[0m (non-interactive).`
|
|
194
214
|
)
|
|
195
215
|
return pick.handle
|
|
@@ -208,7 +228,7 @@ export async function deriveScope({
|
|
|
208
228
|
},
|
|
209
229
|
{
|
|
210
230
|
onCancel: () => {
|
|
211
|
-
console.
|
|
231
|
+
console.error('\nCancelled.')
|
|
212
232
|
process.exit(0)
|
|
213
233
|
}
|
|
214
234
|
}
|
|
@@ -281,7 +301,7 @@ export async function offerCreateOrg({
|
|
|
281
301
|
{ title: 'A new organization…', value: ':new' }
|
|
282
302
|
]
|
|
283
303
|
if (!canClaimPersonal) {
|
|
284
|
-
console.
|
|
304
|
+
console.error(
|
|
285
305
|
`\x1b[2m@${personal} exists but you're not a member of it — ask its admin, or create another org.\x1b[0m`
|
|
286
306
|
)
|
|
287
307
|
}
|
|
@@ -296,7 +316,7 @@ export async function offerCreateOrg({
|
|
|
296
316
|
},
|
|
297
317
|
{
|
|
298
318
|
onCancel: () => {
|
|
299
|
-
console.
|
|
319
|
+
console.error('\nCancelled.')
|
|
300
320
|
process.exit(0)
|
|
301
321
|
}
|
|
302
322
|
}
|
|
@@ -314,7 +334,7 @@ export async function offerCreateOrg({
|
|
|
314
334
|
},
|
|
315
335
|
{
|
|
316
336
|
onCancel: () => {
|
|
317
|
-
console.
|
|
337
|
+
console.error('\nCancelled.')
|
|
318
338
|
process.exit(0)
|
|
319
339
|
}
|
|
320
340
|
}
|
|
@@ -325,7 +345,7 @@ export async function offerCreateOrg({
|
|
|
325
345
|
|
|
326
346
|
try {
|
|
327
347
|
const org = await createOrg({ apiBase, token, handle })
|
|
328
|
-
console.
|
|
348
|
+
console.error(
|
|
329
349
|
`\x1b[32m✓\x1b[0m Created \x1b[1m@${org.handle}\x1b[0m — you're a member${org.is_primary ? ' (primary)' : ''}.`
|
|
330
350
|
)
|
|
331
351
|
return org.handle
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
|
|
53
53
|
import { createHash } from 'node:crypto'
|
|
54
54
|
import { humanBytes } from './bytes.js'
|
|
55
|
+
import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
|
|
55
56
|
|
|
56
57
|
/**
|
|
57
58
|
* Plan + upload a site's static collection data files.
|
|
@@ -189,7 +190,16 @@ export async function uploadSiteData({
|
|
|
189
190
|
try {
|
|
190
191
|
// Relative on the direct arm, absolute on presigned — `new URL` resolves
|
|
191
192
|
// both against the origin.
|
|
192
|
-
|
|
193
|
+
// ⭐ **Retried.** A single connection-level failure used to fail the whole
|
|
194
|
+
// verb after every other file had already gone up. The PUT is idempotent
|
|
195
|
+
// here — same bytes, same target, verified by `x-uniweb-sha256` on receipt
|
|
196
|
+
// — so repeating it is safe by construction, which is why this opts in to
|
|
197
|
+
// `retryOnStatus` rather than inheriting it.
|
|
198
|
+
//
|
|
199
|
+
// ⚠️ 120s per attempt, not the helper's 30s default: this bounds the time
|
|
200
|
+
// to send a whole BODY, and aborting a legitimately slow upload only to
|
|
201
|
+
// retry it is worse than not timing out at all.
|
|
202
|
+
const res = await fetchWithRetry(new URL(target.url, origin), {
|
|
193
203
|
method: target.method || 'PUT',
|
|
194
204
|
headers: {
|
|
195
205
|
'Content-Type': 'application/json',
|
|
@@ -197,7 +207,7 @@ export async function uploadSiteData({
|
|
|
197
207
|
...authHeaders
|
|
198
208
|
},
|
|
199
209
|
body: f.bytes
|
|
200
|
-
})
|
|
210
|
+
}, { timeoutMs: 120_000, retryOnStatus: isTransientStatus })
|
|
201
211
|
if (res.ok) {
|
|
202
212
|
uploaded.push(f.path)
|
|
203
213
|
onProgress(`${f.path}`)
|