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,155 @@
1
+ /**
2
+ * Template validation — reads template.json and finds the content directories
3
+ *
4
+ * ⛔ Two things this used to do are retired [2026-10-08]:
5
+ * - `compatible` (alias `uniweb`), a CLI version range a template declared. It
6
+ * was checked only when a caller passed the CLI's version, and none ever
7
+ * did. An official template now comes from the release its CLI was
8
+ * published with (resolver.js), so it cannot be newer than that CLI.
9
+ * - Unwrapping a package folder that keeps its code under `src/` — the layout of
10
+ * the v0.7 releases (`foundation/src/foundation.js`). Such a folder is now
11
+ * REFUSED, naming the fix (validateTemplate). ⛔ The unwrap went in 0.88.0
12
+ * with a note that the official templates were flat, but its condition was any
13
+ * `src/main.js` too, and `extensions/effects/` still relied on it: 0.88.0
14
+ * copied that folder nested, and every project made from `extensions` failed
15
+ * to build, saying only that it found no section types. The template is flat
16
+ * since templates 0.18.6.
17
+ */
18
+
19
+ import fs from 'node:fs/promises'
20
+ import { existsSync } from 'node:fs'
21
+ import path from 'node:path'
22
+
23
+ /**
24
+ * Validation error with structured details
25
+ */
26
+ export class ValidationError extends Error {
27
+ constructor(message, code, details = {}) {
28
+ super(message)
29
+ this.name = 'ValidationError'
30
+ this.code = code
31
+ this.details = details
32
+ }
33
+ }
34
+
35
+ export const ErrorCodes = {
36
+ MISSING_TEMPLATE_JSON: 'MISSING_TEMPLATE_JSON',
37
+ INVALID_TEMPLATE_JSON: 'INVALID_TEMPLATE_JSON',
38
+ MISSING_CONTENT_DIR: 'MISSING_CONTENT_DIR',
39
+ MISSING_REQUIRED_FIELD: 'MISSING_REQUIRED_FIELD',
40
+ NESTED_PACKAGE_CODE: 'NESTED_PACKAGE_CODE'
41
+ }
42
+
43
+ /**
44
+ * Validate a template directory structure and metadata
45
+ *
46
+ * @param {string} templateRoot - Path to the template root (contains template.json)
47
+ * @returns {Object} Parsed and validated template metadata
48
+ */
49
+ export async function validateTemplate(templateRoot) {
50
+ // Check for template.json
51
+ const metadataPath = path.join(templateRoot, 'template.json')
52
+ if (!existsSync(metadataPath)) {
53
+ throw new ValidationError(
54
+ `Missing template.json in ${templateRoot}`,
55
+ ErrorCodes.MISSING_TEMPLATE_JSON,
56
+ { path: templateRoot }
57
+ )
58
+ }
59
+
60
+ // Parse template.json
61
+ let metadata
62
+ try {
63
+ const content = await fs.readFile(metadataPath, 'utf8')
64
+ metadata = JSON.parse(content)
65
+ } catch (err) {
66
+ throw new ValidationError(
67
+ `Invalid template.json: ${err.message}`,
68
+ ErrorCodes.INVALID_TEMPLATE_JSON,
69
+ { path: metadataPath, error: err.message }
70
+ )
71
+ }
72
+
73
+ // Check required fields
74
+ if (!metadata.name) {
75
+ throw new ValidationError(
76
+ 'template.json missing required field: name',
77
+ ErrorCodes.MISSING_REQUIRED_FIELD,
78
+ { field: 'name' }
79
+ )
80
+ }
81
+
82
+ // Format 2: content template — foundation/ and/or site/ directories alongside template.json
83
+ const contentDirs = resolveContentDirs(templateRoot, metadata)
84
+
85
+ if (contentDirs.length === 0) {
86
+ throw new ValidationError(
87
+ `No content directories found in ${templateRoot}. Templates need foundation/ and/or site/ directories alongside template.json.`,
88
+ ErrorCodes.MISSING_CONTENT_DIR,
89
+ { path: templateRoot }
90
+ )
91
+ }
92
+
93
+ // A foundation or extension folder is copied over the package the CLI
94
+ // scaffolds, as it is. Code kept under src/ would land beside the scaffold's
95
+ // own empty sections/, and the project would fail to build saying only that it
96
+ // found no section types — so it stops here, saying what to move.
97
+ for (const { type, name, dir } of contentDirs) {
98
+ if (type !== 'foundation' && type !== 'extension') continue
99
+ if (['main.js', 'foundation.js'].some((file) => existsSync(path.join(dir, 'src', file)))) {
100
+ throw new ValidationError(
101
+ `The template's ${name}/ keeps its code in ${name}/src/, a layout this CLI does not scaffold. ` +
102
+ `A foundation or extension folder holds main.js, sections/ and styles.css at its root: ` +
103
+ `move ${name}/src/'s contents up into ${name}/.`,
104
+ ErrorCodes.NESTED_PACKAGE_CODE,
105
+ { dir }
106
+ )
107
+ }
108
+ }
109
+
110
+ return {
111
+ ...metadata,
112
+ format: 2,
113
+ contentDirs,
114
+ metadataPath
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Resolve content directories from a format 2 template
120
+ *
121
+ * @param {string} templateRoot - Root of the template (contains template.json)
122
+ * @param {Object} metadata - Parsed template.json
123
+ * @returns {Array<Object>} Content directories: [{ type, name, dir, foundation? }]
124
+ */
125
+ export function resolveContentDirs(templateRoot, metadata) {
126
+ const dirs = []
127
+
128
+ if (metadata.packages) {
129
+ // Multi-package template: iterate declared packages
130
+ for (const pkg of metadata.packages) {
131
+ const dir = path.join(templateRoot, pkg.name)
132
+ if (existsSync(dir)) {
133
+ dirs.push({
134
+ type: pkg.type,
135
+ name: pkg.name,
136
+ dir,
137
+ ...(pkg.foundation ? { foundation: pkg.foundation } : {})
138
+ })
139
+ }
140
+ }
141
+ } else {
142
+ // Standard template: look for foundation/ and site/
143
+ const foundationDir = path.join(templateRoot, 'foundation')
144
+ if (existsSync(foundationDir)) {
145
+ dirs.push({ type: 'foundation', name: 'foundation', dir: foundationDir })
146
+ }
147
+
148
+ const siteDir = path.join(templateRoot, 'site')
149
+ if (existsSync(siteDir)) {
150
+ dirs.push({ type: 'site', name: 'site', dir: siteDir })
151
+ }
152
+ }
153
+
154
+ return dirs
155
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * AGENTS.md version stamp utilities
3
+ *
4
+ * The stamp is an HTML comment on the first line: <!-- uniweb-agents v0.8.32 -->
5
+ * Used by `doctor` (freshness check) and `update` (regeneration).
6
+ */
7
+
8
+ import { existsSync, readFileSync } from 'node:fs'
9
+ import { join, dirname } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { getCliVersion } from '../versions.js'
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url))
14
+
15
+ const STAMP_PATTERN = /^<!-- uniweb-agents v([\d.]+) -->/
16
+
17
+ /**
18
+ * Read the version stamp from an AGENTS.md file
19
+ * @param {string} filePath - Absolute path to AGENTS.md
20
+ * @returns {string|null} Version string or null if no stamp
21
+ */
22
+ export function readAgentsVersion(filePath) {
23
+ if (!existsSync(filePath)) return null
24
+ try {
25
+ const firstLine = readFileSync(filePath, 'utf8').split('\n')[0]
26
+ const match = firstLine.match(STAMP_PATTERN)
27
+ return match ? match[1] : null
28
+ } catch {
29
+ return null
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Generate AGENTS.md content with version stamp
35
+ * @returns {string} Full AGENTS.md content with stamp
36
+ */
37
+ export function generateAgentsContent() {
38
+ const partialsDir = join(__dirname, '..', '..', 'partials')
39
+ const agentsContent = readFileSync(join(partialsDir, 'agents.md'), 'utf8')
40
+ return `<!-- uniweb-agents v${getCliVersion()} -->\n${agentsContent}\n`
41
+ }
42
+
43
+ /**
44
+ * ⭐ THE ONE PART OF AGENTS.md A PROJECT KEEPS: its project-notes blocks. `update` rewrites the
45
+ * file from this CLI's partial, so a note written anywhere else in it is lost on the next version.
46
+ * A block runs from a `<!-- project-notes:start -->` line to a `<!-- project-notes:end -->` line,
47
+ * and is carried over verbatim, at the end of the new file. ⛔ Until 2026-10-01 nothing was kept.
48
+ *
49
+ * A marker is a line of its own, outside a code fence — so the partial can name the markers in a
50
+ * sentence without its own text being taken for a block.
51
+ */
52
+ export const PROJECT_NOTES_START = '<!-- project-notes:start -->'
53
+ export const PROJECT_NOTES_END = '<!-- project-notes:end -->'
54
+
55
+ const MARKER_LINE = /^<!--\s*project-notes:(start|end)\s*-->$/
56
+ const FENCE_LINE = /^(`{3,}|~{3,})/
57
+
58
+ /**
59
+ * The project-notes blocks of an AGENTS.md, in order, each from its start line to its end line.
60
+ *
61
+ * @param {string} text
62
+ * @returns {{ blocks: string[] } | { error: string }} an error when the markers do not pair up —
63
+ * where a block ends would be a guess, and a wrong one loses notes
64
+ */
65
+ export function readProjectNotes(text) {
66
+ const blocks = []
67
+ let block = null
68
+ let fence = null
69
+ let lineNumber = 0
70
+ for (const line of String(text ?? '').split(/(?<=\n)/)) {
71
+ lineNumber++
72
+ const trimmed = line.trim()
73
+ const fenceMark = trimmed.match(FENCE_LINE)?.[1]
74
+ if (fenceMark && (!fence || fenceMark.startsWith(fence))) fence = fence ? null : fenceMark
75
+ const marker = !fence && !fenceMark ? trimmed.match(MARKER_LINE)?.[1] : null
76
+
77
+ if (marker === 'start') {
78
+ if (block) return { error: `a second ${PROJECT_NOTES_START} on line ${lineNumber}, before the first one's ${PROJECT_NOTES_END}` }
79
+ block = [line]
80
+ } else if (marker === 'end') {
81
+ if (!block) return { error: `${PROJECT_NOTES_END} on line ${lineNumber} has no ${PROJECT_NOTES_START} before it` }
82
+ block.push(line)
83
+ blocks.push(block.join('').replace(/\s+$/, ''))
84
+ block = null
85
+ } else if (block) {
86
+ block.push(line)
87
+ }
88
+ }
89
+ if (block) return { error: `${PROJECT_NOTES_START} has no ${PROJECT_NOTES_END} after it` }
90
+ return { blocks }
91
+ }
92
+
93
+ /**
94
+ * AGENTS.md as `update` writes it: this CLI's partial, stamped, and the project-notes blocks of the
95
+ * file it replaces after it.
96
+ *
97
+ * @param {string|null} existing - the current AGENTS.md, or null when there is none
98
+ * @returns {{ content: string, kept: number } | { error: string }}
99
+ */
100
+ export function refreshAgentsContent(existing) {
101
+ const fresh = generateAgentsContent()
102
+ if (existing == null) return { content: fresh, kept: 0 }
103
+ const notes = readProjectNotes(existing)
104
+ if (notes.error) return notes
105
+ if (!notes.blocks.length) return { content: fresh, kept: 0 }
106
+ return {
107
+ content: `${fresh.replace(/\s+$/, '')}\n\n${notes.blocks.join('\n\n')}\n`,
108
+ kept: notes.blocks.length,
109
+ }
110
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * argv parsing helpers shared across CLI commands.
3
+ */
4
+
5
+ /**
6
+ * Read `--flag value` from argv. Accepts both `--flag value` and
7
+ * `--flag=value`.
8
+ *
9
+ * Returns:
10
+ * - undefined when the flag is absent
11
+ * - null when the flag is present without a value (last arg, next is
12
+ * another flag, or `--flag=` empty form)
13
+ * - string when the flag carries a value
14
+ *
15
+ * The three-state return lets callers distinguish "not given" (e.g.,
16
+ * fall back to a default) from "given but empty" (e.g., trigger an
17
+ * interactive prompt).
18
+ *
19
+ * @param {string[]} args
20
+ * @param {string} name — Including the leading dashes, e.g. '--host'.
21
+ * @returns {string | null | undefined}
22
+ */
23
+ /**
24
+ * The workspace a site command names — `--org @acme` — and, on a site's first push or
25
+ * publish, the org it is created under.
26
+ *
27
+ * ⛔ `--as-org` is RETIRED (2026-09-23). It mirrored the wire's `?as_org=`, which the
28
+ * `x-uniweb-workspace` header replaced; `flag-guard.js` refuses it and names `--org`.
29
+ *
30
+ * @param {string[]} args
31
+ * @returns {string|null|undefined}
32
+ */
33
+ export function readOrgFlag(args) {
34
+ return readFlagValue(args, '--org')
35
+ }
36
+
37
+ /**
38
+ * Every `--flag` / `-f` token in `args` that `known` does not list.
39
+ *
40
+ * The CLI reads flags by scanning argv for a literal, so an unrecognized flag is
41
+ * not an error — it is *invisible*, and whatever it was meant to change silently
42
+ * keeps its default. The sharp cases were `--backend` and `--token`, which aimed a
43
+ * command and chose its identity; both left the backend commands on 2026-09-21
44
+ * (the login does both now), and the guard names the login when either is passed.
45
+ *
46
+ * Scanning rules, chosen to avoid false positives (a wrong rejection is worse than
47
+ * a missed one — it breaks a working command):
48
+ * - only tokens beginning with `-` are candidates; a VALUE is never one unless it
49
+ * itself looks like a flag, which `readFlagValue` already refuses to consume;
50
+ * - `--flag=value` is checked on the name half;
51
+ * - a bare `--` ends flag scanning, the POSIX convention;
52
+ * - a lone `-` is a value (stdin), not a flag.
53
+ *
54
+ * @param {string[]} args
55
+ * @param {string[]} known - every flag this command accepts, with dashes
56
+ * @returns {string[]} the unrecognized tokens, in order, deduped
57
+ */
58
+ export function findUnknownFlags(args, known) {
59
+ const set = new Set(known)
60
+ const out = []
61
+ for (const raw of args) {
62
+ if (raw === '--') break
63
+ if (raw === '-' || !raw.startsWith('-')) continue
64
+ const name = raw.split('=')[0]
65
+ if (set.has(name) || out.includes(name)) continue
66
+ out.push(name)
67
+ }
68
+ return out
69
+ }
70
+
71
+ /** Levenshtein distance, small and iterative — used only for a suggestion. */
72
+ function editDistance(a, b) {
73
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i)
74
+ for (let i = 1; i <= a.length; i++) {
75
+ let diag = prev[0]
76
+ prev[0] = i
77
+ for (let j = 1; j <= b.length; j++) {
78
+ const tmp = prev[j]
79
+ prev[j] = Math.min(
80
+ prev[j] + 1,
81
+ prev[j - 1] + 1,
82
+ diag + (a[i - 1] === b[j - 1] ? 0 : 1)
83
+ )
84
+ diag = tmp
85
+ }
86
+ }
87
+ return prev[b.length]
88
+ }
89
+
90
+ /**
91
+ * The closest known flag to `flag`, or null when nothing is close enough.
92
+ * The threshold scales with length so `--forcee` → `--force` is offered while two
93
+ * unrelated short flags are not.
94
+ * @param {string} flag
95
+ * @param {string[]} known
96
+ * @returns {string|null}
97
+ */
98
+ export function didYouMean(flag, known) {
99
+ let best = null
100
+ let bestD = Infinity
101
+ for (const k of known) {
102
+ const d = editDistance(flag, k)
103
+ if (d < bestD) {
104
+ bestD = d
105
+ best = k
106
+ }
107
+ }
108
+ const limit = Math.max(2, Math.floor(flag.length / 3))
109
+ return best && bestD <= limit ? best : null
110
+ }
111
+
112
+ export function readFlagValue(args, name) {
113
+ const eqPrefix = name + '='
114
+ for (let i = 0; i < args.length; i++) {
115
+ if (args[i] === name) {
116
+ const next = args[i + 1]
117
+ if (next === undefined || next.startsWith('--')) return null
118
+ return next
119
+ }
120
+ if (args[i].startsWith(eqPrefix)) {
121
+ const v = args[i].slice(eqPrefix.length)
122
+ return v === '' ? null : v
123
+ }
124
+ }
125
+ return undefined
126
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Site asset delivery — the asset lane for `uniweb publish`. After the link
3
+ * build processes a site's media into `dist/assets/`, those bytes are delivered
4
+ * to the backend's content-addressed asset store, and the publish step rewrites
5
+ * the content's local refs to durable serve URLs:
6
+ *
7
+ * 1. PLAN — POST {apiBase}/dev/assets with the file list ({ path,
8
+ * content_type, size, sha256 }). `sha256` is REQUIRED — it is the
9
+ * content address. The response carries one entry per file
10
+ * ({ path, id, ext, present?, method, url, headers }) plus mode:
11
+ * 'direct' (dev — URLs point back at the backend) or 'presigned'
12
+ * (prod — storage PUTs). `id` is the lowercase-hex sha256 of the
13
+ * bytes; we READ it from the response (never depend on id == sha256)
14
+ * so the client stays correct if the derivation ever changes.
15
+ * 2. UPLOAD — PUT each NEW file's raw bytes to its URL with the given headers.
16
+ * Order is irrelevant (unlike code-upload, there is no "entry").
17
+ * Skip-list: an entry the plan marks `present: true` is already in
18
+ * the content-addressed store, so we SKIP the PUT but still record
19
+ * its id+ext for the rewrite. Absent `present` ⇒ false — an older
20
+ * backend without the flag uploads everything (today's behavior).
21
+ *
22
+ * Assets are content-addressed and global WITHIN ONE BACKEND: identical bytes →
23
+ * same id → dedup across that backend's sites and idempotent re-deploys (a
24
+ * re-PUT is a cheap no-op). This mirrors the foundation code lane
25
+ * (utils/code-upload.js); the one structural difference is that the backend
26
+ * MINTS the per-asset id, so the plan response is what the deploy step rewrites
27
+ * content references to.
28
+ *
29
+ * ⛔ **"Global" is the DEDUP SCOPE, not a claim about other backends.** An id,
30
+ * and the presence of the bytes behind it, are facts about the backend that
31
+ * answered the PLAN and about nothing else — where a backend keeps them is its
32
+ * own business and not a shape this lane models. ⚠️ This said only "GLOBAL" until
33
+ * 2026-09-20 and was read as "the same id works on any backend", which made
34
+ * `assets.json` look portable in a multi-backend design. It is not: `sync.json`
35
+ * keeps asset ids per backend (`backends.<origin>.assets`), and the backend ORIGIN
36
+ * is the segregation value the CLI has.
37
+ *
38
+ * ⚠️ Do not infer portability from `id` looking like the sha256 either — we read
39
+ * it from the response precisely so the derivation is never load-bearing here.
40
+ */
41
+
42
+ import { createHash } from 'node:crypto'
43
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
44
+ import { join } from 'node:path'
45
+ import { contentTypeFor } from './code-upload.js'
46
+ import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
47
+
48
+ /**
49
+ * Walk a built site's `dist/assets/` and produce the upload file list. `path` is
50
+ * POSIX-relative to distDir (e.g. `assets/hero-ab12cd34.webp`); `localUrl` is how
51
+ * the built content references the asset (`/assets/...`) — the rewrite key.
52
+ * Returns `[]` when there is no `assets/` dir (an image-free site).
53
+ *
54
+ * @param {string} distDir - the site's built dist/ directory
55
+ * @returns {Array<{ path: string, content_type: string, size: number, sha256: string, localUrl: string, diskPath: string }>}
56
+ */
57
+ export function collectSiteAssets(distDir) {
58
+ const assetsDir = join(distDir, 'assets')
59
+ if (!existsSync(assetsDir)) return []
60
+ const files = []
61
+ const walk = (dir, prefix) => {
62
+ for (const name of readdirSync(dir).sort()) {
63
+ const full = join(dir, name)
64
+ const rel = prefix ? `${prefix}/${name}` : name
65
+ const st = statSync(full)
66
+ if (st.isDirectory()) {
67
+ walk(full, rel)
68
+ } else if (st.isFile()) {
69
+ const bytes = readFileSync(full)
70
+ files.push({
71
+ path: `assets/${rel}`,
72
+ content_type: contentTypeFor(name),
73
+ size: st.size,
74
+ sha256: createHash('sha256').update(bytes).digest('hex'),
75
+ localUrl: `/assets/${rel}`,
76
+ diskPath: full
77
+ })
78
+ }
79
+ }
80
+ }
81
+ walk(assetsDir, '')
82
+ return files
83
+ }
84
+
85
+ /**
86
+ * Deliver a site's assets: plan, then PUT each NEW file. Returns the rewrite map
87
+ * (`localUrl → { id, ext, serveUrl }`) — populated for files that uploaded
88
+ * successfully OR that the plan reports already `present`, so a partial failure
89
+ * never injects a broken serve URL into content. Throws only on a plan-level
90
+ * failure; per-file PUT failures surface in `failed`.
91
+ *
92
+ * `serveUrl` is the backend's canonical, ready-built serve URL for the asset (the
93
+ * plan entry's `serve_url`; content-addressed, lane-independent). Callers embed it
94
+ * verbatim — origin-relative forms included. Nothing composes one: see the note at
95
+ * the foot of this file for why the reconstruction helper was removed.
96
+ *
97
+ * Skip-list (content-addressed dedup): a plan entry with `present: true` is
98
+ * already in the global store — we don't re-PUT it, but we DO record its id+ext
99
+ * so the rewrite covers every asset. A backend that omits the flag (absent ⇒
100
+ * false) uploads everything, exactly as before.
101
+ *
102
+ * @param {object} opts
103
+ * @param {string} opts.apiBase - backend origin (e.g. http://localhost:8080)
104
+ * @param {string} opts.token - bearer (same session as deploy/register)
105
+ * @param {string} opts.distDir - the site's built dist/ directory
106
+ * @param {Array} [opts.files] - pre-collected list (default: collectSiteAssets)
107
+ * @param {(msg: string) => void} [opts.onProgress]
108
+ * @returns {Promise<{ mode: string, uploaded: string[], skipped: string[], failed: Array<{path, status, detail}>, assetsByLocalUrl: Record<string, { id: string, ext: string, serveUrl?: string }> }>}
109
+ */
110
+ export async function uploadSiteAssets({
111
+ apiBase,
112
+ token,
113
+ distDir,
114
+ files,
115
+ siteUuid = null,
116
+ onProgress = () => {}
117
+ }) {
118
+ const list = files || collectSiteAssets(distDir)
119
+ if (!list.length) {
120
+ return {
121
+ mode: 'none',
122
+ uploaded: [],
123
+ skipped: [],
124
+ failed: [],
125
+ assetsByLocalUrl: {}
126
+ }
127
+ }
128
+
129
+ const origin = apiBase.replace(/\/$/, '')
130
+ const planRes = await fetch(`${origin}/dev/assets`, {
131
+ method: 'POST',
132
+ headers: {
133
+ 'Content-Type': 'application/json',
134
+ Authorization: `Bearer ${token}`
135
+ },
136
+ body: JSON.stringify({
137
+ // The owner these bytes are charged to. Storage is metered against the
138
+ // owning entity and reclaimed by deleting it, so an upload with no owner is
139
+ // charged and can never be freed — there is nothing to delete. Callers
140
+ // create the site first precisely so this is never null; it stays optional
141
+ // only so a backend predating the field still works.
142
+ //
143
+ // The wire key is `entity`, not `site`, and deliberately so: it names the
144
+ // entity the upload is FOR. On this lane that is always the site-content
145
+ // entity — hence the local `siteUuid`, which is the more informative name
146
+ // here — but the visual app sends an article on the same route. A field
147
+ // called `site` would be a lie for half its callers.
148
+ ...(siteUuid ? { entity: siteUuid } : {}),
149
+ files: list.map(({ path, content_type, size, sha256 }) => ({
150
+ path,
151
+ content_type,
152
+ size,
153
+ sha256
154
+ }))
155
+ })
156
+ })
157
+ if (!planRes.ok) {
158
+ const detail = await planRes.text().catch(() => '')
159
+ // The plan refuses with an RFC7807 body carrying a machine-readable `reason`.
160
+ // The message keeps its historical shape so anything reading it still works,
161
+ // but the PARSED body has to survive the throw: flattening it to a string
162
+ // here is what made "branch on `reason`" unimplementable and left the old
163
+ // caller no choice but a status regex. Callers read `err.problem`;
164
+ // `describeAssetRefusal` in backend/site-media.js turns it into user-facing
165
+ // lines.
166
+ let problem = null
167
+ if (detail) {
168
+ try {
169
+ const parsed = JSON.parse(detail)
170
+ if (parsed && typeof parsed === 'object') problem = parsed
171
+ } catch {
172
+ /* not a problem document — prose refusal, or an upstream error page */
173
+ }
174
+ }
175
+ const err = new Error(
176
+ `Asset plan failed: HTTP ${planRes.status} ${planRes.statusText}${detail ? ` — ${detail.slice(0, 300)}` : ''}`
177
+ )
178
+ err.status = planRes.status
179
+ err.problem = problem
180
+ throw err
181
+ }
182
+ const plan = await planRes.json()
183
+ const mode = plan.mode || 'direct'
184
+ const uploads = Array.isArray(plan.uploads) ? plan.uploads : []
185
+ const byPath = new Map(list.map((f) => [f.path, f]))
186
+
187
+ const uploaded = []
188
+ const skipped = []
189
+ const failed = []
190
+ const assetsByLocalUrl = {}
191
+
192
+ for (const up of uploads) {
193
+ const src = byPath.get(up.path)
194
+ if (!src) continue // backend echoed a path we didn't send — ignore
195
+
196
+ // Skip-list: the bytes are already in the content-addressed store. No PUT,
197
+ // but still record id+ext so the rewrite covers this asset. `present` absent
198
+ // ⇒ false (older backend) → falls through to the upload path below.
199
+ if (up.present) {
200
+ skipped.push(src.path)
201
+ assetsByLocalUrl[src.localUrl] = {
202
+ id: up.id,
203
+ ext: String(up.ext || '').replace(/^\./, ''),
204
+ serveUrl: up.serve_url
205
+ }
206
+ continue
207
+ }
208
+
209
+ const headers = { ...(up.headers || {}), 'x-uniweb-sha256': src.sha256 }
210
+ // Direct-mode PUTs are authed requests to the backend; presigned URLs are
211
+ // self-signed, so a foreign auth header can break the SigV4 target. This is
212
+ // the only mode-aware line in the client (same rule as code-upload).
213
+ if (mode !== 'presigned') headers.Authorization = `Bearer ${token}`
214
+ onProgress(`↑ ${src.path}`)
215
+ let putRes
216
+ try {
217
+ // The plan's url may be origin-relative (direct mode → the backend) or
218
+ // absolute (presigned → storage); new URL() resolves both.
219
+ // ⭐ **Retried.** A single connection-level failure used to fail the whole
220
+ // verb after every other file had already gone up. The PUT is idempotent
221
+ // here — same bytes, same target, verified by `x-uniweb-sha256` on receipt
222
+ // — so repeating it is safe by construction, which is why this opts in to
223
+ // `retryOnStatus` rather than inheriting it.
224
+ //
225
+ // ⚠️ 120s per attempt, not the helper's 30s default: this bounds the time
226
+ // to send a whole BODY, and aborting a legitimately slow upload only to
227
+ // retry it is worse than not timing out at all.
228
+ putRes = await fetchWithRetry(new URL(up.url, origin), {
229
+ method: up.method || 'PUT',
230
+ headers,
231
+ body: src.bytes ?? readFileSync(src.diskPath)
232
+ }, { timeoutMs: 120_000, retryOnStatus: isTransientStatus })
233
+ } catch (err) {
234
+ failed.push({ path: src.path, status: 0, detail: err.message })
235
+ continue
236
+ }
237
+ if (putRes.ok) {
238
+ uploaded.push(src.path)
239
+ // Authoritative id + ext from the plan; mapped only on a successful PUT.
240
+ assetsByLocalUrl[src.localUrl] = {
241
+ id: up.id,
242
+ ext: String(up.ext || '').replace(/^\./, ''),
243
+ serveUrl: up.serve_url
244
+ }
245
+ } else {
246
+ failed.push({
247
+ path: src.path,
248
+ status: putRes.status,
249
+ detail: await putRes.text().catch(() => '')
250
+ })
251
+ }
252
+ }
253
+
254
+ return { mode, uploaded, skipped, failed, assetsByLocalUrl }
255
+ }
256
+
257
+ // ⛔ `buildAssetUrl(origin, assetBase, id, ext)` was removed 2026-08-17. Do not
258
+ // bring it back.
259
+ //
260
+ // It composed `{assetBase}dist/{id}/base.{ext}` — i.e. it encoded the BACKEND's
261
+ // path layout inside a published CLI, on a release cadence the backend cannot
262
+ // move, defaulting to one hardcoded production host for every deployment. Read
263
+ // the plan entry's `serve_url` instead. It is part of the asset-plan contract:
264
+ // present on EVERY entry, on both the direct and presigned lanes, including
265
+ // entries the plan reports as already `present` — the backend covers that with a
266
+ // test of its own (confirmed 2026-08-17).
267
+ //
268
+ // An entry without one is UNADDRESSABLE — fail, never guess. A composed location
269
+ // is silently wrong wherever the deployment does not match the guess; a missing
270
+ // one is visibly missing.
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Byte formatting for refusal messages.
3
+ *
4
+ * Lives on its own because TWO lanes now print a storage refusal — the asset plan
5
+ * (`backend/site-media.js`) and the site create (`backend/site-sync.js`) — and the
6
+ * numbers must read identically in both. A second copy would drift in units or
7
+ * rounding, and a user comparing "you have 1.1 GiB" against "this needs 100 MB"
8
+ * cannot tell a real gap from a formatting difference.
9
+ */
10
+
11
+ const KIB = 1024
12
+
13
+ /**
14
+ * `1073741824` → `1 GiB`. Returns null for anything that is not a usable byte
15
+ * count, so a refusal that omits an extra prints nothing rather than `undefined`.
16
+ * @param {unknown} n
17
+ * @returns {string|null}
18
+ */
19
+ export function humanBytes(n) {
20
+ if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null
21
+ if (n < KIB) return `${n} B`
22
+ const units = ['KiB', 'MiB', 'GiB', 'TiB']
23
+ let v = n / KIB
24
+ let i = 0
25
+ while (v >= KIB && i < units.length - 1) {
26
+ v /= KIB
27
+ i++
28
+ }
29
+ return `${v >= 10 || Number.isInteger(v) ? Math.round(v) : v.toFixed(1)} ${units[i]}`
30
+ }