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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
{{#if project}}
|
|
2
|
+
# Working on a Uniweb project
|
|
3
|
+
|
|
4
|
+
You're in the Uniweb project at `{{root}}`.
|
|
5
|
+
|
|
6
|
+
Its pages are markdown files, and how they look and behave comes from its foundation:
|
|
7
|
+
React components that the pages pick by name. The `uniweb` CLI builds and runs the
|
|
8
|
+
project, and brings it into the Uniweb app, where the person you're working with can keep
|
|
9
|
+
editing it visually.
|
|
10
|
+
|
|
11
|
+
`uniweb start` only prints this guide; `npx uniweb dev` runs the site. To start a new
|
|
12
|
+
project instead, run `npx uniweb@latest start` in a folder outside this one.
|
|
13
|
+
{{else}}
|
|
14
|
+
# Build a website with Uniweb
|
|
15
|
+
|
|
16
|
+
You're building a website for the person you're working with. In Uniweb, the pages are
|
|
17
|
+
markdown files, and how they look and behave comes from a foundation: React components
|
|
18
|
+
that the pages pick by name. One project holds both. The `uniweb` CLI builds and runs it,
|
|
19
|
+
and brings it into the Uniweb app, where the person can keep editing it visually.
|
|
20
|
+
{{/if}}
|
|
21
|
+
|
|
22
|
+
## The person doesn't use a terminal
|
|
23
|
+
|
|
24
|
+
Run every command yourself, and never ask them to run one. They do two things, in their
|
|
25
|
+
browser: sign in, and pay when the site goes live. When a command prints an address to
|
|
26
|
+
finish something in a browser, open it for them and say what it's for.
|
|
27
|
+
|
|
28
|
+
Uniweb needs Node.js 22.12 or later. If `node --version` shows an older one, install a
|
|
29
|
+
current one yourself.
|
|
30
|
+
{{#unless project}}
|
|
31
|
+
|
|
32
|
+
## Create the project
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx uniweb@latest create <name> --template starter
|
|
36
|
+
cd <name>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`<name>` is lowercase letters, digits and hyphens, and becomes the project's folder.
|
|
40
|
+
`create` asks no questions when it runs without a terminal. `starter` has one generic
|
|
41
|
+
section type and two short pages: a working example of each convention, to replace as
|
|
42
|
+
you build. `--template none` is the same project without them.
|
|
43
|
+
{{#if otherProject}}
|
|
44
|
+
|
|
45
|
+
Create it outside `{{otherProject}}`: that folder holds another project, and `create`
|
|
46
|
+
does not make one inside it.
|
|
47
|
+
{{/if}}
|
|
48
|
+
{{/unless}}
|
|
49
|
+
|
|
50
|
+
## Read the project's guide
|
|
51
|
+
|
|
52
|
+
{{#if agents}}
|
|
53
|
+
AGENTS.md, at the project's root, is the manual for this project and its version of
|
|
54
|
+
Uniweb. Read its Part 0 before writing code: it says which parts your task needs.
|
|
55
|
+
{{else}}
|
|
56
|
+
This project has no AGENTS.md, the manual for a Uniweb project. Write one with
|
|
57
|
+
`npx uniweb update --agents-only`, then read its Part 0 before writing code: it says
|
|
58
|
+
which parts your task needs.
|
|
59
|
+
{{/if}}
|
|
60
|
+
|
|
61
|
+
Uniweb's mistakes are quiet: an unknown section type or param builds fine and renders
|
|
62
|
+
wrong. AGENTS.md says how to check.
|
|
63
|
+
{{#if project}}
|
|
64
|
+
|
|
65
|
+
## Check what changed
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npx uniweb status
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The site may have been edited in the Uniweb app since this copy last synced, by the
|
|
72
|
+
person or someone they work with, and Uniweb may have a newer release. `status` says so,
|
|
73
|
+
changes nothing, and ends with the commands to run, in order. A step it marks "ask the
|
|
74
|
+
person first" is theirs to decide.
|
|
75
|
+
{{/if}}
|
|
76
|
+
|
|
77
|
+
## Install and run
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
{{install}}
|
|
81
|
+
npx uniweb dev
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Run `dev` in the background. Once the server listens, it prints
|
|
85
|
+
`Serving <site> at <url>`: open that address to show the person.
|
|
86
|
+
`npx uniweb dev --stop` stops it.
|
|
87
|
+
|
|
88
|
+
## Sign them in to Uniweb
|
|
89
|
+
|
|
90
|
+
When they want the site in the Uniweb app, to edit it there or to take it live:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npx uniweb login --browser --personal
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This opens their browser. If they're signed in to Uniweb there, it finishes on its own;
|
|
97
|
+
otherwise they sign in, or sign up if they're new, and it waits up to five minutes. Never
|
|
98
|
+
ask for a password or a token.
|
|
99
|
+
|
|
100
|
+
`--personal` puts their sites in their own workspace. If they ask for one of their
|
|
101
|
+
organizations instead, use `--org @<its handle>`.
|
|
102
|
+
|
|
103
|
+
## Bring the site into the app, and take it live
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npx uniweb push --commit -m "<what you built>" # to see or edit it in the app
|
|
107
|
+
npx uniweb publish --commit -m "<what you built>" # to take it live
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`push` sends the site and its foundation to their workspace, where they'll find it in
|
|
111
|
+
the app. `publish` does the same and then takes the site live, so it needs no push
|
|
112
|
+
first. `--commit` commits the site's content first: in a git repository, both send only
|
|
113
|
+
what is committed.
|
|
114
|
+
|
|
115
|
+
If the site needs paying for, `publish` prints an address instead of going live. Open
|
|
116
|
+
it for the person, tell them it's where they pay, and run `publish` again once they're
|
|
117
|
+
done.
|
|
118
|
+
|
|
119
|
+
Once the site is in the app, it can be edited there at any time. Before you change it
|
|
120
|
+
again, and before each push, run `npx uniweb status`: it says what changed in the app
|
|
121
|
+
since your last push or pull, and what to run.
|
|
122
|
+
|
|
123
|
+
## More
|
|
124
|
+
|
|
125
|
+
- Documentation index: https://uniweb.io/llms.txt
|
|
126
|
+
- Every command's options: `npx uniweb <command> --help`
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What changed on the backend since this copy last synced — READ, never taken.
|
|
3
|
+
*
|
|
4
|
+
* `uniweb pull` takes the backend's content; `uniweb status` asks what there is to take.
|
|
5
|
+
* The two must agree on what "changed" means, so this reads the backend as a pull does —
|
|
6
|
+
* the same two lanes, the same conditional GET (`etagsHold`) — and compares the answer
|
|
7
|
+
* with what this copy recorded at its last sync, each in the backend's own representation:
|
|
8
|
+
*
|
|
9
|
+
* - the site-content entity's version against `baseVersions`: nothing moved, said
|
|
10
|
+
* precisely;
|
|
11
|
+
* - each unit's hash against `unitBases.remote`, which a pull and a push both bank from
|
|
12
|
+
* the backend's own copy: which files moved. A unit's hash is only ever compared with a
|
|
13
|
+
* base in its own representation (`@uniweb/build`'s `site-diff.js`);
|
|
14
|
+
* - each record's version against `baseVersions`: which records moved.
|
|
15
|
+
*
|
|
16
|
+
* Whoever made the change — an author in the app, or a push from another copy — it is the
|
|
17
|
+
* backend's content this copy does not hold yet.
|
|
18
|
+
*
|
|
19
|
+
* ⛔ IT TAKES NOTHING. An ETag, a version or a hash banked here would say this copy holds
|
|
20
|
+
* content it never took, and the next pull would hear 304 and never catch up — the failure
|
|
21
|
+
* `pull.js` records beside `PULL_CACHE_VERSION`. Nothing here writes a file.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { computeUnitHashes, documentSchema, indexFolder, readBackendState } from '@uniweb/build/uwx'
|
|
25
|
+
import {
|
|
26
|
+
etagsHold,
|
|
27
|
+
readPullCache,
|
|
28
|
+
readPullDocuments,
|
|
29
|
+
readPullVersions,
|
|
30
|
+
splitRecordsPull
|
|
31
|
+
} from '../commands/pull.js'
|
|
32
|
+
import { readBaseVersions, readUnitBases } from './site-sync.js'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} p
|
|
36
|
+
* @param {import('./client.js').BackendClient} p.client - signed in; never prompts here
|
|
37
|
+
* @param {string} p.siteDir
|
|
38
|
+
* @param {string} p.siteUuid - the site's uuid on `client.origin` (sync.json)
|
|
39
|
+
* @returns {Promise<{ content: object, records: object }>} each lane's account:
|
|
40
|
+
* `state` — `unchanged`, `changed` or `unknown` (with `detail`) — and what moved:
|
|
41
|
+
* content `changed` / `added` / `removed` unit paths and `outsideUnits` (the site's
|
|
42
|
+
* settings, services or queries moved, and no page did); records `changed` / `added`
|
|
43
|
+
* labels (`<schema>/<slug>`), a `removed` count and `folder` (the records' sub-folders).
|
|
44
|
+
*/
|
|
45
|
+
export async function readAppChanges({ client, siteDir, siteUuid }) {
|
|
46
|
+
const etags = etagsHold(siteDir, client.origin) ? readPullCache(siteDir) : {}
|
|
47
|
+
const bases = readBaseVersions(siteDir, client.origin)
|
|
48
|
+
const [content, folder] = await Promise.all([
|
|
49
|
+
readLane(() => client.pullSiteContent(siteUuid, { etag: etags.content })),
|
|
50
|
+
readLane(() => client.pullFolder(siteUuid, { etag: etags.folder }))
|
|
51
|
+
])
|
|
52
|
+
return {
|
|
53
|
+
content: contentChanges(content, { siteDir, origin: client.origin, siteUuid, bases }),
|
|
54
|
+
records: recordChanges(folder, { siteDir, origin: client.origin, bases })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readLane(request) {
|
|
59
|
+
let res
|
|
60
|
+
try {
|
|
61
|
+
res = await request()
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return { error: err }
|
|
64
|
+
}
|
|
65
|
+
if (res.status === 304) return { notModified: true }
|
|
66
|
+
if (!res.ok) return { status: res.status }
|
|
67
|
+
try {
|
|
68
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
69
|
+
return { docs: readPullDocuments(buf), versions: readPullVersions(buf) }
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return { error: err }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function laneDetail(lane) {
|
|
76
|
+
if (lane.error) return lane.error.message || String(lane.error)
|
|
77
|
+
if (lane.status === 404) return 'the backend has no such site, or you lack access to it'
|
|
78
|
+
return `the backend answered HTTP ${lane.status}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The content lane against this copy's last sync — see the header. */
|
|
82
|
+
export function contentChanges(lane, { siteDir, origin, siteUuid, bases }) {
|
|
83
|
+
const none = { changed: [], added: [], removed: [], outsideUnits: false }
|
|
84
|
+
if (lane.notModified) return { state: 'unchanged', ...none }
|
|
85
|
+
if (!lane.docs) return { state: 'unknown', ...none, detail: laneDetail(lane) }
|
|
86
|
+
const doc = lane.docs.find((d) => d?.info || documentSchema(d)) || lane.docs[0] || null
|
|
87
|
+
if (!doc) return { state: 'unknown', ...none, detail: 'the backend sent no site content' }
|
|
88
|
+
|
|
89
|
+
const now = lane.versions?.[siteUuid]
|
|
90
|
+
const then = bases[siteUuid]
|
|
91
|
+
if (now && then && now === then) return { state: 'unchanged', ...none }
|
|
92
|
+
|
|
93
|
+
const base = readUnitBases(siteDir, origin).remote
|
|
94
|
+
if (!Object.keys(base).length) {
|
|
95
|
+
return { state: 'unknown', ...none, detail: "this copy holds no record of the backend's content at its last sync" }
|
|
96
|
+
}
|
|
97
|
+
const hashes = computeUnitHashes(doc)
|
|
98
|
+
const changed = []
|
|
99
|
+
const added = []
|
|
100
|
+
for (const [path, hash] of Object.entries(hashes)) {
|
|
101
|
+
if (!(path in base)) added.push(path)
|
|
102
|
+
else if (base[path] !== hash) changed.push(path)
|
|
103
|
+
}
|
|
104
|
+
const removed = Object.keys(base).filter((path) => !(path in hashes))
|
|
105
|
+
const units = changed.length + added.length + removed.length
|
|
106
|
+
// The entity moved and no unit did: what moved is outside the pages — the site's
|
|
107
|
+
// settings, its services, its queries.
|
|
108
|
+
const outsideUnits = !units && Boolean(now && then)
|
|
109
|
+
return {
|
|
110
|
+
state: units || outsideUnits ? 'changed' : 'unchanged',
|
|
111
|
+
changed: changed.sort(),
|
|
112
|
+
added: added.sort(),
|
|
113
|
+
removed: removed.sort(),
|
|
114
|
+
outsideUnits
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// `@acme/article` → `article`: how a record's folder under `records/` is named.
|
|
119
|
+
const shortSchema = (schema) => (typeof schema === 'string' ? schema.replace(/^@[^/]+\//, '') : null)
|
|
120
|
+
|
|
121
|
+
/** The folder lane against this copy's last sync — see the header. */
|
|
122
|
+
export function recordChanges(lane, { siteDir, origin, bases }) {
|
|
123
|
+
const none = { changed: [], added: [], removed: 0, folder: false }
|
|
124
|
+
if (lane.notModified) return { state: 'unchanged', ...none }
|
|
125
|
+
if (!lane.docs) return { state: 'unknown', ...none, detail: laneDetail(lane) }
|
|
126
|
+
const { folderDoc, recordDocs } = splitRecordsPull(lane.docs)
|
|
127
|
+
// The records this copy synced, by the backend's uuid (sync.json's `records`: own id → theirs).
|
|
128
|
+
const known = new Set(Object.values(readBackendState(siteDir, origin).records || {}))
|
|
129
|
+
if (!known.size && !recordDocs.length) return { state: 'unchanged', ...none }
|
|
130
|
+
|
|
131
|
+
const index = indexFolder(folderDoc)
|
|
132
|
+
const label = (doc) => {
|
|
133
|
+
const slug = index.get(doc.$uuid)?.slug ?? (typeof doc.$id === 'string' ? doc.$id.split('/').pop() : null)
|
|
134
|
+
const schema = shortSchema(documentSchema(doc))
|
|
135
|
+
return slug ? (schema ? `${schema}/${slug}` : slug) : doc.$uuid
|
|
136
|
+
}
|
|
137
|
+
const versions = lane.versions || {}
|
|
138
|
+
const changed = []
|
|
139
|
+
const added = []
|
|
140
|
+
const seen = new Set()
|
|
141
|
+
for (const doc of recordDocs) {
|
|
142
|
+
const uuid = doc?.$uuid
|
|
143
|
+
if (!uuid) continue
|
|
144
|
+
seen.add(uuid)
|
|
145
|
+
if (!known.has(uuid)) added.push(label(doc))
|
|
146
|
+
else if (bases[uuid] && versions[uuid] && bases[uuid] !== versions[uuid]) changed.push(label(doc))
|
|
147
|
+
}
|
|
148
|
+
const removed = [...known].filter((uuid) => !seen.has(uuid)).length
|
|
149
|
+
const folderUuid = folderDoc?.$uuid
|
|
150
|
+
const folder = Boolean(folderUuid && bases[folderUuid] && versions[folderUuid] && bases[folderUuid] !== versions[folderUuid])
|
|
151
|
+
const moved = changed.length || added.length || removed || folder
|
|
152
|
+
return { state: moved ? 'changed' : 'unchanged', changed: changed.sort(), added: added.sort(), removed, folder }
|
|
153
|
+
}
|
package/src/backend/client.js
CHANGED
|
@@ -948,7 +948,7 @@ export class BackendClient {
|
|
|
948
948
|
* { published: boolean, last_pushed_at?: string, last_published_at?: string, draft_dirty?: boolean }
|
|
949
949
|
* `draft_dirty` = never-published, or the synced draft changed since the last
|
|
950
950
|
* publish ("pushed but not published"). Since 2026-10-13 it also carries `editors`:
|
|
951
|
-
* who has the site open in the app right now, and on what — `status
|
|
951
|
+
* who has the site open in the app right now, and on what — `uniweb status` shows
|
|
952
952
|
* it (`describeEditors`, commands/status.js).
|
|
953
953
|
*
|
|
954
954
|
* ⭐ **The backend also serves a LIVE-SITE record here, and nothing in this CLI reads
|
|
@@ -974,14 +974,14 @@ export class BackendClient {
|
|
|
974
974
|
* The path is VERB-FIRST (`status/{uuid}`)
|
|
975
975
|
* to match the lane (`publish/{uuid}`, `content/push/{uuid}`, `folder/pull/{uuid}`),
|
|
976
976
|
* not the `{uuid}/status` the shipping-verbs §8 sketch assumed. null on
|
|
977
|
-
* 404 (unknown/not-yours) / 401 / any failure — `status
|
|
977
|
+
* 404 (unknown/not-yours) / 401 / any failure — `uniweb status` says what it could not tell.
|
|
978
978
|
* @param {string} uuid - the site-content uuid
|
|
979
979
|
* @returns {Promise<object|null>}
|
|
980
980
|
*/
|
|
981
981
|
/**
|
|
982
982
|
* Whether the backend holds the site `uuid` names — `live`, `gone` or `unknown`, kept
|
|
983
983
|
* apart — for a script that must decide, before any push, whether a binding still
|
|
984
|
-
* points at a site (`uniweb status --
|
|
984
|
+
* points at a site (`uniweb status --json`, its `app.site` check).
|
|
985
985
|
*
|
|
986
986
|
* ⛔ `gone` ONLY ON THE BACKEND'S OWN WORD ABOUT THIS SITE: a `404` whose problem names
|
|
987
987
|
* it — `kind: "site"`, `key: <uuid>`. A push from an unbound copy CREATES a site, so a
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* no local state, multi-machine-safe. When the site references a published
|
|
35
35
|
* registry ref or a URL there's nothing to bring along. When the backend
|
|
36
36
|
* doesn't expose the stored digest yet, the compare DEGRADES to "ask" (same
|
|
37
|
-
* posture as `status
|
|
37
|
+
* posture as `uniweb status` on a 404).
|
|
38
38
|
*
|
|
39
39
|
* "Release" here is literally `uniweb register` run in the foundation directory
|
|
40
40
|
* — same build-if-stale → schema submit → code upload → digest the standalone
|
|
@@ -244,6 +244,28 @@ async function openDoor({ url, say, open }) {
|
|
|
244
244
|
return { opened }
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Where a charge that was not asked is confirmed — at a terminal with `--yes`, or with no one at
|
|
249
|
+
* the terminal at all.
|
|
250
|
+
*
|
|
251
|
+
* ⛔ **UNATTENDED, NEVER "AT A TERMINAL".** The run with no one at the terminal is often an AI agent
|
|
252
|
+
* working for someone who does not use one, and it passes this line on: the person confirms in
|
|
253
|
+
* their browser, at the door the refusal printed, or in the app when there is none. *(Until
|
|
254
|
+
* 2026-10-26 every case said "run `uniweb publish` at a terminal".)* Either way a person confirms
|
|
255
|
+
* it, which is why the line says that `--yes` cannot.
|
|
256
|
+
*
|
|
257
|
+
* @param {{ interactive: boolean, door: boolean }} o - someone is at the terminal; a door was printed
|
|
258
|
+
* @returns {string}
|
|
259
|
+
*/
|
|
260
|
+
function confirmChargeLine({ interactive, door }) {
|
|
261
|
+
if (interactive) {
|
|
262
|
+
return 'This change charges the site\'s card, so only you can confirm it: run `uniweb publish` without `--yes`.'
|
|
263
|
+
}
|
|
264
|
+
return door
|
|
265
|
+
? 'This change charges the site\'s card, so a person confirms it, in a browser at the address above. `--yes` never confirms a charge.'
|
|
266
|
+
: 'This change charges the site\'s card, so a person confirms it, in the app. `--yes` never confirms a charge.'
|
|
267
|
+
}
|
|
268
|
+
|
|
247
269
|
const CONFIRM = 'Confirm and publish'
|
|
248
270
|
const CONTINUE = 'Continue and publish'
|
|
249
271
|
const OPEN = 'Open it in the app'
|
|
@@ -275,7 +297,8 @@ const MAX_UNATTENDED = 3
|
|
|
275
297
|
* "allow it for reductions only, keep charges interactive".]*
|
|
276
298
|
*
|
|
277
299
|
* With no one at the terminal and nothing to answer it, there is no prompt: the refusal is reported as
|
|
278
|
-
* before, its door printed, and the publish fails
|
|
300
|
+
* before, its door printed, and the publish fails — and a charge says where a person confirms it, in a
|
|
301
|
+
* browser (`confirmChargeLine`). A refusal with no `confirm` behaves as it always has.
|
|
279
302
|
*
|
|
280
303
|
* @param {object} o
|
|
281
304
|
* @param {ReturnType<typeof readPaymentRefusal>} o.verdict - the first refusal
|
|
@@ -312,7 +335,7 @@ export async function settleRefusal({ verdict, args = [], say, republish, intera
|
|
|
312
335
|
} else if (!offer || !interactive || (yes && !reduction)) {
|
|
313
336
|
await reportPaymentRefusal({ verdict: current, args, say, open, interactive: interactive && !yes })
|
|
314
337
|
if (offer && !reduction) {
|
|
315
|
-
say.dim(
|
|
338
|
+
say.dim(confirmChargeLine({ interactive, door: current.kind === 'settle' }))
|
|
316
339
|
} else if (reduction) {
|
|
317
340
|
say.dim('This change only lowers what the site pays: `uniweb publish --yes` continues it without a prompt.')
|
|
318
341
|
}
|
package/src/commands/clone.js
CHANGED
|
@@ -421,6 +421,8 @@ export async function clone(args = [], deps = {}) {
|
|
|
421
421
|
{
|
|
422
422
|
projectName: workspaceName,
|
|
423
423
|
workspaceGlobs: ['site'],
|
|
424
|
+
packages: [{ path: 'site', site: true }],
|
|
425
|
+
cloned: true,
|
|
424
426
|
scripts: { dev: 'uniweb dev', build: 'uniweb build' }
|
|
425
427
|
},
|
|
426
428
|
{ onProgress }
|
package/src/commands/doctor.js
CHANGED
|
@@ -62,11 +62,16 @@ const colors = {
|
|
|
62
62
|
blue: '\x1b[36m'
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
65
|
+
// Where the report goes: the terminal — whatever `console.log` is when a line is printed —
|
|
66
|
+
// or nowhere while another command reads the findings (`doctor(args, { quiet: true })`,
|
|
67
|
+
// `uniweb status`).
|
|
68
|
+
const toConsole = (...args) => console.log(...args)
|
|
69
|
+
let print = toConsole
|
|
70
|
+
const success = (msg) => print(`${colors.green}✓${colors.reset} ${msg}`)
|
|
71
|
+
const warn = (msg) => print(`${colors.yellow}⚠${colors.reset} ${msg}`)
|
|
72
|
+
const error = (msg) => print(`${colors.red}✗${colors.reset} ${msg}`)
|
|
73
|
+
const info = (msg) => print(`${colors.blue}→${colors.reset} ${msg}`)
|
|
74
|
+
const log = (...args) => print(...args)
|
|
70
75
|
|
|
71
76
|
/**
|
|
72
77
|
* Check if a directory is a site
|
|
@@ -1196,7 +1201,24 @@ export function checkGeneratedDataDir({ sitePath, siteName, siteYml, issues, sho
|
|
|
1196
1201
|
/**
|
|
1197
1202
|
* Main doctor command
|
|
1198
1203
|
*/
|
|
1199
|
-
|
|
1204
|
+
/**
|
|
1205
|
+
* @param {string[]} [args]
|
|
1206
|
+
* @param {{ quiet?: boolean }} [opts] - `quiet`: print nothing, for a command that reads the
|
|
1207
|
+
* findings (`uniweb status`). Without `--fix` doctor writes nothing, so a quiet run is a
|
|
1208
|
+
* read.
|
|
1209
|
+
* @returns {Promise<{ issues: object[], errors: number, warnings: number, fixed: number }>}
|
|
1210
|
+
*/
|
|
1211
|
+
export async function doctor(args = [], { quiet = false } = {}) {
|
|
1212
|
+
const before = print
|
|
1213
|
+
print = quiet ? () => {} : toConsole
|
|
1214
|
+
try {
|
|
1215
|
+
return await runDoctor(args)
|
|
1216
|
+
} finally {
|
|
1217
|
+
print = before
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
async function runDoctor(args = []) {
|
|
1200
1222
|
log('')
|
|
1201
1223
|
log(`${colors.blue}${colors.bright}Uniweb Doctor${colors.reset}`)
|
|
1202
1224
|
log(`${colors.dim}Checking project configuration...${colors.reset}`)
|
|
@@ -1212,7 +1234,7 @@ export async function doctor(args = []) {
|
|
|
1212
1234
|
// the failure the guess was moved here to avoid. Those checks ask for the id.
|
|
1213
1235
|
const shouldFixExplicitly = (id) => fixFlag === id
|
|
1214
1236
|
const fixed = (msg) =>
|
|
1215
|
-
|
|
1237
|
+
print(` ${colors.green}↳ Fixed:${colors.reset} ${msg}`)
|
|
1216
1238
|
|
|
1217
1239
|
const projectDir = resolve(process.cwd())
|
|
1218
1240
|
|
|
@@ -1225,7 +1247,14 @@ export async function doctor(args = []) {
|
|
|
1225
1247
|
log(
|
|
1226
1248
|
`${colors.dim}Run this command from your project root or a site/foundation directory.${colors.reset}`
|
|
1227
1249
|
)
|
|
1228
|
-
|
|
1250
|
+
// Returned, not exited, so a command reading the findings gets them; the CLI exits 1 on
|
|
1251
|
+
// an error either way.
|
|
1252
|
+
return {
|
|
1253
|
+
issues: [{ id: 'not-a-workspace', type: 'error', message: 'Not in a Uniweb workspace' }],
|
|
1254
|
+
errors: 1,
|
|
1255
|
+
warnings: 0,
|
|
1256
|
+
fixed: 0
|
|
1257
|
+
}
|
|
1229
1258
|
}
|
|
1230
1259
|
|
|
1231
1260
|
log('')
|
|
@@ -2076,6 +2105,7 @@ export async function doctor(args = []) {
|
|
|
2076
2105
|
)
|
|
2077
2106
|
info(`Run: uniweb update`)
|
|
2078
2107
|
issues.push({
|
|
2108
|
+
id: 'deps-behind-cli',
|
|
2079
2109
|
type: 'warn',
|
|
2080
2110
|
message: `${behindDeps.length} @uniweb/* dep declaration(s) behind CLI v${cliVersion}`
|
|
2081
2111
|
})
|
|
@@ -2093,16 +2123,17 @@ export async function doctor(args = []) {
|
|
|
2093
2123
|
if (!existsSync(agentsPath)) {
|
|
2094
2124
|
warn('AGENTS.md not found')
|
|
2095
2125
|
info(`Run: uniweb update`)
|
|
2096
|
-
issues.push({ type: 'warn', message: 'AGENTS.md not found' })
|
|
2126
|
+
issues.push({ id: 'agents-md-missing', type: 'warn', message: 'AGENTS.md not found' })
|
|
2097
2127
|
} else if (!agentsVersion) {
|
|
2098
2128
|
// No stamp — manually created or pre-stamp version
|
|
2099
2129
|
warn('AGENTS.md has no version stamp (may be outdated)')
|
|
2100
2130
|
info(`Run: uniweb update`)
|
|
2101
|
-
issues.push({ type: 'warn', message: 'AGENTS.md has no version stamp' })
|
|
2131
|
+
issues.push({ id: 'agents-md-unstamped', type: 'warn', message: 'AGENTS.md has no version stamp' })
|
|
2102
2132
|
} else if (agentsVersion !== cliVersion) {
|
|
2103
2133
|
warn(`AGENTS.md is outdated (v${agentsVersion} → v${cliVersion})`)
|
|
2104
2134
|
info(`Run: uniweb update`)
|
|
2105
2135
|
issues.push({
|
|
2136
|
+
id: 'agents-md-outdated',
|
|
2106
2137
|
type: 'warn',
|
|
2107
2138
|
message: `AGENTS.md outdated (v${agentsVersion} → v${cliVersion})`
|
|
2108
2139
|
})
|
package/src/commands/pull.js
CHANGED
|
@@ -238,7 +238,7 @@ function pullCachePath(siteDir) {
|
|
|
238
238
|
// Ignoring those once costs one full pull. (In a repository an ETag is echoed only while
|
|
239
239
|
// the sync point it was banked with is in the history — see `cacheHolds` below.)
|
|
240
240
|
const PULL_CACHE_VERSION = 2
|
|
241
|
-
function readPullCache(siteDir) {
|
|
241
|
+
export function readPullCache(siteDir) {
|
|
242
242
|
try {
|
|
243
243
|
const obj = JSON.parse(readFileSync(pullCachePath(siteDir), 'utf8'))
|
|
244
244
|
return obj && typeof obj === 'object' && obj.version >= PULL_CACHE_VERSION ? obj : {}
|
|
@@ -246,6 +246,21 @@ function readPullCache(siteDir) {
|
|
|
246
246
|
return {}
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Whether the ETags this copy banked still describe its files — the condition a
|
|
252
|
+
* conditional GET is sent on. Outside git, always. In a repository, only while HEAD's
|
|
253
|
+
* history holds the sync point they were banked with: a merge abandoned, a branch reset,
|
|
254
|
+
* and the backend did not move but the files did.
|
|
255
|
+
*
|
|
256
|
+
* One rule for both readers of the lanes — this pull and `uniweb status`, which asks the
|
|
257
|
+
* same question without taking the answer.
|
|
258
|
+
*/
|
|
259
|
+
export function etagsHold(siteDir, origin, repo = repoOf(siteDir)) {
|
|
260
|
+
if (!repo) return true
|
|
261
|
+
const held = readSyncPoint(siteDir, origin)
|
|
262
|
+
return Boolean(held && isAncestor(repo, held))
|
|
263
|
+
}
|
|
249
264
|
function writePullCache(siteDir, { content, folder }) {
|
|
250
265
|
const p = pullCachePath(siteDir)
|
|
251
266
|
mkdirSync(dirname(p), { recursive: true })
|
|
@@ -767,7 +782,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
767
782
|
// only while the sync point it was banked with is.
|
|
768
783
|
const cache = readPullCache(siteDir)
|
|
769
784
|
const held = repo ? readSyncPoint(siteDir, client.origin) : null
|
|
770
|
-
const cacheHolds =
|
|
785
|
+
const cacheHolds = etagsHold(siteDir, client.origin, repo)
|
|
771
786
|
let etagContent = cache.content
|
|
772
787
|
let etagFolder = cache.folder
|
|
773
788
|
|
package/src/commands/register.js
CHANGED
|
@@ -385,21 +385,6 @@ export async function settleFoundationScope(targetDir, { args, isPreview, flagSc
|
|
|
385
385
|
return { scope, source }
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
-
/**
|
|
389
|
-
* The scope a bare name registers under when `--scope` names none — as `@handle`, or null
|
|
390
|
-
* when none was chosen.
|
|
391
|
-
*
|
|
392
|
-
* ⭐ THE WORKSPACE THIS COMMAND WORKS IN *[Diego, 2026-10-06]* — an organization's handle,
|
|
393
|
-
* or your own for your personal workspace — said, not asked (`scopeOfWorkspace`). A team
|
|
394
|
-
* working in `@acme` registers `@acme/…`, which any of its members can release.
|
|
395
|
-
* ⛔ Until 2026-10-06 the default was your personal scope wherever you worked, so a team's
|
|
396
|
-
* foundation registered as `@jane/…` — releasable by Jane alone, and fixed only by
|
|
397
|
-
* renaming it into a new foundation.
|
|
398
|
-
*
|
|
399
|
-
* When the workspace names no scope — none is chosen, or it is a unit without a handle —
|
|
400
|
-
* the scope is derived from your orgs as before (`deriveScope`): your personal scope when
|
|
401
|
-
* you belong to none, else a pick at a terminal, your personal scope without one.
|
|
402
|
-
*/
|
|
403
388
|
/**
|
|
404
389
|
* Refuse a reserved scope wherever it came from, before anything is written or sent —
|
|
405
390
|
* `@uniweb` names the framework's own packages and a system account on every Uniweb
|
|
@@ -417,6 +402,21 @@ function refusedReservedScope(scope, lead) {
|
|
|
417
402
|
return true
|
|
418
403
|
}
|
|
419
404
|
|
|
405
|
+
/**
|
|
406
|
+
* The scope a bare name registers under when `--scope` names none — as `@handle`, or null
|
|
407
|
+
* when none was chosen.
|
|
408
|
+
*
|
|
409
|
+
* ⭐ THE WORKSPACE THIS COMMAND WORKS IN *[Diego, 2026-10-06]* — an organization's handle,
|
|
410
|
+
* or your own for your personal workspace — said, not asked (`scopeOfWorkspace`). A team
|
|
411
|
+
* working in `@acme` registers `@acme/…`, which any of its members can release.
|
|
412
|
+
* ⛔ Until 2026-10-06 the default was your personal scope wherever you worked, so a team's
|
|
413
|
+
* foundation registered as `@jane/…` — releasable by Jane alone, and fixed only by
|
|
414
|
+
* renaming it into a new foundation.
|
|
415
|
+
*
|
|
416
|
+
* When the workspace names no scope — none is chosen, or it is a unit without a handle —
|
|
417
|
+
* the scope is derived from your orgs as before (`deriveScope`): your personal scope when
|
|
418
|
+
* you belong to none, else a pick at a terminal, your personal scope without one.
|
|
419
|
+
*/
|
|
420
420
|
async function deriveScopeFromLogin(client, args) {
|
|
421
421
|
const token = await client.token()
|
|
422
422
|
const ws = await resolveWorkspace({ client, args })
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `uniweb start` — the guide an AI agent reads first.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ FOR AN AGENT WORKING FOR SOMEONE WHO DOES NOT USE A TERMINAL. The person asks their
|
|
5
|
+
* agent for a website; the agent runs `npx uniweb@latest start` and follows what it
|
|
6
|
+
* prints: create a project, read its AGENTS.md, run it, sign the person in, and bring the
|
|
7
|
+
* site into the Uniweb app. The person's part is their browser — signing in, and paying
|
|
8
|
+
* when the site goes live — so nothing here asks them to run a command.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ READ-ONLY, AND SAFE ANYWHERE. It asks nothing, writes nothing and makes no request,
|
|
11
|
+
* so an agent can run it in any folder, as often as it likes. That is also why
|
|
12
|
+
* `index.js` answers it before the update check, which can write a cache and fetch.
|
|
13
|
+
*
|
|
14
|
+
* ⭐ THE TEXT SHIPS WITH THIS CLI (`partials/start.md`), so it describes the version that
|
|
15
|
+
* prints it. Inside a Uniweb project (`findUniwebWorkspace`) it starts from that project
|
|
16
|
+
* instead of creating one, and names that project's own package manager.
|
|
17
|
+
*
|
|
18
|
+
* It is answered by the CLI that was invoked, never delegated to a project's own copy
|
|
19
|
+
* (`STANDALONE_COMMANDS`): an older project CLI does not have it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
23
|
+
import { dirname, join } from 'node:path'
|
|
24
|
+
import { fileURLToPath } from 'node:url'
|
|
25
|
+
import Handlebars from 'handlebars'
|
|
26
|
+
import { findUniwebWorkspace, findWorkspaceRoot } from '../utils/workspace.js'
|
|
27
|
+
import { detectWorkspacePm, installCmd } from '../utils/pm.js'
|
|
28
|
+
|
|
29
|
+
const PARTIAL = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'partials', 'start.md')
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The guide, as Markdown, for a command run in `cwd`.
|
|
33
|
+
*
|
|
34
|
+
* Outside a project, `otherProject` names a workspace of another kind that holds `cwd`:
|
|
35
|
+
* `create` refuses to make a project inside one, so the guide says to create it elsewhere.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ cwd?: string }} [o]
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function startGuide({ cwd = process.cwd() } = {}) {
|
|
41
|
+
const root = findUniwebWorkspace(cwd)
|
|
42
|
+
const template = Handlebars.compile(readFileSync(PARTIAL, 'utf8'), { noEscape: true })
|
|
43
|
+
return template({
|
|
44
|
+
project: !!root,
|
|
45
|
+
root,
|
|
46
|
+
// A project `create` makes has one; one already here may not.
|
|
47
|
+
agents: root ? existsSync(join(root, 'AGENTS.md')) : true,
|
|
48
|
+
otherProject: root ? null : findWorkspaceRoot(cwd),
|
|
49
|
+
install: installCmd(root ? detectWorkspacePm(root) : 'npm')
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {string[]} [args] - none are read
|
|
55
|
+
* @returns {Promise<{ exitCode: number }>}
|
|
56
|
+
*/
|
|
57
|
+
export async function start(args = []) {
|
|
58
|
+
console.log(startGuide())
|
|
59
|
+
return { exitCode: 0 }
|
|
60
|
+
}
|