uniweb 1.6.1 → 1.7.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/package.json +7 -7
- package/partials/agents.md +2 -0
- package/src/backend/client.js +104 -12
- package/src/backend/site-sync.js +10 -5
- package/src/commands/publish.js +15 -3
- package/src/commands/pull.js +5 -3
- package/src/commands/push.js +4 -1
- package/src/commands/refresh.js +16 -8
- package/src/commands/status.js +6 -3
- package/src/framework-index.json +6 -6
- package/src/utils/git.js +13 -7
- package/src/utils/site-data-upload.js +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,15 +41,15 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/
|
|
45
|
-
"@uniweb/schemas": "~1.3.0",
|
|
44
|
+
"@uniweb/content-writer": "~1.1.2",
|
|
46
45
|
"@uniweb/core": "~1.2.2",
|
|
47
|
-
"@uniweb/
|
|
46
|
+
"@uniweb/schemas": "~1.3.0",
|
|
47
|
+
"@uniweb/semantic-parser": "~1.8.1"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@uniweb/build": "~1.3.
|
|
51
|
-
"@uniweb/
|
|
52
|
-
"@uniweb/
|
|
50
|
+
"@uniweb/build": "~1.3.5",
|
|
51
|
+
"@uniweb/content-reader": "~1.4.0",
|
|
52
|
+
"@uniweb/semantic-parser": "~1.8.1"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -572,6 +572,8 @@ Content is markdown. Components decide how it renders, so a tag written in it
|
|
|
572
572
|
|
|
573
573
|
The one exception is `<br/>`, a line break where markdown has no other spelling — inside a heading. For a headline split across lines, the multi-line heading above usually says it better.
|
|
574
574
|
|
|
575
|
+
A plain newline inside a paragraph is a space, so wrap prose however you like — re-wrapping a paragraph changes nothing, and pushes nothing. A line break in a paragraph is written on purpose: a backslash (`\`) at the end of the line.
|
|
576
|
+
|
|
575
577
|
### Fenced code: data blocks vs snippets
|
|
576
578
|
|
|
577
579
|
Fenced code serves three purposes depending on its info string: `yaml:`/`json:` for data, `md:` for a named kind of prose (see *Concept blocks*), and a bare language for a code sample.
|
package/src/backend/client.js
CHANGED
|
@@ -192,12 +192,14 @@ export function workspaceHeader(value) {
|
|
|
192
192
|
*/
|
|
193
193
|
export class WorkspaceMismatchError extends Error {
|
|
194
194
|
/**
|
|
195
|
-
* @param {{ named: string|null, answer: string|null, source?: string, origin?: string|null }} p -
|
|
195
|
+
* @param {{ named: string|null, answer: string|null, nowhere?: boolean, source?: string, origin?: string|null, message?: string|null }} p -
|
|
196
196
|
* the workspace the request named and the one the backend named (`@handle`, a unit's
|
|
197
197
|
* uuid, null for personal), where the named one came from (`workspace.js`), and the
|
|
198
|
-
* backend — a login that switches must name it unless it is the default (`loginCommand`)
|
|
198
|
+
* backend — a login that switches must name it unless it is the default (`loginCommand`).
|
|
199
|
+
* `nowhere`: the backend named NO workspace the caller may open (`workspace: null`), so
|
|
200
|
+
* there is nothing to switch to. `message` replaces the sentence (`WorkspaceRefusedError`).
|
|
199
201
|
*/
|
|
200
|
-
constructor({ named, answer, source = 'login', origin = null }) {
|
|
202
|
+
constructor({ named, answer, nowhere = false, source = 'login', origin = null, message = null }) {
|
|
201
203
|
const where = (w) =>
|
|
202
204
|
!w ? 'your personal workspace' : w.startsWith('@') ? w : `the unit ${w}`
|
|
203
205
|
// How to work in the site's workspace, said for the way this one was chosen.
|
|
@@ -219,15 +221,64 @@ export class WorkspaceMismatchError extends Error {
|
|
|
219
221
|
? `To work on it, ${switchTo.env}.`
|
|
220
222
|
: `To work on it: ${login}`
|
|
221
223
|
const why = source === 'flag' ? ' (named on this command)' : source === 'env' ? ' (UNIWEB_WORKSPACE)' : ''
|
|
222
|
-
super(
|
|
224
|
+
super(
|
|
225
|
+
message ??
|
|
226
|
+
(nowhere
|
|
227
|
+
? `This site is not in a workspace you can open${origin ? ` on ${origin}` : ''}, so it cannot be worked on from here — there is none to switch to.`
|
|
228
|
+
: `This site is in ${where(answer)}, and you are working in ${where(named)}${why}. ${fix}`)
|
|
229
|
+
)
|
|
223
230
|
this.name = 'WorkspaceMismatchError'
|
|
224
231
|
this.status = 409
|
|
225
232
|
this.named = named
|
|
226
233
|
this.answer = answer
|
|
234
|
+
this.nowhere = nowhere
|
|
227
235
|
this.source = source
|
|
228
236
|
}
|
|
229
237
|
}
|
|
230
238
|
|
|
239
|
+
/**
|
|
240
|
+
* The workspace this command named is not one it may open — `403`, `op: "act_as_unit"`.
|
|
241
|
+
*
|
|
242
|
+
* ⭐ The header admits exactly the workspaces the account may open: an organization it is a
|
|
243
|
+
* direct member of, and not an archived one — its own members included [backend, 2026-10-16].
|
|
244
|
+
* So a login whose workspace was archived after it was chosen hears this on every site
|
|
245
|
+
* request. ⛔ It is not a refused credential, and "log in again" is the wrong advice for it:
|
|
246
|
+
* the session is fine, the workspace is not.
|
|
247
|
+
*
|
|
248
|
+
* ⭐ Keyed on `op`, never on `title`, which is the `"Forbidden"` every denial shares; and only for
|
|
249
|
+
* a request that named a workspace, the one kind that can answer it [backend, 2026-10-16].
|
|
250
|
+
*
|
|
251
|
+
* A kind of `WorkspaceMismatchError` because every caller treats the two alike — the command
|
|
252
|
+
* stops and says how to choose — except `siteState`: a refused workspace says nothing about the
|
|
253
|
+
* site, so it is `unknown` there, never `live`.
|
|
254
|
+
*/
|
|
255
|
+
export class WorkspaceRefusedError extends WorkspaceMismatchError {
|
|
256
|
+
/**
|
|
257
|
+
* @param {{ named: string, source?: string, origin?: string|null }} p - the workspace the
|
|
258
|
+
* request named (`@handle`, or a unit's uuid), where it came from (`workspace.js`), the backend
|
|
259
|
+
*/
|
|
260
|
+
constructor({ named, source = 'login', origin = null }) {
|
|
261
|
+
const where = named.startsWith('@') ? named : `the unit ${named}`
|
|
262
|
+
const on = origin ? ` on ${origin}` : ''
|
|
263
|
+
const why = source === 'flag' ? ' (named on this command)' : source === 'env' ? ' (UNIWEB_WORKSPACE)' : ''
|
|
264
|
+
const fix =
|
|
265
|
+
source === 'flag'
|
|
266
|
+
? 'Name one you can open, or pass --personal.'
|
|
267
|
+
: source === 'env'
|
|
268
|
+
? 'Set UNIWEB_WORKSPACE to one you can open, or to personal.'
|
|
269
|
+
: `Your login works in it. Choose another: ${loginCommand(origin)} --org @<org> (or --personal) — it does not sign you in again.`
|
|
270
|
+
super({
|
|
271
|
+
named,
|
|
272
|
+
answer: null,
|
|
273
|
+
source,
|
|
274
|
+
origin,
|
|
275
|
+
message: `${where}${why} is not a workspace you can open${on}: you are not a member of it, or it is archived. ${fix}`
|
|
276
|
+
})
|
|
277
|
+
this.name = 'WorkspaceRefusedError'
|
|
278
|
+
this.status = 403
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
231
282
|
/**
|
|
232
283
|
* The backend could not be reached at all — the request never got an answer.
|
|
233
284
|
*
|
|
@@ -329,6 +380,11 @@ export class BackendClient {
|
|
|
329
380
|
return this._workspace
|
|
330
381
|
}
|
|
331
382
|
|
|
383
|
+
/** Where that workspace came from (`workspace.js`) — so a refusal says how to switch. */
|
|
384
|
+
get workspaceSource() {
|
|
385
|
+
return this._workspaceSource
|
|
386
|
+
}
|
|
387
|
+
|
|
332
388
|
/**
|
|
333
389
|
* Name the workspace every site request from here on works in (`x-uniweb-workspace`)
|
|
334
390
|
* — the one `resolveWorkspace` answered for this command.
|
|
@@ -383,7 +439,9 @@ export class BackendClient {
|
|
|
383
439
|
* (string → application/json, Buffer/Uint8Array → application/zip). Returns
|
|
384
440
|
* the raw Response so callers branch on status themselves (409 resume,
|
|
385
441
|
* 404 → null, 401/403 messaging, …) — except a `409 wrong_workspace`, thrown as
|
|
386
|
-
* `WorkspaceMismatchError`: the site is in another workspace than this command's
|
|
442
|
+
* `WorkspaceMismatchError`: the site is in another workspace than this command's, or in
|
|
443
|
+
* none the caller may open; and a `403` refusing the workspace this request named, thrown
|
|
444
|
+
* as `WorkspaceRefusedError`.
|
|
387
445
|
*
|
|
388
446
|
* @param {string} path - leading-slash path, e.g. '/dev/site/content'
|
|
389
447
|
* @param {object} [opts]
|
|
@@ -396,16 +454,43 @@ export class BackendClient {
|
|
|
396
454
|
*/
|
|
397
455
|
async request(path, opts = {}) {
|
|
398
456
|
const res = await this._send(path, opts)
|
|
399
|
-
if (res?.status !== 409) return res
|
|
457
|
+
if (res?.status !== 409 && res?.status !== 403) return res
|
|
458
|
+
const problem =
|
|
459
|
+
typeof res.clone === 'function' ? await res.clone().json().catch(() => null) : null
|
|
460
|
+
|
|
461
|
+
// ⭐ THE WORKSPACE THIS REQUEST NAMED IS NOT ONE THE ACCOUNT MAY OPEN — `403`,
|
|
462
|
+
// `op: "act_as_unit"`: not a member of it, or it is archived (`WorkspaceRefusedError`).
|
|
463
|
+
// Only for a request that named one: any other 403 — a credential, a foundation the site
|
|
464
|
+
// may not take — is the caller's, as before.
|
|
465
|
+
if (res.status === 403) {
|
|
466
|
+
if (problem?.op !== 'act_as_unit' || !this._workspace || !namesWorkspace(path)) return res
|
|
467
|
+
throw new WorkspaceRefusedError({
|
|
468
|
+
named: this._workspace,
|
|
469
|
+
source: this._workspaceSource,
|
|
470
|
+
origin: this.origin
|
|
471
|
+
})
|
|
472
|
+
}
|
|
400
473
|
|
|
401
474
|
// ⭐ THE WORKSPACE A REQUEST NAMES IS CHECKED BY THE BACKEND, NOT HERE. A request whose
|
|
402
475
|
// workspace does not contain the site is refused with `409 wrong_workspace`, naming a
|
|
403
476
|
// workspace the caller CAN open that does — the site's own unit, or a parent. The
|
|
404
477
|
// command works in one workspace, so this stops it (`WorkspaceMismatchError`).
|
|
405
478
|
// ⛔ No handle comparison anywhere: a site in `@acme/labs` is worked on from `@acme`.
|
|
406
|
-
const problem =
|
|
407
|
-
typeof res.clone === 'function' ? await res.clone().json().catch(() => null) : null
|
|
408
479
|
if (problem?.reason !== 'wrong_workspace') return res
|
|
480
|
+
// ⭐ `workspace: null` — NO workspace the caller may open holds the site — is not the
|
|
481
|
+
// personal workspace, which is the object with both fields null (backend, 2026-10-16). There
|
|
482
|
+
// is nothing to switch to, so it stops the command whatever workspace it works in.
|
|
483
|
+
// ⛔ Until 2026-10-16 it was read as personal, and the command said "To work on it: uniweb
|
|
484
|
+
// login --personal" — false, and following it ended on the same 409.
|
|
485
|
+
if (problem.workspace === null) {
|
|
486
|
+
throw new WorkspaceMismatchError({
|
|
487
|
+
named: this._workspace,
|
|
488
|
+
answer: null,
|
|
489
|
+
nowhere: true,
|
|
490
|
+
source: this._workspaceSource,
|
|
491
|
+
origin: this.origin
|
|
492
|
+
})
|
|
493
|
+
}
|
|
409
494
|
const where = problem.workspace || {}
|
|
410
495
|
// A unit with no handle is named by its uuid, bare — the header takes either.
|
|
411
496
|
const answer = where.handle
|
|
@@ -885,23 +970,30 @@ export class BackendClient {
|
|
|
885
970
|
* the first. Folding `unknown` into `gone` would be worse than no answer.
|
|
886
971
|
*
|
|
887
972
|
* live the site's status came back, or the site was refused as belonging to
|
|
888
|
-
* another workspace (`409 wrong_workspace` names where it is: it exists)
|
|
973
|
+
* another workspace (`409 wrong_workspace` names where it is: it exists) —
|
|
974
|
+
* or to none the caller may open (`workspace: null`: it exists too)
|
|
889
975
|
* gone a `404` naming this site
|
|
890
|
-
* unknown anything else — no answer, a credential or an id refused,
|
|
891
|
-
*
|
|
976
|
+
* unknown anything else — no answer, a credential or an id refused, the workspace
|
|
977
|
+
* this request named refused (`refused: true`), a `404` that names something
|
|
978
|
+
* else (a backend too old for the route)
|
|
892
979
|
*
|
|
893
980
|
* *Measured 2026-10-06 against a local backend — its wire, its to change:* `200` with
|
|
894
981
|
* the status; `409 wrong_workspace` naming the site's workspace; `404 {kind: "site",
|
|
895
982
|
* key}`; `400` for a malformed id; `401` for a bad bearer.
|
|
896
983
|
*
|
|
897
984
|
* @param {string} uuid
|
|
898
|
-
* @returns {Promise<{ state: 'live'|'gone'|'unknown', status: number|null, site?: object, workspace?: string|null, detail?: string|null }>}
|
|
985
|
+
* @returns {Promise<{ state: 'live'|'gone'|'unknown', status: number|null, site?: object, workspace?: string|null, refused?: boolean, detail?: string|null }>}
|
|
899
986
|
*/
|
|
900
987
|
async siteState(uuid) {
|
|
901
988
|
let res
|
|
902
989
|
try {
|
|
903
990
|
res = await this.request(`/dev/site/status/${encodeURIComponent(uuid)}`)
|
|
904
991
|
} catch (err) {
|
|
992
|
+
// The workspace this request named was refused: that says nothing about the site.
|
|
993
|
+
if (err instanceof WorkspaceRefusedError) {
|
|
994
|
+
return { state: 'unknown', status: 403, refused: true, detail: err.message }
|
|
995
|
+
}
|
|
996
|
+
// In another workspace, or in none the caller may open: either way it exists.
|
|
905
997
|
if (err instanceof WorkspaceMismatchError) {
|
|
906
998
|
return { state: 'live', status: 409, workspace: err.answer, detail: err.message }
|
|
907
999
|
}
|
package/src/backend/site-sync.js
CHANGED
|
@@ -855,13 +855,18 @@ export function writeSyncCache(siteDir, backend, hashes, applied) {
|
|
|
855
855
|
* Lives beside the content hashes because it is the same kind of thing — "the
|
|
856
856
|
* backend state this clone last agreed with" — and is already per-entity, which
|
|
857
857
|
* the per-lane `pull-cache.json` (two ETags) is not. Gitignored, per-clone,
|
|
858
|
-
* deletable: losing it
|
|
859
|
-
*
|
|
858
|
+
* deletable: losing it leaves the next write no base to claim, which a pull
|
|
859
|
+
* resolves — never a wrong-but-plausible token.
|
|
860
860
|
*
|
|
861
861
|
* Fed from BOTH directions, which is what makes the gate usable: `pull` records
|
|
862
|
-
*
|
|
863
|
-
* post-write `finalized[].version` the backend returns.
|
|
864
|
-
* second consecutive push would be stale by construction
|
|
862
|
+
* each manifest entry's top-level `version` (`readPullVersions`), and every
|
|
863
|
+
* successful push records the post-write `finalized[].version` the backend returns.
|
|
864
|
+
* Without the push half a second consecutive push would be stale by construction
|
|
865
|
+
* and refused.
|
|
866
|
+
*
|
|
867
|
+
* ⛔ Until 2026-10-16 this said `pull` records the manifest's `extra.version`. There is
|
|
868
|
+
* no `extra` on the wire in either direction — the token rides beside `sha256` — and
|
|
869
|
+
* building against that name disarms the gate without an error.
|
|
865
870
|
*/
|
|
866
871
|
export function readBaseVersions(siteDir, backend) {
|
|
867
872
|
return readMap(siteDir, backend, 'baseVersions')
|
package/src/commands/publish.js
CHANGED
|
@@ -8,9 +8,15 @@
|
|
|
8
8
|
*
|
|
9
9
|
* 1. resolves WHICH site (your location, or the workspace's one site; multiple
|
|
10
10
|
* → prompt);
|
|
11
|
-
* 2. BRINGS THE FOUNDATION ALONG —
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* 2. BRINGS THE FOUNDATION ALONG — a local foundation is released when its
|
|
12
|
+
* version is not registered yet, and under the next patch (written into its
|
|
13
|
+
* package.json) when its code changed since that version was registered;
|
|
14
|
+
* unchanged code is not re-released, and a newer registered version stops
|
|
15
|
+
* the publish (`--bump`, `--no-release`). It asks only when the backend
|
|
16
|
+
* cannot say whether the code changed. A published registry ref needs
|
|
17
|
+
* nothing (§4, foundation-bring-along.js). *(This read "releases the new
|
|
18
|
+
* version first (or asks)" until 2026-10-16; changed code has been released
|
|
19
|
+
* without asking since 2026-09-24.)*
|
|
14
20
|
* 3. SYNCS — builds the site data (link mode), uploads media and the
|
|
15
21
|
* schema-less data files, and pushes content (the same two-lane sync
|
|
16
22
|
* `uniweb push` uses);
|
|
@@ -752,6 +758,7 @@ export async function publish(args = []) {
|
|
|
752
758
|
siteUuid: site.uuid,
|
|
753
759
|
// The plan and every direct PUT name the workspace the rest of the publish did.
|
|
754
760
|
workspace: client.workspace,
|
|
761
|
+
workspaceSource: client.workspaceSource,
|
|
755
762
|
ball,
|
|
756
763
|
onProgress: (m) => say.dim(` ${m}`)
|
|
757
764
|
})
|
|
@@ -766,6 +773,11 @@ export async function publish(args = []) {
|
|
|
766
773
|
}
|
|
767
774
|
say.dim(`Record data : ${r.uploaded.length} file(s) [${r.mode}]`)
|
|
768
775
|
} catch (err) {
|
|
776
|
+
// The workspace this publish works in, refused — said as itself, as at every other door.
|
|
777
|
+
if (err instanceof WorkspaceMismatchError) {
|
|
778
|
+
say.err(err.message)
|
|
779
|
+
return { exitCode: 1 }
|
|
780
|
+
}
|
|
769
781
|
// A typed plan refusal gets its own account (quota, or whatever else the
|
|
770
782
|
// backend names); anything else falls through to the raw message. Same
|
|
771
783
|
// treatment the asset plan and the site create already get — this was the
|
package/src/commands/pull.js
CHANGED
|
@@ -653,8 +653,10 @@ async function confirm(question) {
|
|
|
653
653
|
}
|
|
654
654
|
|
|
655
655
|
export async function pull(args = [], deps = {}) {
|
|
656
|
-
// See utils/flag-guard.js — an unrecognized flag is invisible to a
|
|
657
|
-
//
|
|
656
|
+
// See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan, so
|
|
657
|
+
// it silently keeps the default: a mistyped `--dry-run` would pull for real, and a
|
|
658
|
+
// mistyped `--no-delete` would prune. *(This named `--backend` until 2026-10-16;
|
|
659
|
+
// pull has taken no backend flag since 2026-09-21.)*
|
|
658
660
|
const badFlag = checkFlags('pull', args)
|
|
659
661
|
if (badFlag) {
|
|
660
662
|
error(badFlag.message)
|
|
@@ -690,7 +692,7 @@ export async function pull(args = [], deps = {}) {
|
|
|
690
692
|
if (captured === null) {
|
|
691
693
|
error('Cannot merge: this site is not in a git repository.')
|
|
692
694
|
note(
|
|
693
|
-
'The common ancestor a merge needs is the
|
|
695
|
+
'The common ancestor a merge needs is in git history: the version of each file your last pull wrote.'
|
|
694
696
|
)
|
|
695
697
|
note(
|
|
696
698
|
'Without a repo there is nothing to merge against — use `uniweb pull --force` to take the backend version.'
|
package/src/commands/push.js
CHANGED
|
@@ -133,7 +133,10 @@ function flagValue(args, name) {
|
|
|
133
133
|
|
|
134
134
|
export async function push(args = [], deps = {}) {
|
|
135
135
|
// An unrecognized flag is invisible to a literal scan, so it silently keeps the
|
|
136
|
-
// default —
|
|
136
|
+
// default — a mistyped `--dry-run` would push for real. A backend flag or `--token`
|
|
137
|
+
// is refused too, naming `uniweb login` (`utils/flag-guard.js`). *(This named
|
|
138
|
+
// `--backend` as the example until 2026-10-16; push has taken no backend flag since
|
|
139
|
+
// 2026-09-21.)*
|
|
137
140
|
//
|
|
138
141
|
// `sync` validates the UNION of its two halves' flags and forwards raw argv, so a
|
|
139
142
|
// legal `uniweb sync --no-git` would be rejected here. It passes `skipFlagCheck`.
|
package/src/commands/refresh.js
CHANGED
|
@@ -17,10 +17,17 @@
|
|
|
17
17
|
* which is the whole point of a start-of-day command. A version that also pushed
|
|
18
18
|
* would be one you had to think about first, and so one people would stop running.
|
|
19
19
|
*
|
|
20
|
-
* Order is deliberate: git first, then the backend.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* Order is deliberate: git first, then the backend. Teammates' commits are then in
|
|
21
|
+
* the files when the backend's content arrives, and `pull --merge` merges a
|
|
22
|
+
* teammate's change to a file the last pull wrote just as it merges your own edits:
|
|
23
|
+
* against the version that pull wrote, found in the repo's history (`pull.js`,
|
|
24
|
+
* `captureLocalWork`). And if git itself conflicts, refresh stops there — one source
|
|
25
|
+
* of conflict at a time.
|
|
26
|
+
*
|
|
27
|
+
* ⛔ Until 2026-10-16 this said the merge's ancestor was the COMMITTED version of each
|
|
28
|
+
* file, so taking teammates' commits first made it fresher. That was HEAD — the rule
|
|
29
|
+
* `pull --merge` dropped on 2026-09-23, because HEAD stops being the version a pull
|
|
30
|
+
* wrote the moment an edit is committed on top of it.
|
|
24
31
|
*
|
|
25
32
|
* Exits NON-ZERO when a merge left conflicts, the way a conflicted `git merge`
|
|
26
33
|
* does. That is what makes `uniweb refresh && uniweb push` correct by
|
|
@@ -91,10 +98,11 @@ function siteContentUuid(siteDir, backend) {
|
|
|
91
98
|
*/
|
|
92
99
|
export async function refresh(args = [], deps = {}) {
|
|
93
100
|
// An unrecognized flag is invisible to a literal scan, so it silently keeps the
|
|
94
|
-
// default —
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
101
|
+
// default — a mistyped `--no-git` would still pull from the git remote. And this is
|
|
102
|
+
// the only check refresh's argv gets: the pull it runs is handed `--merge` alone
|
|
103
|
+
// (below). A backend flag or `--token` is refused here too, naming `uniweb login`
|
|
104
|
+
// (`utils/flag-guard.js`). *(This explained the check by a mistyped `--backend`
|
|
105
|
+
// until 2026-10-16; the verb has taken no backend flag since 2026-09-21.)*
|
|
98
106
|
//
|
|
99
107
|
// `sync` validates the UNION of both halves and forwards raw argv, so a legal
|
|
100
108
|
// `uniweb sync --force` would be rejected here. It passes `skipFlagCheck`.
|
package/src/commands/status.js
CHANGED
|
@@ -221,6 +221,9 @@ export async function status(args = []) {
|
|
|
221
221
|
site = siteState.site || null
|
|
222
222
|
// In another workspace: it exists, and the refusal says where — report it.
|
|
223
223
|
if (siteState.status === 409) remoteError = siteState.detail
|
|
224
|
+
} else if (siteState.refused) {
|
|
225
|
+
// The workspace this status works in, refused: say so — it is not a fact about the site.
|
|
226
|
+
remoteError = siteState.detail
|
|
224
227
|
}
|
|
225
228
|
}
|
|
226
229
|
// Foundation freshness: prefer the LOCAL foundation's scoped name (so a
|
|
@@ -321,9 +324,9 @@ export async function status(args = []) {
|
|
|
321
324
|
`To push this as a new site: uniweb forget --server ${probeBackend}, then uniweb push.`
|
|
322
325
|
)
|
|
323
326
|
} else if (siteState?.state === 'unknown') {
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
)
|
|
327
|
+
// A refused workspace is already said above, as itself.
|
|
328
|
+
const detail = siteState.refused ? null : siteState.detail
|
|
329
|
+
say.dim(`Could not tell whether the backend holds this site${detail ? ` (${detail})` : ''}.`)
|
|
327
330
|
}
|
|
328
331
|
if (site) {
|
|
329
332
|
if (site.draft_dirty) {
|
package/src/framework-index.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-16T22:49:32.412Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
6
|
"version": "1.2.5"
|
|
7
7
|
},
|
|
8
8
|
"@uniweb/build": {
|
|
9
|
-
"version": "1.3.
|
|
9
|
+
"version": "1.3.5"
|
|
10
10
|
},
|
|
11
11
|
"@uniweb/content-reader": {
|
|
12
|
-
"version": "1.
|
|
12
|
+
"version": "1.4.0"
|
|
13
13
|
},
|
|
14
14
|
"@uniweb/content-writer": {
|
|
15
15
|
"version": "1.1.2"
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"version": "1.1.1"
|
|
37
37
|
},
|
|
38
38
|
"@uniweb/runtime": {
|
|
39
|
-
"version": "1.1.
|
|
39
|
+
"version": "1.1.4"
|
|
40
40
|
},
|
|
41
41
|
"@uniweb/scene": {
|
|
42
42
|
"version": "1.0.1"
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"version": "1.1.0"
|
|
49
49
|
},
|
|
50
50
|
"@uniweb/semantic-parser": {
|
|
51
|
-
"version": "1.8.
|
|
51
|
+
"version": "1.8.1"
|
|
52
52
|
},
|
|
53
53
|
"@uniweb/snapshot": {
|
|
54
54
|
"version": "1.1.0"
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"version": "1.2.0"
|
|
61
61
|
},
|
|
62
62
|
"@uniweb/unipress": {
|
|
63
|
-
"version": "1.1.
|
|
63
|
+
"version": "1.1.5"
|
|
64
64
|
}
|
|
65
65
|
},
|
|
66
66
|
"templates": {
|
package/src/utils/git.js
CHANGED
|
@@ -161,17 +161,23 @@ export function uncommittedUnder(dir, relPaths) {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
|
-
* A file's
|
|
164
|
+
* A file's content at HEAD — the ancestor of a three-way merge for a file no pull
|
|
165
|
+
* wrote. For one a pull wrote, the ancestor is the version it wrote, found in history
|
|
166
|
+
* by `findCommitted` below: HEAD stops being that version the moment an edit is
|
|
167
|
+
* committed on top of it.
|
|
165
168
|
*
|
|
166
|
-
*
|
|
169
|
+
* Either way the ancestor is on this side, which is the point: the backend keeps no
|
|
167
170
|
* per-version item content, and retaining some so it could feed a merge running on
|
|
168
171
|
* the client would put a permanent second write on its hottest table. It doesn't
|
|
169
|
-
* have to
|
|
170
|
-
* committed version IS the state both sides diverged from — the ancestor was always
|
|
171
|
-
* on this side, in the tool that already knows how to merge.
|
|
172
|
+
* have to — the tool that already knows how to merge holds the history.
|
|
172
173
|
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
174
|
+
* ⛔ Until 2026-10-16 this called HEAD's content THE common ancestor ("`uniweb pull`
|
|
175
|
+
* writes the backend's content into these files, so the committed version IS the
|
|
176
|
+
* state both sides diverged from") — the rule `pull --merge` dropped on 2026-09-23.
|
|
177
|
+
*
|
|
178
|
+
* @returns {Buffer|null} null when the path isn't in HEAD — a file added locally and
|
|
179
|
+
* never committed has no ancestor; when the backend holds it too, `pull --merge`
|
|
180
|
+
* merges the two versions two-way.
|
|
175
181
|
*/
|
|
176
182
|
export function showAtHead(dir, relPath) {
|
|
177
183
|
return showAt(dir, 'HEAD', relPath)
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
* demonstrated.**
|
|
52
52
|
*/
|
|
53
53
|
|
|
54
|
-
import { WORKSPACE_HEADER } from '../backend/client.js'
|
|
54
|
+
import { WORKSPACE_HEADER, WorkspaceRefusedError } from '../backend/client.js'
|
|
55
55
|
import { createHash } from 'node:crypto'
|
|
56
56
|
import { humanBytes } from './bytes.js'
|
|
57
57
|
import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
|
|
@@ -66,6 +66,8 @@ import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
|
|
|
66
66
|
* @param {string|null} [opts.workspace] - the workspace the requests name, `@handle`
|
|
67
67
|
* (`x-uniweb-workspace`) — on the plan and on every DIRECT PUT, never on a presigned
|
|
68
68
|
* one, which is self-authorizing and must carry nothing it was not signed with
|
|
69
|
+
* @param {string} [opts.workspaceSource] - where that workspace came from (`workspace.js`),
|
|
70
|
+
* so a refusal of it says how to choose another (`WorkspaceRefusedError`)
|
|
69
71
|
* @param {{ data: Record<string, unknown> }|null} opts.ball - source of the set:
|
|
70
72
|
* `{ "<relpath under dist/data>": <json> }`, media refs already rewritten
|
|
71
73
|
* @param {(m: string) => void} [opts.onProgress]
|
|
@@ -77,6 +79,7 @@ export async function uploadSiteData({
|
|
|
77
79
|
token,
|
|
78
80
|
siteUuid,
|
|
79
81
|
workspace = null,
|
|
82
|
+
workspaceSource = 'login',
|
|
80
83
|
ball,
|
|
81
84
|
onProgress = () => {}
|
|
82
85
|
}) {
|
|
@@ -167,6 +170,11 @@ export async function uploadSiteData({
|
|
|
167
170
|
/* not a problem document — prose refusal, or an upstream error page */
|
|
168
171
|
}
|
|
169
172
|
}
|
|
173
|
+
// ⭐ The workspace the plan named is not one the account may open — said as every other
|
|
174
|
+
// site request says it (`WorkspaceRefusedError`, backend/client.js), never as a raw 403.
|
|
175
|
+
if (planRes.status === 403 && problem?.op === 'act_as_unit' && workspace) {
|
|
176
|
+
throw new WorkspaceRefusedError({ named: workspace, source: workspaceSource, origin })
|
|
177
|
+
}
|
|
170
178
|
const err = new Error(
|
|
171
179
|
`site data-uploads plan rejected: HTTP ${planRes.status}${body ? ` — ${body.slice(0, 300)}` : ''}`
|
|
172
180
|
)
|