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
package/src/index.js
ADDED
|
@@ -0,0 +1,2241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Uniweb CLI
|
|
5
|
+
*
|
|
6
|
+
* Scaffolds new Uniweb sites and foundations, builds projects, and generates docs.
|
|
7
|
+
*
|
|
8
|
+
* Install globally:
|
|
9
|
+
* npm i -g uniweb
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* uniweb create [project-name]
|
|
13
|
+
* uniweb create --template marketing
|
|
14
|
+
* uniweb add foundation [name]
|
|
15
|
+
* uniweb build
|
|
16
|
+
* uniweb docs
|
|
17
|
+
*
|
|
18
|
+
* Global install delegation:
|
|
19
|
+
* When installed globally, project-bound commands (build, docs, etc.) are
|
|
20
|
+
* delegated to the project-local CLI if one exists in node_modules. This
|
|
21
|
+
* ensures version alignment between the CLI and @uniweb/build.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
25
|
+
import { execSync, spawn as spawnChild } from 'node:child_process'
|
|
26
|
+
import { resolve, join, relative, dirname, basename } from 'node:path'
|
|
27
|
+
import { fileURLToPath } from 'node:url'
|
|
28
|
+
import prompts from 'prompts'
|
|
29
|
+
// `doctor`, `add`, `publish`, and `deploy` are loaded lazily via
|
|
30
|
+
// importProjectCommand() — each imports `@uniweb/build` at the top,
|
|
31
|
+
// so a static import here would crash `npx uniweb@latest create …`
|
|
32
|
+
// (no @uniweb/build in the npx scratch dir) before any command runs.
|
|
33
|
+
// Same pattern as `build` and `docs`.
|
|
34
|
+
import { i18n } from './commands/i18n.js'
|
|
35
|
+
import { inspect } from './commands/inspect.js'
|
|
36
|
+
import { invite } from './commands/invite.js'
|
|
37
|
+
import { handoff } from './commands/handoff.js'
|
|
38
|
+
import { update } from './commands/update.js'
|
|
39
|
+
import { clone } from './commands/clone.js'
|
|
40
|
+
import { template } from './commands/template.js'
|
|
41
|
+
import {
|
|
42
|
+
resolveTemplate,
|
|
43
|
+
parseTemplateId,
|
|
44
|
+
buildTemplateChoices,
|
|
45
|
+
templatePickerPageSize
|
|
46
|
+
} from './templates/index.js'
|
|
47
|
+
import { validateTemplate } from './templates/validator.js'
|
|
48
|
+
import {
|
|
49
|
+
scaffoldWorkspace,
|
|
50
|
+
scaffoldFoundation,
|
|
51
|
+
scaffoldSite,
|
|
52
|
+
applyContent,
|
|
53
|
+
applyStarter,
|
|
54
|
+
mergeTemplateDependencies,
|
|
55
|
+
getWorkspaceTemplateOutputs
|
|
56
|
+
} from './utils/scaffold.js'
|
|
57
|
+
import {
|
|
58
|
+
normalizeFoundationName,
|
|
59
|
+
ensureFoundationName
|
|
60
|
+
} from './utils/foundation-name.js'
|
|
61
|
+
import {
|
|
62
|
+
detectPackageManager,
|
|
63
|
+
detectWorkspacePm,
|
|
64
|
+
filterCmd,
|
|
65
|
+
installCmd,
|
|
66
|
+
runCmd,
|
|
67
|
+
isPnpmAvailable
|
|
68
|
+
} from './utils/pm.js'
|
|
69
|
+
import {
|
|
70
|
+
isNonInteractive,
|
|
71
|
+
getCliPrefix,
|
|
72
|
+
stripNonInteractiveFlag,
|
|
73
|
+
formatOptions
|
|
74
|
+
} from './utils/interactive.js'
|
|
75
|
+
import { findWorkspaceRoot } from './utils/workspace.js'
|
|
76
|
+
import { exitWhenDrained } from './utils/exit.js'
|
|
77
|
+
|
|
78
|
+
// Colors for terminal output
|
|
79
|
+
const colors = {
|
|
80
|
+
reset: '\x1b[0m',
|
|
81
|
+
bright: '\x1b[1m',
|
|
82
|
+
dim: '\x1b[2m',
|
|
83
|
+
cyan: '\x1b[36m',
|
|
84
|
+
green: '\x1b[32m',
|
|
85
|
+
yellow: '\x1b[33m',
|
|
86
|
+
red: '\x1b[31m'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Template choices for the interactive prompt — built-ins first, then every
|
|
90
|
+
// official template in this CLI's snapshot (the list `create` can download),
|
|
91
|
+
// with "Blank" last. Composed by buildTemplateChoices() in templates/resolver.js.
|
|
92
|
+
const TEMPLATE_CHOICES = buildTemplateChoices()
|
|
93
|
+
|
|
94
|
+
// Files that may pre-exist in the target dir during `uniweb create .` and
|
|
95
|
+
// will be silently overwritten by the scaffold. Anything else colliding
|
|
96
|
+
// causes the verb to abort. README and .gitignore are the only two files
|
|
97
|
+
// the workspace template writes that overlap with what `gh repo create`
|
|
98
|
+
// puts in a fresh repo, and the scaffold's versions are more useful in
|
|
99
|
+
// this context (Vite/Node-aware .gitignore, project-shaped README).
|
|
100
|
+
const IN_PLACE_OVERWRITE_ALLOWED = new Set(['README.md', '.gitignore'])
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Slugify a directory name into a valid project slug — lowercase,
|
|
104
|
+
* `[a-z0-9-]+`, no leading/trailing/duplicated hyphens. Matches the
|
|
105
|
+
* validation regex used for the interactive name prompt.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} name
|
|
108
|
+
* @returns {string} Slugified name; empty if no valid characters remain.
|
|
109
|
+
*/
|
|
110
|
+
function slugifyName(name) {
|
|
111
|
+
return String(name)
|
|
112
|
+
.toLowerCase()
|
|
113
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
114
|
+
.replace(/^-+|-+$/g, '')
|
|
115
|
+
.replace(/-{2,}/g, '-')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function log(message) {
|
|
119
|
+
console.log(message)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function success(message) {
|
|
123
|
+
console.log(`${colors.green}✓${colors.reset} ${message}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function error(message) {
|
|
127
|
+
console.error(`${colors.red}✗${colors.reset} ${message}`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function title(message) {
|
|
131
|
+
console.log(`\n${colors.cyan}${colors.bright}${message}${colors.reset}\n`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// CLI version (read once, lazily)
|
|
135
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
136
|
+
let _cliVersion = null
|
|
137
|
+
function getCliVersion() {
|
|
138
|
+
if (!_cliVersion) {
|
|
139
|
+
const pkg = JSON.parse(
|
|
140
|
+
readFileSync(join(__dirname, '..', 'package.json'), 'utf8')
|
|
141
|
+
)
|
|
142
|
+
_cliVersion = pkg.version
|
|
143
|
+
}
|
|
144
|
+
return _cliVersion
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Commands that always run from the global CLI (no project context needed)
|
|
149
|
+
*/
|
|
150
|
+
// Commands that always run from the global CLI, never delegating to a
|
|
151
|
+
// project-local copy. `update` is here because it reconciles the project
|
|
152
|
+
// against *this* CLI's version matrix — when run from a (newer) global
|
|
153
|
+
// install that's the whole point; delegating to the project-local copy
|
|
154
|
+
// would align the project to the version it already has, i.e. a no-op.
|
|
155
|
+
const STANDALONE_COMMANDS = new Set([
|
|
156
|
+
'create',
|
|
157
|
+
'clone',
|
|
158
|
+
'--help',
|
|
159
|
+
'-h',
|
|
160
|
+
'--version',
|
|
161
|
+
'-v',
|
|
162
|
+
'login',
|
|
163
|
+
'logout',
|
|
164
|
+
'update'
|
|
165
|
+
])
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Check if this CLI is running from a global install.
|
|
169
|
+
* When installed globally, process.argv[1] points outside any node_modules.
|
|
170
|
+
* When run via npx or as a local dependency, it's inside node_modules.
|
|
171
|
+
*/
|
|
172
|
+
function isGlobalInstall() {
|
|
173
|
+
const scriptPath = process.argv[1]
|
|
174
|
+
if (!scriptPath) return false
|
|
175
|
+
// Normalize path separators for Windows compatibility
|
|
176
|
+
return (
|
|
177
|
+
!scriptPath.split('/').includes('node_modules') &&
|
|
178
|
+
!scriptPath.split('\\').includes('node_modules')
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Find the project-local CLI entry point, if one exists.
|
|
184
|
+
* Walks up from cwd looking for node_modules/uniweb/src/index.js.
|
|
185
|
+
*/
|
|
186
|
+
function findLocalCli() {
|
|
187
|
+
let dir = process.cwd()
|
|
188
|
+
while (true) {
|
|
189
|
+
const localCli = join(dir, 'node_modules', 'uniweb', 'src', 'index.js')
|
|
190
|
+
if (existsSync(localCli)) return localCli
|
|
191
|
+
const parent = dirname(dir)
|
|
192
|
+
if (parent === dir) break
|
|
193
|
+
dir = parent
|
|
194
|
+
}
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Delegate execution to the project-local CLI.
|
|
200
|
+
* Spawns the local CLI with the same arguments and inherits stdio.
|
|
201
|
+
* Warns if the local version differs from the global version.
|
|
202
|
+
*/
|
|
203
|
+
function delegateToLocal(localCliPath) {
|
|
204
|
+
// Check for version mismatch between global and local CLI
|
|
205
|
+
try {
|
|
206
|
+
const localPkgPath = join(dirname(localCliPath), '..', 'package.json')
|
|
207
|
+
const localPkg = JSON.parse(readFileSync(localPkgPath, 'utf8'))
|
|
208
|
+
const globalVersion = getCliVersion()
|
|
209
|
+
if (localPkg.version && localPkg.version !== globalVersion) {
|
|
210
|
+
const yellow = '\x1b[33m'
|
|
211
|
+
const dim = '\x1b[2m'
|
|
212
|
+
const reset = '\x1b[0m'
|
|
213
|
+
console.error(
|
|
214
|
+
`${yellow}Note:${reset} Global CLI is ${dim}${globalVersion}${reset}, project has ${dim}${localPkg.version}${reset} ${dim}(using project version)${reset}`
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
} catch {
|
|
218
|
+
/* ignore — version check is best-effort */
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const child = spawnChild(
|
|
223
|
+
process.execPath,
|
|
224
|
+
[localCliPath, ...process.argv.slice(2)],
|
|
225
|
+
{ stdio: 'inherit' }
|
|
226
|
+
)
|
|
227
|
+
child.on('close', (code) => exitWhenDrained(code ?? 0))
|
|
228
|
+
child.on('error', reject)
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Import a command module that may depend on an optional @uniweb/* peer
|
|
234
|
+
* (`@uniweb/build` above all). Sixteen commands load through here, so this
|
|
235
|
+
* is the error most users meet when something is not installed.
|
|
236
|
+
*
|
|
237
|
+
* ⛔ IT MUST NOT NAME A CAUSE IT HAS NOT CHECKED. This said "This command
|
|
238
|
+
* must be run from inside a Uniweb project" for every `ERR_MODULE_NOT_FOUND`
|
|
239
|
+
* on a `@uniweb/*` specifier — a guess, and wrong in the two cases that do
|
|
240
|
+
* not involve your location at all: a workspace whose dependencies are simply
|
|
241
|
+
* not installed, and a CLI installed on its own (the optional peers are not
|
|
242
|
+
* installed with it, so `npm install uniweb --prefix X` yields exactly this).
|
|
243
|
+
* Project resolution is cwd-based (`findWorkspaceRoot`) and always was, so
|
|
244
|
+
* the check the message needed was one call away and already imported here.
|
|
245
|
+
*
|
|
246
|
+
* ⚠️ A wrong cause is worse than a vague one: it does not merely fail to
|
|
247
|
+
* explain, it DIRECTS the investigation. Reported 2026-09-09 by a consumer
|
|
248
|
+
* who lost an hour testing the cause this text named, having already done
|
|
249
|
+
* the `cd` and the install it advised.
|
|
250
|
+
*/
|
|
251
|
+
async function importProjectCommand(modulePath) {
|
|
252
|
+
try {
|
|
253
|
+
return await import(modulePath)
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (
|
|
256
|
+
err.code !== 'ERR_MODULE_NOT_FOUND' ||
|
|
257
|
+
!err.message?.includes('@uniweb/')
|
|
258
|
+
) {
|
|
259
|
+
throw err
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Name the package that actually failed to resolve, rather than making
|
|
263
|
+
// the reader guess which of the optional peers this command needed.
|
|
264
|
+
const missing =
|
|
265
|
+
err.message.match(/'(@uniweb\/[^']+)'/)?.[1] ?? 'a @uniweb/* package'
|
|
266
|
+
const root = findWorkspaceRoot(process.cwd())
|
|
267
|
+
|
|
268
|
+
// ⛔ THREE STATES, and the second and third look identical from here — the
|
|
269
|
+
// import failed either way. Discriminate on what is ON DISK, because
|
|
270
|
+
// "declared but not installed" and "installed but not reachable from where
|
|
271
|
+
// THIS CLI lives" have opposite remedies, and telling a fully-installed
|
|
272
|
+
// workspace to run its installer is the same wrong-cause defect again.
|
|
273
|
+
const workspaceModules = root ? join(root, 'node_modules') : null
|
|
274
|
+
const workspaceInstalled = !!workspaceModules && existsSync(workspaceModules)
|
|
275
|
+
const localCli = root ? join(workspaceModules, '.bin', 'uniweb') : null
|
|
276
|
+
|
|
277
|
+
error(`Cannot load ${missing}, which this command needs.`)
|
|
278
|
+
log('')
|
|
279
|
+
|
|
280
|
+
if (!root) {
|
|
281
|
+
log(
|
|
282
|
+
`No Uniweb workspace found above ${colors.dim}${process.cwd()}${colors.reset}.`
|
|
283
|
+
)
|
|
284
|
+
log(` ${colors.cyan}cd${colors.reset} into a project, or create one:`)
|
|
285
|
+
log(` ${colors.cyan}uniweb create my-project${colors.reset}`)
|
|
286
|
+
log('')
|
|
287
|
+
log(
|
|
288
|
+
`${colors.dim}Driving projects from a standalone CLI? ${missing} is an optional${colors.reset}`
|
|
289
|
+
)
|
|
290
|
+
log(
|
|
291
|
+
`${colors.dim}peer, so a bare \`npm i uniweb\` does not bring it:${colors.reset}`
|
|
292
|
+
)
|
|
293
|
+
log(` ${colors.cyan}npm install uniweb ${missing}${colors.reset}`)
|
|
294
|
+
} else if (!workspaceInstalled) {
|
|
295
|
+
// Declared, not on disk. Prefer the workspace's own package manager,
|
|
296
|
+
// read off its lockfile — but a workspace that has NEVER been installed
|
|
297
|
+
// has no lockfile to read, so fall back the same way `create` does
|
|
298
|
+
// rather than to whichever PM happens to be running this process.
|
|
299
|
+
const pm =
|
|
300
|
+
detectWorkspacePm(root) ||
|
|
301
|
+
(isPnpmAvailable() ? 'pnpm' : detectPackageManager())
|
|
302
|
+
log(`Uniweb workspace: ${colors.dim}${root}${colors.reset}`)
|
|
303
|
+
log(`Its dependencies are not installed:`)
|
|
304
|
+
log(` ${colors.cyan}${installCmd(pm)}${colors.reset}`)
|
|
305
|
+
} else {
|
|
306
|
+
// Installed, and still unreachable: Node resolves a command's imports
|
|
307
|
+
// from the CLI's own location, so a CLI installed elsewhere cannot see
|
|
308
|
+
// this workspace's copy. Both remedies are real; the local bin is the
|
|
309
|
+
// one that guarantees the project's own pinned toolchain does the work.
|
|
310
|
+
log(
|
|
311
|
+
`Uniweb workspace: ${colors.dim}${root}${colors.reset} ${colors.dim}(dependencies are installed)${colors.reset}`
|
|
312
|
+
)
|
|
313
|
+
log(
|
|
314
|
+
`This CLI resolves its imports from its own install location, not from`
|
|
315
|
+
)
|
|
316
|
+
log(`the workspace, so it cannot see the workspace's ${missing}.`)
|
|
317
|
+
log('')
|
|
318
|
+
log(`Use the project's own CLI:`)
|
|
319
|
+
log(` ${colors.cyan}${localCli}${colors.reset}`)
|
|
320
|
+
log(`or install the peer next to this one:`)
|
|
321
|
+
log(` ${colors.cyan}npm install uniweb ${missing}${colors.reset}`)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
await exitWhenDrained(1)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Create a project using the new package template flow (default)
|
|
330
|
+
*/
|
|
331
|
+
async function createFromPackageTemplates(
|
|
332
|
+
projectDir,
|
|
333
|
+
projectName,
|
|
334
|
+
options = {}
|
|
335
|
+
) {
|
|
336
|
+
const { onProgress, onWarning, pm = 'pnpm', includeStarter = true } = options
|
|
337
|
+
|
|
338
|
+
onProgress?.('Setting up workspace...')
|
|
339
|
+
|
|
340
|
+
// 1. Scaffold workspace.
|
|
341
|
+
// dev/build go through `uniweb` verbs so the scripts stay PM-agnostic
|
|
342
|
+
// (the verb resolves the right PM at runtime instead of locking the
|
|
343
|
+
// root scripts to whichever PM ran `npx uniweb create`). preview stays
|
|
344
|
+
// PM-filtered until a `uniweb preview` verb exists.
|
|
345
|
+
await scaffoldWorkspace(
|
|
346
|
+
projectDir,
|
|
347
|
+
{
|
|
348
|
+
projectName,
|
|
349
|
+
workspaceGlobs: ['site', 'src'],
|
|
350
|
+
scripts: {
|
|
351
|
+
dev: 'uniweb dev',
|
|
352
|
+
build: 'uniweb build',
|
|
353
|
+
preview: filterCmd(pm, 'site', 'preview')
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
{ onProgress, onWarning }
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
// 2. Scaffold foundation (folder: src/, package name: src)
|
|
360
|
+
// The folder name 'src' carries the meaning — a foundation is the site's
|
|
361
|
+
// source code. The package name 'src' keeps it unique within the
|
|
362
|
+
// workspace, since 'site' is taken by the site package. Neither is the
|
|
363
|
+
// foundation's NAME — what it registers as — which is main.js's `name`:
|
|
364
|
+
// the project's, since a project's code is its foundation.
|
|
365
|
+
onProgress?.('Creating foundation...')
|
|
366
|
+
const registryName = normalizeFoundationName(projectName)
|
|
367
|
+
await scaffoldFoundation(
|
|
368
|
+
join(projectDir, 'src'),
|
|
369
|
+
{
|
|
370
|
+
name: 'src',
|
|
371
|
+
registryName,
|
|
372
|
+
projectName,
|
|
373
|
+
isExtension: false
|
|
374
|
+
},
|
|
375
|
+
{ onProgress, onWarning }
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
// 3. Scaffold site
|
|
379
|
+
onProgress?.('Creating site...')
|
|
380
|
+
await scaffoldSite(
|
|
381
|
+
join(projectDir, 'site'),
|
|
382
|
+
{
|
|
383
|
+
name: 'site',
|
|
384
|
+
projectName,
|
|
385
|
+
foundationName: 'src',
|
|
386
|
+
foundationPath: 'file:../src',
|
|
387
|
+
foundationRef: 'src'
|
|
388
|
+
},
|
|
389
|
+
{ onProgress, onWarning }
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
// 4. Apply starter content (unless creating a "none" project). Its main.js
|
|
393
|
+
// replaces the scaffolded one, so the name is given again.
|
|
394
|
+
if (includeStarter) {
|
|
395
|
+
onProgress?.('Adding starter content...')
|
|
396
|
+
await applyStarter(projectDir, { projectName }, { onProgress, onWarning })
|
|
397
|
+
ensureFoundationName(join(projectDir, 'src'), registryName)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
success(`Created project: ${projectName}`)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Create a blank workspace (no packages, grow with `add`)
|
|
405
|
+
*/
|
|
406
|
+
async function createBlankWorkspace(projectDir, projectName, options = {}) {
|
|
407
|
+
const { onProgress, onWarning } = options
|
|
408
|
+
|
|
409
|
+
onProgress?.('Setting up blank workspace...')
|
|
410
|
+
|
|
411
|
+
await scaffoldWorkspace(
|
|
412
|
+
projectDir,
|
|
413
|
+
{
|
|
414
|
+
projectName,
|
|
415
|
+
workspaceGlobs: [],
|
|
416
|
+
scripts: {
|
|
417
|
+
build: 'uniweb build'
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
{ onProgress, onWarning }
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
success(`Created blank workspace: ${projectName}`)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Create a project from a format 2 content template
|
|
428
|
+
*
|
|
429
|
+
* Scaffolds workspace structure from package templates, then overlays
|
|
430
|
+
* content (sections, pages, theme) from the content template.
|
|
431
|
+
*/
|
|
432
|
+
async function createFromContentTemplate(
|
|
433
|
+
projectDir,
|
|
434
|
+
projectName,
|
|
435
|
+
metadata,
|
|
436
|
+
templateRootPath,
|
|
437
|
+
options = {}
|
|
438
|
+
) {
|
|
439
|
+
const { onProgress, onWarning, pm = 'pnpm' } = options
|
|
440
|
+
|
|
441
|
+
// Determine packages to create
|
|
442
|
+
// Default single-foundation single-site project uses package names
|
|
443
|
+
// 'src' (folder: src/) and 'site' (folder: site/). The folder
|
|
444
|
+
// convention is set in computePlacement() below.
|
|
445
|
+
const packages = metadata.packages || [
|
|
446
|
+
{ type: 'foundation', name: 'src' },
|
|
447
|
+
{ type: 'site', name: 'site', foundation: 'src' }
|
|
448
|
+
]
|
|
449
|
+
|
|
450
|
+
// Compute placement for each package
|
|
451
|
+
const placed = computePlacement(packages)
|
|
452
|
+
|
|
453
|
+
// Compute workspace globs and scripts from placement
|
|
454
|
+
const workspaceGlobs = placed.map((p) => p.relativePath)
|
|
455
|
+
const sites = placed.filter((p) => p.type === 'site')
|
|
456
|
+
const scripts = {
|
|
457
|
+
build: 'uniweb build'
|
|
458
|
+
}
|
|
459
|
+
// dev goes through `uniweb` (PM-agnostic; see computeRootScripts).
|
|
460
|
+
// preview stays PM-filtered until a `uniweb preview` verb exists.
|
|
461
|
+
if (sites.length === 1) {
|
|
462
|
+
scripts.dev = 'uniweb dev'
|
|
463
|
+
scripts.preview = filterCmd(pm, sites[0].name, 'preview')
|
|
464
|
+
} else {
|
|
465
|
+
for (const s of sites) {
|
|
466
|
+
scripts[`dev:${s.name}`] = `uniweb dev ${s.name}`
|
|
467
|
+
scripts[`preview:${s.name}`] = filterCmd(pm, s.name, 'preview')
|
|
468
|
+
}
|
|
469
|
+
// First site gets unqualified aliases
|
|
470
|
+
if (sites.length > 0) {
|
|
471
|
+
scripts.dev = 'uniweb dev'
|
|
472
|
+
scripts.preview = filterCmd(pm, sites[0].name, 'preview')
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// 1. Scaffold workspace
|
|
477
|
+
onProgress?.('Setting up workspace...')
|
|
478
|
+
await scaffoldWorkspace(
|
|
479
|
+
projectDir,
|
|
480
|
+
{
|
|
481
|
+
projectName,
|
|
482
|
+
workspaceGlobs,
|
|
483
|
+
scripts
|
|
484
|
+
},
|
|
485
|
+
{ onProgress, onWarning }
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
// 2. Scaffold and apply content for each package
|
|
489
|
+
const claimed = new Set() // names this project's foundations register as
|
|
490
|
+
for (const pkg of placed) {
|
|
491
|
+
const fullPath = join(projectDir, pkg.relativePath)
|
|
492
|
+
|
|
493
|
+
// A foundation's name — what it registers as — is the template's name for
|
|
494
|
+
// the package when that is one, else the project's (`src` and `foundation`
|
|
495
|
+
// name a folder); `<project>-<package>` if another package here took it. A
|
|
496
|
+
// template's own main.js `name` outranks all of them.
|
|
497
|
+
let registryName = null
|
|
498
|
+
if (pkg.type === 'foundation' || pkg.type === 'extension') {
|
|
499
|
+
registryName =
|
|
500
|
+
[
|
|
501
|
+
normalizeFoundationName(pkg.name) || normalizeFoundationName(projectName),
|
|
502
|
+
normalizeFoundationName(`${projectName}-${pkg.name}`)
|
|
503
|
+
].find((n) => n && !claimed.has(n)) || null
|
|
504
|
+
if (registryName) claimed.add(registryName)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (pkg.type === 'foundation' || pkg.type === 'extension') {
|
|
508
|
+
onProgress?.(`Creating ${pkg.type}: ${pkg.name}...`)
|
|
509
|
+
await scaffoldFoundation(
|
|
510
|
+
fullPath,
|
|
511
|
+
{
|
|
512
|
+
name: pkg.name,
|
|
513
|
+
registryName,
|
|
514
|
+
projectName,
|
|
515
|
+
isExtension: pkg.type === 'extension'
|
|
516
|
+
},
|
|
517
|
+
{ onProgress, onWarning }
|
|
518
|
+
)
|
|
519
|
+
} else if (pkg.type === 'site') {
|
|
520
|
+
// Find the foundation this site wires to
|
|
521
|
+
const foundationName = pkg.foundation || 'src'
|
|
522
|
+
const foundationPkg = placed.find(
|
|
523
|
+
(p) => p.type === 'foundation' && p.name === foundationName
|
|
524
|
+
)
|
|
525
|
+
const foundationPath = foundationPkg
|
|
526
|
+
? computeFoundationFilePath(
|
|
527
|
+
pkg.relativePath,
|
|
528
|
+
foundationPkg.relativePath
|
|
529
|
+
)
|
|
530
|
+
: 'file:../src'
|
|
531
|
+
|
|
532
|
+
onProgress?.(`Creating site: ${pkg.name}...`)
|
|
533
|
+
// Always write `foundation: <name>` to site.yml — the value is
|
|
534
|
+
// never the implicit default in the new layout (the build's
|
|
535
|
+
// `detectFoundationType` defaults to 'foundation' when absent,
|
|
536
|
+
// which doesn't match 'src').
|
|
537
|
+
await scaffoldSite(
|
|
538
|
+
fullPath,
|
|
539
|
+
{
|
|
540
|
+
name: pkg.name,
|
|
541
|
+
projectName,
|
|
542
|
+
foundationName,
|
|
543
|
+
foundationPath,
|
|
544
|
+
foundationRef: foundationName
|
|
545
|
+
},
|
|
546
|
+
{ onProgress, onWarning }
|
|
547
|
+
)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Apply content from the matching content directory
|
|
551
|
+
const contentDir = findContentDirFor(metadata.contentDirs, pkg)
|
|
552
|
+
if (contentDir) {
|
|
553
|
+
onProgress?.(`Applying ${metadata.name} content to ${pkg.name}...`)
|
|
554
|
+
await applyContent(
|
|
555
|
+
contentDir.dir,
|
|
556
|
+
fullPath,
|
|
557
|
+
{ projectName },
|
|
558
|
+
{ onProgress, onWarning }
|
|
559
|
+
)
|
|
560
|
+
ensureFoundationName(fullPath, registryName)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Merge template dependencies into package.json
|
|
564
|
+
if (metadata.dependencies) {
|
|
565
|
+
const deps =
|
|
566
|
+
metadata.dependencies[pkg.name] || metadata.dependencies[pkg.type]
|
|
567
|
+
if (deps) {
|
|
568
|
+
await mergeTemplateDependencies(join(fullPath, 'package.json'), deps)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
success(`Created project: ${projectName}`)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Compute placement (relative paths) for packages
|
|
578
|
+
*
|
|
579
|
+
* Rules:
|
|
580
|
+
* - 1 foundation → src/ (folder name is 'src' regardless of package name;
|
|
581
|
+
* the package name is typically 'src' or
|
|
582
|
+
* whatever the template declared)
|
|
583
|
+
* - Multiple foundations → foundations/{name}/
|
|
584
|
+
* - Extensions → extensions/{name}/
|
|
585
|
+
* - 1 site named "site" → site/
|
|
586
|
+
* - Multiple sites → sites/{name}/
|
|
587
|
+
*/
|
|
588
|
+
function computePlacement(packages) {
|
|
589
|
+
const foundations = packages.filter((p) => p.type === 'foundation')
|
|
590
|
+
const extensions = packages.filter((p) => p.type === 'extension')
|
|
591
|
+
const sites = packages.filter((p) => p.type === 'site')
|
|
592
|
+
|
|
593
|
+
const placed = []
|
|
594
|
+
|
|
595
|
+
for (const f of foundations) {
|
|
596
|
+
if (foundations.length === 1) {
|
|
597
|
+
placed.push({ ...f, relativePath: 'src' })
|
|
598
|
+
} else {
|
|
599
|
+
placed.push({ ...f, relativePath: `foundations/${f.name}` })
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
for (const e of extensions) {
|
|
604
|
+
placed.push({ ...e, relativePath: `extensions/${e.name}` })
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
for (const s of sites) {
|
|
608
|
+
if (sites.length === 1 && s.name === 'site') {
|
|
609
|
+
placed.push({ ...s, relativePath: 'site' })
|
|
610
|
+
} else {
|
|
611
|
+
placed.push({ ...s, relativePath: `sites/${s.name}` })
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return placed
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Find the content directory that matches a placed package
|
|
620
|
+
*/
|
|
621
|
+
function findContentDirFor(contentDirs, pkg) {
|
|
622
|
+
if (!contentDirs) return null
|
|
623
|
+
// Match by name first, then by type
|
|
624
|
+
return (
|
|
625
|
+
contentDirs.find((d) => d.name === pkg.name) ||
|
|
626
|
+
contentDirs.find((d) => d.type === pkg.type && d.name === pkg.type)
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Compute relative file: path from site to foundation
|
|
632
|
+
*/
|
|
633
|
+
function computeFoundationFilePath(sitePath, foundationPath) {
|
|
634
|
+
const rel = relative(sitePath, foundationPath)
|
|
635
|
+
return `file:${rel}`
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function main() {
|
|
639
|
+
const rawArgs = process.argv.slice(2)
|
|
640
|
+
const nonInteractive = isNonInteractive(rawArgs)
|
|
641
|
+
const args = stripNonInteractiveFlag(rawArgs)
|
|
642
|
+
const command = args[0]
|
|
643
|
+
const pm = detectPackageManager()
|
|
644
|
+
|
|
645
|
+
// ⛔ THE VARIABLE THAT AIMS A PROCESS IS UNIWEB_SERVER — UNIWEB_REGISTER_URL until 2026-10-07.
|
|
646
|
+
// Set ALONE, the old name is refused rather than ignored: ignored, a script that meant a
|
|
647
|
+
// local or staging backend would go to the default one, uniweb.app, and say nothing. Set
|
|
648
|
+
// beside the new one it is ignored, so a script can carry both while CLIs of both ages run it.
|
|
649
|
+
if (process.env.UNIWEB_REGISTER_URL && !process.env.UNIWEB_SERVER) {
|
|
650
|
+
error('UNIWEB_REGISTER_URL is now UNIWEB_SERVER — rename it, and the command goes to the same backend.')
|
|
651
|
+
await exitWhenDrained(2)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Handle --version / -v
|
|
655
|
+
//
|
|
656
|
+
// Output convention: the version goes to stdout (parseable, scriptable —
|
|
657
|
+
// `version=$(uniweb --version)` should keep working). Any staleness
|
|
658
|
+
// notice goes to stderr, so it shows in interactive terminals but
|
|
659
|
+
// doesn't pollute captured output.
|
|
660
|
+
//
|
|
661
|
+
// Two staleness paths split by stdout TTY-ness:
|
|
662
|
+
// - TTY (interactive user typed it): fetch the registry with a tight
|
|
663
|
+
// timeout. Accuracy matters — a fresh install would otherwise see
|
|
664
|
+
// no notice on its first invocation (the cache is empty). Network
|
|
665
|
+
// latency is acceptable here, capped at ~1.5s by the abort timeout.
|
|
666
|
+
// - Non-TTY (script captured stdout, piped through, etc.): cache-only.
|
|
667
|
+
// Scripts must stay fast and offline-safe. The `gh --version` /
|
|
668
|
+
// `claude --version` convention.
|
|
669
|
+
if (command === '--version' || command === '-v') {
|
|
670
|
+
console.log(`uniweb ${getCliVersion()}`)
|
|
671
|
+
if (isGlobalInstall()) {
|
|
672
|
+
try {
|
|
673
|
+
const { fetchAndNotifyIfNewer, maybeNotifyFromCache } =
|
|
674
|
+
await import('./utils/update-check.js')
|
|
675
|
+
if (process.stdout.isTTY) {
|
|
676
|
+
await fetchAndNotifyIfNewer(getCliVersion())
|
|
677
|
+
} else {
|
|
678
|
+
maybeNotifyFromCache(getCliVersion())
|
|
679
|
+
}
|
|
680
|
+
} catch {
|
|
681
|
+
/* ignore */
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Global install launcher: delegate project-bound commands to local CLI
|
|
688
|
+
const global = isGlobalInstall()
|
|
689
|
+
if (global && command && !STANDALONE_COMMANDS.has(command)) {
|
|
690
|
+
const localCli = findLocalCli()
|
|
691
|
+
if (localCli) {
|
|
692
|
+
await delegateToLocal(localCli)
|
|
693
|
+
return
|
|
694
|
+
}
|
|
695
|
+
// No local CLI found — fall through and try to run the command directly.
|
|
696
|
+
// Commands that need @uniweb/build will get a helpful error via importProjectCommand().
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Update check for global installs.
|
|
700
|
+
//
|
|
701
|
+
// - `create` / `clone` STOP when a newer release exists, before anything is
|
|
702
|
+
// written: a project starts on the packages and templates of the CLI that
|
|
703
|
+
// creates it, and a global install runs these two itself — there is no
|
|
704
|
+
// project CLI to delegate to yet (refuseOutdatedProjectStart). Their
|
|
705
|
+
// `--help` still prints.
|
|
706
|
+
// - every other verb gets a trailing notice (showUpdateNotification): they
|
|
707
|
+
// are project-bound (delegated to local node_modules) or do not depend on
|
|
708
|
+
// the version, and the local-vs-global mismatch warning in
|
|
709
|
+
// delegateToLocal covers the first case.
|
|
710
|
+
let showUpdateNotification = () => {}
|
|
711
|
+
if (global) {
|
|
712
|
+
let updateCheck = null
|
|
713
|
+
try {
|
|
714
|
+
updateCheck = await import('./utils/update-check.js')
|
|
715
|
+
} catch {
|
|
716
|
+
// Update check is optional — don't fail if the module is missing
|
|
717
|
+
}
|
|
718
|
+
const startsProject = command === 'create' || command === 'clone'
|
|
719
|
+
const asksForHelp = args.slice(1).some((a) => a === '--help' || a === '-h')
|
|
720
|
+
if (
|
|
721
|
+
updateCheck &&
|
|
722
|
+
startsProject &&
|
|
723
|
+
!asksForHelp &&
|
|
724
|
+
(await updateCheck.refuseOutdatedProjectStart(getCliVersion(), args))
|
|
725
|
+
) {
|
|
726
|
+
await exitWhenDrained(1)
|
|
727
|
+
}
|
|
728
|
+
if (updateCheck) showUpdateNotification = updateCheck.startUpdateCheck(getCliVersion())
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// Show help
|
|
732
|
+
if (!command || command === '--help' || command === '-h') {
|
|
733
|
+
showHelp()
|
|
734
|
+
await showUpdateNotification()
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Per-command --help: short-circuit BEFORE the command's side effects run.
|
|
739
|
+
// Critical for `deploy --help` (used to open a browser to production for
|
|
740
|
+
// login because deploy.js doesn't parse --help and ensureAuth ran first).
|
|
741
|
+
// Falls back to the global help when a command has no dedicated block.
|
|
742
|
+
if (args.slice(1).some((a) => a === '--help' || a === '-h')) {
|
|
743
|
+
const printed = printCommandHelp(command)
|
|
744
|
+
if (printed) {
|
|
745
|
+
await showUpdateNotification()
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
// No dedicated block — show global help as a useful fallback rather
|
|
749
|
+
// than executing the command (which often has side effects).
|
|
750
|
+
showHelp()
|
|
751
|
+
await showUpdateNotification()
|
|
752
|
+
return
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Handle build command (dynamic import — depends on @uniweb/build)
|
|
756
|
+
if (command === 'build') {
|
|
757
|
+
const { build } = await importProjectCommand('./commands/build.js')
|
|
758
|
+
await build(args.slice(1))
|
|
759
|
+
await showUpdateNotification()
|
|
760
|
+
return
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Handle dev command — thin wrapper that shells to the package manager's
|
|
764
|
+
// workspace-filtered `dev` script (mirrors what `uniweb create` writes
|
|
765
|
+
// into the root package.json::scripts.dev). Lazy import keeps startup
|
|
766
|
+
// fast when the user is not running dev.
|
|
767
|
+
if (command === 'dev') {
|
|
768
|
+
const { dev } = await import('./commands/dev.js')
|
|
769
|
+
await dev(args.slice(1))
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Handle snapshot command (dynamic import — depends on @uniweb/build; loads
|
|
774
|
+
// @uniweb/snapshot itself, from the workspace, and says how to add it if absent)
|
|
775
|
+
if (command === 'snapshot') {
|
|
776
|
+
const { snapshot } = await importProjectCommand('./commands/snapshot.js')
|
|
777
|
+
await snapshot(args.slice(1))
|
|
778
|
+
await showUpdateNotification()
|
|
779
|
+
return
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Handle docs command (dynamic import — depends on @uniweb/build)
|
|
783
|
+
if (command === 'docs') {
|
|
784
|
+
const { docs } = await importProjectCommand('./commands/docs.js')
|
|
785
|
+
await docs(args.slice(1))
|
|
786
|
+
await showUpdateNotification()
|
|
787
|
+
return
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Handle i18n command
|
|
791
|
+
if (command === 'i18n') {
|
|
792
|
+
await i18n(args.slice(1))
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Handle doctor command (dynamic import — depends on @uniweb/build)
|
|
797
|
+
if (command === 'doctor') {
|
|
798
|
+
const { doctor } = await importProjectCommand('./commands/doctor.js')
|
|
799
|
+
const result = await doctor(args.slice(1))
|
|
800
|
+
await exitWhenDrained(result?.errors > 0 ? 1 : 0)
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Handle validate command (dynamic import — depends on @uniweb/build)
|
|
804
|
+
if (command === 'validate') {
|
|
805
|
+
const { validate } = await importProjectCommand('./commands/validate.js')
|
|
806
|
+
const result = await validate(args.slice(1))
|
|
807
|
+
await exitWhenDrained(result?.exitCode ?? 0)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Handle register command (dynamic import — depends on @uniweb/build).
|
|
811
|
+
// `release` is a code-only synonym: `register` reads naturally the first
|
|
812
|
+
// time, `release` for updates (shipping-model.md §6.1). Same act, same code.
|
|
813
|
+
if (command === 'register' || command === 'release') {
|
|
814
|
+
const { register } = await importProjectCommand('./commands/register.js')
|
|
815
|
+
const result = await register(args.slice(1))
|
|
816
|
+
await exitWhenDrained(result?.exitCode ?? 0)
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// Handle push command (dynamic import — depends on @uniweb/build)
|
|
820
|
+
if (command === 'push') {
|
|
821
|
+
const { push } = await importProjectCommand('./commands/push.js')
|
|
822
|
+
const result = await push(args.slice(1))
|
|
823
|
+
await exitWhenDrained(result?.exitCode ?? 0)
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Handle pull command (dynamic import — depends on @uniweb/build)
|
|
827
|
+
if (command === 'pull') {
|
|
828
|
+
const { pull } = await importProjectCommand('./commands/pull.js')
|
|
829
|
+
const result = await pull(args.slice(1))
|
|
830
|
+
await exitWhenDrained(result?.exitCode ?? 0)
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Handle sync command (refresh + push; both halves imported, never reimplemented)
|
|
834
|
+
if (command === 'sync') {
|
|
835
|
+
const { sync } = await importProjectCommand('./commands/sync.js')
|
|
836
|
+
const result = await sync(args.slice(1))
|
|
837
|
+
await exitWhenDrained(result?.exitCode ?? 0)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Handle refresh command (dynamic import — depends on @uniweb/build via pull)
|
|
841
|
+
if (command === 'refresh') {
|
|
842
|
+
const { refresh } = await importProjectCommand('./commands/refresh.js')
|
|
843
|
+
const result = await refresh(args.slice(1))
|
|
844
|
+
await exitWhenDrained(result?.exitCode ?? 0)
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// Handle forget — remove one backend's local records (sync.json, cache, deploy
|
|
848
|
+
// records), or with --all everything a copied project inherited. Local only: every
|
|
849
|
+
// site still exists on its backend. Needs @uniweb/build (the store).
|
|
850
|
+
if (command === 'forget') {
|
|
851
|
+
const { forget } = await importProjectCommand('./commands/forget.js')
|
|
852
|
+
const result = await forget(args.slice(1))
|
|
853
|
+
await exitWhenDrained(result?.exitCode ?? 0)
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Handle status command (dynamic import — offline emit via @uniweb/build)
|
|
857
|
+
if (command === 'status') {
|
|
858
|
+
const { status } = await importProjectCommand('./commands/status.js')
|
|
859
|
+
const result = await status(args.slice(1))
|
|
860
|
+
await exitWhenDrained(result?.exitCode ?? 0)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Handle clone command (global — bootstraps a new project from a backend site;
|
|
864
|
+
// STANDALONE, so a global `uniweb clone` runs here instead of delegating to a
|
|
865
|
+
// project-local CLI that doesn't exist yet. clone.js avoids any static
|
|
866
|
+
// @uniweb/build import, then delegates the projection to the project-local
|
|
867
|
+
// `uniweb pull` after install.)
|
|
868
|
+
if (command === 'clone') {
|
|
869
|
+
const result = await clone(args.slice(1))
|
|
870
|
+
await exitWhenDrained(result?.exitCode ?? 0)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Handle update command
|
|
874
|
+
if (command === 'update') {
|
|
875
|
+
await update(args.slice(1))
|
|
876
|
+
return
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Handle inspect command
|
|
880
|
+
if (command === 'inspect') {
|
|
881
|
+
await inspect(args.slice(1))
|
|
882
|
+
return
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Handle add command (dynamic import — depends on @uniweb/build)
|
|
886
|
+
if (command === 'add') {
|
|
887
|
+
const { add } = await importProjectCommand('./commands/add.js')
|
|
888
|
+
await add(args.slice(1))
|
|
889
|
+
return
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Handle rename command (dynamic import — depends on @uniweb/build via deps)
|
|
893
|
+
if (command === 'rename') {
|
|
894
|
+
const { rename } = await importProjectCommand('./commands/rename.js')
|
|
895
|
+
await rename(args.slice(1))
|
|
896
|
+
return
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// Handle publish command — the smart Uniweb-hosting path: bring the
|
|
900
|
+
// foundation along, sync, settle payment, go live. Distinct from `deploy`
|
|
901
|
+
// (third-party hosts) and `register`/`release` (foundation code → catalog).
|
|
902
|
+
if (command === 'publish') {
|
|
903
|
+
const { publish } = await importProjectCommand('./commands/publish.js')
|
|
904
|
+
const result = await publish(args.slice(1))
|
|
905
|
+
await exitWhenDrained(result?.exitCode ?? 0)
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Handle deploy command — third-party hosts only (dynamic import — @uniweb/build)
|
|
909
|
+
if (command === 'deploy') {
|
|
910
|
+
const { deploy } = await importProjectCommand('./commands/deploy.js')
|
|
911
|
+
await deploy(args.slice(1))
|
|
912
|
+
return
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Handle export command (dynamic import — depends on @uniweb/build)
|
|
916
|
+
if (command === 'export') {
|
|
917
|
+
const { exportSite } = await importProjectCommand('./commands/export.js')
|
|
918
|
+
await exportSite(args.slice(1))
|
|
919
|
+
return
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Handle content command (dynamic import — depends on @uniweb/build/uwx)
|
|
923
|
+
if (command === 'content') {
|
|
924
|
+
const { content } = await importProjectCommand('./commands/content.js')
|
|
925
|
+
await content(args.slice(1))
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Handle login command — the backend (username/password · paste a token ·
|
|
930
|
+
// --token <bearer>). ⭐ `--server <url>` names it; without the flag it is the DEFAULT
|
|
931
|
+
// backend — UNIWEB_SERVER, else ~/.uniweb/config.json, else https://uniweb.app —
|
|
932
|
+
// and never the project's backend *[Diego, 2026-09-21: "the default backend for login, if
|
|
933
|
+
// not specified, is uniweb.app"]*. ⭐ Except a WORKSPACE SWITCH — `--org` / `--personal`
|
|
934
|
+
// and no way of signing in — which acts on the backend you are logged in to *[Diego,
|
|
935
|
+
// 2026-10-06]* (`resolveLoginOrigin`). The backend logged in to becomes CURRENT, and
|
|
936
|
+
// every backend command goes there (only UNIWEB_SERVER outranks it) — which is why
|
|
937
|
+
// this is the one place a backend is chosen.
|
|
938
|
+
//
|
|
939
|
+
// ⛔ Until 2026-09-21 a bare login went to the backend of the project in the cwd, and
|
|
940
|
+
// asked when the machine knew several backends. Both are gone: the default is the
|
|
941
|
+
// default, and any other backend is named.
|
|
942
|
+
if (command === 'login') {
|
|
943
|
+
const loginArgs = args.slice(1)
|
|
944
|
+
const { readFlagValue } = await import('./utils/args.js')
|
|
945
|
+
const { runRegistryLogin } = await import('./utils/registry-auth.js')
|
|
946
|
+
const { resolveLoginOrigin } = await import('./utils/config.js')
|
|
947
|
+
// `--backend` was this flag's name until 2026-10-07 — renamed so it is not read as a
|
|
948
|
+
// site's `backend` service. Refused with the new name, never treated as unknown and dropped:
|
|
949
|
+
// dropped, the login would go to the default backend.
|
|
950
|
+
if (loginArgs.some((a) => a === '--backend' || a.startsWith('--backend='))) {
|
|
951
|
+
console.error('\x1b[31m✗\x1b[0m `--backend` is now `--server`: uniweb login --server <url>')
|
|
952
|
+
await exitWhenDrained(2)
|
|
953
|
+
}
|
|
954
|
+
let apiBase
|
|
955
|
+
try {
|
|
956
|
+
apiBase = resolveLoginOrigin(readFlagValue(loginArgs, '--server'), loginArgs)
|
|
957
|
+
} catch (err) {
|
|
958
|
+
console.error(
|
|
959
|
+
`\x1b[31m✗\x1b[0m ${err.message} — e.g. uniweb login --server http://localhost:8080`
|
|
960
|
+
)
|
|
961
|
+
await exitWhenDrained(2)
|
|
962
|
+
}
|
|
963
|
+
await runRegistryLogin({ apiBase, args: loginArgs })
|
|
964
|
+
return
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Handle logout — THE session. ⭐ There is at most one *[Diego, 2026-09-21: "`uniweb
|
|
968
|
+
// logout` always logs you out of the one backend you may be logged in"]*, so logout
|
|
969
|
+
// takes no options: `--backend` and `--all` existed only while sessions were kept per
|
|
970
|
+
// backend. ⚠️ Local only — the token is forgotten here, not revoked on the backend.
|
|
971
|
+
if (command === 'logout') {
|
|
972
|
+
const stray = args.slice(1).find((a) => a.startsWith('-') && a !== '--non-interactive')
|
|
973
|
+
if (stray) {
|
|
974
|
+
console.error(
|
|
975
|
+
`\x1b[31m✗\x1b[0m \`uniweb logout\` takes no options (got ${stray}) — it logs you out of the backend you are logged in to.`
|
|
976
|
+
)
|
|
977
|
+
await exitWhenDrained(2)
|
|
978
|
+
}
|
|
979
|
+
const { clearRegistryAuth } = await import('./utils/registry-auth.js')
|
|
980
|
+
const { loggedInOrigin } = await import('./utils/config.js')
|
|
981
|
+
const origin = loggedInOrigin()
|
|
982
|
+
const cleared = await clearRegistryAuth()
|
|
983
|
+
if (!cleared.length) {
|
|
984
|
+
console.log('Not logged in — nothing to clear.')
|
|
985
|
+
return
|
|
986
|
+
}
|
|
987
|
+
console.log(`\x1b[32m✓\x1b[0m Logged out${origin ? ` of ${origin}` : ''}.`)
|
|
988
|
+
// A file from before sessions were single can hold several; all of them go.
|
|
989
|
+
const others = cleared.filter((o) => o !== origin)
|
|
990
|
+
if (others.length)
|
|
991
|
+
console.log(`\x1b[2mAlso removed stored sessions for: ${others.join(', ')}\x1b[0m`)
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// Handle site command — the sites in a workspace: list them, unpublish or delete one.
|
|
996
|
+
// Runs anywhere: inside a project it can act on the site the project is synced to.
|
|
997
|
+
if (command === 'site') {
|
|
998
|
+
const { site } = await import('./commands/site.js')
|
|
999
|
+
const result = await site(args.slice(1))
|
|
1000
|
+
// Its long list is where a bare exit was first caught cutting a pipe short — pinned by
|
|
1001
|
+
// test/site-list-pipe.test.js; why every exit waits: utils/exit.js.
|
|
1002
|
+
await exitWhenDrained(result?.exitCode ?? 0)
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Handle org command (new-backend orgs/units — publish-scope management)
|
|
1006
|
+
if (command === 'org') {
|
|
1007
|
+
const { org } = await import('./commands/org.js')
|
|
1008
|
+
await org(args.slice(1))
|
|
1009
|
+
return
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Handle invite command
|
|
1013
|
+
if (command === 'invite') {
|
|
1014
|
+
await invite(args.slice(1))
|
|
1015
|
+
return
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Handle handoff command
|
|
1019
|
+
if (command === 'handoff') {
|
|
1020
|
+
await handoff(args.slice(1))
|
|
1021
|
+
return
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Handle families command — the standard section families this CLI ships with
|
|
1025
|
+
if (command === 'families') {
|
|
1026
|
+
const { families } = await import('./commands/families.js')
|
|
1027
|
+
await families(args.slice(1))
|
|
1028
|
+
return
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Handle template command
|
|
1032
|
+
if (command === 'template') {
|
|
1033
|
+
const result = await template(args.slice(1))
|
|
1034
|
+
await exitWhenDrained(result?.exitCode ?? 0)
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Handle create command
|
|
1038
|
+
if (command !== 'create') {
|
|
1039
|
+
error(`Unknown command: ${command}`)
|
|
1040
|
+
showHelp()
|
|
1041
|
+
await exitWhenDrained(1)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
title('Uniweb Project Generator')
|
|
1045
|
+
|
|
1046
|
+
// Guard: prevent creating nested workspaces
|
|
1047
|
+
const existingRoot = findWorkspaceRoot(process.cwd())
|
|
1048
|
+
if (existingRoot) {
|
|
1049
|
+
error(`Already inside a Uniweb workspace: ${existingRoot}`)
|
|
1050
|
+
log(`\nTo add packages to this workspace, use:`)
|
|
1051
|
+
log(` ${colors.cyan}uniweb add foundation [name]${colors.reset}`)
|
|
1052
|
+
log(` ${colors.cyan}uniweb add site [name]${colors.reset}`)
|
|
1053
|
+
log(
|
|
1054
|
+
` ${colors.cyan}uniweb add foundation --from <template>${colors.reset}\n`
|
|
1055
|
+
)
|
|
1056
|
+
await exitWhenDrained(1)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Parse arguments
|
|
1060
|
+
let projectName = args[1]
|
|
1061
|
+
let templateType = null // null = use new package template flow
|
|
1062
|
+
|
|
1063
|
+
// In-place mode: `uniweb create .` scaffolds into the current working
|
|
1064
|
+
// directory instead of creating a new one. Pairs with the GitHub-first
|
|
1065
|
+
// workflow where the user already cloned an empty repo (README.md and
|
|
1066
|
+
// optionally .gitignore present) and wants to scaffold inside it.
|
|
1067
|
+
const inPlace = projectName === '.'
|
|
1068
|
+
if (inPlace) {
|
|
1069
|
+
// Clear the positional so downstream logic (template prompt, name
|
|
1070
|
+
// prompt, etc.) doesn't see `.` as a literal name. The actual project
|
|
1071
|
+
// name is derived below from the cwd basename or `--name`.
|
|
1072
|
+
projectName = null
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Check for --template flag
|
|
1076
|
+
const templateIndex = args.indexOf('--template')
|
|
1077
|
+
if (templateIndex !== -1 && args[templateIndex + 1]) {
|
|
1078
|
+
templateType = args[templateIndex + 1]
|
|
1079
|
+
// Validate template identifier (will throw if invalid)
|
|
1080
|
+
try {
|
|
1081
|
+
parseTemplateId(templateType)
|
|
1082
|
+
} catch (err) {
|
|
1083
|
+
error(`Invalid template: ${err.message}`)
|
|
1084
|
+
await exitWhenDrained(1)
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Check for --name flag. Accepts both `--name foo` and `--name=foo`.
|
|
1089
|
+
let displayName = null
|
|
1090
|
+
const nameIndex = args.indexOf('--name')
|
|
1091
|
+
if (nameIndex !== -1 && args[nameIndex + 1]) {
|
|
1092
|
+
displayName = args[nameIndex + 1]
|
|
1093
|
+
} else {
|
|
1094
|
+
const nameEq = args.find((a) => a.startsWith('--name='))
|
|
1095
|
+
if (nameEq) displayName = nameEq.slice('--name='.length)
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Check for --blank flag
|
|
1099
|
+
let isBlank = args.includes('--blank')
|
|
1100
|
+
|
|
1101
|
+
// Handle --template blank as alias for --blank
|
|
1102
|
+
if (templateType === 'blank') {
|
|
1103
|
+
isBlank = true
|
|
1104
|
+
templateType = null
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Check for --no-git flag
|
|
1108
|
+
const noGit = args.includes('--no-git')
|
|
1109
|
+
|
|
1110
|
+
// Skip positional name if it starts with -- (it's a flag, not a name)
|
|
1111
|
+
if (projectName && projectName.startsWith('--')) {
|
|
1112
|
+
projectName = null
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const prefix = getCliPrefix()
|
|
1116
|
+
|
|
1117
|
+
// In-place: derive the project name from the cwd basename (slugified),
|
|
1118
|
+
// or use --name when provided. Skip the interactive name prompt below.
|
|
1119
|
+
if (inPlace) {
|
|
1120
|
+
if (displayName) {
|
|
1121
|
+
projectName = displayName
|
|
1122
|
+
} else {
|
|
1123
|
+
const dirName = basename(process.cwd())
|
|
1124
|
+
const slug = slugifyName(dirName)
|
|
1125
|
+
if (!slug) {
|
|
1126
|
+
error(
|
|
1127
|
+
`Could not derive a valid project name from the current directory ("${dirName}").`
|
|
1128
|
+
)
|
|
1129
|
+
log(`Re-run with ${colors.cyan}--name=<your-name>${colors.reset}.`)
|
|
1130
|
+
await exitWhenDrained(1)
|
|
1131
|
+
}
|
|
1132
|
+
projectName = slug
|
|
1133
|
+
if (slug !== dirName) {
|
|
1134
|
+
log(
|
|
1135
|
+
`${colors.dim}Project name:${colors.reset} ${slug} ${colors.dim}(slugified from "${dirName}")${colors.reset}`
|
|
1136
|
+
)
|
|
1137
|
+
} else {
|
|
1138
|
+
log(`${colors.dim}Project name:${colors.reset} ${slug}`)
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// Non-interactive: fail with actionable message instead of prompting
|
|
1144
|
+
if (nonInteractive && !projectName) {
|
|
1145
|
+
error(`Missing project name.\n`)
|
|
1146
|
+
log(`Usage: ${prefix} create <project-name> [--template <name>] [--blank]`)
|
|
1147
|
+
await exitWhenDrained(1)
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Non-interactive: default to starter when no template specified
|
|
1151
|
+
if (nonInteractive && !templateType && !isBlank) {
|
|
1152
|
+
templateType = 'starter'
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Interactive prompts (skipped in in-place mode — name was derived above)
|
|
1156
|
+
const response = await prompts(
|
|
1157
|
+
[
|
|
1158
|
+
{
|
|
1159
|
+
type: projectName ? null : 'text',
|
|
1160
|
+
name: 'projectName',
|
|
1161
|
+
message: 'Project name:',
|
|
1162
|
+
initial: 'website',
|
|
1163
|
+
validate: (value) => {
|
|
1164
|
+
if (!value) return 'Project name is required'
|
|
1165
|
+
if (!/^[a-z0-9-]+$/.test(value)) {
|
|
1166
|
+
return 'Project name can only contain lowercase letters, numbers, and hyphens'
|
|
1167
|
+
}
|
|
1168
|
+
return true
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
],
|
|
1172
|
+
{
|
|
1173
|
+
onCancel: () => {
|
|
1174
|
+
log('\nScaffolding cancelled.')
|
|
1175
|
+
process.exit(0) // a prompt: a terminal, so nothing to drain — and it must stop here, now
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
projectName = projectName || response.projectName
|
|
1181
|
+
|
|
1182
|
+
if (!projectName) {
|
|
1183
|
+
error('Missing project name')
|
|
1184
|
+
await exitWhenDrained(1)
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// Prompt for template if not specified via --template or --blank
|
|
1188
|
+
if (!templateType && !isBlank) {
|
|
1189
|
+
const templateResponse = await prompts(
|
|
1190
|
+
{
|
|
1191
|
+
type: 'select',
|
|
1192
|
+
name: 'template',
|
|
1193
|
+
message: 'Template:',
|
|
1194
|
+
choices: TEMPLATE_CHOICES,
|
|
1195
|
+
initial: 1,
|
|
1196
|
+
optionsPerPage: templatePickerPageSize(TEMPLATE_CHOICES.length)
|
|
1197
|
+
},
|
|
1198
|
+
{
|
|
1199
|
+
onCancel: () => {
|
|
1200
|
+
log('\nScaffolding cancelled.')
|
|
1201
|
+
process.exit(0) // a prompt: a terminal, so nothing to drain — and it must stop here, now
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
)
|
|
1205
|
+
templateType = templateResponse.template
|
|
1206
|
+
// Handle "blank" selection from interactive prompt
|
|
1207
|
+
if (templateType === 'blank') {
|
|
1208
|
+
isBlank = true
|
|
1209
|
+
templateType = null
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const effectiveName = displayName || projectName
|
|
1214
|
+
|
|
1215
|
+
// Resolve target directory. In-place mode scaffolds into the cwd;
|
|
1216
|
+
// otherwise create `./<projectName>`.
|
|
1217
|
+
const projectDir = inPlace
|
|
1218
|
+
? process.cwd()
|
|
1219
|
+
: resolve(process.cwd(), projectName)
|
|
1220
|
+
|
|
1221
|
+
if (!inPlace && existsSync(projectDir)) {
|
|
1222
|
+
error(`Directory already exists: ${projectName}`)
|
|
1223
|
+
await exitWhenDrained(1)
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
if (inPlace) {
|
|
1227
|
+
// Conflict check: enumerate the workspace template's would-write paths
|
|
1228
|
+
// (the only stage that touches the project root) and bail if any
|
|
1229
|
+
// collide with existing files outside the small allowlist.
|
|
1230
|
+
//
|
|
1231
|
+
// Allowlist: README.md and .gitignore overwrite cleanly. README is
|
|
1232
|
+
// typically GitHub-generated boilerplate; .gitignore should be ours
|
|
1233
|
+
// since the scaffold ships sensible Vite/Node ignores.
|
|
1234
|
+
const outputs = await getWorkspaceTemplateOutputs({ blank: isBlank })
|
|
1235
|
+
const conflicts = []
|
|
1236
|
+
for (const rel of outputs) {
|
|
1237
|
+
const full = resolve(projectDir, rel)
|
|
1238
|
+
if (existsSync(full) && !IN_PLACE_OVERWRITE_ALLOWED.has(rel)) {
|
|
1239
|
+
conflicts.push(rel)
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
if (conflicts.length > 0) {
|
|
1243
|
+
error(`Cannot scaffold in place — these files would be overwritten:`)
|
|
1244
|
+
for (const c of conflicts) log(` ${colors.yellow}${c}${colors.reset}`)
|
|
1245
|
+
log('')
|
|
1246
|
+
log(
|
|
1247
|
+
`Move or remove them, then re-run ${colors.cyan}uniweb create .${colors.reset}.`
|
|
1248
|
+
)
|
|
1249
|
+
await exitWhenDrained(1)
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// Template routing logic
|
|
1254
|
+
const progressCb = (msg) => log(` ${colors.dim}${msg}${colors.reset}`)
|
|
1255
|
+
const warningCb = (msg) =>
|
|
1256
|
+
log(` ${colors.yellow}Warning: ${msg}${colors.reset}`)
|
|
1257
|
+
|
|
1258
|
+
if (isBlank) {
|
|
1259
|
+
// Blank workspace (--blank or --template blank)
|
|
1260
|
+
log('\nCreating blank workspace...')
|
|
1261
|
+
await createBlankWorkspace(projectDir, effectiveName, {
|
|
1262
|
+
onProgress: progressCb,
|
|
1263
|
+
onWarning: warningCb
|
|
1264
|
+
})
|
|
1265
|
+
} else if (templateType === 'none') {
|
|
1266
|
+
// Foundation + site with no content
|
|
1267
|
+
log('\nCreating project...')
|
|
1268
|
+
await createFromPackageTemplates(projectDir, effectiveName, {
|
|
1269
|
+
onProgress: progressCb,
|
|
1270
|
+
onWarning: warningCb,
|
|
1271
|
+
pm,
|
|
1272
|
+
includeStarter: false
|
|
1273
|
+
})
|
|
1274
|
+
} else if (templateType === 'starter') {
|
|
1275
|
+
// Starter: foundation + site + sample content
|
|
1276
|
+
log('\nCreating project...')
|
|
1277
|
+
await createFromPackageTemplates(projectDir, effectiveName, {
|
|
1278
|
+
onProgress: progressCb,
|
|
1279
|
+
onWarning: warningCb,
|
|
1280
|
+
pm
|
|
1281
|
+
})
|
|
1282
|
+
} else {
|
|
1283
|
+
// External: official/npm/github/local
|
|
1284
|
+
log(`\nResolving template: ${templateType}...`)
|
|
1285
|
+
|
|
1286
|
+
try {
|
|
1287
|
+
const resolved = await resolveTemplate(templateType, {
|
|
1288
|
+
onProgress: progressCb
|
|
1289
|
+
})
|
|
1290
|
+
|
|
1291
|
+
log(
|
|
1292
|
+
`\nCreating project from ${resolved.name || resolved.package || `${resolved.owner}/${resolved.repo}`}...`
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
// Validate and apply as format 2 content template
|
|
1296
|
+
const metadata = await validateTemplate(resolved.path)
|
|
1297
|
+
|
|
1298
|
+
try {
|
|
1299
|
+
await createFromContentTemplate(
|
|
1300
|
+
projectDir,
|
|
1301
|
+
effectiveName,
|
|
1302
|
+
metadata,
|
|
1303
|
+
resolved.path,
|
|
1304
|
+
{
|
|
1305
|
+
onProgress: progressCb,
|
|
1306
|
+
onWarning: warningCb,
|
|
1307
|
+
pm
|
|
1308
|
+
}
|
|
1309
|
+
)
|
|
1310
|
+
} finally {
|
|
1311
|
+
if (resolved.cleanup) await resolved.cleanup()
|
|
1312
|
+
}
|
|
1313
|
+
} catch (err) {
|
|
1314
|
+
error(`Failed to apply template: ${err.message}`)
|
|
1315
|
+
log('')
|
|
1316
|
+
log(`${colors.yellow}Troubleshooting:${colors.reset}`)
|
|
1317
|
+
log(` • Check your network connection`)
|
|
1318
|
+
log(
|
|
1319
|
+
` • Official templates require GitHub access (may be blocked by corporate networks)`
|
|
1320
|
+
)
|
|
1321
|
+
log(
|
|
1322
|
+
` • Try the starter template instead: ${colors.cyan}uniweb create ${projectName} --template starter${colors.reset}`
|
|
1323
|
+
)
|
|
1324
|
+
await exitWhenDrained(1)
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Initialize git repository
|
|
1329
|
+
if (!noGit) {
|
|
1330
|
+
// Skip git init if already inside a git repo (common for monorepos/workspaces)
|
|
1331
|
+
let insideGitRepo = false
|
|
1332
|
+
try {
|
|
1333
|
+
execSync('git rev-parse --is-inside-work-tree', {
|
|
1334
|
+
cwd: projectDir,
|
|
1335
|
+
stdio: 'ignore'
|
|
1336
|
+
})
|
|
1337
|
+
insideGitRepo = true
|
|
1338
|
+
} catch {
|
|
1339
|
+
// Not inside a git repo — proceed with init
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
if (insideGitRepo) {
|
|
1343
|
+
log(
|
|
1344
|
+
` ${colors.dim}Skipping git init — already inside a git repository${colors.reset}`
|
|
1345
|
+
)
|
|
1346
|
+
} else {
|
|
1347
|
+
try {
|
|
1348
|
+
execSync('git --version', { stdio: 'ignore' })
|
|
1349
|
+
try {
|
|
1350
|
+
execSync('git init', { cwd: projectDir, stdio: 'ignore' })
|
|
1351
|
+
execSync('git add -A', { cwd: projectDir, stdio: 'ignore' })
|
|
1352
|
+
execSync('git commit -m "Initial commit from uniweb"', {
|
|
1353
|
+
cwd: projectDir,
|
|
1354
|
+
stdio: 'ignore'
|
|
1355
|
+
})
|
|
1356
|
+
success('Git repository initialized')
|
|
1357
|
+
} catch {
|
|
1358
|
+
log(
|
|
1359
|
+
` ${colors.yellow}Warning: Git repository initialized but initial commit failed${colors.reset}`
|
|
1360
|
+
)
|
|
1361
|
+
log(
|
|
1362
|
+
` ${colors.dim}Run 'git commit -m "Initial commit"' after configuring git${colors.reset}`
|
|
1363
|
+
)
|
|
1364
|
+
}
|
|
1365
|
+
} catch {
|
|
1366
|
+
// git not available — skip silently
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// Success message
|
|
1372
|
+
title('Project created successfully!')
|
|
1373
|
+
|
|
1374
|
+
// Recommend pnpm (the framework's package manager) when it's installed,
|
|
1375
|
+
// otherwise fall back to however the user invoked the CLI. The scaffold
|
|
1376
|
+
// supports both — the root package.json declares npm `workspaces` and a
|
|
1377
|
+
// pnpm-workspace.yaml ships next to it.
|
|
1378
|
+
const recPm = isPnpmAvailable() ? 'pnpm' : pm
|
|
1379
|
+
|
|
1380
|
+
if (isBlank) {
|
|
1381
|
+
log(`Next steps:\n`)
|
|
1382
|
+
if (!inPlace) log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
|
|
1383
|
+
log(` ${colors.cyan}${prefix} add project${colors.reset}`)
|
|
1384
|
+
log(` ${colors.cyan}${installCmd(recPm)}${colors.reset}`)
|
|
1385
|
+
log(
|
|
1386
|
+
` ${colors.cyan}${runCmd(recPm, 'dev')}${colors.reset} ${colors.dim}# start the dev server${colors.reset}`
|
|
1387
|
+
)
|
|
1388
|
+
} else {
|
|
1389
|
+
log(`Next steps:\n`)
|
|
1390
|
+
if (!inPlace) log(` ${colors.cyan}cd ${projectName}${colors.reset}`)
|
|
1391
|
+
log(` ${colors.cyan}${installCmd(recPm)}${colors.reset}`)
|
|
1392
|
+
log(
|
|
1393
|
+
` ${colors.cyan}${runCmd(recPm, 'dev')}${colors.reset} ${colors.dim}# start the dev server${colors.reset}`
|
|
1394
|
+
)
|
|
1395
|
+
}
|
|
1396
|
+
if (recPm !== 'pnpm') {
|
|
1397
|
+
log('')
|
|
1398
|
+
log(
|
|
1399
|
+
` ${colors.dim}Tip: pnpm is recommended (https://pnpm.io) — npm works too.${colors.reset}`
|
|
1400
|
+
)
|
|
1401
|
+
}
|
|
1402
|
+
log('')
|
|
1403
|
+
log(
|
|
1404
|
+
` ${colors.dim}See ${colors.reset}${colors.cyan}${prefix} <command> --help${colors.reset}${colors.dim} for command-specific options.${colors.reset}`
|
|
1405
|
+
)
|
|
1406
|
+
log('')
|
|
1407
|
+
|
|
1408
|
+
await showUpdateNotification()
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* Print help for a specific command. Returns true if a dedicated help
|
|
1413
|
+
* block exists for the command, false to signal "fall back to global
|
|
1414
|
+
* help."
|
|
1415
|
+
*
|
|
1416
|
+
* Help text intentionally lives next to the dispatcher rather than in
|
|
1417
|
+
* the per-command files because most help-seekers haven't run that
|
|
1418
|
+
* command yet — keeping it here means `uniweb foo --help` prints
|
|
1419
|
+
* without loading @uniweb/build or any project context.
|
|
1420
|
+
*/
|
|
1421
|
+
function printCommandHelp(command) {
|
|
1422
|
+
// `release` is a synonym of `register` (shipping-model.md §6.1) — show the
|
|
1423
|
+
// same help block.
|
|
1424
|
+
if (command === 'release') command = 'register'
|
|
1425
|
+
const blocks = {
|
|
1426
|
+
deploy: `
|
|
1427
|
+
${colors.cyan}${colors.bright}uniweb deploy${colors.reset} ${colors.dim}— Ship a site to a host${colors.reset}
|
|
1428
|
+
|
|
1429
|
+
${colors.bright}Usage:${colors.reset}
|
|
1430
|
+
uniweb deploy [options]
|
|
1431
|
+
|
|
1432
|
+
With no destination configured, opens the WIZARD — "Where should this site go?" —
|
|
1433
|
+
and only offers destinations that can actually be acted on. Pick a host and it
|
|
1434
|
+
asks whether to set up CI (every push deploys) or upload from this machine now.
|
|
1435
|
+
Your answer is remembered in deploy.yml, so later runs go straight there.
|
|
1436
|
+
|
|
1437
|
+
Choosing ${colors.bright}Uniweb Cloud${colors.reset} delegates to ${colors.cyan}uniweb publish${colors.reset} (sync + dynamic serving;
|
|
1438
|
+
brings the foundation along). Choosing ${colors.bright}Somewhere else${colors.reset} runs ${colors.cyan}uniweb export${colors.reset}.
|
|
1439
|
+
|
|
1440
|
+
${colors.bright}Hosts:${colors.reset}
|
|
1441
|
+
github-pages GitHub Pages — CI workflow, or commit dist/ to gh-pages
|
|
1442
|
+
cloudflare-pages Cloudflare Pages — CI workflow + PR previews, or wrangler
|
|
1443
|
+
netlify Netlify — CI workflow + PR previews, or the netlify CLI
|
|
1444
|
+
vercel Vercel — CI workflow + PR previews, or the vercel CLI
|
|
1445
|
+
s3-cloudfront AWS S3 + CloudFront — uploads + invalidates via the aws CLI
|
|
1446
|
+
uniweb Uniweb Cloud (delegates to \`uniweb publish\`)
|
|
1447
|
+
|
|
1448
|
+
${colors.bright}Options:${colors.reset}
|
|
1449
|
+
--host <name> The host to ship to (no value → the wizard, TTY only)
|
|
1450
|
+
--target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
|
|
1451
|
+
--dry-run Resolve the target + adapter; print summary; upload nothing
|
|
1452
|
+
--no-save Do not record this deploy in deploy.yml
|
|
1453
|
+
--no-validate Skip the content-conformance check (a static host warns;
|
|
1454
|
+
Uniweb Cloud stops on content that does not conform)
|
|
1455
|
+
--non-interactive Fail with usage info instead of prompting
|
|
1456
|
+
|
|
1457
|
+
${colors.bright}Examples:${colors.reset}
|
|
1458
|
+
uniweb deploy # Wizard: pick a destination
|
|
1459
|
+
uniweb deploy --host=cloudflare-pages # Build + upload via wrangler
|
|
1460
|
+
uniweb deploy --host=s3-cloudfront # Build + upload + invalidate
|
|
1461
|
+
uniweb deploy --host=uniweb # → delegates to \`uniweb publish\`
|
|
1462
|
+
uniweb deploy --target=preview # Named target from deploy.yml
|
|
1463
|
+
|
|
1464
|
+
${colors.dim}To deploy on every push instead, see \`uniweb add ci --help\`.${colors.reset}
|
|
1465
|
+
`,
|
|
1466
|
+
publish: `
|
|
1467
|
+
${colors.cyan}${colors.bright}uniweb publish${colors.reset} ${colors.dim}— Publish a site to Uniweb hosting (the smart path)${colors.reset}
|
|
1468
|
+
|
|
1469
|
+
${colors.bright}Usage:${colors.reset}
|
|
1470
|
+
uniweb publish [options]
|
|
1471
|
+
|
|
1472
|
+
The most ergonomic command in the tool: \`uniweb login && uniweb publish\` reads
|
|
1473
|
+
your project and makes the site live on Uniweb hosting. It resolves which site,
|
|
1474
|
+
BRINGS THE FOUNDATION ALONG (releases the local foundation when its code
|
|
1475
|
+
changed — under the next version when its own is registered; commit the
|
|
1476
|
+
package.json it writes), syncs content, settles payment when go-live needs it
|
|
1477
|
+
(a browser handoff), and goes live. A published-registry foundation needs no
|
|
1478
|
+
release; an already-paid site opens no browser.
|
|
1479
|
+
|
|
1480
|
+
For a third-party host use \`uniweb deploy --host=<name>\`; to register a
|
|
1481
|
+
FOUNDATION on its own use \`uniweb register\` (alias \`uniweb release\`).
|
|
1482
|
+
|
|
1483
|
+
${colors.bright}Options:${colors.reset}
|
|
1484
|
+
--dry-run Resolve everything; release/sync/POST nothing
|
|
1485
|
+
--yes Skip confirmations (CI); never block on a prompt
|
|
1486
|
+
--no-release Ship the content against the code already released; release
|
|
1487
|
+
nothing. For editing content and a component in one sitting
|
|
1488
|
+
when only the content should go live. Refused if the
|
|
1489
|
+
foundation has never been released — there is nothing to
|
|
1490
|
+
bind to, and the app cannot open such a site.
|
|
1491
|
+
--bump When the registry holds a newer version of the foundation
|
|
1492
|
+
than yours, release yours above it instead of stopping
|
|
1493
|
+
--no-save Do not record this deploy in deploy.yml
|
|
1494
|
+
--no-validate Skip the content-conformance check, which stops a publish
|
|
1495
|
+
whose content does not conform to its data schemas
|
|
1496
|
+
--org @org Work in @org for this publish, instead of the workspace
|
|
1497
|
+
--personal chosen at \`uniweb login\` (or your personal workspace). A
|
|
1498
|
+
site this publish creates is created — owned — there, and a
|
|
1499
|
+
site the backend keeps in another workspace stops it.
|
|
1500
|
+
`,
|
|
1501
|
+
create: `
|
|
1502
|
+
${colors.cyan}${colors.bright}uniweb create${colors.reset} ${colors.dim}— Create a new project${colors.reset}
|
|
1503
|
+
|
|
1504
|
+
${colors.bright}Usage:${colors.reset}
|
|
1505
|
+
uniweb create [name] [options]
|
|
1506
|
+
uniweb create . Scaffold into the current directory
|
|
1507
|
+
|
|
1508
|
+
${colors.bright}Options:${colors.reset}
|
|
1509
|
+
--template <type> Project template (default: starter)
|
|
1510
|
+
Built-in: starter, none, blank
|
|
1511
|
+
Official: marketing, docs, … (\`uniweb template list\`)
|
|
1512
|
+
Local: ./path/to/template
|
|
1513
|
+
npm: @scope/template-name
|
|
1514
|
+
GitHub: github:user/repo or https://github.com/user/repo
|
|
1515
|
+
--blank Create an empty workspace (grow with \`uniweb add\`)
|
|
1516
|
+
--name <name> Project name (overrides slugified basename when used with \`.\`)
|
|
1517
|
+
--no-git Skip git repository initialization
|
|
1518
|
+
|
|
1519
|
+
${colors.bright}In-place mode (\`uniweb create .\`):${colors.reset}
|
|
1520
|
+
Pairs with the GitHub-first workflow — clone an empty repo locally
|
|
1521
|
+
(README, optional .gitignore), then scaffold inside it. Project name
|
|
1522
|
+
is the cwd basename, slugified to a valid npm name. Pass \`--name\` to
|
|
1523
|
+
override. Pre-existing \`README.md\` and \`.gitignore\` are overwritten;
|
|
1524
|
+
any other collision aborts with the list of conflicting files. Skips
|
|
1525
|
+
\`git init\` when a \`.git/\` directory already exists.
|
|
1526
|
+
|
|
1527
|
+
${colors.bright}Examples:${colors.reset}
|
|
1528
|
+
uniweb create my-project # Foundation + site + starter content
|
|
1529
|
+
uniweb create my-project --template marketing # Official template
|
|
1530
|
+
uniweb create my-project --blank # Empty workspace
|
|
1531
|
+
uniweb create . # Scaffold into current dir
|
|
1532
|
+
uniweb create . --template docs # In place + a content template
|
|
1533
|
+
uniweb create . --name=my-app # In place, explicit slug
|
|
1534
|
+
`,
|
|
1535
|
+
dev: `
|
|
1536
|
+
${colors.cyan}${colors.bright}uniweb dev${colors.reset} ${colors.dim}— Start a dev server for a site${colors.reset}
|
|
1537
|
+
|
|
1538
|
+
${colors.bright}Usage:${colors.reset}
|
|
1539
|
+
uniweb dev Start dev server for the (single) site
|
|
1540
|
+
uniweb dev <site> Start dev server for a specific site
|
|
1541
|
+
uniweb dev --site <name> Same, with explicit flag form
|
|
1542
|
+
|
|
1543
|
+
Thin wrapper around the package manager's workspace-filtered \`dev\`
|
|
1544
|
+
script (\`pnpm --filter <site> dev\` or \`npm -w <site> run dev\`). Picks
|
|
1545
|
+
the single site automatically; for multi-site workspaces the first
|
|
1546
|
+
site runs by default with a notice pointing at \`--site\` for explicit
|
|
1547
|
+
selection.
|
|
1548
|
+
`,
|
|
1549
|
+
snapshot: `
|
|
1550
|
+
${colors.cyan}${colors.bright}uniweb snapshot${colors.reset} ${colors.dim}— Compose a preview image of a site${colors.reset}
|
|
1551
|
+
|
|
1552
|
+
${colors.bright}Usage:${colors.reset}
|
|
1553
|
+
uniweb snapshot [<site>] [options]
|
|
1554
|
+
uniweb snapshot --compare [options]
|
|
1555
|
+
|
|
1556
|
+
Opens the site in a headless Chrome, captures it, and composes the captures into
|
|
1557
|
+
one image: by default ${colors.bright}site/public/preview.webp${colors.reset}, recorded as ${colors.cyan}preview:${colors.reset} in site.yml
|
|
1558
|
+
when site.yml has none, or only the app's generated one. A URL or image path you
|
|
1559
|
+
wrote is never replaced.
|
|
1560
|
+
|
|
1561
|
+
A page that scrolls gets the ${colors.bright}split${colors.reset} layout: the first view in a browser window,
|
|
1562
|
+
beside a long strip of the page. A page that does not scroll as a page (a docs
|
|
1563
|
+
shell, an app) gets ${colors.bright}device${colors.reset}: a desktop window and a phone.
|
|
1564
|
+
|
|
1565
|
+
Needs ${colors.cyan}@uniweb/snapshot${colors.reset} in the workspace (\`pnpm add -D -w @uniweb/snapshot\`) and
|
|
1566
|
+
Google Chrome, Microsoft Edge, or a Chromium named by $UNIWEB_SNAPSHOT_BROWSER.
|
|
1567
|
+
|
|
1568
|
+
${colors.bright}Where the site comes from:${colors.reset}
|
|
1569
|
+
(default) Build the site, then capture dist/
|
|
1570
|
+
--no-build Capture the existing dist/ as is
|
|
1571
|
+
--dev Capture the site's Vite dev server (no build)
|
|
1572
|
+
--url <address> Capture a site that is already running
|
|
1573
|
+
|
|
1574
|
+
${colors.bright}The look:${colors.reset}
|
|
1575
|
+
--layout <name> auto (default), split, device
|
|
1576
|
+
--gap <px> Space between the two frames (split default: 48)
|
|
1577
|
+
--overlap <px> Overlap them instead (device default: 31)
|
|
1578
|
+
--strip <width> split: fit (default), or 1:N — narrower shows more of a long page
|
|
1579
|
+
--side <side> Where the strip or phone goes: right (default), left
|
|
1580
|
+
--frame <style> browser (default, with a title bar), plain
|
|
1581
|
+
--tone <name> Background: auto (default), light, deep
|
|
1582
|
+
|
|
1583
|
+
${colors.bright}Choosing and keeping a look:${colors.reset}
|
|
1584
|
+
--compare One capture, several looks on one sheet, each captioned with
|
|
1585
|
+
its flags (site/.uniweb/snapshot/compare.webp)
|
|
1586
|
+
--save Keep this run's flags in site/snapshot.yml, the site's defaults
|
|
1587
|
+
|
|
1588
|
+
${colors.bright}Options:${colors.reset}
|
|
1589
|
+
--site <name> The site (default: the one you are in, or the only one)
|
|
1590
|
+
--route <path> The page to capture (default: the home page)
|
|
1591
|
+
--size <WxH> Image size in CSS pixels (default: 1600x1000)
|
|
1592
|
+
--scale <n> 1 (default) or 2 for a double-density image
|
|
1593
|
+
--quality <n> Encoder quality, 1–100 (default: 82)
|
|
1594
|
+
--out <file> Where to write it; .webp, .png, .jpg or .avif
|
|
1595
|
+
--hide <selector> Hide matching elements before capturing (repeatable)
|
|
1596
|
+
--no-set-preview Write the image without touching site.yml
|
|
1597
|
+
`,
|
|
1598
|
+
build: `
|
|
1599
|
+
${colors.cyan}${colors.bright}uniweb build${colors.reset} ${colors.dim}— Build the current project${colors.reset}
|
|
1600
|
+
|
|
1601
|
+
${colors.bright}Usage:${colors.reset}
|
|
1602
|
+
uniweb build [options]
|
|
1603
|
+
|
|
1604
|
+
At workspace root, builds all foundations first, then all sites.
|
|
1605
|
+
Pre-rendering is enabled by default when build.prerender: true in site.yml.
|
|
1606
|
+
|
|
1607
|
+
${colors.bright}Options:${colors.reset}
|
|
1608
|
+
--target <type> Build target (foundation, site) — auto-detected if not specified
|
|
1609
|
+
--prerender Force pre-rendering (overrides site.yml)
|
|
1610
|
+
--no-prerender Skip pre-rendering (overrides site.yml)
|
|
1611
|
+
--foundation-dir Path to foundation directory (for prerendering)
|
|
1612
|
+
--host <name> Apply host-specific postBuild (e.g., cloudflare-pages emits _redirects)
|
|
1613
|
+
--platform <name> (Deprecated alias for --host)
|
|
1614
|
+
`,
|
|
1615
|
+
add: `
|
|
1616
|
+
${colors.cyan}${colors.bright}Uniweb Add${colors.reset}
|
|
1617
|
+
|
|
1618
|
+
Add projects, foundations, sites, extensions, section types, or CI workflows to your workspace.
|
|
1619
|
+
|
|
1620
|
+
${colors.bright}Usage:${colors.reset}
|
|
1621
|
+
uniweb add project [name] [options]
|
|
1622
|
+
uniweb add foundation [name] [options]
|
|
1623
|
+
uniweb add site [name] [options]
|
|
1624
|
+
uniweb add extension <name> [options]
|
|
1625
|
+
uniweb add section <name> [options]
|
|
1626
|
+
uniweb add ci [options]
|
|
1627
|
+
|
|
1628
|
+
${colors.bright}Common Options:${colors.reset}
|
|
1629
|
+
--from <template> Apply content from a template after scaffolding
|
|
1630
|
+
--path <dir> The folder to put the package in
|
|
1631
|
+
--non-interactive Fail with usage info instead of prompting
|
|
1632
|
+
|
|
1633
|
+
${colors.bright}Foundation Options:${colors.reset}
|
|
1634
|
+
--project <name> Group under a project directory (co-located layout)
|
|
1635
|
+
|
|
1636
|
+
${colors.bright}Site Options:${colors.reset}
|
|
1637
|
+
--foundation <n> Foundation to wire to (prompted if multiple exist)
|
|
1638
|
+
--project <name> Group under a project directory (co-located layout)
|
|
1639
|
+
|
|
1640
|
+
${colors.bright}Extension Options:${colors.reset}
|
|
1641
|
+
--site <name> Site to wire extension URL into
|
|
1642
|
+
|
|
1643
|
+
${colors.bright}Section Options:${colors.reset}
|
|
1644
|
+
--foundation <n> Foundation to add section to (prompted if multiple exist)
|
|
1645
|
+
--starter Generate starter content from the section's \`content:\`
|
|
1646
|
+
declaration — what an author would begin editing. Works on
|
|
1647
|
+
a section that already exists (nothing is written), and on
|
|
1648
|
+
a new one (the scaffold gets a matching declaration)
|
|
1649
|
+
--preset <name> Frontmatter the starter content with this preset's params
|
|
1650
|
+
--write <file> Write the starter markdown to a file instead of printing it
|
|
1651
|
+
--json Emit the content structure and ProseMirror doc instead
|
|
1652
|
+
|
|
1653
|
+
${colors.bright}CI Options:${colors.reset}
|
|
1654
|
+
--host <name> github-pages | cloudflare-pages | netlify | vercel
|
|
1655
|
+
(prompted when omitted)
|
|
1656
|
+
--target <what> site (default) | foundation
|
|
1657
|
+
'foundation' publishes built foundations at permanent
|
|
1658
|
+
versioned URLs — the free alternative to the catalog
|
|
1659
|
+
--site <name> Site the workflow builds (prompted if multiple exist)
|
|
1660
|
+
--foundation <name> With --target foundation: publish just this one
|
|
1661
|
+
--domain <host> Custom domain (GitHub Pages: writes CNAME, serves at root)
|
|
1662
|
+
--project-name <name> Name to register under on the host (default: the
|
|
1663
|
+
workspace name; Cloudflare Pages project, etc.)
|
|
1664
|
+
--no-previews Skip the per-PR preview workflow
|
|
1665
|
+
--force Overwrite an existing workflow file
|
|
1666
|
+
|
|
1667
|
+
${colors.dim}Hosts that support PR previews: cloudflare-pages, netlify, vercel.
|
|
1668
|
+
GitHub Pages has no preview environment, so it scaffolds a deploy workflow only.${colors.reset}
|
|
1669
|
+
|
|
1670
|
+
${colors.bright}Examples:${colors.reset}
|
|
1671
|
+
uniweb add project docs # Create docs/src/ + docs/site/
|
|
1672
|
+
uniweb add project docs --from academic # Co-located pair + academic content
|
|
1673
|
+
uniweb add foundation # Create ./src/ at root
|
|
1674
|
+
uniweb add foundation ui # Create ./ui/ — the folder you name
|
|
1675
|
+
uniweb add foundation foundations/ui # Create ./foundations/ui/
|
|
1676
|
+
uniweb add site # Create ./site/ at root
|
|
1677
|
+
uniweb add site blog --foundation marketing # Create ./blog/ wired to marketing
|
|
1678
|
+
uniweb add extension effects --site site # Create ./effects/ — the folder you name
|
|
1679
|
+
uniweb add extension extensions/effects # Create ./extensions/effects/
|
|
1680
|
+
uniweb add section Hero # Create Hero section type
|
|
1681
|
+
uniweb add section Hero --foundation ui # Target specific foundation
|
|
1682
|
+
uniweb add section Hero --starter # Starter content for an existing Hero
|
|
1683
|
+
uniweb add section Pricing --starter # Scaffold Pricing + content that fills it
|
|
1684
|
+
uniweb add section Hero --starter --preset split # Frontmatter it with the 'split' preset
|
|
1685
|
+
uniweb add section Hero --starter --json # The structure + ProseMirror, for a script
|
|
1686
|
+
uniweb add foundation --project docs # Create ./docs/src/ (co-located)
|
|
1687
|
+
uniweb add site --project docs # Create ./docs/site/ (co-located)
|
|
1688
|
+
uniweb add ci # Pick a host, add a deploy workflow
|
|
1689
|
+
uniweb add ci --host github-pages --site marketing # Pick host + site explicitly
|
|
1690
|
+
uniweb add ci --host netlify # Deploy + PR-preview workflows
|
|
1691
|
+
uniweb add ci --host vercel --no-previews # Deploy workflow only
|
|
1692
|
+
uniweb add ci --domain mysite.com # Custom domain → writes CNAME + UNIWEB_BASE=/
|
|
1693
|
+
uniweb add ci --target foundation # Publish foundations at versioned URLs
|
|
1694
|
+
`,
|
|
1695
|
+
export: `
|
|
1696
|
+
${colors.cyan}${colors.bright}uniweb export${colors.reset} ${colors.dim}— Export a self-contained site for third-party hosting${colors.reset}
|
|
1697
|
+
|
|
1698
|
+
${colors.bright}Usage:${colors.reset}
|
|
1699
|
+
uniweb export [options]
|
|
1700
|
+
|
|
1701
|
+
Builds dist/ and prints upload examples for common static hosts. No login,
|
|
1702
|
+
no deploy step — you push the artifact to your host of choice yourself.
|
|
1703
|
+
For Uniweb-hosted sites, use \`uniweb deploy\`.
|
|
1704
|
+
|
|
1705
|
+
${colors.bright}Options:${colors.reset}
|
|
1706
|
+
--no-prerender Skip per-page prerendered HTML
|
|
1707
|
+
--host <name> Apply host-specific postBuild (cloudflare-pages, github-pages, …)
|
|
1708
|
+
`,
|
|
1709
|
+
doctor: `
|
|
1710
|
+
${colors.cyan}${colors.bright}uniweb doctor${colors.reset} ${colors.dim}— Diagnose project configuration issues${colors.reset}
|
|
1711
|
+
|
|
1712
|
+
${colors.bright}Usage:${colors.reset}
|
|
1713
|
+
uniweb doctor [options]
|
|
1714
|
+
|
|
1715
|
+
${colors.bright}Options:${colors.reset}
|
|
1716
|
+
--fix Apply fixes for safely-fixable issues
|
|
1717
|
+
--fix <issue-id> Apply fix for a specific issue id only
|
|
1718
|
+
--non-interactive Fail with usage info instead of prompting
|
|
1719
|
+
|
|
1720
|
+
Exit code is 1 if errors are found (warnings only → exit 0).
|
|
1721
|
+
`,
|
|
1722
|
+
validate: `
|
|
1723
|
+
${colors.cyan}${colors.bright}uniweb validate${colors.reset} ${colors.dim}— Check your content against your foundation's data schemas${colors.reset}
|
|
1724
|
+
|
|
1725
|
+
${colors.bright}Usage:${colors.reset}
|
|
1726
|
+
uniweb validate [path] [options]
|
|
1727
|
+
|
|
1728
|
+
Checks each section's file-based data inputs against the schema your
|
|
1729
|
+
foundation declared for that input (meta.js \`data:\`). Answers "does my
|
|
1730
|
+
data match what I promised?" — distinct from \`doctor\`, which checks your
|
|
1731
|
+
project against framework conventions.
|
|
1732
|
+
|
|
1733
|
+
Every record file is checked against the schema its folder names, whether or
|
|
1734
|
+
not a section reads it. A violation fails the run; \`push\` and \`publish\` refuse
|
|
1735
|
+
the same findings. The live render path stays tolerant. Remote (\`url:\`)
|
|
1736
|
+
inputs are not in the project, so they're reported as deferred, never silently
|
|
1737
|
+
skipped. A reference field's value is not checked against the entity it names.
|
|
1738
|
+
|
|
1739
|
+
${colors.bright}Options:${colors.reset}
|
|
1740
|
+
--lax Report violations without failing (exit 0)
|
|
1741
|
+
--json Machine-readable output (for CI annotations)
|
|
1742
|
+
--site <name> Check one site in a multi-site workspace
|
|
1743
|
+
|
|
1744
|
+
Exit codes: 0 clean (or --lax), 1 violations, 2 setup error.
|
|
1745
|
+
`,
|
|
1746
|
+
register: `
|
|
1747
|
+
${colors.cyan}${colors.bright}uniweb register${colors.reset} ${colors.dim}— Register (release) a foundation + its data schemas with the backend registry${colors.reset}
|
|
1748
|
+
|
|
1749
|
+
${colors.bright}Usage:${colors.reset}
|
|
1750
|
+
uniweb register [options]
|
|
1751
|
+
uniweb release [options] ${colors.dim}(synonym — reads naturally for updates)${colors.reset}
|
|
1752
|
+
|
|
1753
|
+
Builds one \`.uwx\` document and submits it to the registry over HTTP. Run
|
|
1754
|
+
\`uniweb login\` first. \`register\`/\`release\` are for
|
|
1755
|
+
FOUNDATIONS (and schemas) — code only; \`uniweb publish\` makes a synced SITE
|
|
1756
|
+
live (and brings its foundation along); \`uniweb deploy\` hosts on a third-party
|
|
1757
|
+
host. \`register\` and \`release\` are the same act: \`register\` reads naturally
|
|
1758
|
+
the first time, \`release\` for updates.
|
|
1759
|
+
|
|
1760
|
+
Auto-detects what you run it in:
|
|
1761
|
+
• a foundation the foundation + the data schemas it defines/renders
|
|
1762
|
+
• a schemas-only pkg just its data schemas, no foundation — e.g. @uniweb/schemas,
|
|
1763
|
+
any @org/schemas package, or a bare schemas/*.yml folder
|
|
1764
|
+
|
|
1765
|
+
A foundation's scope is part of its name — name: '@scope/<name>' in main.js. A scope
|
|
1766
|
+
is a namespace: your personal one (@<your handle>, no org needed) or an org's. A bare
|
|
1767
|
+
name takes --scope, else the workspace you work in (an org's scope, or your personal
|
|
1768
|
+
one), and register writes it into the name.
|
|
1769
|
+
|
|
1770
|
+
Schema scopes:
|
|
1771
|
+
@/name your own schema, in the foundation's scope (@/x -> @org/x)
|
|
1772
|
+
@std/name a shared standard schema (from @uniweb/schemas)
|
|
1773
|
+
@org/name another org's published schema, referenced by name
|
|
1774
|
+
|
|
1775
|
+
${colors.bright}Options:${colors.reset}
|
|
1776
|
+
--scope @scope A foundation with a bare name: register under @scope — yours or an
|
|
1777
|
+
org's — and write it into the name (refused when the name has another
|
|
1778
|
+
scope). A schemas-only package: publish under @scope; default:
|
|
1779
|
+
package.json uniweb.scope
|
|
1780
|
+
--org @org Work in @org for this command: a bare name registers under it
|
|
1781
|
+
--personal Work in your personal workspace: a bare name registers under your
|
|
1782
|
+
personal scope
|
|
1783
|
+
--dry-run Print the .uwx; submit nothing
|
|
1784
|
+
-o, --output <f> Write the .uwx to a file; submit nothing
|
|
1785
|
+
--non-interactive Fail with usage info instead of prompting
|
|
1786
|
+
|
|
1787
|
+
Run from a foundation, a schemas-only package, or a workspace with a single foundation.
|
|
1788
|
+
`,
|
|
1789
|
+
rename: `
|
|
1790
|
+
${colors.cyan}${colors.bright}uniweb rename${colors.reset} ${colors.dim}— Rename a workspace package${colors.reset}
|
|
1791
|
+
|
|
1792
|
+
${colors.bright}Usage:${colors.reset}
|
|
1793
|
+
uniweb rename foundation <old> <new>
|
|
1794
|
+
uniweb rename site <old> <new>
|
|
1795
|
+
uniweb rename extension <old> <new>
|
|
1796
|
+
|
|
1797
|
+
Each subcommand updates a different set of touch points:
|
|
1798
|
+
|
|
1799
|
+
foundation: package.json::name + folder + every dependent site's
|
|
1800
|
+
package.json (dep key + file: path) + every site.yml::foundation +
|
|
1801
|
+
pnpm-workspace.yaml / package.json::workspaces + root scripts.
|
|
1802
|
+
|
|
1803
|
+
site: package.json::name + folder + workspace manifests + root
|
|
1804
|
+
scripts (\`dev\` / \`preview\` are filtered by site name).
|
|
1805
|
+
|
|
1806
|
+
extension: package.json::name + folder + every site.yml::extensions
|
|
1807
|
+
URL whose path matches the old folder + workspace manifests.
|
|
1808
|
+
(Sites don't carry a \`file:\` dep on extensions — they load by
|
|
1809
|
+
URL at runtime, so no per-site package.json updates.)
|
|
1810
|
+
|
|
1811
|
+
Transactional — bails on conflict (target name taken, target not found,
|
|
1812
|
+
folder collision, type mismatch) before any filesystem mutation.
|
|
1813
|
+
|
|
1814
|
+
Type guards: \`rename foundation\` against an extension errors and
|
|
1815
|
+
points at \`rename extension\` (and vice versa). They share a build
|
|
1816
|
+
shape but the touch-point sets differ; using the wrong subcommand
|
|
1817
|
+
would update the wrong things.
|
|
1818
|
+
`,
|
|
1819
|
+
login: `
|
|
1820
|
+
${colors.cyan}${colors.bright}uniweb login${colors.reset} ${colors.dim}— Log in to your Uniweb account${colors.reset}
|
|
1821
|
+
|
|
1822
|
+
${colors.bright}Usage:${colors.reset}
|
|
1823
|
+
uniweb login [options]
|
|
1824
|
+
|
|
1825
|
+
Authenticates with a backend and stores its session in
|
|
1826
|
+
~/.uniweb/registry-auth.json. ${colors.bright}One backend at a time:${colors.reset} logging in to another one
|
|
1827
|
+
logs you out of the first (once the new login succeeds). The backend you are
|
|
1828
|
+
logged in to is where the backend commands go (push, pull, publish, status,
|
|
1829
|
+
register, clone) — so this is how you switch. Already logged in to it, login
|
|
1830
|
+
does nothing; add --password, --browser, --token-paste or --token to log in again.
|
|
1831
|
+
|
|
1832
|
+
Without --server: https://uniweb.app (or \$UNIWEB_SERVER). No command talks
|
|
1833
|
+
to a backend you are not logged in to — run one before logging in and it asks first.
|
|
1834
|
+
|
|
1835
|
+
${colors.bright}The workspace you work in.${colors.reset} A login works in ONE workspace — your
|
|
1836
|
+
personal one, or an organization's — and every push, pull and publish works in it:
|
|
1837
|
+
a site it creates is created there, and a site kept in another workspace is refused.
|
|
1838
|
+
With no organization it is your personal workspace; with organizations you are asked,
|
|
1839
|
+
or name it. Already logged in, \`uniweb login --org @other\` (or \`--personal\`) switches
|
|
1840
|
+
the workspace on the backend you are logged in to, without logging in again.
|
|
1841
|
+
|
|
1842
|
+
${colors.bright}Options:${colors.reset}
|
|
1843
|
+
--server <url> The backend to log in to (without it: the default backend — or, for
|
|
1844
|
+
--org / --personal alone, the one you are logged in to)
|
|
1845
|
+
--org @org Work in @org (an organization you belong to)
|
|
1846
|
+
--personal Work in your personal workspace
|
|
1847
|
+
--token <bearer> Seed + verify a session from a bearer token (non-interactive)
|
|
1848
|
+
--browser Force the browser method
|
|
1849
|
+
--password Force the username/password method
|
|
1850
|
+
--token-paste Force the paste-a-token prompt
|
|
1851
|
+
|
|
1852
|
+
In non-interactive mode (no TTY — an agent, a script), pass \`--token <bearer>\` with
|
|
1853
|
+
\`--org @org\` or \`--personal\`, or set \`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`.
|
|
1854
|
+
A login with organizations that names no workspace signs you in but exits 2;
|
|
1855
|
+
\`uniweb login --org @org\` (or \`--personal\`) finishes it without signing in again.
|
|
1856
|
+
\`uniweb logout\` logs you out.
|
|
1857
|
+
|
|
1858
|
+
${colors.bright}Or skip the login.${colors.reset} With \`UNIWEB_TOKEN\` set — and \`UNIWEB_WORKSPACE=@org\` or
|
|
1859
|
+
\`personal\` — every backend command in that process uses the token, ahead of any
|
|
1860
|
+
session, and nothing is stored. \`uniweb login\` there only checks the token with the
|
|
1861
|
+
backend those commands go to — \`UNIWEB_SERVER\`, else the one you are logged in to,
|
|
1862
|
+
else the default — and says who and where they work as.
|
|
1863
|
+
`,
|
|
1864
|
+
refresh: `
|
|
1865
|
+
${colors.cyan}${colors.bright}uniweb refresh${colors.reset} ${colors.dim}— Catch up with teammates AND app authors${colors.reset}
|
|
1866
|
+
|
|
1867
|
+
${colors.bright}Usage:${colors.reset}
|
|
1868
|
+
uniweb refresh [options]
|
|
1869
|
+
|
|
1870
|
+
A developer on a synced site has TWO external sources: the git remote (teammates'
|
|
1871
|
+
commits) and the backend (content authors' edits in the app). Run only \`git pull\`
|
|
1872
|
+
and you miss every app edit; run only \`uniweb pull\` and you miss your teammates.
|
|
1873
|
+
This does both — git first, then \`pull --merge\`.
|
|
1874
|
+
|
|
1875
|
+
${colors.bright}READ-ONLY.${colors.reset} It never pushes and cannot ship anything, so it is safe to run
|
|
1876
|
+
reflexively. Exits non-zero if a merge leaves conflicts.
|
|
1877
|
+
|
|
1878
|
+
${colors.bright}Options:${colors.reset}
|
|
1879
|
+
--no-git Skip the git remote; backend only
|
|
1880
|
+
--no-backend Skip the backend; git only
|
|
1881
|
+
|
|
1882
|
+
\`--force\` and \`--merge\` are NOT accepted here: this verb builds the delegated
|
|
1883
|
+
pull's arguments itself, and to pull \`--force\` means "discard my local work".
|
|
1884
|
+
Use \`uniweb sync\` when you also mean to share.
|
|
1885
|
+
`,
|
|
1886
|
+
sync: `
|
|
1887
|
+
${colors.cyan}${colors.bright}uniweb sync${colors.reset} ${colors.dim}— Catch up, then share${colors.reset}
|
|
1888
|
+
|
|
1889
|
+
${colors.bright}Usage:${colors.reset}
|
|
1890
|
+
uniweb sync [options]
|
|
1891
|
+
|
|
1892
|
+
\`uniweb refresh\` followed by \`uniweb push\`, and nothing more. It adds no
|
|
1893
|
+
guarantees of its own — refresh stops on conflicts, push refuses when the backend
|
|
1894
|
+
has moved under you, and sync inherits both. \`uniweb refresh && uniweb push\`
|
|
1895
|
+
behaves identically.
|
|
1896
|
+
|
|
1897
|
+
${colors.bright}It does NOT publish.${colors.reset} Sync brings your copy and the backend DRAFT into
|
|
1898
|
+
agreement; going live stays a separate act (\`uniweb publish\`). So the worst case
|
|
1899
|
+
of an unintended sync is work-in-progress visible to authors, not shipped to
|
|
1900
|
+
visitors.
|
|
1901
|
+
|
|
1902
|
+
${colors.bright}Options:${colors.reset}
|
|
1903
|
+
Accepts every option of both halves — see \`uniweb refresh --help\` and the push
|
|
1904
|
+
options in \`uniweb --help\`. The common ones:
|
|
1905
|
+
|
|
1906
|
+
--no-git Skip the git remote half of the refresh
|
|
1907
|
+
--force Overwrite upstream changes (reaches the PUSH half only)
|
|
1908
|
+
|
|
1909
|
+
\`--force\` means "overwrite upstream" to push but "discard my local work" to pull,
|
|
1910
|
+
so it is deliberately kept away from the refresh half.
|
|
1911
|
+
`,
|
|
1912
|
+
forget: `
|
|
1913
|
+
${colors.cyan}${colors.bright}uniweb forget${colors.reset} ${colors.dim}— Remove what this project recorded about where it synced${colors.reset}
|
|
1914
|
+
|
|
1915
|
+
${colors.bright}Usage:${colors.reset}
|
|
1916
|
+
uniweb forget --server <url>
|
|
1917
|
+
uniweb forget --all
|
|
1918
|
+
|
|
1919
|
+
Local files only. Every site stays where it is on its backend.
|
|
1920
|
+
|
|
1921
|
+
${colors.bright}Options:${colors.reset}
|
|
1922
|
+
--server <url> Forget one backend: its entries in sync.json and in the local
|
|
1923
|
+
cache, and its deploy records in deploy.yml. Its targets stay,
|
|
1924
|
+
so the next publish there creates a new site.
|
|
1925
|
+
--all For a COPY of a project that should become a new one. Deletes
|
|
1926
|
+
sync.json, deploy.yml and the local cache — everything naming
|
|
1927
|
+
the original's sites and destinations. Without it, the copy's
|
|
1928
|
+
first push updates the original's site.
|
|
1929
|
+
|
|
1930
|
+
Record files keep their \`$uuid\`: it is the record's own id, not a backend's.
|
|
1931
|
+
|
|
1932
|
+
To duplicate a project:
|
|
1933
|
+
cp -r my-site my-site-2 && cd my-site-2 && uniweb forget --all
|
|
1934
|
+
`,
|
|
1935
|
+
invite: `
|
|
1936
|
+
${colors.cyan}${colors.bright}uniweb invite${colors.reset} ${colors.dim}— (reserved; not available on the new backend yet)${colors.reset}
|
|
1937
|
+
|
|
1938
|
+
The foundation client-invite flow was retired with the legacy backend.
|
|
1939
|
+
Invite clients to your foundation from the Uniweb app for now.
|
|
1940
|
+
`,
|
|
1941
|
+
handoff: `
|
|
1942
|
+
${colors.cyan}${colors.bright}uniweb handoff${colors.reset} ${colors.dim}— (reserved; not available on the new backend yet)${colors.reset}
|
|
1943
|
+
|
|
1944
|
+
The site-handoff flow was retired with the legacy backend.
|
|
1945
|
+
Manage client sites from the Uniweb app for now.
|
|
1946
|
+
`,
|
|
1947
|
+
families: `
|
|
1948
|
+
${colors.cyan}${colors.bright}uniweb families${colors.reset} ${colors.dim}— The standard section families${colors.reset}
|
|
1949
|
+
|
|
1950
|
+
${colors.bright}Usage:${colors.reset}
|
|
1951
|
+
uniweb families List them, grouped
|
|
1952
|
+
uniweb families <filter> Match an id, a label or a group name
|
|
1953
|
+
uniweb families --json Machine-readable (for scripts)
|
|
1954
|
+
|
|
1955
|
+
${colors.bright}What a family is for:${colors.reset}
|
|
1956
|
+
It tells an editor which illustration to show for a section type, and gives
|
|
1957
|
+
it a label it can translate. Declare it in a section's meta.js:
|
|
1958
|
+
|
|
1959
|
+
${colors.dim}export default { title: 'Researcher Profile', family: 'profile' }${colors.reset}
|
|
1960
|
+
|
|
1961
|
+
${colors.bright}Most components need no declaration.${colors.reset} The section type IS the component
|
|
1962
|
+
name, so \`Hero\`, \`Footer\` and \`Pricing\` already resolve. \`family:\` is for the
|
|
1963
|
+
ones whose name does not give it away — \`ProfileHero\`, \`CvEntry\`.
|
|
1964
|
+
|
|
1965
|
+
An unrecognized value is legal: it falls back to a generic illustration and
|
|
1966
|
+
nothing breaks, so a foundation with its own vocabulary is never blocked.
|
|
1967
|
+
|
|
1968
|
+
\`uniweb doctor\` reports what each of your sections resolved to, and suggests
|
|
1969
|
+
a family for the ones it recognizes by another name.
|
|
1970
|
+
|
|
1971
|
+
${colors.bright}Note:${colors.reset} this is the roster ${colors.bright}this CLI ships with${colors.reset}, not a live fetch — and it is
|
|
1972
|
+
the same list \`doctor\` matches against.
|
|
1973
|
+
`,
|
|
1974
|
+
template: `
|
|
1975
|
+
${colors.cyan}${colors.bright}uniweb template${colors.reset} ${colors.dim}— Official templates${colors.reset}
|
|
1976
|
+
|
|
1977
|
+
${colors.bright}Usage:${colors.reset}
|
|
1978
|
+
uniweb template list List the official templates
|
|
1979
|
+
uniweb template list --json Same, machine-readable (for scripts)
|
|
1980
|
+
|
|
1981
|
+
${colors.bright}What \`list\` reports:${colors.reset}
|
|
1982
|
+
The roster ${colors.bright}this CLI ships with${colors.reset} — a snapshot baked in at publish time,
|
|
1983
|
+
not a live fetch. \`create\` downloads these templates from the release the same
|
|
1984
|
+
snapshot names, which is why \`--json\` stamps \`cliVersion\`. A template newer than
|
|
1985
|
+
your CLI is not listed: \`npx uniweb@latest template list\` shows the current roster.
|
|
1986
|
+
|
|
1987
|
+
It is the ${colors.bright}official${colors.reset} roster, not the set of resolvable names: an unknown
|
|
1988
|
+
name is not fatal, since \`create\` falls through to npm \`@uniweb/template-<name>\`.
|
|
1989
|
+
`,
|
|
1990
|
+
docs: `
|
|
1991
|
+
${colors.cyan}${colors.bright}uniweb docs${colors.reset} ${colors.dim}— Generate component documentation${colors.reset}
|
|
1992
|
+
|
|
1993
|
+
${colors.bright}Subcommands:${colors.reset}
|
|
1994
|
+
docs Generate COMPONENTS.md from foundation schema
|
|
1995
|
+
docs site Show site.yml configuration reference
|
|
1996
|
+
docs page Show page.yml configuration reference
|
|
1997
|
+
docs meta Show component meta.js reference
|
|
1998
|
+
|
|
1999
|
+
${colors.bright}Options:${colors.reset}
|
|
2000
|
+
--output <file> Output filename (default: COMPONENTS.md)
|
|
2001
|
+
--from-source Read meta.js files directly instead of schema.json
|
|
2002
|
+
`,
|
|
2003
|
+
i18n: `
|
|
2004
|
+
${colors.cyan}${colors.bright}uniweb i18n${colors.reset} ${colors.dim}— Internationalization workflow${colors.reset}
|
|
2005
|
+
|
|
2006
|
+
${colors.bright}Subcommands:${colors.reset}
|
|
2007
|
+
i18n extract Extract translatable strings to manifest
|
|
2008
|
+
i18n sync Update manifest with content changes
|
|
2009
|
+
i18n status Show translation coverage per locale
|
|
2010
|
+
`,
|
|
2011
|
+
inspect: `
|
|
2012
|
+
${colors.cyan}${colors.bright}uniweb inspect${colors.reset} ${colors.dim}— Inspect parsed content shape${colors.reset}
|
|
2013
|
+
|
|
2014
|
+
${colors.bright}Usage:${colors.reset}
|
|
2015
|
+
uniweb inspect <path>
|
|
2016
|
+
|
|
2017
|
+
Prints the parsed content shape of a markdown file or folder — the
|
|
2018
|
+
{ content, params, items, … } object that components actually receive.
|
|
2019
|
+
Useful for debugging "why isn't my section getting X?".
|
|
2020
|
+
`,
|
|
2021
|
+
update: `
|
|
2022
|
+
${colors.cyan}${colors.bright}uniweb update${colors.reset} ${colors.dim}— Align this project with the running CLI${colors.reset}
|
|
2023
|
+
|
|
2024
|
+
${colors.bright}Usage:${colors.reset}
|
|
2025
|
+
uniweb update Align deps + refresh AGENTS.md
|
|
2026
|
+
uniweb update --deps-only Only align workspace @uniweb/* deps
|
|
2027
|
+
uniweb update --agents-only Only refresh AGENTS.md
|
|
2028
|
+
uniweb update --no-deps Skip the deps-alignment step
|
|
2029
|
+
uniweb update --no-agents Skip the AGENTS.md step
|
|
2030
|
+
uniweb update --dry-run Print survey + would-be writes; no mutations
|
|
2031
|
+
uniweb update --allow-mismatch Refresh AGENTS.md even if declared deps lag
|
|
2032
|
+
uniweb update --yes Don't prompt — apply edits and run the install
|
|
2033
|
+
|
|
2034
|
+
${colors.bright}What it does:${colors.reset}
|
|
2035
|
+
Prints a version survey first (CLI version, AGENTS.md stamp, every
|
|
2036
|
+
@uniweb/* + uniweb dep declared in workspace package.json files,
|
|
2037
|
+
marked aligned / behind / ahead). Then two steps:
|
|
2038
|
+
|
|
2039
|
+
1. ${colors.bright}Align workspace deps${colors.reset} to this CLI's bundled version matrix —
|
|
2040
|
+
rewrites the @uniweb/* + uniweb keys in each package.json that lags
|
|
2041
|
+
(deps ahead of the matrix are left alone — never downgraded;
|
|
2042
|
+
existing indentation is preserved), then offers to run the
|
|
2043
|
+
workspace's package manager (lockfile-detected: pnpm-lock.yaml →
|
|
2044
|
+
pnpm, yarn.lock → yarn, package-lock.json → npm). If the install
|
|
2045
|
+
fails, the package.json edits are kept and a revert command printed.
|
|
2046
|
+
2. ${colors.bright}Refresh AGENTS.md${colors.reset} from this CLI's bundled partial. Won't run
|
|
2047
|
+
if deps were edited but not installed (node_modules would be behind
|
|
2048
|
+
package.json), or while declared deps still lag the CLI (would
|
|
2049
|
+
document features not in your packages) — pass --allow-mismatch for
|
|
2050
|
+
the latter.
|
|
2051
|
+
|
|
2052
|
+
${colors.bright}Which matrix?${colors.reset}
|
|
2053
|
+
\`update\` pins to the version matrix *this* CLI shipped with — not
|
|
2054
|
+
necessarily the latest release. To reconcile against the latest release
|
|
2055
|
+
without touching a global install, run \`npx uniweb@latest update\`. This
|
|
2056
|
+
command does NOT update the CLI itself; use your package manager
|
|
2057
|
+
(\`npm i -g uniweb@latest\`, \`pnpm add -g uniweb@latest\`, …).
|
|
2058
|
+
|
|
2059
|
+
${colors.bright}Project-local installs:${colors.reset}
|
|
2060
|
+
When run from a project-local CLI (in node_modules), it aligns the
|
|
2061
|
+
project to that pinned version — bump \`uniweb\` in package.json (or use
|
|
2062
|
+
\`npx uniweb@latest update\`) to align to something newer.
|
|
2063
|
+
`
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
if (!blocks[command]) return false
|
|
2067
|
+
log(blocks[command])
|
|
2068
|
+
return true
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
function showHelp() {
|
|
2072
|
+
log(`
|
|
2073
|
+
${colors.cyan}${colors.bright}Uniweb CLI${colors.reset} ${colors.dim}v${getCliVersion()}${colors.reset}
|
|
2074
|
+
|
|
2075
|
+
${colors.bright}Usage:${colors.reset}
|
|
2076
|
+
uniweb <command> [options]
|
|
2077
|
+
|
|
2078
|
+
${colors.bright}Commands:${colors.reset}
|
|
2079
|
+
create [name] Create a new project
|
|
2080
|
+
clone <site-uuid> Clone a backend site into a local file project
|
|
2081
|
+
add <type> [name] Add a foundation, site, or extension to a project
|
|
2082
|
+
rename <type> Rename a foundation, site, or extension across the workspace
|
|
2083
|
+
dev Start a dev server for a site
|
|
2084
|
+
build Build the current project
|
|
2085
|
+
snapshot Compose a preview image of a site (its site.yml preview:)
|
|
2086
|
+
publish Publish a site to Uniweb hosting (smart: foundation + sync + go live)
|
|
2087
|
+
deploy Ship a site to a host (asks where, if not yet configured)
|
|
2088
|
+
export Export a self-contained site for third-party hosting
|
|
2089
|
+
register Register a foundation + its data schemas with the backend registry
|
|
2090
|
+
release Release a foundation version (synonym of register)
|
|
2091
|
+
push Push a site's content to the backend
|
|
2092
|
+
pull Pull a site's content from the backend
|
|
2093
|
+
refresh Catch up with the git remote AND the backend (never pushes)
|
|
2094
|
+
sync Catch up, then push (refresh + push)
|
|
2095
|
+
status Show a site's sync state (unpushed content, foundation)
|
|
2096
|
+
forget Forget a backend, or --all to make a copied project a new one
|
|
2097
|
+
site <cmd> The sites in a workspace: list, unpublish, delete
|
|
2098
|
+
inspect <path> Inspect parsed content shape of a markdown file or folder
|
|
2099
|
+
docs Generate component documentation
|
|
2100
|
+
families List the standard section families (for meta.js family:)
|
|
2101
|
+
template list List the official templates (for create --template)
|
|
2102
|
+
doctor Diagnose project configuration issues
|
|
2103
|
+
validate Check your content against your foundation's data schemas
|
|
2104
|
+
update Align workspace deps + AGENTS.md to the running CLI
|
|
2105
|
+
i18n <cmd> Internationalization (extract, sync, status)
|
|
2106
|
+
login Log in to your Uniweb account
|
|
2107
|
+
logout Log out of the backend you are logged in to
|
|
2108
|
+
|
|
2109
|
+
${colors.bright}Create Options:${colors.reset}
|
|
2110
|
+
--template <type> Project template (default: starter)
|
|
2111
|
+
--blank Create an empty workspace (grow with uniweb add)
|
|
2112
|
+
--name <name> Project display name
|
|
2113
|
+
--no-git Skip git repository initialization
|
|
2114
|
+
|
|
2115
|
+
${colors.bright}Add Subcommands:${colors.reset}
|
|
2116
|
+
add project [name] Add a co-located foundation + site pair
|
|
2117
|
+
add foundation [name] Add a foundation (--from, --path, --project)
|
|
2118
|
+
add site [name] Add a site (--from, --foundation, --path, --project)
|
|
2119
|
+
add extension <name> Add an extension (--from, --site, --path)
|
|
2120
|
+
add section <name> Add a section type to a foundation (--foundation, --starter)
|
|
2121
|
+
add ci Add a CI workflow so every push deploys (--host, --target)
|
|
2122
|
+
|
|
2123
|
+
${colors.bright}Global Options:${colors.reset}
|
|
2124
|
+
--version, -v Show version
|
|
2125
|
+
--non-interactive Fail with usage info instead of prompting
|
|
2126
|
+
Auto-detected when CI=true or no TTY (pipes, agents)
|
|
2127
|
+
|
|
2128
|
+
Backend commands (push, pull, publish, status, register, clone) go to the
|
|
2129
|
+
backend you are logged in to, with its session. Switch with
|
|
2130
|
+
\`uniweb login --server <url>\` (add \`--token <bearer>\` to sign in with a token);
|
|
2131
|
+
a script can aim and authenticate one process with UNIWEB_SERVER and
|
|
2132
|
+
UNIWEB_TOKEN instead.
|
|
2133
|
+
|
|
2134
|
+
${colors.bright}Push Options:${colors.reset}
|
|
2135
|
+
--dry-run Report what would be pushed; send nothing (-o <file> writes the .uwx)
|
|
2136
|
+
--no-release Send content against the already-released code; release nothing
|
|
2137
|
+
--bump Release above a newer registered foundation version
|
|
2138
|
+
--force Overwrite upstream changes (drop the staleness gate)
|
|
2139
|
+
--all Send every record (bypass the changed-only cache)
|
|
2140
|
+
--org @org Work in @org for this push, not your login's workspace
|
|
2141
|
+
--personal Work in your personal workspace for this push
|
|
2142
|
+
--no-validate Skip the content-conformance check (it stops a push)
|
|
2143
|
+
|
|
2144
|
+
${colors.bright}Publish Options:${colors.reset}
|
|
2145
|
+
--dry-run Resolve everything; release/sync/POST nothing
|
|
2146
|
+
--yes Skip confirmations (CI); never block on a prompt
|
|
2147
|
+
--no-release Ship content against the already-released code; release nothing
|
|
2148
|
+
--bump Release above a newer registered foundation version
|
|
2149
|
+
--org @org Work in @org for this publish, not your login's workspace
|
|
2150
|
+
--personal Work in your personal workspace for this publish
|
|
2151
|
+
--no-save Do not record this deploy in deploy.yml
|
|
2152
|
+
--no-validate Skip the content-conformance check (it stops a publish)
|
|
2153
|
+
|
|
2154
|
+
uniweb publish is the smart Uniweb-hosting path: it brings the site's
|
|
2155
|
+
foundation along, syncs, and goes live. To register a foundation on its own
|
|
2156
|
+
use \`uniweb register\` (alias \`uniweb release\`); for a third-party host use
|
|
2157
|
+
\`uniweb deploy --host=<name>\`.
|
|
2158
|
+
|
|
2159
|
+
${colors.bright}Deploy Options:${colors.reset}
|
|
2160
|
+
--host <name> The host to ship to. Omit it (TTY) to open the wizard:
|
|
2161
|
+
github-pages, cloudflare-pages, netlify, vercel,
|
|
2162
|
+
s3-cloudfront, or \`uniweb\` (delegates to \`uniweb publish\`).
|
|
2163
|
+
--target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
|
|
2164
|
+
--dry-run Resolve the target + adapter; print summary; upload nothing
|
|
2165
|
+
--no-save Do not record this deploy in deploy.yml
|
|
2166
|
+
--no-validate Skip the content-conformance check (a static host warns)
|
|
2167
|
+
To deploy on every push instead, see \`uniweb add ci --help\`.
|
|
2168
|
+
|
|
2169
|
+
${colors.bright}Dev Options:${colors.reset}
|
|
2170
|
+
<site> Site name to run (positional)
|
|
2171
|
+
--site <name> Site name to run (explicit form)
|
|
2172
|
+
|
|
2173
|
+
${colors.bright}Export Options:${colors.reset}
|
|
2174
|
+
--no-prerender Skip per-page prerendered HTML
|
|
2175
|
+
--host <name> Apply host-specific postBuild (cloudflare-pages, github-pages, …)
|
|
2176
|
+
|
|
2177
|
+
${colors.bright}Build Options:${colors.reset}
|
|
2178
|
+
--target <type> Build target (foundation, site) — auto-detected if not specified
|
|
2179
|
+
--prerender Force pre-rendering (overrides site.yml)
|
|
2180
|
+
--no-prerender Skip pre-rendering (overrides site.yml)
|
|
2181
|
+
--foundation-dir Path to foundation directory (for prerendering)
|
|
2182
|
+
--host <name> Apply host-specific postBuild (cloudflare-pages, s3-cloudfront, …)
|
|
2183
|
+
--platform <name> (Deprecated alias for --host)
|
|
2184
|
+
|
|
2185
|
+
At workspace root, builds all foundations first, then all sites.
|
|
2186
|
+
Pre-rendering is enabled by default when build.prerender: true in site.yml.
|
|
2187
|
+
See \`uniweb <command> --help\` for command-specific detail and examples.
|
|
2188
|
+
|
|
2189
|
+
${colors.bright}Docs Subcommands:${colors.reset}
|
|
2190
|
+
docs Generate COMPONENTS.md from foundation schema
|
|
2191
|
+
docs site Show site.yml configuration reference
|
|
2192
|
+
docs page Show page.yml configuration reference
|
|
2193
|
+
docs meta Show component meta.js reference
|
|
2194
|
+
|
|
2195
|
+
${colors.bright}Docs Options:${colors.reset}
|
|
2196
|
+
--output <file> Output filename (default: COMPONENTS.md)
|
|
2197
|
+
--from-source Read meta.js files directly instead of schema.json
|
|
2198
|
+
|
|
2199
|
+
${colors.bright}i18n Commands:${colors.reset}
|
|
2200
|
+
extract Extract translatable strings to manifest
|
|
2201
|
+
sync Update manifest with content changes
|
|
2202
|
+
status Show translation coverage per locale
|
|
2203
|
+
|
|
2204
|
+
${colors.bright}Template Types:${colors.reset}
|
|
2205
|
+
starter Foundation + site + sample content (default)
|
|
2206
|
+
none Foundation + site with no content
|
|
2207
|
+
marketing Official marketing template
|
|
2208
|
+
./path/to/template Local directory
|
|
2209
|
+
@scope/template-name npm package
|
|
2210
|
+
github:user/repo GitHub repository
|
|
2211
|
+
https://github.com/user/repo GitHub URL
|
|
2212
|
+
|
|
2213
|
+
${colors.bright}Examples:${colors.reset}
|
|
2214
|
+
uniweb create my-project # Foundation + site + starter content
|
|
2215
|
+
uniweb create my-project --template none # Foundation + site, no content
|
|
2216
|
+
uniweb create my-project --blank # Empty workspace
|
|
2217
|
+
uniweb create my-project --template marketing # Official template
|
|
2218
|
+
uniweb create my-project --template ./my-template # Local template
|
|
2219
|
+
|
|
2220
|
+
cd my-project
|
|
2221
|
+
uniweb add project docs # Add docs/src/ + docs/site/
|
|
2222
|
+
uniweb add project docs --from academic # Co-located pair + academic content
|
|
2223
|
+
uniweb add foundation marketing # Add marketing/ at root
|
|
2224
|
+
uniweb add site blog --foundation marketing # Add blog/ wired to marketing
|
|
2225
|
+
uniweb add extension effects --site site # Add effects/
|
|
2226
|
+
|
|
2227
|
+
uniweb build
|
|
2228
|
+
uniweb build --target src # Build src/ package
|
|
2229
|
+
cd src && uniweb docs # Generate COMPONENTS.md
|
|
2230
|
+
|
|
2231
|
+
${colors.bright}Install:${colors.reset}
|
|
2232
|
+
npm i -g uniweb Global install (recommended)
|
|
2233
|
+
npx uniweb <command> Run without installing
|
|
2234
|
+
`)
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// Run CLI
|
|
2238
|
+
main().catch(async (err) => {
|
|
2239
|
+
error(err.message)
|
|
2240
|
+
await exitWhenDrained(1)
|
|
2241
|
+
})
|