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,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
|
+
}
|