uniweb 1.20.3 → 1.21.0
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/README.md +145 -113
- package/package.json +5 -5
- package/partials/agents.md +19 -8
- package/partials/start.md +126 -0
- package/src/backend/app-changes.js +153 -0
- package/src/backend/client.js +3 -3
- package/src/backend/foundation-bring-along.js +1 -1
- package/src/backend/payment-handoff.js +25 -2
- package/src/commands/clone.js +2 -0
- package/src/commands/doctor.js +41 -10
- package/src/commands/pull.js +17 -2
- package/src/commands/register.js +15 -15
- package/src/commands/start.js +60 -0
- package/src/commands/status.js +712 -254
- package/src/commands/update.js +1 -23
- package/src/index.js +80 -2
- package/src/utils/code-upload.js +27 -27
- package/src/utils/flag-guard.js +17 -2
- package/src/utils/git.js +91 -1
- package/src/utils/registry-auth.js +15 -5
- package/src/utils/scaffold.js +4 -0
- package/src/utils/status-next.js +47 -0
- package/src/utils/update-check.js +8 -6
- package/src/utils/workspace.js +26 -0
- package/starter/foundation/sections/Section/index.jsx +6 -6
- package/starter/site/pages/home/1-welcome.md.hbs +1 -1
- package/templates/site/site.yml.hbs +4 -5
- package/templates/workspace/README.md.hbs +132 -50
- package/partials/adding-pages.hbs +0 -77
- package/partials/ai-assistance.hbs +0 -28
- package/partials/components-docs.hbs +0 -13
- package/partials/config-reference.hbs +0 -66
- package/partials/deployment.hbs +0 -13
- package/partials/learn-more.hbs +0 -6
- package/partials/quick-start.hbs +0 -8
- package/partials/search-docs.hbs +0 -110
package/src/commands/update.js
CHANGED
|
@@ -44,7 +44,7 @@ import { join } from 'node:path'
|
|
|
44
44
|
import { spawn } from 'node:child_process'
|
|
45
45
|
import prompts from 'prompts'
|
|
46
46
|
|
|
47
|
-
import {
|
|
47
|
+
import { findUniwebWorkspace } from '../utils/workspace.js'
|
|
48
48
|
import {
|
|
49
49
|
readAgentsVersion,
|
|
50
50
|
refreshAgentsContent,
|
|
@@ -108,28 +108,6 @@ function isNpxInvocation() {
|
|
|
108
108
|
return p.includes('/_npx/')
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/**
|
|
112
|
-
* Find a *Uniweb* workspace root from cwd. Stricter than findWorkspaceRoot
|
|
113
|
-
* — also requires that the workspace's root package.json declares uniweb
|
|
114
|
-
* as a dep or devDep. Otherwise we'd write AGENTS.md and edit package.json
|
|
115
|
-
* files in unrelated monorepos.
|
|
116
|
-
*/
|
|
117
|
-
function findUniwebWorkspace(cwd) {
|
|
118
|
-
const workspaceDir = findWorkspaceRoot(cwd)
|
|
119
|
-
if (!workspaceDir) return null
|
|
120
|
-
const pkgPath = join(workspaceDir, 'package.json')
|
|
121
|
-
if (!existsSync(pkgPath)) return null
|
|
122
|
-
try {
|
|
123
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
124
|
-
const hasUniwebDep = !!(
|
|
125
|
-
pkg.devDependencies?.uniweb || pkg.dependencies?.uniweb
|
|
126
|
-
)
|
|
127
|
-
return hasUniwebDep ? workspaceDir : null
|
|
128
|
-
} catch {
|
|
129
|
-
return null
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
111
|
/**
|
|
134
112
|
* Trailing advisory: a newer CLI exists, and this run could not have used it.
|
|
135
113
|
*
|
package/src/index.js
CHANGED
|
@@ -152,6 +152,8 @@ function getCliVersion() {
|
|
|
152
152
|
// against *this* CLI's version matrix — when run from a (newer) global
|
|
153
153
|
// install that's the whole point; delegating to the project-local copy
|
|
154
154
|
// would align the project to the version it already has, i.e. a no-op.
|
|
155
|
+
// `start` is here because a project's older CLI may not have it, and its
|
|
156
|
+
// guide describes the CLI that prints it.
|
|
155
157
|
const STANDALONE_COMMANDS = new Set([
|
|
156
158
|
'create',
|
|
157
159
|
'clone',
|
|
@@ -161,6 +163,7 @@ const STANDALONE_COMMANDS = new Set([
|
|
|
161
163
|
'-v',
|
|
162
164
|
'login',
|
|
163
165
|
'logout',
|
|
166
|
+
'start',
|
|
164
167
|
'update'
|
|
165
168
|
])
|
|
166
169
|
|
|
@@ -347,6 +350,10 @@ async function createFromPackageTemplates(
|
|
|
347
350
|
{
|
|
348
351
|
projectName,
|
|
349
352
|
workspaceGlobs: ['site', 'src'],
|
|
353
|
+
packages: [
|
|
354
|
+
{ path: 'src', foundation: true },
|
|
355
|
+
{ path: 'site', site: true }
|
|
356
|
+
],
|
|
350
357
|
scripts: {
|
|
351
358
|
dev: 'uniweb dev',
|
|
352
359
|
build: 'uniweb build',
|
|
@@ -413,6 +420,7 @@ async function createBlankWorkspace(projectDir, projectName, options = {}) {
|
|
|
413
420
|
{
|
|
414
421
|
projectName,
|
|
415
422
|
workspaceGlobs: [],
|
|
423
|
+
packages: [],
|
|
416
424
|
scripts: {
|
|
417
425
|
build: 'uniweb build'
|
|
418
426
|
}
|
|
@@ -480,6 +488,8 @@ async function createFromContentTemplate(
|
|
|
480
488
|
{
|
|
481
489
|
projectName,
|
|
482
490
|
workspaceGlobs,
|
|
491
|
+
// `type` is foundation, extension or site: the README's table reads it as a flag.
|
|
492
|
+
packages: placed.map((p) => ({ path: p.relativePath, [p.type]: true })),
|
|
483
493
|
scripts
|
|
484
494
|
},
|
|
485
495
|
{ onProgress, onWarning }
|
|
@@ -696,6 +706,19 @@ async function main() {
|
|
|
696
706
|
// Commands that need @uniweb/build will get a helpful error via importProjectCommand().
|
|
697
707
|
}
|
|
698
708
|
|
|
709
|
+
// `start` — the guide an AI agent reads first (commands/start.js). Answered before the
|
|
710
|
+
// update check: it asks nothing, writes nothing and makes no request, and the check can
|
|
711
|
+
// write a cache and fetch.
|
|
712
|
+
if (command === 'start') {
|
|
713
|
+
if (args.slice(1).some((a) => a === '--help' || a === '-h')) {
|
|
714
|
+
printCommandHelp('start')
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
const { start } = await import('./commands/start.js')
|
|
718
|
+
await start(args.slice(1))
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
|
|
699
722
|
// Update check for global installs.
|
|
700
723
|
//
|
|
701
724
|
// - `create` / `clone` STOP when a newer release exists, before anything is
|
|
@@ -853,7 +876,7 @@ async function main() {
|
|
|
853
876
|
await exitWhenDrained(result?.exitCode ?? 0)
|
|
854
877
|
}
|
|
855
878
|
|
|
856
|
-
// Handle status command (dynamic import —
|
|
879
|
+
// Handle status command (dynamic import — its checks reach @uniweb/build)
|
|
857
880
|
if (command === 'status') {
|
|
858
881
|
const { status } = await importProjectCommand('./commands/status.js')
|
|
859
882
|
const result = await status(args.slice(1))
|
|
@@ -1132,6 +1155,9 @@ async function main() {
|
|
|
1132
1155
|
if (nonInteractive && !projectName) {
|
|
1133
1156
|
error(`Missing project name.\n`)
|
|
1134
1157
|
log(`Usage: ${prefix} create <project-name> [--template <name>] [--blank]`)
|
|
1158
|
+
log(
|
|
1159
|
+
`\nAn AI agent building a site for someone: run ${colors.cyan}npx uniweb@latest start${colors.reset} first — it says how, step by step.`
|
|
1160
|
+
)
|
|
1135
1161
|
await exitWhenDrained(1)
|
|
1136
1162
|
}
|
|
1137
1163
|
|
|
@@ -1389,6 +1415,10 @@ async function main() {
|
|
|
1389
1415
|
` ${colors.cyan}${runCmd(recPm, 'dev')}${colors.reset} ${colors.dim}# start the dev server${colors.reset}`
|
|
1390
1416
|
)
|
|
1391
1417
|
}
|
|
1418
|
+
log('')
|
|
1419
|
+
log(
|
|
1420
|
+
` ${colors.bright}AGENTS.md${colors.reset} is the project's manual, for you and for AI agents: its Part 0 says where to begin.`
|
|
1421
|
+
)
|
|
1392
1422
|
if (recPm !== 'pnpm') {
|
|
1393
1423
|
log('')
|
|
1394
1424
|
log(
|
|
@@ -1529,6 +1559,21 @@ ${colors.bright}Examples:${colors.reset}
|
|
|
1529
1559
|
uniweb create . # Scaffold into current dir
|
|
1530
1560
|
uniweb create . --template docs # In place + a content template
|
|
1531
1561
|
uniweb create . --name=my-app # In place, explicit slug
|
|
1562
|
+
`,
|
|
1563
|
+
start: `
|
|
1564
|
+
${colors.cyan}${colors.bright}uniweb start${colors.reset} ${colors.dim}— How an AI agent begins a Uniweb site${colors.reset}
|
|
1565
|
+
|
|
1566
|
+
${colors.bright}Usage:${colors.reset}
|
|
1567
|
+
npx uniweb@latest start Print the guide, in Markdown
|
|
1568
|
+
|
|
1569
|
+
A short guide for an AI agent building a website for someone: create a
|
|
1570
|
+
project, read its AGENTS.md, run it, sign the person in, and bring the site
|
|
1571
|
+
into the Uniweb app. The person only uses their browser, to sign in and to
|
|
1572
|
+
pay when the site goes live; the guide has the agent run every command.
|
|
1573
|
+
|
|
1574
|
+
Inside a Uniweb project it starts from that project instead of creating one.
|
|
1575
|
+
It asks nothing, writes nothing and makes no request, so it is safe to run
|
|
1576
|
+
anywhere. To run a site's dev server, use \`uniweb dev\`.
|
|
1532
1577
|
`,
|
|
1533
1578
|
dev: `
|
|
1534
1579
|
${colors.cyan}${colors.bright}uniweb dev${colors.reset} ${colors.dim}— Start a dev server for a site${colors.reset}
|
|
@@ -1725,6 +1770,38 @@ ${colors.bright}Options:${colors.reset}
|
|
|
1725
1770
|
--non-interactive Fail with usage info instead of prompting
|
|
1726
1771
|
|
|
1727
1772
|
Exit code is 1 if errors are found (warnings only → exit 0).
|
|
1773
|
+
`,
|
|
1774
|
+
status: `
|
|
1775
|
+
${colors.cyan}${colors.bright}uniweb status${colors.reset} ${colors.dim}— What to know before working on a site, and what to run about it${colors.reset}
|
|
1776
|
+
|
|
1777
|
+
${colors.bright}Usage:${colors.reset}
|
|
1778
|
+
uniweb status [options]
|
|
1779
|
+
|
|
1780
|
+
Run it when you begin, before you push, and after a command refuses. It checks:
|
|
1781
|
+
|
|
1782
|
+
git conflicts, a merge waiting, the site's files not committed, the git remote
|
|
1783
|
+
app the session, what changed on the backend since this copy last synced,
|
|
1784
|
+
what is not pushed, whether the site is live, who has it open in the app,
|
|
1785
|
+
the foundation
|
|
1786
|
+
uniweb whether a newer release is out
|
|
1787
|
+
health doctor's findings
|
|
1788
|
+
dev the dev server
|
|
1789
|
+
|
|
1790
|
+
and ends with the steps to take, in the order to take them. A step marked
|
|
1791
|
+
"ask the person first" is theirs to decide — going live, for one.
|
|
1792
|
+
|
|
1793
|
+
It changes nothing and asks nothing: with no session it says how to sign in.
|
|
1794
|
+
A check it could not make says so, and is never reported as fine.
|
|
1795
|
+
|
|
1796
|
+
${colors.bright}Options:${colors.reset}
|
|
1797
|
+
--offline Ask nothing beyond this machine — no backend, npm, git remote
|
|
1798
|
+
or dev server
|
|
1799
|
+
--json One JSON document: { schema: 1, checks: [...], next: [...] }
|
|
1800
|
+
--org @org The workspace to ask the backend in, for this command only
|
|
1801
|
+
--personal Your personal workspace, for this command only
|
|
1802
|
+
|
|
1803
|
+
Exit code is 0 whenever a status is produced, 2 for a usage error — read the
|
|
1804
|
+
document, not the exit code, for what needs doing.
|
|
1728
1805
|
`,
|
|
1729
1806
|
validate: `
|
|
1730
1807
|
${colors.cyan}${colors.bright}uniweb validate${colors.reset} ${colors.dim}— Check your content against your foundation's data schemas${colors.reset}
|
|
@@ -2075,6 +2152,7 @@ ${colors.bright}Usage:${colors.reset}
|
|
|
2075
2152
|
uniweb <command> [options]
|
|
2076
2153
|
|
|
2077
2154
|
${colors.bright}Commands:${colors.reset}
|
|
2155
|
+
start How an AI agent begins a site (a guide; changes nothing)
|
|
2078
2156
|
create [name] Create a new project
|
|
2079
2157
|
clone <site-uuid> Clone a backend site into a local file project
|
|
2080
2158
|
add <type> [name] Add a foundation, site, or extension to a project
|
|
@@ -2091,7 +2169,7 @@ ${colors.bright}Commands:${colors.reset}
|
|
|
2091
2169
|
pull Pull a site's content from the backend
|
|
2092
2170
|
refresh Catch up with the git remote AND the backend (never pushes)
|
|
2093
2171
|
sync Catch up, then push (refresh + push)
|
|
2094
|
-
status
|
|
2172
|
+
status What changed in the app, in git and in Uniweb — and what to run
|
|
2095
2173
|
forget Forget a backend, or --all to make a copied project a new one
|
|
2096
2174
|
site <cmd> The sites in a workspace: list, unpublish, delete
|
|
2097
2175
|
inspect <path> Inspect parsed content shape of a markdown file or folder
|
package/src/utils/code-upload.js
CHANGED
|
@@ -119,33 +119,6 @@ export function uploadOrder(files) {
|
|
|
119
119
|
return [...rest, ...entry]
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
/**
|
|
123
|
-
* The deterministic content digest of a built foundation — the freshness
|
|
124
|
-
* fingerprint `register` records and `publish`/`status` later compare against
|
|
125
|
-
* (shipping-model.md §4.1). It answers one question — "did the foundation change
|
|
126
|
-
* since it was released?" — with NO local state, so it has to be reproducible:
|
|
127
|
-
* the same source, built on any machine, must yield the same digest.
|
|
128
|
-
*
|
|
129
|
-
* Algorithm (§4.1): `sha256` over the newline-joined, SORTED list of the
|
|
130
|
-
* per-file `sha256` (hex) of exactly what `register` ships for a foundation —
|
|
131
|
-
* - the code-upload set (`collectDistFiles`: entry + chunks + assets, with
|
|
132
|
-
* `meta/**` and `*.map` already excluded), and
|
|
133
|
-
* - the schema source (`meta/schema.json`) — NOT uploaded as a file, and not
|
|
134
|
-
* sent verbatim: register submits its *content* as the foundation-schema
|
|
135
|
-
* ENTITY in the `.uwx`. It's read here only to fold the schema into the
|
|
136
|
-
* local freshness fingerprint (so a schema edit shows as "changed").
|
|
137
|
-
* Hashes are SORTED (so the digest is order-independent) and taken over file
|
|
138
|
-
* CONTENTS, not names, so content-hashed chunk filenames don't perturb it.
|
|
139
|
-
* Returns null when there's nothing to hash (no usable dist), so callers skip
|
|
140
|
-
* cleanly rather than fingerprinting emptiness.
|
|
141
|
-
*
|
|
142
|
-
* The result is OPAQUE to the backend — it stores the string and returns it
|
|
143
|
-
* verbatim; the framework owns the algorithm end-to-end and can change it
|
|
144
|
-
* freely (a digest only ever compares against one the same CLI computed).
|
|
145
|
-
*
|
|
146
|
-
* @param {string} distDir - the built `dist/` directory
|
|
147
|
-
* @returns {string|null} `sha256:<hex>` or null when there's nothing to hash
|
|
148
|
-
*/
|
|
149
122
|
/**
|
|
150
123
|
* The `@uniweb/runtime` version a built foundation linked against, from
|
|
151
124
|
* `dist/runtime-pin.json`.
|
|
@@ -180,6 +153,33 @@ export function readRuntimePin(distDir) {
|
|
|
180
153
|
}
|
|
181
154
|
}
|
|
182
155
|
|
|
156
|
+
/**
|
|
157
|
+
* The deterministic content digest of a built foundation — the freshness
|
|
158
|
+
* fingerprint `register` records and `publish`/`status` later compare against
|
|
159
|
+
* (shipping-model.md §4.1). It answers one question — "did the foundation change
|
|
160
|
+
* since it was released?" — with NO local state, so it has to be reproducible:
|
|
161
|
+
* the same source, built on any machine, must yield the same digest.
|
|
162
|
+
*
|
|
163
|
+
* Algorithm (§4.1): `sha256` over the newline-joined, SORTED list of the
|
|
164
|
+
* per-file `sha256` (hex) of exactly what `register` ships for a foundation —
|
|
165
|
+
* - the code-upload set (`collectDistFiles`: entry + chunks + assets, with
|
|
166
|
+
* `meta/**` and `*.map` already excluded), and
|
|
167
|
+
* - the schema source (`meta/schema.json`) — NOT uploaded as a file, and not
|
|
168
|
+
* sent verbatim: register submits its *content* as the foundation-schema
|
|
169
|
+
* ENTITY in the `.uwx`. It's read here only to fold the schema into the
|
|
170
|
+
* local freshness fingerprint (so a schema edit shows as "changed").
|
|
171
|
+
* Hashes are SORTED (so the digest is order-independent) and taken over file
|
|
172
|
+
* CONTENTS, not names, so content-hashed chunk filenames don't perturb it.
|
|
173
|
+
* Returns null when there's nothing to hash (no usable dist), so callers skip
|
|
174
|
+
* cleanly rather than fingerprinting emptiness.
|
|
175
|
+
*
|
|
176
|
+
* The result is OPAQUE to the backend — it stores the string and returns it
|
|
177
|
+
* verbatim; the framework owns the algorithm end-to-end and can change it
|
|
178
|
+
* freely (a digest only ever compares against one the same CLI computed).
|
|
179
|
+
*
|
|
180
|
+
* @param {string} distDir - the built `dist/` directory
|
|
181
|
+
* @returns {string|null} `sha256:<hex>` or null when there's nothing to hash
|
|
182
|
+
*/
|
|
183
183
|
export function computeFoundationDigest(distDir) {
|
|
184
184
|
const hashes = collectDistFiles(distDir).map((f) => f.sha256)
|
|
185
185
|
// The schema rides in the register `.uwx`, not the code-upload set, so fold
|
package/src/utils/flag-guard.js
CHANGED
|
@@ -109,11 +109,14 @@ const VERBS = {
|
|
|
109
109
|
*/
|
|
110
110
|
site: ['--json', '--yes', '--org', '--personal'],
|
|
111
111
|
status: [
|
|
112
|
-
'--json', '--
|
|
112
|
+
'--json', '--offline', '--dry-run',
|
|
113
113
|
'--force', '--no-verify', '--no-validate', '--yes', '--org', '--personal',
|
|
114
114
|
// inert here, reachable through the bring-along module status imports for
|
|
115
115
|
// `resolveLocalFoundation` — listed per the over-approximation note above
|
|
116
|
-
'--no-release', '--bump', ...VIA_DEPLOY
|
|
116
|
+
'--no-release', '--bump', ...VIA_DEPLOY,
|
|
117
|
+
// inert here too, reachable through `commands/pull.js`, whose lane readers status uses
|
|
118
|
+
// to ask what changed on the backend (`backend/app-changes.js`)
|
|
119
|
+
'--no-assets', '--no-delete', '--no-prune', '--no-records', '--content-only'
|
|
117
120
|
],
|
|
118
121
|
/**
|
|
119
122
|
* `refresh` = `git pull`, then a DELEGATED `pull`.
|
|
@@ -249,6 +252,18 @@ export function checkFlags(verb, args = []) {
|
|
|
249
252
|
message: `\`--as-org\` is retired — use \`--org @org\` (e.g. \`uniweb ${verb} --org @acme\`).`
|
|
250
253
|
}
|
|
251
254
|
}
|
|
255
|
+
// ⭐ `status --remote` is RETIRED (2026-10-26): a status asks the backend by default, and
|
|
256
|
+
// `--offline` keeps it to this machine.
|
|
257
|
+
if (flag === '--remote' && verb === 'status') {
|
|
258
|
+
return {
|
|
259
|
+
flag,
|
|
260
|
+
suggestion: null,
|
|
261
|
+
message: [
|
|
262
|
+
'`uniweb status` has no `--remote`: it asks the backend by default now.',
|
|
263
|
+
' Run `uniweb status`, or `uniweb status --offline` to ask nothing beyond this machine.'
|
|
264
|
+
].join('\n')
|
|
265
|
+
}
|
|
266
|
+
}
|
|
252
267
|
// ⭐ `pull --merge` is RETIRED (2026-10-17): in a repository a pull always merges — from the
|
|
253
268
|
// newest sync point the history holds (`utils/sync-point.js`) — and outside one there is
|
|
254
269
|
// nothing to merge against.
|
package/src/utils/git.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* project counted as inside the harness, so `refresh` ran `git pull` on the harness.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { execFileSync } from 'node:child_process'
|
|
24
|
+
import { execFile, execFileSync } from 'node:child_process'
|
|
25
25
|
import { readFileSync } from 'node:fs'
|
|
26
26
|
import { join } from 'node:path'
|
|
27
27
|
import yaml from 'js-yaml'
|
|
@@ -198,6 +198,96 @@ export function pullRemote(dir) {
|
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* The branch HEAD is on and the one it tracks — `{ branch, remote, merge, tracking }`
|
|
203
|
+
* (`main`, `origin`, `refs/heads/main`, `origin/main`) — or null: no branch, or no upstream.
|
|
204
|
+
*/
|
|
205
|
+
export function upstreamOf(dir) {
|
|
206
|
+
try {
|
|
207
|
+
const branch = git(['symbolic-ref', '--short', '-q', 'HEAD'], dir).trim()
|
|
208
|
+
if (!branch) return null
|
|
209
|
+
const remote = git(['config', '--get', `branch.${branch}.remote`], dir).trim()
|
|
210
|
+
const merge = git(['config', '--get', `branch.${branch}.merge`], dir).trim()
|
|
211
|
+
const tracking = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], dir).trim()
|
|
212
|
+
return remote && merge && tracking ? { branch, remote, merge, tracking } : null
|
|
213
|
+
} catch {
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The commits HEAD has that its upstream lacks (`ahead`), and the reverse (`behind`) — as of
|
|
220
|
+
* the last fetch, which is all a repository knows without asking its remote. Null with no
|
|
221
|
+
* upstream.
|
|
222
|
+
*/
|
|
223
|
+
export function aheadBehind(dir) {
|
|
224
|
+
try {
|
|
225
|
+
const [behind, ahead] = git(['rev-list', '--left-right', '--count', '@{u}...HEAD'], dir)
|
|
226
|
+
.trim()
|
|
227
|
+
.split(/\s+/)
|
|
228
|
+
.map(Number)
|
|
229
|
+
return Number.isInteger(ahead) && Number.isInteger(behind) ? { ahead, behind } : null
|
|
230
|
+
} catch {
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Does the git remote hold commits HEAD does not — teammates' work since the last fetch?
|
|
237
|
+
* Asked with `git ls-remote`, which fetches nothing and writes nothing, so a read-only
|
|
238
|
+
* command can ask it.
|
|
239
|
+
*
|
|
240
|
+
* ⛔ IT NEVER PROMPTS. A credential prompt would hang a command run by an agent, so: no
|
|
241
|
+
* terminal prompt (`GIT_TERMINAL_PROMPT=0`), no credential manager window
|
|
242
|
+
* (`GCM_INTERACTIVE=never`), ssh in batch mode with a connect timeout — added to the ssh
|
|
243
|
+
* command the person configured, never instead of it — and the whole call bounded.
|
|
244
|
+
*
|
|
245
|
+
* @param {string} dir
|
|
246
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
247
|
+
* @returns {Promise<{ moved: boolean, upstream: object } | null>} null when there is no
|
|
248
|
+
* upstream, or the remote did not answer in time
|
|
249
|
+
*/
|
|
250
|
+
export async function remoteMoved(dir, { timeoutMs = 5000 } = {}) {
|
|
251
|
+
const upstream = upstreamOf(dir)
|
|
252
|
+
if (!upstream) return null
|
|
253
|
+
let ssh = process.env.GIT_SSH_COMMAND || ''
|
|
254
|
+
try {
|
|
255
|
+
ssh ||= git(['config', '--get', 'core.sshCommand'], dir).trim()
|
|
256
|
+
} catch {
|
|
257
|
+
// not configured
|
|
258
|
+
}
|
|
259
|
+
const env = {
|
|
260
|
+
...process.env,
|
|
261
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
262
|
+
GCM_INTERACTIVE: 'never',
|
|
263
|
+
GIT_SSH_COMMAND: `${ssh || 'ssh'} -o BatchMode=yes -o ConnectTimeout=${Math.ceil(timeoutMs / 1000)}`
|
|
264
|
+
}
|
|
265
|
+
let out
|
|
266
|
+
try {
|
|
267
|
+
out = await new Promise((resolve, reject) => {
|
|
268
|
+
execFile('git', ['ls-remote', '--', upstream.remote, upstream.merge], { cwd: dir, env, timeout: timeoutMs, encoding: 'utf8' }, (err, stdout) =>
|
|
269
|
+
err ? reject(err) : resolve(stdout)
|
|
270
|
+
)
|
|
271
|
+
})
|
|
272
|
+
} catch {
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
const head = String(out)
|
|
276
|
+
.split('\n')
|
|
277
|
+
.map((line) => line.split('\t'))
|
|
278
|
+
.find(([, ref]) => ref === upstream.merge)?.[0]
|
|
279
|
+
if (!head) return null
|
|
280
|
+
// A head HEAD already holds — fetched earlier, or HEAD's own — is nothing new.
|
|
281
|
+
let held = false
|
|
282
|
+
try {
|
|
283
|
+
git(['merge-base', '--is-ancestor', head, 'HEAD'], dir)
|
|
284
|
+
held = true
|
|
285
|
+
} catch {
|
|
286
|
+
held = false
|
|
287
|
+
}
|
|
288
|
+
return { moved: !held, upstream }
|
|
289
|
+
}
|
|
290
|
+
|
|
201
291
|
/**
|
|
202
292
|
* Provenance for a deploy record: `{ sha, dirty }`, or null outside a repo.
|
|
203
293
|
* Answers "what is actually live?" later, which a version number cannot.
|
|
@@ -311,12 +311,15 @@ export async function ensureRegistryAuth({
|
|
|
311
311
|
`\x1b[31m✗\x1b[0m ${reason} ${command} requires a Uniweb account, and the CLI is non-interactive (CI / no TTY / --non-interactive).`
|
|
312
312
|
)
|
|
313
313
|
console.error(' Options:')
|
|
314
|
-
|
|
314
|
+
// ⛔ Not "run `login` interactively": with no one at the terminal this is often an AI
|
|
315
|
+
// agent working for someone who does not use one, and it passes the line on. A browser
|
|
316
|
+
// sign-in asks nothing here. *(It said "interactively" until 2026-10-26.)*
|
|
315
317
|
console.error(
|
|
316
|
-
` •
|
|
318
|
+
` • Run \`${prefix} login --browser\` first — someone signs in, in a browser on this machine — then re-run.`
|
|
317
319
|
)
|
|
320
|
+
console.error(` • Set UNIWEB_TOKEN to a bearer token.`)
|
|
318
321
|
console.error(
|
|
319
|
-
` •
|
|
322
|
+
` • Set UNIWEB_USERNAME + UNIWEB_PASSWORD to log in non-interactively.`
|
|
320
323
|
)
|
|
321
324
|
process.exit(1)
|
|
322
325
|
}
|
|
@@ -515,7 +518,9 @@ export async function awaitBrowserCallback({
|
|
|
515
518
|
res.end(
|
|
516
519
|
`<!doctype html><html><body style="font-family:system-ui,sans-serif;text-align:center;padding:60px">` +
|
|
517
520
|
`<h2 style="color:${failed ? '#dc2626' : '#16a34a'}">${failed ? errTitle : okTitle}</h2>` +
|
|
518
|
-
|
|
521
|
+
// ⛔ Not "return to your terminal": the person signing in may have none — an AI
|
|
522
|
+
// agent ran the login for them. *(It said so until 2026-10-26.)*
|
|
523
|
+
`<p>You can close this tab.</p></body></html>`
|
|
519
524
|
)
|
|
520
525
|
cleanup()
|
|
521
526
|
resolve(failed ? { error: verdict.error } : { value: verdict.value })
|
|
@@ -817,11 +822,16 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
|
|
|
817
822
|
} else {
|
|
818
823
|
// Only what works without a terminal: `--password` and `--token-paste` prompt. The
|
|
819
824
|
// command keeps the backend named, or re-run as printed it would go to the default.
|
|
825
|
+
// ⭐ `--browser` first: it asks nothing here, and it is the way for an AI agent working
|
|
826
|
+
// for someone who signs in in their browser. *(It was missing until 2026-10-26.)*
|
|
820
827
|
const server = args.some((a) => a === '--server' || a.startsWith('--server='))
|
|
821
828
|
? ` --server ${key}`
|
|
822
829
|
: ''
|
|
823
830
|
console.error(
|
|
824
|
-
'\x1b[31m✗\x1b[0m Without a terminal, a login needs a way to sign in that asks nothing:'
|
|
831
|
+
'\x1b[31m✗\x1b[0m Without a terminal, a login needs a way to sign in that asks nothing here:'
|
|
832
|
+
)
|
|
833
|
+
console.error(
|
|
834
|
+
` ${getCliPrefix()} login${server} --browser (someone signs in, in a browser on this machine)`
|
|
825
835
|
)
|
|
826
836
|
console.error(
|
|
827
837
|
` ${getCliPrefix()} login${server} --token <bearer> (with --org @org or --personal)`
|
package/src/utils/scaffold.js
CHANGED
|
@@ -31,6 +31,10 @@ const STARTER_DIR = join(__dirname, '..', '..', 'starter')
|
|
|
31
31
|
* @param {string} context.projectName - Workspace/project name
|
|
32
32
|
* @param {string[]} context.workspaceGlobs - Workspace glob patterns
|
|
33
33
|
* @param {Object} context.scripts - Root package.json scripts
|
|
34
|
+
* @param {Array<{ path: string, foundation?: boolean, extension?: boolean, site?: boolean }>} context.packages -
|
|
35
|
+
* The packages the project starts with, each by its folder and kind, for the README's
|
|
36
|
+
* table. `[]` for a blank workspace, which the README tells how to grow.
|
|
37
|
+
* @param {boolean} [context.cloned] - The project is a `uniweb clone` of a site in the app
|
|
34
38
|
* @param {Object} [options] - Processing options
|
|
35
39
|
*/
|
|
36
40
|
export async function scaffoldWorkspace(targetDir, context, options = {}) {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The order `uniweb status`'s next steps are taken in.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ FOLLOWED FROM THE TOP, THE LIST WORKS: each step's precondition is met by the ones above
|
|
5
|
+
* it. A list of individually right steps in the wrong order is a guard that names an
|
|
6
|
+
* unavailable recovery step — a push before the pull it needs is refused, and the refusal
|
|
7
|
+
* then sends the reader back up. So the order is one table, not each check's own idea:
|
|
8
|
+
*
|
|
9
|
+
* sign in → settle conflicts → finish a merge → take what others did (refresh, or pull)
|
|
10
|
+
* → push → push to git → go live → update Uniweb → read doctor's findings
|
|
11
|
+
*
|
|
12
|
+
* Each check proposes steps by name (`step`); this sorts them, folds the ones another step
|
|
13
|
+
* already does, and drops duplicates.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const ORDER = ['login', 'conflicts', 'merge', 'refresh', 'pull', 'push', 'gitpush', 'publish', 'update', 'doctor']
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Array<{ step: string, command: string|null, why: string, ask?: boolean }>} proposals
|
|
20
|
+
* @returns {Array<{ command: string|null, why: string, ask: boolean }>}
|
|
21
|
+
*/
|
|
22
|
+
export function planNext(proposals) {
|
|
23
|
+
const steps = [...proposals]
|
|
24
|
+
// `refresh` is `git pull`, then the same pull: it takes the app's changes too.
|
|
25
|
+
const refresh = steps.find((p) => p.step === 'refresh')
|
|
26
|
+
const pull = steps.find((p) => p.step === 'pull')
|
|
27
|
+
if (refresh && pull) {
|
|
28
|
+
refresh.why = `${refresh.why}, and ${pull.why}`
|
|
29
|
+
steps.splice(steps.indexOf(pull), 1)
|
|
30
|
+
}
|
|
31
|
+
// A newer release's update aligns the dependencies and AGENTS.md too: the project's own
|
|
32
|
+
// CLI aligning them to its matrix is then one step too many.
|
|
33
|
+
if (steps.some((p) => p.step === 'update' && p.latest)) {
|
|
34
|
+
for (let i = steps.length - 1; i >= 0; i--) if (steps[i].step === 'update' && !steps[i].latest) steps.splice(i, 1)
|
|
35
|
+
}
|
|
36
|
+
const seen = new Set()
|
|
37
|
+
return steps
|
|
38
|
+
.filter((p) => ORDER.includes(p.step))
|
|
39
|
+
.sort((a, b) => ORDER.indexOf(a.step) - ORDER.indexOf(b.step))
|
|
40
|
+
.filter((p) => {
|
|
41
|
+
const key = p.command ?? `${p.step}:${p.why}`
|
|
42
|
+
if (seen.has(key)) return false
|
|
43
|
+
seen.add(key)
|
|
44
|
+
return true
|
|
45
|
+
})
|
|
46
|
+
.map(({ command, why, ask }) => ({ command: command ?? null, why, ask: Boolean(ask) }))
|
|
47
|
+
}
|
|
@@ -12,8 +12,10 @@ import { join } from 'node:path'
|
|
|
12
12
|
import { detectGlobalCliPm, globalCliUpdateCmd } from './pm.js'
|
|
13
13
|
|
|
14
14
|
const CHECK_INTERVAL = 24 * 60 * 60 * 1000 // 1 day
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Found when read, not when this module loads — as `getAuthDir` is — so HOME is the one in
|
|
16
|
+
// effect: a run with its own HOME (a test's) never reads or writes the person's cache.
|
|
17
|
+
const stateDir = () => join(homedir(), '.uniweb')
|
|
18
|
+
const stateFile = () => join(stateDir(), 'update-check.json')
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* Compare two semver strings.
|
|
@@ -34,8 +36,8 @@ function compareSemver(a, b) {
|
|
|
34
36
|
*/
|
|
35
37
|
function readState() {
|
|
36
38
|
try {
|
|
37
|
-
if (existsSync(
|
|
38
|
-
return JSON.parse(readFileSync(
|
|
39
|
+
if (existsSync(stateFile())) {
|
|
40
|
+
return JSON.parse(readFileSync(stateFile(), 'utf8'))
|
|
39
41
|
}
|
|
40
42
|
} catch {
|
|
41
43
|
/* ignore corrupt cache */
|
|
@@ -48,8 +50,8 @@ function readState() {
|
|
|
48
50
|
*/
|
|
49
51
|
function writeState(state) {
|
|
50
52
|
try {
|
|
51
|
-
if (!existsSync(
|
|
52
|
-
writeFileSync(
|
|
53
|
+
if (!existsSync(stateDir())) mkdirSync(stateDir(), { recursive: true })
|
|
54
|
+
writeFileSync(stateFile(), JSON.stringify(state))
|
|
53
55
|
} catch {
|
|
54
56
|
/* ignore write errors */
|
|
55
57
|
}
|
package/src/utils/workspace.js
CHANGED
|
@@ -60,6 +60,32 @@ export function findWorkspaceRoot(startDir = process.cwd()) {
|
|
|
60
60
|
return null
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Find a *Uniweb* workspace root from cwd. Stricter than findWorkspaceRoot
|
|
65
|
+
* — also requires that the workspace's root package.json declares uniweb
|
|
66
|
+
* as a dep or devDep. Otherwise `update` would write AGENTS.md and edit
|
|
67
|
+
* package.json files in unrelated monorepos, and `start` would take one for
|
|
68
|
+
* a Uniweb project.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} [cwd]
|
|
71
|
+
* @returns {string|null} - Workspace root path or null
|
|
72
|
+
*/
|
|
73
|
+
export function findUniwebWorkspace(cwd = process.cwd()) {
|
|
74
|
+
const workspaceDir = findWorkspaceRoot(cwd)
|
|
75
|
+
if (!workspaceDir) return null
|
|
76
|
+
const pkgPath = join(workspaceDir, 'package.json')
|
|
77
|
+
if (!existsSync(pkgPath)) return null
|
|
78
|
+
try {
|
|
79
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
80
|
+
const hasUniwebDep = !!(
|
|
81
|
+
pkg.devDependencies?.uniweb || pkg.dependencies?.uniweb
|
|
82
|
+
)
|
|
83
|
+
return hasUniwebDep ? workspaceDir : null
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
63
89
|
/**
|
|
64
90
|
* Get workspace packages from pnpm-workspace.yaml or package.json workspaces —
|
|
65
91
|
* read as pnpm reads them (`workspacePackages`, ./workspace-packages.js).
|
|
@@ -5,14 +5,14 @@ import { H1, H2, P, Link, cn } from '@uniweb/kit'
|
|
|
5
5
|
*
|
|
6
6
|
* A versatile content section that handles headings, text, and links.
|
|
7
7
|
* Uses semantic tokens so it adapts to any theme context automatically.
|
|
8
|
+
*
|
|
9
|
+
* No fallbacks needed: the runtime hands every section the full content shape
|
|
10
|
+
* (empty strings and arrays where the markdown has nothing), and fills each
|
|
11
|
+
* param from its `default` in meta.js.
|
|
8
12
|
*/
|
|
9
13
|
export default function Section({ content, params }) {
|
|
10
|
-
const { title, pretitle, subtitle, paragraphs
|
|
11
|
-
|
|
12
|
-
const {
|
|
13
|
-
align = 'center',
|
|
14
|
-
width = 'default',
|
|
15
|
-
} = params || {}
|
|
14
|
+
const { title, pretitle, subtitle, paragraphs, links, images } = content
|
|
15
|
+
const { align, width } = params
|
|
16
16
|
|
|
17
17
|
const alignments = {
|
|
18
18
|
left: 'text-left',
|
|
@@ -32,11 +32,10 @@ index: home
|
|
|
32
32
|
# mounted directory is a folder of pages. Whatever that stub leaves unset comes
|
|
33
33
|
# from the mounted directory's own folder.yml, so it need only say what differs.
|
|
34
34
|
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
# `paths: { pages: <dir> }` IS carried by both. Static hosting: use either.
|
|
35
|
+
# A `pages/<segment>:` mount goes wherever the site goes: builds, `uniweb export`
|
|
36
|
+
# and `uniweb deploy --host <adapter>`, and `uniweb push` / `uniweb publish`,
|
|
37
|
+
# which send the mounted pages as they are on disk — update a mounted repository
|
|
38
|
+
# before you publish. A pull never writes into a mount.
|
|
40
39
|
|
|
41
40
|
# ─── Structured Content ───────────────────────────────────────────────────────
|
|
42
41
|
# Three things, and they are separate on purpose:
|