uniweb 1.0.0 → 1.0.2

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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Workspace `@uniweb/*` dependency survey.
3
+ *
4
+ * Compares the `@uniweb/*` + `uniweb` versions *declared* in every
5
+ * package.json across a workspace against the running CLI's bundled
6
+ * version matrix (`getResolvedVersions`). Shared by `uniweb update`
7
+ * (which fixes the drift) and `uniweb doctor` (which only reports it) so
8
+ * the two never disagree about what "out of date" means.
9
+ *
10
+ * Comparison is on declared specs, not installed (node_modules) versions
11
+ * — that's what's committed and what `git diff` will show after a fix.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { getResolvedVersions } from '../versions.js'
17
+ import { getWorkspacePackages } from './workspace.js'
18
+
19
+ /**
20
+ * Strip a leading semver range operator (^, ~, >=, <, …) so two specs can
21
+ * be compared by their underlying version. Range expressions like
22
+ * ">=0.5 <0.7" aren't fully parsed — the first version-shaped token wins.
23
+ * Sufficient for `@uniweb/*` deps, which use `^x.y.z` / `x.y.z`.
24
+ * @param {string} spec
25
+ * @returns {string}
26
+ */
27
+ export function stripVersionRange(spec) {
28
+ return (
29
+ (spec || '')
30
+ .replace(/^[\^~>=<\s]+/, '')
31
+ .trim()
32
+ .split(/\s+/)[0] || ''
33
+ )
34
+ }
35
+
36
+ /**
37
+ * How far a bump moves, in the terms our versioning actually uses.
38
+ *
39
+ * ⭐ **The MINOR slot is the breaking one — in 1.x as in 0.x** *[ruled
40
+ * 2026-10-11]*. `scripts/framework/publish.js` derives a package's bump from its own
41
+ * commits: a breaking marker means minor, everything else patch. So
42
+ * `^0.14.1 → ^0.16.0` is two breaking releases and `^0.15.0 → ^0.15.2` is not —
43
+ * and until 2026-09-02 `update` printed both as `behind`, in the same colour,
44
+ * and `--yes` applied them without a word.
45
+ *
46
+ * That is the one signal the version scheme exists to send, discarded by the
47
+ * command we tell every project to run — `AGENTS.md` ships that instruction
48
+ * into every scaffold. The `flows` lane crossed `@uniweb/core` `^0.14.1 →
49
+ * ^0.15.0` this way and learned it afterwards, from a changelog.
50
+ *
51
+ * @param {string} from - the currently declared range or version
52
+ * @param {string} to - the version the matrix carries
53
+ * @returns {'patch'|'minor'|'major'|'none'}
54
+ */
55
+ export function bumpClass(from, to) {
56
+ const [aMaj = 0, aMin = 0, aPat = 0] = stripVersionRange(from).split('.').map(Number)
57
+ const [bMaj = 0, bMin = 0, bPat = 0] = stripVersionRange(to).split('.').map(Number)
58
+ if (bMaj !== aMaj) return 'major'
59
+ if (bMin !== aMin) return 'minor'
60
+ if (bPat !== aPat) return 'patch'
61
+ return 'none'
62
+ }
63
+
64
+ /**
65
+ * Is this crossing one a consumer must act on?
66
+ *
67
+ * A major, or a minor — at every major. Our scheme ships a breaking change as a minor
68
+ * bump, in 1.x as in 0.x *[ruled 2026-10-11]*, and the ranges a project carries (`~`)
69
+ * treat the minor slot as the wall: `~1.4.0` admits 1.4.x and refuses 1.5.0, as
70
+ * `^0.14.1` refuses 0.15.0. ⛔ Until then a minor counted only below 1.0, where a caret
71
+ * refuses it — so this would have stopped warning at the first 1.x minor.
72
+ *
73
+ * @param {string} from
74
+ * @param {string} to
75
+ * @returns {boolean}
76
+ */
77
+ export function isBreakingBump(from, to) {
78
+ const cls = bumpClass(from, to)
79
+ return cls === 'major' || cls === 'minor'
80
+ }
81
+
82
+ /**
83
+ * Compare two version specs (range prefix tolerated). Returns 1 / -1 / 0.
84
+ * @param {string} a
85
+ * @param {string} b
86
+ * @returns {number}
87
+ */
88
+ export function compareSemver(a, b) {
89
+ const pa = stripVersionRange(a).split('.').map(Number)
90
+ const pb = stripVersionRange(b).split('.').map(Number)
91
+ for (let i = 0; i < 3; i++) {
92
+ if ((pa[i] || 0) > (pb[i] || 0)) return 1
93
+ if ((pa[i] || 0) < (pb[i] || 0)) return -1
94
+ }
95
+ return 0
96
+ }
97
+
98
+ /**
99
+ * @typedef {object} DepRow
100
+ * @property {string} relDir Workspace-relative dir, or '(root)'.
101
+ * @property {string} section 'dependencies' | 'devDependencies' | 'peerDependencies'
102
+ * @property {string} name Package name (e.g. '@uniweb/core' or 'uniweb').
103
+ * @property {string} current The spec declared in package.json.
104
+ * @property {string} target The spec the running CLI's matrix wants.
105
+ * @property {'aligned'|'behind'|'ahead'} status current vs target.
106
+ */
107
+
108
+ /**
109
+ * Survey a workspace's declared `@uniweb/*` + `uniweb` deps against the
110
+ * running CLI's bundled matrix.
111
+ *
112
+ * @param {string} workspaceDir Absolute path to the workspace root.
113
+ * @returns {Promise<{ targets: Record<string,string>, rows: DepRow[], anyDrift: boolean, anyAhead: boolean }>}
114
+ * `anyDrift` — at least one dep lags the matrix. `anyAhead` — at least
115
+ * one dep is newer than the matrix.
116
+ */
117
+ export async function surveyWorkspaceDeps(workspaceDir) {
118
+ const targets = getResolvedVersions()
119
+ const packages = await getWorkspacePackages(workspaceDir)
120
+ const dirs = ['', ...packages]
121
+ const rows = []
122
+ let anyDrift = false
123
+ let anyAhead = false
124
+
125
+ for (const relDir of dirs) {
126
+ const pkgDir = relDir ? join(workspaceDir, relDir) : workspaceDir
127
+ const pkgPath = join(pkgDir, 'package.json')
128
+ if (!existsSync(pkgPath)) continue
129
+ let pkg
130
+ try {
131
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
132
+ } catch {
133
+ continue
134
+ }
135
+
136
+ for (const sectionName of [
137
+ 'dependencies',
138
+ 'devDependencies',
139
+ 'peerDependencies'
140
+ ]) {
141
+ const section = pkg[sectionName]
142
+ if (!section) continue
143
+ for (const [name, current] of Object.entries(section)) {
144
+ if (!(name.startsWith('@uniweb/') || name === 'uniweb')) continue
145
+ const target = targets[name]
146
+ if (!target) continue
147
+ const cmp = compareSemver(target, current)
148
+ let status
149
+ if (cmp > 0) {
150
+ status = 'behind'
151
+ anyDrift = true
152
+ } else if (cmp < 0) {
153
+ status = 'ahead'
154
+ anyAhead = true
155
+ } else {
156
+ status = 'aligned'
157
+ }
158
+ rows.push({
159
+ relDir: relDir || '(root)',
160
+ section: sectionName,
161
+ name,
162
+ current,
163
+ target,
164
+ status,
165
+ // Classified here rather than at print time so every consumer of a
166
+ // survey row gets the same answer — the report, the summary, and any
167
+ // gate a caller applies.
168
+ bump: status === 'behind' ? bumpClass(current, target) : 'none',
169
+ breaking: status === 'behind' && isBreakingBump(current, target)
170
+ })
171
+ }
172
+ }
173
+ }
174
+
175
+ return { targets, rows, anyDrift, anyAhead }
176
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The deploy wizard — "Where should this site go?"
3
+ *
4
+ * Distinct from `host-prompt.js`, and the split matters. `promptForHost`
5
+ * answers a *build* question — which host-specific helper files should
6
+ * land in `dist/` — so it lists every registered adapter, including
7
+ * shape-only entries like `generic-static`. This module answers a
8
+ * *shipping* question, so it lists destinations you can actually act on
9
+ * and says what acting will do.
10
+ *
11
+ * The bug that motivated the split: the deploy path used to reuse
12
+ * `promptForHost`, offering six adapters when only one implemented a
13
+ * deploy hook. Five of six choices dead-ended on "does not implement a
14
+ * deploy step". A picker must never offer a door that doesn't open — so
15
+ * entries are derived from real adapter capability (`deploy` / `initCi`),
16
+ * not from a registry key list.
17
+ *
18
+ * Two synthetic entries round out the menu:
19
+ * - Uniweb Cloud — not an adapter; it delegates to `uniweb publish`.
20
+ * Listed after the third-party hosts rather than first: the framework
21
+ * is backend-optional and standalone-first, so leading a generic
22
+ * `deploy` with the paid product would read as an upsell.
23
+ * - Somewhere else — delegates to `uniweb export`.
24
+ */
25
+
26
+ import { promptSelect } from './workspace.js'
27
+ import { isNonInteractive } from './interactive.js'
28
+
29
+ export const UNIWEB_DESTINATION = {
30
+ value: { kind: 'uniweb' },
31
+ title: 'Uniweb Cloud · paid, dynamic + visual editing',
32
+ description:
33
+ 'Sync + dynamic SSR, visual editing for content authors, foundation propagation. Runs `uniweb publish`.'
34
+ }
35
+
36
+ export const EXPORT_DESTINATION = {
37
+ value: { kind: 'export' },
38
+ title: 'Somewhere else · export a folder',
39
+ description:
40
+ 'Builds a self-contained dist/ you upload yourself. Runs `uniweb export`.'
41
+ }
42
+
43
+ /**
44
+ * Build the destination list from the adapter registry.
45
+ *
46
+ * @returns {Promise<Array<{value: object, title: string, description: string}>>}
47
+ */
48
+ export async function buildDestinationChoices() {
49
+ const { listAdapters, getAdapter } = await import('@uniweb/build/hosts')
50
+
51
+ const adapters = listAdapters()
52
+ .map((name) => getAdapter(name))
53
+ .filter((a) => a.display?.wizard !== false)
54
+ // Never offer a door that doesn't open.
55
+ .filter(
56
+ (a) => typeof a.deploy === 'function' || typeof a.initCi === 'function'
57
+ )
58
+ .sort((a, b) => (a.display?.order ?? 999) - (b.display?.order ?? 999))
59
+
60
+ const choices = adapters.map((a) => ({
61
+ value: { kind: 'adapter', host: a.name },
62
+ title: `${a.display?.title || a.name} · ${a.display?.qualifier || ''}`
63
+ .trim()
64
+ .replace(/ ·\s*$/, ''),
65
+ description: a.display?.summary || ''
66
+ }))
67
+
68
+ return [...choices, UNIWEB_DESTINATION, EXPORT_DESTINATION]
69
+ }
70
+
71
+ /**
72
+ * Ask *how* to ship to an adapter that supports both paths. Skipped when
73
+ * the adapter only supports one — no point asking a question with a
74
+ * single answer.
75
+ *
76
+ * @returns {Promise<'ci'|'deploy'|null>} null when cancelled.
77
+ */
78
+ async function promptForAction(adapter) {
79
+ const canCi = typeof adapter.initCi === 'function'
80
+ const canDeploy = typeof adapter.deploy === 'function'
81
+
82
+ if (canCi && !canDeploy) return 'ci'
83
+ if (canDeploy && !canCi) return 'deploy'
84
+
85
+ const label = adapter.display?.title || adapter.name
86
+ const previews = adapter.display?.previews
87
+ ? ' Pull requests get their own preview URL.'
88
+ : ''
89
+
90
+ return promptSelect(`${label} — how?`, [
91
+ {
92
+ value: 'ci',
93
+ title: 'Set it up to deploy on every push · recommended',
94
+ description: `Writes a GitHub Actions workflow. One-time setup — after this, pushing to the default branch deploys.${previews}`
95
+ },
96
+ {
97
+ value: 'deploy',
98
+ title: 'Upload from this machine now',
99
+ description: `Builds dist/ here and pushes it with ${adapter.display?.pushWith || 'the host CLI'}.`
100
+ }
101
+ ])
102
+ }
103
+
104
+ /**
105
+ * Run the wizard.
106
+ *
107
+ * @param {object} opts
108
+ * @param {string[]} opts.args — Argv, used only to gate non-interactive mode.
109
+ * @param {string|null} [opts.preselect] — Host name to float to the top, so
110
+ * Enter repeats what deploy.yml already records.
111
+ * @returns {Promise<{kind: string, host?: string, action?: string}|null>}
112
+ * null when the user cancels.
113
+ * @throws {Error} When non-interactive — the caller prints the guidance.
114
+ */
115
+ export async function promptForDestination({
116
+ args = [],
117
+ preselect = null
118
+ } = {}) {
119
+ if (isNonInteractive(args)) {
120
+ throw new Error(
121
+ 'Cannot prompt for a destination when running non-interactively.'
122
+ )
123
+ }
124
+
125
+ let choices = await buildDestinationChoices()
126
+
127
+ // Float the remembered target so Enter does the obvious thing.
128
+ if (preselect) {
129
+ const idx = choices.findIndex(
130
+ (c) => c.value.kind === 'adapter' && c.value.host === preselect
131
+ )
132
+ if (idx > 0)
133
+ choices = [choices[idx], ...choices.filter((_, i) => i !== idx)]
134
+ }
135
+
136
+ const picked = await promptSelect('Where should this site go?', choices)
137
+ if (!picked) return null
138
+ if (picked.kind !== 'adapter') return picked
139
+
140
+ const { getAdapter } = await import('@uniweb/build/hosts')
141
+ const adapter = getAdapter(picked.host)
142
+ const action = await promptForAction(adapter)
143
+ if (!action) return null
144
+
145
+ return { ...picked, action }
146
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Workspace package discovery
3
+ *
4
+ * Walks the workspace globs and classifies each package as a site or
5
+ * foundation using `classifyPackage` from `@uniweb/build` — the canonical
6
+ * classifier shared with the build pipeline. It keys on real signals
7
+ * (site.yml for sites, generated entry for foundations) rather than which
8
+ * `@uniweb/*` packages happen to be in dependencies, so templates whose
9
+ * sites pull runtime transitively through the foundation (e.g., marketing)
10
+ * are classified correctly.
11
+ *
12
+ * Why this lives in its own file: `@uniweb/build` is an OPTIONAL peer
13
+ * dependency of the CLI. The CLI's startup path (`src/index.js` and
14
+ * everything it statically imports) MUST run in environments where
15
+ * `@uniweb/build` is not installed — `npx uniweb create` in a scratch
16
+ * dir, `npm i -g uniweb` before any project exists, etc. Anything that
17
+ * imports `@uniweb/build` therefore must NOT be reachable from the
18
+ * startup graph; it must be loaded dynamically by commands that already
19
+ * require a project context. Keeping discovery in this dedicated module
20
+ * makes that boundary structural rather than conventional.
21
+ */
22
+
23
+ import { existsSync } from 'node:fs'
24
+ import { readFile } from 'node:fs/promises'
25
+ import { join } from 'node:path'
26
+ import { classifyPackage } from '@uniweb/build'
27
+ import { readWorkspaceConfig, resolveGlob } from './config.js'
28
+
29
+ /**
30
+ * Discover foundations in the workspace.
31
+ * @param {string} rootDir - Workspace root directory
32
+ * @returns {Promise<Array<{name: string, path: string}>>}
33
+ */
34
+ export async function discoverFoundations(rootDir) {
35
+ return discoverByKind(rootDir, 'foundation')
36
+ }
37
+
38
+ /**
39
+ * Discover sites in the workspace.
40
+ * @param {string} rootDir - Workspace root directory
41
+ * @returns {Promise<Array<{name: string, path: string}>>}
42
+ */
43
+ export async function discoverSites(rootDir) {
44
+ return discoverByKind(rootDir, 'site')
45
+ }
46
+
47
+ async function discoverByKind(rootDir, kind) {
48
+ const { packages } = await readWorkspaceConfig(rootDir)
49
+ const out = []
50
+
51
+ for (const pattern of packages) {
52
+ const dirs = await resolveGlob(rootDir, pattern)
53
+ for (const dir of dirs) {
54
+ const fullPath = join(rootDir, dir)
55
+ if (classifyPackage(fullPath) !== kind) continue
56
+
57
+ // Read package.json for the package name. Synthesize one from
58
+ // the directory if it's missing or malformed — we still want
59
+ // the package to surface in pickers.
60
+ const pkgPath = join(fullPath, 'package.json')
61
+ let name = dir.split('/').pop()
62
+ if (existsSync(pkgPath)) {
63
+ try {
64
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
65
+ if (pkg.name) name = pkg.name
66
+ } catch {
67
+ // keep directory-derived name
68
+ }
69
+ }
70
+ out.push({ name, path: dir })
71
+ }
72
+ }
73
+
74
+ return out
75
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Internal env-var helpers.
3
+ *
4
+ * UNIWEB_* env vars are escape hatches for developers, operators, and the
5
+ * platform test team — not user-facing settings. Documented in
6
+ * `framework/cli/docs/env-vars.md`. Anything truly user-facing should be a
7
+ * flag, not an env var.
8
+ */
9
+
10
+ /**
11
+ * Parse a boolean-shaped env var. Returns true for "1", "true", "yes" (any
12
+ * case); false otherwise (including unset, empty, or any other string).
13
+ *
14
+ * @param {string} name - Env var name (e.g., "UNIWEB_SKIP_BUILD").
15
+ * @returns {boolean}
16
+ */
17
+ export function parseBoolEnv(name) {
18
+ const raw = process.env[name]
19
+ if (!raw) return false
20
+ const v = String(raw).trim().toLowerCase()
21
+ return v === '1' || v === 'true' || v === 'yes'
22
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Exit once everything this process wrote to stdout and stderr has left it.
3
+ *
4
+ * ⛔ `process.exit()` does not wait for output a pipe has not taken yet. On macOS Node writes
5
+ * to a pipe asynchronously, and an exit drops whatever is still queued: `uniweb site list
6
+ * --json` reached a reading process as half a JSON document, cut at 8192 bytes, and a single
7
+ * 100 000-byte write followed by an exit delivered 65 536 — on stdout and on stderr alike
8
+ * (measured 2026-10-07). Into a terminal or a file, or on Linux, the writes are synchronous
9
+ * and nothing is lost, which is how it went unseen: the reader it bites is a script, `| jq`,
10
+ * or an agent capturing the output.
11
+ *
12
+ * ⭐ An empty write's callback runs once every write before it on that stream has been handed
13
+ * over, so waiting for one per stream is enough.
14
+ *
15
+ * Every exit in `index.js` goes through here — `test/exit-drain.test.js` holds it to that.
16
+ *
17
+ * @param {number} [code]
18
+ * @returns {Promise<never>}
19
+ */
20
+ export async function exitWhenDrained(code = 0) {
21
+ await Promise.all([process.stdout, process.stderr].map(drained))
22
+ process.exit(code)
23
+ }
24
+
25
+ function drained(stream) {
26
+ return new Promise((resolve) => {
27
+ if (!stream || stream.destroyed || stream.writableEnded) return resolve()
28
+ stream.write('', () => resolve())
29
+ })
30
+ }
@@ -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