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.
- package/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- 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
|
+
}
|
package/src/utils/env.js
ADDED
|
@@ -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
|