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,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `site/snapshot.yml` — how `uniweb snapshot` composes a site's preview image,
|
|
3
|
+
* kept with the site so the next run reproduces the same look.
|
|
4
|
+
*
|
|
5
|
+
* It is the command's defaults for this site, and flags override it. Nothing else
|
|
6
|
+
* reads it: the build, `push` and `pull` all leave a site's root files alone
|
|
7
|
+
* unless they name them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
12
|
+
import yaml from 'js-yaml'
|
|
13
|
+
|
|
14
|
+
import { didYouMean } from './args.js'
|
|
15
|
+
|
|
16
|
+
export const SETTINGS_FILE = 'snapshot.yml'
|
|
17
|
+
|
|
18
|
+
/** In the order they are written. */
|
|
19
|
+
export const SETTING_KEYS = [
|
|
20
|
+
'route',
|
|
21
|
+
'layout',
|
|
22
|
+
'tone',
|
|
23
|
+
'gap',
|
|
24
|
+
'overlap',
|
|
25
|
+
'strip',
|
|
26
|
+
'side',
|
|
27
|
+
'frame',
|
|
28
|
+
'size',
|
|
29
|
+
'scale',
|
|
30
|
+
'quality',
|
|
31
|
+
'hide',
|
|
32
|
+
'out'
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const HEADER = [
|
|
36
|
+
'# How `uniweb snapshot` composes this site\'s preview image.',
|
|
37
|
+
'# Flags override these; `uniweb snapshot <flags> --save` writes them here.',
|
|
38
|
+
''
|
|
39
|
+
].join('\n')
|
|
40
|
+
|
|
41
|
+
export class SettingsError extends Error {}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The settings in `<siteDir>/snapshot.yml`, or `{}` when there is none. `out` comes
|
|
45
|
+
* back absolute, resolved against the site folder.
|
|
46
|
+
*/
|
|
47
|
+
export function readSnapshotSettings(siteDir) {
|
|
48
|
+
const file = join(siteDir, SETTINGS_FILE)
|
|
49
|
+
if (!existsSync(file)) return { file, settings: {} }
|
|
50
|
+
|
|
51
|
+
let data
|
|
52
|
+
try {
|
|
53
|
+
data = yaml.load(readFileSync(file, 'utf8'))
|
|
54
|
+
} catch (err) {
|
|
55
|
+
throw new SettingsError(`${SETTINGS_FILE} is not valid YAML: ${err.message.split('\n')[0]}`)
|
|
56
|
+
}
|
|
57
|
+
if (data === undefined || data === null) return { file, settings: {} }
|
|
58
|
+
if (typeof data !== 'object' || Array.isArray(data)) {
|
|
59
|
+
throw new SettingsError(`${SETTINGS_FILE} should be a list of settings, like \`gap: 48\`.`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const settings = {}
|
|
63
|
+
for (const [key, value] of Object.entries(data)) {
|
|
64
|
+
if (!SETTING_KEYS.includes(key)) {
|
|
65
|
+
const suggestion = didYouMean(key, SETTING_KEYS)
|
|
66
|
+
throw new SettingsError(
|
|
67
|
+
`${SETTINGS_FILE} has an unknown setting \`${key}\`.` + (suggestion ? ` Did you mean \`${suggestion}\`?` : '')
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
if (value === null || value === undefined) continue
|
|
71
|
+
if (key === 'hide') settings.hide = Array.isArray(value) ? value.map(String) : [String(value)]
|
|
72
|
+
else if (key === 'out') settings.out = resolve(siteDir, String(value))
|
|
73
|
+
else settings[key] = value
|
|
74
|
+
}
|
|
75
|
+
return { file, settings }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Flags over the file. A gap or an overlap from the flags replaces either from the file. */
|
|
79
|
+
export function mergeSettings(fromFile = {}, fromFlags = {}) {
|
|
80
|
+
const merged = { ...fromFile }
|
|
81
|
+
if (fromFlags.gap !== undefined || fromFlags.overlap !== undefined) {
|
|
82
|
+
delete merged.gap
|
|
83
|
+
delete merged.overlap
|
|
84
|
+
}
|
|
85
|
+
for (const [key, value] of Object.entries(fromFlags)) {
|
|
86
|
+
if (value !== undefined) merged[key] = value
|
|
87
|
+
}
|
|
88
|
+
return merged
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Write the flags of this run into `<siteDir>/snapshot.yml`, over what it already
|
|
93
|
+
* holds. The file is rewritten in full, in `SETTING_KEYS` order, under a header.
|
|
94
|
+
*
|
|
95
|
+
* @returns {{ file: string, saved: string[] }} the keys this run set
|
|
96
|
+
*/
|
|
97
|
+
export function saveSnapshotSettings(siteDir, fromFlags, fromFile = readSnapshotSettings(siteDir).settings) {
|
|
98
|
+
const file = join(siteDir, SETTINGS_FILE)
|
|
99
|
+
const merged = mergeSettings(fromFile, fromFlags)
|
|
100
|
+
const ordered = {}
|
|
101
|
+
for (const key of SETTING_KEYS) {
|
|
102
|
+
if (merged[key] === undefined) continue
|
|
103
|
+
ordered[key] = key === 'out' ? toSitePath(siteDir, merged.out) : merged[key]
|
|
104
|
+
}
|
|
105
|
+
writeFileSync(file, HEADER + yaml.dump(ordered, { lineWidth: -1 }))
|
|
106
|
+
return { file, saved: SETTING_KEYS.filter((key) => fromFlags[key] !== undefined) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function toSitePath(siteDir, path) {
|
|
110
|
+
const rel = relative(siteDir, path)
|
|
111
|
+
return isAbsolute(rel) ? path : rel.split(sep).join('/')
|
|
112
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Update Notification
|
|
3
|
+
*
|
|
4
|
+
* Checks the npm registry for newer versions of the CLI.
|
|
5
|
+
* Runs at most once per day, caches results in ~/.uniweb/update-check.json.
|
|
6
|
+
* Uses Node 20+ built-in fetch — no external dependencies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { detectGlobalCliPm, globalCliUpdateCmd } from './pm.js'
|
|
13
|
+
|
|
14
|
+
const CHECK_INTERVAL = 24 * 60 * 60 * 1000 // 1 day
|
|
15
|
+
const STATE_DIR = join(homedir(), '.uniweb')
|
|
16
|
+
const STATE_FILE = join(STATE_DIR, 'update-check.json')
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compare two semver strings.
|
|
20
|
+
* Returns 1 if a > b, -1 if a < b, 0 if equal.
|
|
21
|
+
*/
|
|
22
|
+
function compareSemver(a, b) {
|
|
23
|
+
const pa = a.split('.').map(Number)
|
|
24
|
+
const pb = b.split('.').map(Number)
|
|
25
|
+
for (let i = 0; i < 3; i++) {
|
|
26
|
+
if ((pa[i] || 0) > (pb[i] || 0)) return 1
|
|
27
|
+
if ((pa[i] || 0) < (pb[i] || 0)) return -1
|
|
28
|
+
}
|
|
29
|
+
return 0
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read cached update check state.
|
|
34
|
+
*/
|
|
35
|
+
function readState() {
|
|
36
|
+
try {
|
|
37
|
+
if (existsSync(STATE_FILE)) {
|
|
38
|
+
return JSON.parse(readFileSync(STATE_FILE, 'utf8'))
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
/* ignore corrupt cache */
|
|
42
|
+
}
|
|
43
|
+
return {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Write update check state to disk.
|
|
48
|
+
*/
|
|
49
|
+
function writeState(state) {
|
|
50
|
+
try {
|
|
51
|
+
if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true })
|
|
52
|
+
writeFileSync(STATE_FILE, JSON.stringify(state))
|
|
53
|
+
} catch {
|
|
54
|
+
/* ignore write errors */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Print update notification to stderr (doesn't interfere with piped output).
|
|
60
|
+
* A trailing notice for a finished command; `create` and `clone` do not get
|
|
61
|
+
* one, they stop first (refuseOutdatedProjectStart).
|
|
62
|
+
*/
|
|
63
|
+
function printNotification(current, latest) {
|
|
64
|
+
const yellow = '\x1b[33m'
|
|
65
|
+
const cyan = '\x1b[36m'
|
|
66
|
+
const dim = '\x1b[2m'
|
|
67
|
+
const reset = '\x1b[0m'
|
|
68
|
+
const updateCmd = globalCliUpdateCmd(detectGlobalCliPm())
|
|
69
|
+
console.error('')
|
|
70
|
+
console.error(
|
|
71
|
+
`${yellow}Update available:${reset} ${dim}${current}${reset} → ${cyan}${latest}${reset}`
|
|
72
|
+
)
|
|
73
|
+
console.error(
|
|
74
|
+
`${dim}Run${reset} ${updateCmd} ${dim}to update the CLI${reset}`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Synchronously read the cache and print a notification if a newer
|
|
80
|
+
* version is known. No network fetch — only reads what `startUpdateCheck`
|
|
81
|
+
* has previously cached. Returns true if a notification was printed.
|
|
82
|
+
*
|
|
83
|
+
* For `--version` / `-v` off a terminal: the user was already asking about
|
|
84
|
+
* the version, so staleness is mentioned while we're here. Goes to stderr so
|
|
85
|
+
* scripts capturing stdout aren't affected.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} currentVersion
|
|
88
|
+
* @returns {boolean} true if a notification was printed
|
|
89
|
+
*/
|
|
90
|
+
export function maybeNotifyFromCache(currentVersion) {
|
|
91
|
+
const state = readState()
|
|
92
|
+
if (!state.latestVersion) return false
|
|
93
|
+
if (compareSemver(state.latestVersion, currentVersion) <= 0) return false
|
|
94
|
+
printNotification(currentVersion, state.latestVersion)
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether a release newer than `current` is published. Unknown (`latest` null:
|
|
100
|
+
* offline, or a slow registry) is not behind.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} current
|
|
103
|
+
* @param {string|null} latest
|
|
104
|
+
* @returns {boolean}
|
|
105
|
+
*/
|
|
106
|
+
export function isBehind(current, latest) {
|
|
107
|
+
return Boolean(latest) && compareSemver(latest, current) > 0
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Arguments as a shell would need them typed again. */
|
|
111
|
+
export function shellJoin(args) {
|
|
112
|
+
return args
|
|
113
|
+
.map((a) => (/^[\w@%+=:,./-]+$/.test(a) ? a : `'${String(a).replace(/'/g, `'\\''`)}'`))
|
|
114
|
+
.join(' ')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Stop `create` or `clone` when a global CLI has been replaced by a newer
|
|
119
|
+
* release — before anything is written.
|
|
120
|
+
*
|
|
121
|
+
* ⭐ A PROJECT STARTS ON THE CLI THAT CREATES IT: its version table pins the
|
|
122
|
+
* `@uniweb/*` packages, and its official templates come from the release it was
|
|
123
|
+
* published with. Inside a project a global install only launches the project's
|
|
124
|
+
* own CLI; outside one, for `create` and `clone`, it does the work itself — so
|
|
125
|
+
* an outdated one starts the project outdated, and says nothing. `npx uniweb`
|
|
126
|
+
* with no version runs a global install too, which is how a 0.12 CLI went on
|
|
127
|
+
* offering the ten templates it knew while sixteen shipped (2026-10-08).
|
|
128
|
+
*
|
|
129
|
+
* Any newer release stops it, not only a newer minor: the version table moves
|
|
130
|
+
* whenever any package does — a patch of the CLI can pin a new breaking line of
|
|
131
|
+
* one — and the fix is one command either way. Unknown proceeds: this stops a
|
|
132
|
+
* known mistake, it does not require the network.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} current - this CLI's version
|
|
135
|
+
* @param {string[]} argv - the command and its arguments, repeated in the fix
|
|
136
|
+
* @returns {Promise<boolean>} true when it refused, the reason printed
|
|
137
|
+
*/
|
|
138
|
+
export async function refuseOutdatedProjectStart(current, argv) {
|
|
139
|
+
const latest = await getLatestVersion({ maxAgeMs: 60 * 60 * 1000 })
|
|
140
|
+
if (!isBehind(current, latest)) return false
|
|
141
|
+
const red = '\x1b[31m'
|
|
142
|
+
const cyan = '\x1b[36m'
|
|
143
|
+
const dim = '\x1b[2m'
|
|
144
|
+
const reset = '\x1b[0m'
|
|
145
|
+
console.error(`${red}✗${reset} This uniweb is ${current}; the latest release is ${latest}.`)
|
|
146
|
+
console.error(
|
|
147
|
+
` ${dim}A project starts on the packages and templates of the CLI that creates it.${reset}`
|
|
148
|
+
)
|
|
149
|
+
console.error(` Run the latest: ${cyan}npx uniweb@latest ${shellJoin(argv)}${reset}`)
|
|
150
|
+
console.error(
|
|
151
|
+
` Or update this one: ${cyan}${globalCliUpdateCmd(detectGlobalCliPm())}${reset}`
|
|
152
|
+
)
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resolve the latest published CLI version WITHOUT printing anything.
|
|
158
|
+
*
|
|
159
|
+
* The notify helpers in this module own both the lookup and the message.
|
|
160
|
+
* That's right for callers who want the house notice, and wrong for callers
|
|
161
|
+
* whose remedy differs — `uniweb update` running from a project's
|
|
162
|
+
* node_modules can't tell the user to `npm i -g uniweb`, because the version
|
|
163
|
+
* they're on is pinned by their own package.json. Those callers need the
|
|
164
|
+
* fact, not the sentence.
|
|
165
|
+
*
|
|
166
|
+
* Caching and the timeout live here rather than at the call site so every
|
|
167
|
+
* lookup gets them. `update.js` previously had its own copy with neither: an
|
|
168
|
+
* unbounded `await fetch()` before the command did any work, which turns a
|
|
169
|
+
* flaky network into a hang on a routine verb.
|
|
170
|
+
*
|
|
171
|
+
* @param {object} [opts]
|
|
172
|
+
* @param {number} [opts.timeoutMs=1500] Network cap. Slow/offline returns
|
|
173
|
+
* whatever the cache holds rather than blocking.
|
|
174
|
+
* @param {boolean} [opts.allowNetwork=true] False = cache-only. Pass false
|
|
175
|
+
* for non-TTY callers; scripts must stay fast and offline-safe (the same
|
|
176
|
+
* convention `--version` follows).
|
|
177
|
+
* @param {number} [opts.maxAgeMs] How old a cache entry may be before a
|
|
178
|
+
* refetch is attempted. Defaults to the module's 1-day interval.
|
|
179
|
+
* @returns {Promise<string|null>} The latest version, or null if unknown.
|
|
180
|
+
*/
|
|
181
|
+
export async function getLatestVersion({
|
|
182
|
+
timeoutMs = 1500,
|
|
183
|
+
allowNetwork = true,
|
|
184
|
+
maxAgeMs = CHECK_INTERVAL
|
|
185
|
+
} = {}) {
|
|
186
|
+
const state = readState()
|
|
187
|
+
const cached = state.latestVersion || null
|
|
188
|
+
const fresh = state.lastCheck && Date.now() - state.lastCheck < maxAgeMs
|
|
189
|
+
if (fresh && cached) return cached
|
|
190
|
+
// A stale cache entry still beats nothing for an advisory, so it's the
|
|
191
|
+
// fallback for every failure path below rather than a hard null.
|
|
192
|
+
if (!allowNetwork) return cached
|
|
193
|
+
|
|
194
|
+
const controller = new AbortController()
|
|
195
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
196
|
+
try {
|
|
197
|
+
const res = await fetch('https://registry.npmjs.org/uniweb/latest', {
|
|
198
|
+
signal: controller.signal
|
|
199
|
+
})
|
|
200
|
+
if (!res.ok) return cached
|
|
201
|
+
const data = await res.json()
|
|
202
|
+
const latest = data?.version || null
|
|
203
|
+
if (!latest) return cached
|
|
204
|
+
writeState({ lastCheck: Date.now(), latestVersion: latest })
|
|
205
|
+
return latest
|
|
206
|
+
} catch {
|
|
207
|
+
return cached
|
|
208
|
+
} finally {
|
|
209
|
+
clearTimeout(timer)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Fetch the latest version (with a tight timeout) and print a notice if
|
|
215
|
+
* a newer version is found. Updates the on-disk cache as a side effect
|
|
216
|
+
* so future cache-only callers benefit too.
|
|
217
|
+
*
|
|
218
|
+
* Use this for TTY invocations of `--version` / `-v` where the user is
|
|
219
|
+
* interactively asking about the version and a brief network wait is
|
|
220
|
+
* acceptable. Don't use it for non-TTY callers — scripts capturing
|
|
221
|
+
* stdout (`version=$(uniweb -v)`) need a fast, offline-safe path.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} currentVersion
|
|
224
|
+
* @param {object} [opts]
|
|
225
|
+
* @param {number} [opts.timeoutMs=1500] Network timeout. Slow / offline
|
|
226
|
+
* calls return silently — never block the verb for long.
|
|
227
|
+
* @returns {Promise<boolean>} true if a notice was printed.
|
|
228
|
+
*/
|
|
229
|
+
export async function fetchAndNotifyIfNewer(
|
|
230
|
+
currentVersion,
|
|
231
|
+
{ timeoutMs = 1500 } = {}
|
|
232
|
+
) {
|
|
233
|
+
const controller = new AbortController()
|
|
234
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
235
|
+
let latest = null
|
|
236
|
+
try {
|
|
237
|
+
const res = await fetch('https://registry.npmjs.org/uniweb/latest', {
|
|
238
|
+
signal: controller.signal
|
|
239
|
+
})
|
|
240
|
+
if (res.ok) {
|
|
241
|
+
const data = await res.json()
|
|
242
|
+
latest = data?.version || null
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
// Aborted, network error, parse error — all silent. The verb
|
|
246
|
+
// shouldn't block on update-check failures.
|
|
247
|
+
} finally {
|
|
248
|
+
clearTimeout(timer)
|
|
249
|
+
}
|
|
250
|
+
if (!latest) return false
|
|
251
|
+
// Refresh the cache so other code paths see this fresh result.
|
|
252
|
+
writeState({ lastCheck: Date.now(), latestVersion: latest })
|
|
253
|
+
if (compareSemver(latest, currentVersion) <= 0) return false
|
|
254
|
+
printNotification(currentVersion, latest)
|
|
255
|
+
return true
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Start a non-blocking update check.
|
|
260
|
+
*
|
|
261
|
+
* Returns a function that, when called (optionally awaited), prints
|
|
262
|
+
* the notification if a newer version was found.
|
|
263
|
+
*
|
|
264
|
+
* @param {string} currentVersion - The currently running CLI version
|
|
265
|
+
* @returns {Function} Call at the end of command execution to show notification
|
|
266
|
+
*/
|
|
267
|
+
export function startUpdateCheck(currentVersion) {
|
|
268
|
+
let notification = null
|
|
269
|
+
const state = readState()
|
|
270
|
+
|
|
271
|
+
// Use cached result if checked recently
|
|
272
|
+
if (state.lastCheck && Date.now() - state.lastCheck < CHECK_INTERVAL) {
|
|
273
|
+
if (
|
|
274
|
+
state.latestVersion &&
|
|
275
|
+
compareSemver(state.latestVersion, currentVersion) > 0
|
|
276
|
+
) {
|
|
277
|
+
notification = state.latestVersion
|
|
278
|
+
}
|
|
279
|
+
return () => {
|
|
280
|
+
if (notification) printNotification(currentVersion, notification)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Background fetch (non-blocking)
|
|
285
|
+
const fetchPromise = fetch('https://registry.npmjs.org/uniweb/latest')
|
|
286
|
+
.then((r) => r.json())
|
|
287
|
+
.then((data) => {
|
|
288
|
+
const latest = data.version
|
|
289
|
+
writeState({ lastCheck: Date.now(), latestVersion: latest })
|
|
290
|
+
if (compareSemver(latest, currentVersion) > 0) {
|
|
291
|
+
notification = latest
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
.catch(() => {
|
|
295
|
+
/* network error — ignore silently */
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
return async () => {
|
|
299
|
+
await fetchPromise
|
|
300
|
+
if (notification) printNotification(currentVersion, notification)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal `.uwx` reader — for the commands that run BEFORE a project exists.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists rather than importing the real one
|
|
5
|
+
*
|
|
6
|
+
* Every other `.uwx` reader in the CLI (`pull.js`, `site-sync.js`, `content.js`)
|
|
7
|
+
* imports `readZip` from `@uniweb/build/uwx`. That is correct for them: they run
|
|
8
|
+
* *inside* a project, where `@uniweb/build` resolves from the project's own
|
|
9
|
+
* `node_modules`.
|
|
10
|
+
*
|
|
11
|
+
* **`@uniweb/build` is not a dependency of this package** (see `package.json`),
|
|
12
|
+
* so a command that runs where no project exists cannot reach it — statically or
|
|
13
|
+
* dynamically. `uniweb clone` is exactly that command: its whole job is to turn a
|
|
14
|
+
* backend site into a local project that does not exist yet.
|
|
15
|
+
*
|
|
16
|
+
* So this is a deliberate second implementation, kept minimal and dependency-free
|
|
17
|
+
* (`node:zlib` is a builtin), the same way `clone.js` keeps its own
|
|
18
|
+
* `extractDocument` for the same reason. **Do not import this from a command that
|
|
19
|
+
* runs inside a project** — use `@uniweb/build/uwx`'s `readZip` there, so the
|
|
20
|
+
* producer and the consumer of a `.uwx` stay one implementation wherever they can.
|
|
21
|
+
*
|
|
22
|
+
* ## Two things measured on a real payload, both of which would break a naive reader
|
|
23
|
+
*
|
|
24
|
+
* 1. ⛔ **The archive is NOT "stored".** `pull.js` describes the format as *"our
|
|
25
|
+
* Stored ZIP"*, and that is true of `manifest.json` and false of the entity
|
|
26
|
+
* files — measured on a live pull: `manifest.json` method 0 (STORED), the
|
|
27
|
+
* entity JSON method 8 (DEFLATED). A stored-only reader silently yields the
|
|
28
|
+
* manifest and drops the document, i.e. it returns success and no entities.
|
|
29
|
+
* **Both methods are handled below and the tests cover a mixed archive.**
|
|
30
|
+
* 2. The entity payload is the *only* thing wanted; `manifest.json` is skipped, as
|
|
31
|
+
* every other reader in the tree does.
|
|
32
|
+
*
|
|
33
|
+
* We read the **central directory** rather than walking local file headers,
|
|
34
|
+
* because a local header may carry zeroed sizes when the general-purpose bit 3
|
|
35
|
+
* flag defers them to a trailing data descriptor. The central directory always
|
|
36
|
+
* carries the real sizes.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { inflateRawSync } from 'node:zlib'
|
|
40
|
+
|
|
41
|
+
const SIG_EOCD = 0x06054b50
|
|
42
|
+
const SIG_CENTRAL = 0x02014b50
|
|
43
|
+
const SIG_LOCAL = 0x04034b50
|
|
44
|
+
const METHOD_STORED = 0
|
|
45
|
+
const METHOD_DEFLATED = 8
|
|
46
|
+
|
|
47
|
+
/** ZIP local-file-header magic, "PK\x03\x04" — the first two bytes are enough. */
|
|
48
|
+
export function looksLikeZip(buf) {
|
|
49
|
+
return Boolean(buf) && buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Locate the end-of-central-directory record.
|
|
54
|
+
*
|
|
55
|
+
* Scanned backwards because the record is last and variable-length (it carries an
|
|
56
|
+
* optional trailing comment).
|
|
57
|
+
*
|
|
58
|
+
* @param {Buffer} buf
|
|
59
|
+
* @returns {number} offset of the EOCD, or -1
|
|
60
|
+
*/
|
|
61
|
+
function findEocd(buf) {
|
|
62
|
+
const min = Math.max(0, buf.length - 22 - 0xffff)
|
|
63
|
+
for (let i = buf.length - 22; i >= min; i--) {
|
|
64
|
+
if (buf.readUInt32LE(i) === SIG_EOCD) return i
|
|
65
|
+
}
|
|
66
|
+
return -1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read every entry out of a ZIP as `[name, Buffer]` pairs.
|
|
71
|
+
*
|
|
72
|
+
* @param {Buffer} buf
|
|
73
|
+
* @returns {Array<[string, Buffer]>} empty when the buffer is not a readable ZIP
|
|
74
|
+
*/
|
|
75
|
+
export function readUwxZip(buf) {
|
|
76
|
+
if (!looksLikeZip(buf)) return []
|
|
77
|
+
const eocd = findEocd(buf)
|
|
78
|
+
if (eocd < 0) return []
|
|
79
|
+
|
|
80
|
+
const count = buf.readUInt16LE(eocd + 10)
|
|
81
|
+
let ptr = buf.readUInt32LE(eocd + 16)
|
|
82
|
+
const out = []
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < count; i++) {
|
|
85
|
+
if (ptr + 46 > buf.length || buf.readUInt32LE(ptr) !== SIG_CENTRAL) break
|
|
86
|
+
|
|
87
|
+
const method = buf.readUInt16LE(ptr + 10)
|
|
88
|
+
const csize = buf.readUInt32LE(ptr + 20)
|
|
89
|
+
const nameLen = buf.readUInt16LE(ptr + 28)
|
|
90
|
+
const extraLen = buf.readUInt16LE(ptr + 30)
|
|
91
|
+
const commentLen = buf.readUInt16LE(ptr + 32)
|
|
92
|
+
const localOff = buf.readUInt32LE(ptr + 42)
|
|
93
|
+
const name = buf.subarray(ptr + 46, ptr + 46 + nameLen).toString('utf8')
|
|
94
|
+
|
|
95
|
+
// The local header's name/extra lengths are independent of the central
|
|
96
|
+
// directory's — read them where the data actually starts, not from above.
|
|
97
|
+
if (localOff + 30 <= buf.length && buf.readUInt32LE(localOff) === SIG_LOCAL) {
|
|
98
|
+
const lNameLen = buf.readUInt16LE(localOff + 26)
|
|
99
|
+
const lExtraLen = buf.readUInt16LE(localOff + 28)
|
|
100
|
+
const start = localOff + 30 + lNameLen + lExtraLen
|
|
101
|
+
const raw = buf.subarray(start, start + csize)
|
|
102
|
+
try {
|
|
103
|
+
if (method === METHOD_STORED) out.push([name, Buffer.from(raw)])
|
|
104
|
+
else if (method === METHOD_DEFLATED) out.push([name, inflateRawSync(raw)])
|
|
105
|
+
// any other method: skip rather than guess
|
|
106
|
+
} catch {
|
|
107
|
+
/* a corrupt entry must not lose the readable ones */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
ptr += 46 + nameLen + extraLen + commentLen
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The entity `$`-documents inside a `.uwx`, or a JSON body's documents.
|
|
119
|
+
*
|
|
120
|
+
* Mirrors `pull.js`'s `readPullDocuments` in behaviour — including its JSON
|
|
121
|
+
* fallback, so a future envelope change does not break this lane either — but
|
|
122
|
+
* without the `@uniweb/build` import. See the header for why that matters.
|
|
123
|
+
*
|
|
124
|
+
* @param {Buffer} buf
|
|
125
|
+
* @returns {object[]} parsed documents, possibly empty
|
|
126
|
+
*/
|
|
127
|
+
export function readUwxDocuments(buf) {
|
|
128
|
+
if (!buf || buf.length === 0) return []
|
|
129
|
+
|
|
130
|
+
if (looksLikeZip(buf)) {
|
|
131
|
+
const docs = []
|
|
132
|
+
for (const [name, data] of readUwxZip(buf)) {
|
|
133
|
+
if (name === 'manifest.json' || !name.endsWith('.json')) continue
|
|
134
|
+
try {
|
|
135
|
+
docs.push(JSON.parse(data.toString('utf8')))
|
|
136
|
+
} catch {
|
|
137
|
+
/* skip a non-document entry */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return docs
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let payload
|
|
144
|
+
try {
|
|
145
|
+
payload = JSON.parse(buf.toString('utf8'))
|
|
146
|
+
} catch {
|
|
147
|
+
return []
|
|
148
|
+
}
|
|
149
|
+
if (Array.isArray(payload)) return payload.filter(Boolean)
|
|
150
|
+
const list = Array.isArray(payload?.entities)
|
|
151
|
+
? payload.entities
|
|
152
|
+
: Array.isArray(payload?.documents)
|
|
153
|
+
? payload.documents
|
|
154
|
+
: null
|
|
155
|
+
if (list) return list.filter(Boolean)
|
|
156
|
+
return payload ? [payload] : []
|
|
157
|
+
}
|