uniweb 0.82.1 → 0.83.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 +6 -6
- package/partials/agents.md +7 -4
- package/src/backend/client.js +89 -16
- package/src/backend/foundation-bring-along.js +81 -11
- package/src/backend/site-sync.js +72 -3
- package/src/backend/workspace.js +52 -17
- package/src/commands/publish.js +5 -4
- package/src/commands/push.js +6 -2
- package/src/commands/register.js +64 -19
- package/src/commands/status.js +32 -3
- package/src/framework-index.json +2 -2
- package/src/index.js +28 -10
- package/src/utils/config.js +61 -6
- package/src/utils/registry-auth.js +18 -5
- package/src/utils/registry-orgs.js +43 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.83.0",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,16 +41,16 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/
|
|
45
|
-
"@uniweb/schemas": "^0.12.0",
|
|
44
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
46
45
|
"@uniweb/runtime": "^0.29.1",
|
|
47
|
-
"@uniweb/semantic-parser": "^1.5.1",
|
|
48
46
|
"@uniweb/kit": "^0.19.17",
|
|
49
|
-
"@uniweb/
|
|
47
|
+
"@uniweb/schemas": "^0.12.0",
|
|
48
|
+
"@uniweb/core": "^0.37.1",
|
|
49
|
+
"@uniweb/semantic-parser": "^1.5.1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"@uniweb/content-reader": "^1.2.8",
|
|
53
52
|
"@uniweb/build": "^0.76.1",
|
|
53
|
+
"@uniweb/content-reader": "^1.2.8",
|
|
54
54
|
"@uniweb/semantic-parser": "^1.5.1"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
package/partials/agents.md
CHANGED
|
@@ -2635,9 +2635,12 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
|
|
|
2635
2635
|
> uniweb push --org @acme # this command only
|
|
2636
2636
|
> ```
|
|
2637
2637
|
>
|
|
2638
|
-
> Already logged in, `uniweb login --org @other`
|
|
2639
|
-
>
|
|
2640
|
-
>
|
|
2638
|
+
> Already logged in, `uniweb login --org @other` (or `--personal`) switches the workspace on the
|
|
2639
|
+
> backend you are logged in to, without logging in again. **Without a
|
|
2640
|
+
> terminal — you, an agent — a login with organizations and no workspace named signs you in but
|
|
2641
|
+
> exits 2**: `uniweb login --org @acme` (or `--personal`) finishes it without signing in again.
|
|
2642
|
+
> Until then every command that works on a site refuses, and so does registering a foundation whose
|
|
2643
|
+
> name has no scope. A process logged in with `UNIWEB_TOKEN` names its workspace with
|
|
2641
2644
|
> `UNIWEB_WORKSPACE=@acme` (or `personal`). **Ask the human which workspace to use** rather than
|
|
2642
2645
|
> picking for them — a site created in the wrong one cannot be moved from here.
|
|
2643
2646
|
>
|
|
@@ -2696,7 +2699,7 @@ Platform-specific configuration that doesn't belong in npm-standard fields. All
|
|
|
2696
2699
|
|---|---|---|---|
|
|
2697
2700
|
| `runtimePolicy` | `dist/runtime-pin.json` | unset | Declares how far past the recorded runtime version a host may move a site. |
|
|
2698
2701
|
|
|
2699
|
-
**The foundation's name is not here — it is `name` in `main.js`** (else `package.json`'s `name`), **and so is the scope it registers under: the scope is part of the name**, `name: '@acme/marketing'`. A scope is a namespace — your personal one, `@<your handle>`, which needs no org, or an org's. A name with no scope has not been registered yet: the first `uniweb register` takes `--scope`, else
|
|
2702
|
+
**The foundation's name is not here — it is `name` in `main.js`** (else `package.json`'s `name`), **and so is the scope it registers under: the scope is part of the name**, `name: '@acme/marketing'`. A scope is a namespace — your personal one, `@<your handle>`, which needs no org, or an org's. A name with no scope has not been registered yet: the first `uniweb register` takes `--scope`, else the workspace you work in — an org's scope, or your personal one; when you belong to orgs and chose no workspace, it asks at a terminal and refuses without one — and writes it into the name; a `--scope` naming another scope than the name's is refused. The foundation's own data schemas register under the same scope (`@/article` → `@acme/article`). ⛔ `uniweb.id` and `uniweb.scope` are no longer read for a foundation: `uniweb register` refuses them and prints the `main.js` line to write instead. (A schemas-only package has no `main.js`, and still records its scope in `uniweb.scope`.)
|
|
2700
2703
|
|
|
2701
2704
|
**Runtime updates — handled for you.** Your foundation's code links against the runtime: it externalizes `react`, `react-dom`, `react-dom/server`, both JSX runtimes and `@uniweb/core`, and the runtime supplies all of them at load time. So a build is bound to *that* React and *that* core API.
|
|
2702
2705
|
|
package/src/backend/client.js
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* `fetch(… Authorization: Bearer …)` against a per-command default origin. It
|
|
8
8
|
* owns the three things that were previously scattered across a dozen files:
|
|
9
9
|
*
|
|
10
|
-
* 1. ORIGIN — where the backend is. resolveBackendOrigin():
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* 1. ORIGIN — where the backend is. resolveBackendOrigin(): UNIWEB_REGISTER_URL >
|
|
11
|
+
* the backend you are logged in to > the default backend (below).
|
|
12
|
+
* Each is reduced to its origin, http(s) only. *(This named a
|
|
13
|
+
* `--backend` flag as the first tier until 2026-10-05; the verbs
|
|
14
|
+
* lost that flag on 2026-09-21.)*
|
|
13
15
|
* 2. AUTH — the opaque session bearer (utils/registry-auth.js). Resolved
|
|
14
16
|
* LAZILY on the first authed call, so dry-runs and fully-local
|
|
15
17
|
* work never trigger a login.
|
|
@@ -27,7 +29,7 @@
|
|
|
27
29
|
* single home.
|
|
28
30
|
*/
|
|
29
31
|
|
|
30
|
-
import { getRegistryApiBaseUrl } from '../utils/config.js'
|
|
32
|
+
import { getRegistryApiBaseUrl, loginCommand } from '../utils/config.js'
|
|
31
33
|
import {
|
|
32
34
|
ensureRegistryAuth,
|
|
33
35
|
fetchMe,
|
|
@@ -189,11 +191,12 @@ export function workspaceHeader(value) {
|
|
|
189
191
|
*/
|
|
190
192
|
export class WorkspaceMismatchError extends Error {
|
|
191
193
|
/**
|
|
192
|
-
* @param {{ named: string|null, answer: string|null, source?: string }} p -
|
|
193
|
-
* workspace the request named and the one the backend named (`@handle`, a unit's
|
|
194
|
-
* uuid, null for personal),
|
|
194
|
+
* @param {{ named: string|null, answer: string|null, source?: string, origin?: string|null }} p -
|
|
195
|
+
* the workspace the request named and the one the backend named (`@handle`, a unit's
|
|
196
|
+
* uuid, null for personal), where the named one came from (`workspace.js`), and the
|
|
197
|
+
* backend — a login that switches must name it unless it is the default (`loginCommand`)
|
|
195
198
|
*/
|
|
196
|
-
constructor({ named, answer, source = 'login' }) {
|
|
199
|
+
constructor({ named, answer, source = 'login', origin = null }) {
|
|
197
200
|
const where = (w) =>
|
|
198
201
|
!w ? 'your personal workspace' : w.startsWith('@') ? w : `the unit ${w}`
|
|
199
202
|
// How to work in the site's workspace, said for the way this one was chosen.
|
|
@@ -202,7 +205,11 @@ export class WorkspaceMismatchError extends Error {
|
|
|
202
205
|
: answer.startsWith('@')
|
|
203
206
|
? { flag: `pass --org ${answer}`, env: `set UNIWEB_WORKSPACE=${answer}` }
|
|
204
207
|
: null
|
|
205
|
-
const login = !answer
|
|
208
|
+
const login = !answer
|
|
209
|
+
? `${loginCommand(origin)} --personal`
|
|
210
|
+
: answer.startsWith('@')
|
|
211
|
+
? `${loginCommand(origin)} --org ${answer}`
|
|
212
|
+
: null
|
|
206
213
|
const fix = !switchTo
|
|
207
214
|
? 'It has no handle, so the CLI cannot work in it.'
|
|
208
215
|
: source === 'flag'
|
|
@@ -330,12 +337,16 @@ export class BackendClient {
|
|
|
330
337
|
return this._token
|
|
331
338
|
}
|
|
332
339
|
// ⛔ NO ORIGIN-MISMATCH GUARD HERE, AND DO NOT RE-ADD ONE (removed 2026-09-20).
|
|
333
|
-
// It existed because the session store held ONE record
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
// (utils/registry-auth.js), so
|
|
337
|
-
//
|
|
338
|
-
//
|
|
340
|
+
// It existed because the session store held ONE record, read without an origin: a
|
|
341
|
+
// session for another backend was about to be sent here and rejected, and a warning
|
|
342
|
+
// was the best available move. The session is looked up BY ORIGIN now
|
|
343
|
+
// (`readRegistryAuth(origin)`, utils/registry-auth.js), so another backend's token is
|
|
344
|
+
// never sent here — `ensureRegistryAuth` below finds this origin's session or asks
|
|
345
|
+
// for one. ⚠️ There is still ONE session (since 2026-09-21): a login it asks for
|
|
346
|
+
// replaces the other backend's session, and says so. *(Until 2026-10-05 this read
|
|
347
|
+
// "sessions are keyed by origin now … being logged into another backend is just a
|
|
348
|
+
// fact about another backend" — true of the per-backend store of 2026-09-20→21, and
|
|
349
|
+
// read as "several sessions coexist".)*
|
|
339
350
|
this._token = await ensureRegistryAuth({
|
|
340
351
|
apiBase: this.origin,
|
|
341
352
|
command: this._command,
|
|
@@ -386,7 +397,8 @@ export class BackendClient {
|
|
|
386
397
|
throw new WorkspaceMismatchError({
|
|
387
398
|
named: this._workspace,
|
|
388
399
|
answer,
|
|
389
|
-
source: this._workspaceSource
|
|
400
|
+
source: this._workspaceSource,
|
|
401
|
+
origin: this.origin
|
|
390
402
|
})
|
|
391
403
|
}
|
|
392
404
|
|
|
@@ -807,6 +819,67 @@ export class BackendClient {
|
|
|
807
819
|
* @param {string} uuid - the site-content uuid
|
|
808
820
|
* @returns {Promise<object|null>}
|
|
809
821
|
*/
|
|
822
|
+
/**
|
|
823
|
+
* Whether the backend holds the site `uuid` names — `live`, `gone` or `unknown`, kept
|
|
824
|
+
* apart — for a script that must decide, before any push, whether a binding still
|
|
825
|
+
* points at a site (`uniweb status --remote --json`, `remote.site_state`).
|
|
826
|
+
*
|
|
827
|
+
* ⛔ `gone` ONLY ON THE BACKEND'S OWN WORD ABOUT THIS SITE: a `404` whose problem names
|
|
828
|
+
* it — `kind: "site"`, `key: <uuid>`. A push from an unbound copy CREATES a site, so a
|
|
829
|
+
* binding dropped because its site was merely unreadable leaves a second copy beside
|
|
830
|
+
* the first. Folding `unknown` into `gone` would be worse than no answer.
|
|
831
|
+
*
|
|
832
|
+
* live the site's status came back, or the site was refused as belonging to
|
|
833
|
+
* another workspace (`409 wrong_workspace` names where it is: it exists)
|
|
834
|
+
* gone a `404` naming this site
|
|
835
|
+
* unknown anything else — no answer, a credential or an id refused, a `404` that
|
|
836
|
+
* names something else (a backend too old for the route)
|
|
837
|
+
*
|
|
838
|
+
* *Measured 2026-10-06 against a local backend — its wire, its to change:* `200` with
|
|
839
|
+
* the status; `409 wrong_workspace` naming the site's workspace; `404 {kind: "site",
|
|
840
|
+
* key}`; `400` for a malformed id; `401` for a bad bearer.
|
|
841
|
+
*
|
|
842
|
+
* @param {string} uuid
|
|
843
|
+
* @returns {Promise<{ state: 'live'|'gone'|'unknown', status: number|null, site?: object, workspace?: string|null, detail?: string|null }>}
|
|
844
|
+
*/
|
|
845
|
+
async siteState(uuid) {
|
|
846
|
+
let res
|
|
847
|
+
try {
|
|
848
|
+
res = await this.request(`/dev/site/status/${encodeURIComponent(uuid)}`)
|
|
849
|
+
} catch (err) {
|
|
850
|
+
if (err instanceof WorkspaceMismatchError) {
|
|
851
|
+
return { state: 'live', status: 409, workspace: err.answer, detail: err.message }
|
|
852
|
+
}
|
|
853
|
+
return { state: 'unknown', status: null, detail: err?.message || String(err) }
|
|
854
|
+
}
|
|
855
|
+
const body = await res.text().catch(() => '')
|
|
856
|
+
let parsed = null
|
|
857
|
+
try {
|
|
858
|
+
parsed = body ? JSON.parse(body) : null
|
|
859
|
+
} catch {
|
|
860
|
+
parsed = null
|
|
861
|
+
}
|
|
862
|
+
if (res.ok) return { state: 'live', status: res.status, ...(parsed ? { site: parsed } : {}) }
|
|
863
|
+
if (res.status === 409 && parsed?.reason === 'wrong_workspace') {
|
|
864
|
+
const w = parsed.workspace || {}
|
|
865
|
+
return {
|
|
866
|
+
state: 'live',
|
|
867
|
+
status: 409,
|
|
868
|
+
workspace: w.handle ? workspaceHandle(w.handle) : w.unit_uuid || null,
|
|
869
|
+
detail: parsed.detail || null
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
const names = (v) => String(v ?? '').toLowerCase() === String(uuid).toLowerCase()
|
|
873
|
+
if (res.status === 404 && parsed?.kind === 'site' && names(parsed.key)) {
|
|
874
|
+
return { state: 'gone', status: 404, detail: parsed.detail || null }
|
|
875
|
+
}
|
|
876
|
+
return {
|
|
877
|
+
state: 'unknown',
|
|
878
|
+
status: res.status,
|
|
879
|
+
detail: parsed?.detail || parsed?.title || (body ? body.slice(0, 200) : null)
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
810
883
|
async siteStatus(uuid) {
|
|
811
884
|
try {
|
|
812
885
|
const res = await this.request(
|
|
@@ -63,6 +63,8 @@ import {
|
|
|
63
63
|
} from '@uniweb/build'
|
|
64
64
|
import { computeFoundationDigest } from '../utils/code-upload.js'
|
|
65
65
|
import { isNonInteractive } from '../utils/interactive.js'
|
|
66
|
+
import { readOrgFlag } from '../utils/args.js'
|
|
67
|
+
import { belongsToScope } from '../utils/registry-orgs.js'
|
|
66
68
|
import { writeJsonPreservingStyle } from '../utils/json-file.js'
|
|
67
69
|
import {
|
|
68
70
|
compareSemverPrecedence,
|
|
@@ -191,13 +193,22 @@ function writePkgVersion(dir, version) {
|
|
|
191
193
|
writeJsonPreservingStyle(path, { ...JSON.parse(src), version }, src)
|
|
192
194
|
}
|
|
193
195
|
|
|
194
|
-
// What travels to the spawned `uniweb register`
|
|
195
|
-
//
|
|
196
|
-
// (releaseFoundation), and the SESSION is the shared session
|
|
197
|
-
// which the child inherits. The backend commands take no
|
|
198
|
-
|
|
196
|
+
// What travels to the spawned `uniweb register` on the command line: --non-interactive,
|
|
197
|
+
// and the WORKSPACE this command names (`--org` / `--personal`). The BACKEND travels in
|
|
198
|
+
// the child's environment (releaseFoundation), and the SESSION is the shared session
|
|
199
|
+
// file — or UNIWEB_TOKEN, which the child inherits. The backend commands take no
|
|
200
|
+
// `--backend` or `--token`.
|
|
201
|
+
//
|
|
202
|
+
// ⭐ The workspace travels because a bare name registers under the workspace the command
|
|
203
|
+
// works in *[Diego, 2026-10-06]*, and a workspace named on this command is this command's
|
|
204
|
+
// alone: the child cannot read it from the session. One chosen at the login, or in
|
|
205
|
+
// UNIWEB_WORKSPACE, reaches the child on its own.
|
|
206
|
+
export function forwardedFlags(args) {
|
|
199
207
|
const out = []
|
|
200
208
|
if (isNonInteractive(args)) out.push('--non-interactive')
|
|
209
|
+
const org = readOrgFlag(args)
|
|
210
|
+
if (org) out.push('--org', org)
|
|
211
|
+
else if (args.includes('--personal')) out.push('--personal')
|
|
201
212
|
return out
|
|
202
213
|
}
|
|
203
214
|
|
|
@@ -280,9 +291,16 @@ async function bringLocalCodeAlong({
|
|
|
280
291
|
}) {
|
|
281
292
|
const Kind = kind === 'extension' ? 'Extension ' : 'Foundation'
|
|
282
293
|
const scopedName = await foundationScopedName(local.dir)
|
|
294
|
+
// A name with no scope yet (it has never registered) is still a name: say it. ⛔ Until
|
|
295
|
+
// 2026-10-06 the label fell back to the KIND, and the line read "Releasing the foundation
|
|
296
|
+
// foundation@0.1.0".
|
|
297
|
+
const bareName = scopedName
|
|
298
|
+
? null
|
|
299
|
+
: await readFoundationName(local.dir).then((r) => (r?.name && !checkFoundationName(r.name) ? r.name : null), () => null)
|
|
300
|
+
const shown = scopedName || bareName
|
|
283
301
|
const label =
|
|
284
|
-
|
|
285
|
-
? `${
|
|
302
|
+
shown || local.version
|
|
303
|
+
? `${shown || kind}${local.version ? `@${local.version}` : ''}`
|
|
286
304
|
: `the local ${kind}`
|
|
287
305
|
const skipPrompts =
|
|
288
306
|
args.includes('--yes') ||
|
|
@@ -361,6 +379,16 @@ async function bringLocalCodeAlong({
|
|
|
361
379
|
// answer / no scoped name to look up) → release.
|
|
362
380
|
const reg = scopedName ? await client.readFoundationLatest(scopedName) : null
|
|
363
381
|
|
|
382
|
+
// Every release below goes through here, so a refusal for a scope you are not a member
|
|
383
|
+
// of is said as that, with the ways on (`explainReleaseFailure`).
|
|
384
|
+
const release = async () => {
|
|
385
|
+
try {
|
|
386
|
+
return releaseFoundation(local, args, cliBin, say, client?.origin)
|
|
387
|
+
} catch (err) {
|
|
388
|
+
throw await explainReleaseFailure(err, { client, local, reg, verb })
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
364
392
|
if (!reg) {
|
|
365
393
|
// ⛔ Nothing to bind to. Releasing anyway would be the opposite of what was asked,
|
|
366
394
|
// and shipping anyway would leave a site the app cannot open — so stop and say so.
|
|
@@ -376,7 +404,7 @@ async function bringLocalCodeAlong({
|
|
|
376
404
|
}
|
|
377
405
|
say.info(`Releasing the ${kind} ${label} (not yet registered)…`)
|
|
378
406
|
return {
|
|
379
|
-
released:
|
|
407
|
+
released: await release(),
|
|
380
408
|
proceed: true,
|
|
381
409
|
ref: await pinnedRef()
|
|
382
410
|
}
|
|
@@ -406,7 +434,7 @@ async function bringLocalCodeAlong({
|
|
|
406
434
|
}
|
|
407
435
|
writePkgVersion(local.dir, bumpTo)
|
|
408
436
|
say.info(`Releasing the ${kind} ${scopedName || kind} as ${bumpTo} — ${why}…`)
|
|
409
|
-
const released =
|
|
437
|
+
const released = await release()
|
|
410
438
|
say.dim(`The ${kind}'s package.json now says ${bumpTo} — commit it.`)
|
|
411
439
|
if (optOut) say.dim('To send content without releasing code, pass `--no-release`.')
|
|
412
440
|
return { released, proceed: true, bumped: bumpTo, ref: await pinnedRef() }
|
|
@@ -480,7 +508,7 @@ async function bringLocalCodeAlong({
|
|
|
480
508
|
: `Releasing the ${kind} ${label} (registered latest is ${reg.latest_version})…`
|
|
481
509
|
)
|
|
482
510
|
return {
|
|
483
|
-
released:
|
|
511
|
+
released: await release(),
|
|
484
512
|
proceed: true,
|
|
485
513
|
ref: await pinnedRef()
|
|
486
514
|
}
|
|
@@ -514,7 +542,7 @@ async function bringLocalCodeAlong({
|
|
|
514
542
|
)
|
|
515
543
|
if (reRelease)
|
|
516
544
|
return {
|
|
517
|
-
released:
|
|
545
|
+
released: await release(),
|
|
518
546
|
proceed: true,
|
|
519
547
|
ref: await pinnedRef()
|
|
520
548
|
}
|
|
@@ -570,6 +598,48 @@ function releaseFoundation(local, args, cliBin, say, origin) {
|
|
|
570
598
|
return true
|
|
571
599
|
}
|
|
572
600
|
|
|
601
|
+
/**
|
|
602
|
+
* A failed release, explained when the cause is a scope you are not a member of
|
|
603
|
+
* *[Diego, 2026-10-06]* — or the error as it came.
|
|
604
|
+
*
|
|
605
|
+
* The scope is the one in the foundation's name: `register` writes a bare name's scope
|
|
606
|
+
* before it submits, so a refused release leaves the name scoped. When the orgs read
|
|
607
|
+
* says the account is neither that scope's owner nor a member of its org, the error
|
|
608
|
+
* says so (`notMember`) and carries the ways on (`ways`): `--no-release`, where a
|
|
609
|
+
* released version exists to send the content against, and a member releasing it.
|
|
610
|
+
* Its message is what the refusal means for the push — nothing was sent — since the
|
|
611
|
+
* `register` it ran has just printed the refusal itself, membership and the registry's
|
|
612
|
+
* own sentence included (measured against a local backend, 2026-10-06: HTTP 400, no
|
|
613
|
+
* typed reason).
|
|
614
|
+
*
|
|
615
|
+
* ⛔ The registry decides membership; this only names it after a refusal, and passes
|
|
616
|
+
* the error through whenever it cannot tell.
|
|
617
|
+
*
|
|
618
|
+
* @param {Error} err - the release's failure
|
|
619
|
+
* @param {object} o
|
|
620
|
+
* @param {import('./client.js').BackendClient} o.client
|
|
621
|
+
* @param {{ dir: string }} o.local
|
|
622
|
+
* @param {{ latest_version?: string }|null} o.reg - the catalog's answer for this name
|
|
623
|
+
* @param {string} o.verb - `push` or `publish`, for the ways on
|
|
624
|
+
* @returns {Promise<Error>}
|
|
625
|
+
*/
|
|
626
|
+
export async function explainReleaseFailure(err, { client, local, reg, verb }) {
|
|
627
|
+
const name = await foundationScopedName(local.dir).catch(() => null)
|
|
628
|
+
const scope = name ? name.split('/')[0] : null
|
|
629
|
+
if (!scope || typeof client?.fetchOrgs !== 'function') return err
|
|
630
|
+
const member = belongsToScope(scope, await client.fetchOrgs().catch(() => null))
|
|
631
|
+
if (member !== false) return err
|
|
632
|
+
const explained = new Error(`${name} was not released, so nothing was sent.`)
|
|
633
|
+
explained.notMember = true
|
|
634
|
+
explained.ways = reg?.latest_version
|
|
635
|
+
? [
|
|
636
|
+
`Send the content against the released ${reg.latest_version}: \`uniweb ${verb} --no-release\`.`,
|
|
637
|
+
`Or ask a member of ${scope} to release it.`
|
|
638
|
+
]
|
|
639
|
+
: [`Ask a member of ${scope} to release it.`]
|
|
640
|
+
return explained
|
|
641
|
+
}
|
|
642
|
+
|
|
573
643
|
/**
|
|
574
644
|
* Bring the site's LOCAL extensions along — the exact parallel of
|
|
575
645
|
* `bringFoundationAlong`, run for each workspace-local extension.
|
package/src/backend/site-sync.js
CHANGED
|
@@ -1109,8 +1109,8 @@ function recordSiteWorkspace(siteDir, backend, owner) {
|
|
|
1109
1109
|
* ⚖️ The wording deliberately DIVERGES from the asset lane's on one point. There the
|
|
1110
1110
|
* allowance belongs to the site's owner, who may not be the person pushing. Here
|
|
1111
1111
|
* there is no site yet, so the only workspace in play is the one the site would be
|
|
1112
|
-
* created in — `--
|
|
1113
|
-
* incoherent for a site that does not exist.
|
|
1112
|
+
* created in — one named with `--org` included. Saying "the site owner's workspace"
|
|
1113
|
+
* would be incoherent for a site that does not exist.
|
|
1114
1114
|
*
|
|
1115
1115
|
* @param {string} body - the raw response body
|
|
1116
1116
|
* @returns {string|null}
|
|
@@ -1125,6 +1125,9 @@ function describeCreateRefusal(body) {
|
|
|
1125
1125
|
}
|
|
1126
1126
|
if (!p || typeof p !== 'object' || typeof p.reason !== 'string') return null
|
|
1127
1127
|
|
|
1128
|
+
const attach = describeAttachRefusal(p)
|
|
1129
|
+
if (attach) return [attach.headline, ...attach.steps].join('\n ')
|
|
1130
|
+
|
|
1128
1131
|
if (p.reason === 'storage_quota_exceeded') {
|
|
1129
1132
|
const parts = []
|
|
1130
1133
|
const used = humanBytes(p.used_bytes)
|
|
@@ -1144,6 +1147,63 @@ function describeCreateRefusal(body) {
|
|
|
1144
1147
|
return `the backend refused the site create (${p.reason})${detail ? ` — ${detail}` : ''}`
|
|
1145
1148
|
}
|
|
1146
1149
|
|
|
1150
|
+
/**
|
|
1151
|
+
* The backend's refusals to ATTACH a foundation to a site, as what happened and what to
|
|
1152
|
+
* do next — or null for any other problem document.
|
|
1153
|
+
*
|
|
1154
|
+
* A site takes any version of a foundation it already uses, and a new one only from a
|
|
1155
|
+
* scope its author owns. The create, the first push and a push that changes the site's
|
|
1156
|
+
* foundation can each be refused:
|
|
1157
|
+
*
|
|
1158
|
+
* `foundation_not_licensed` not yours to attach. `templates` names the app
|
|
1159
|
+
* templates that carry it: start from one in the app,
|
|
1160
|
+
* then clone the site you made. With none, register a
|
|
1161
|
+
* foundation under a scope you own.
|
|
1162
|
+
* `foundation_not_registered` this backend has no such version.
|
|
1163
|
+
*
|
|
1164
|
+
* ⛔ Branch on `reason`, never on the status: a `403` is also a rejected credential, and
|
|
1165
|
+
* "log in again" is the wrong advice for a foundation that is not yours. ⚠️ Until
|
|
1166
|
+
* 2026-10-06 a push printed exactly that for this refusal, and the create printed the
|
|
1167
|
+
* reason's bare name.
|
|
1168
|
+
*
|
|
1169
|
+
* @param {object|null} problem - a parsed problem document
|
|
1170
|
+
* @returns {{ headline: string, steps: string[] } | null}
|
|
1171
|
+
*/
|
|
1172
|
+
export function describeAttachRefusal(problem) {
|
|
1173
|
+
if (!problem || typeof problem !== 'object') return null
|
|
1174
|
+
const named = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null)
|
|
1175
|
+
|
|
1176
|
+
if (problem.reason === 'foundation_not_licensed') {
|
|
1177
|
+
const pkg = named(problem.package) || 'This foundation'
|
|
1178
|
+
const templates = (Array.isArray(problem.templates) ? problem.templates : [])
|
|
1179
|
+
.map((t) => named(t?.name))
|
|
1180
|
+
.filter(Boolean)
|
|
1181
|
+
const SHOWN = 5
|
|
1182
|
+
const list =
|
|
1183
|
+
templates
|
|
1184
|
+
.slice(0, SHOWN)
|
|
1185
|
+
.map((n) => `“${n}”`)
|
|
1186
|
+
.join(', ') + (templates.length > SHOWN ? ` and ${templates.length - SHOWN} more` : '')
|
|
1187
|
+
const steps = templates.length
|
|
1188
|
+
? [
|
|
1189
|
+
`Start from ${templates.length === 1 ? list : `one of ${list}`} in the app — ` +
|
|
1190
|
+
`${templates.length === 1 ? 'that template carries' : 'those templates carry'} it — ` +
|
|
1191
|
+
"then `uniweb clone <your site's uuid>`."
|
|
1192
|
+
]
|
|
1193
|
+
: ['Register a foundation under a scope you own, and name it in site.yml.']
|
|
1194
|
+
return { headline: `${pkg} is not yours to attach.`, steps }
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
if (problem.reason === 'foundation_not_registered') {
|
|
1198
|
+
const f = named(problem.foundation) || 'The foundation this site names'
|
|
1199
|
+
return {
|
|
1200
|
+
headline: `${f} is not registered on this backend.`,
|
|
1201
|
+
steps: ['Register it here first (`uniweb register` in its directory), or name a version this backend has.']
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
return null
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1147
1207
|
/**
|
|
1148
1208
|
* Guarantee the site EXISTS on the backend before anything is uploaded against it.
|
|
1149
1209
|
*
|
|
@@ -1759,13 +1819,22 @@ export async function pushSyncPackages({
|
|
|
1759
1819
|
// Two unrelated conflicts share HTTP 409, so branch on the machine-readable
|
|
1760
1820
|
// `reason` — never on `detail`, which is prose the backend may reword.
|
|
1761
1821
|
let problem = null
|
|
1762
|
-
if ((res.status === 409 || res.status === 400 || res.status === 422) && body) {
|
|
1822
|
+
if ((res.status === 409 || res.status === 400 || res.status === 403 || res.status === 422) && body) {
|
|
1763
1823
|
try {
|
|
1764
1824
|
problem = JSON.parse(body)
|
|
1765
1825
|
} catch {
|
|
1766
1826
|
/* not a problem document */
|
|
1767
1827
|
}
|
|
1768
1828
|
}
|
|
1829
|
+
// A foundation the site may not take (`foundation_not_licensed`, a 403) or that this
|
|
1830
|
+
// backend does not have (`foundation_not_registered`) — said, with what to do next.
|
|
1831
|
+
// Before the credential branch below, which a 403 would otherwise reach.
|
|
1832
|
+
const attach = describeAttachRefusal(problem)
|
|
1833
|
+
if (attach) {
|
|
1834
|
+
error(`${label} push refused — ${attach.headline}`)
|
|
1835
|
+
for (const step of attach.steps) note(step)
|
|
1836
|
+
return null
|
|
1837
|
+
}
|
|
1769
1838
|
// The package carried no identity for records the backend already stores, so
|
|
1770
1839
|
// applying it would replace every one of them. `ensureItemUuids` is supposed
|
|
1771
1840
|
// to make this unreachable, so reaching it means that recovery failed — say so
|
package/src/backend/workspace.js
CHANGED
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { readOrgFlag } from '../utils/args.js'
|
|
29
|
-
import {
|
|
29
|
+
import { loginCommand } from '../utils/config.js'
|
|
30
|
+
import { fetchOrgs, bareHandle, validateHandle } from '../utils/registry-orgs.js'
|
|
30
31
|
import { readRegistryAuth } from '../utils/registry-auth.js'
|
|
31
32
|
import { workspaceHandle, describeWorkspace } from './client.js'
|
|
32
33
|
|
|
@@ -65,12 +66,14 @@ export const SOURCE_LABEL = {
|
|
|
65
66
|
offline: 'not resolved in a preview'
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
69
|
+
/** How to choose, for a command on `origin` — its login names the backend when it must (`loginCommand`). */
|
|
70
|
+
const choose = (origin) =>
|
|
71
|
+
[
|
|
72
|
+
'Choose the workspace you work in:',
|
|
73
|
+
` ${loginCommand(origin)} --org @acme (or --personal) — for every command after`,
|
|
74
|
+
' --org @acme / --personal — for this command only',
|
|
75
|
+
` ${WORKSPACE_ENV}=@acme — for a process logged in with UNIWEB_TOKEN`
|
|
76
|
+
].join('\n')
|
|
74
77
|
|
|
75
78
|
/**
|
|
76
79
|
* The workspace this command works in.
|
|
@@ -119,10 +122,38 @@ export async function resolveWorkspace({ client, args = [], offline = false }) {
|
|
|
119
122
|
if (!orgs.length) return { workspace: null, source: 'personal' }
|
|
120
123
|
return {
|
|
121
124
|
refused: true,
|
|
122
|
-
reason: `You belong to organizations, so no workspace is assumed.\n ${
|
|
125
|
+
reason: `You belong to organizations, so no workspace is assumed.\n ${choose(client.origin)}`
|
|
123
126
|
}
|
|
124
127
|
}
|
|
125
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The scope a bare name registers under by default — the workspace the command works in
|
|
131
|
+
* *[Diego, 2026-10-06]*: an organization's handle, or your own handle for your personal
|
|
132
|
+
* workspace.
|
|
133
|
+
*
|
|
134
|
+
* ⭐ A default, asked once: a name that carries a scope keeps it, and `--scope` names
|
|
135
|
+
* another. The workspace still never decides anything else about a foundation — it
|
|
136
|
+
* decides which SITE a command works on; here it only answers the question a bare name
|
|
137
|
+
* asks the first time it registers.
|
|
138
|
+
*
|
|
139
|
+
* Null when the workspace names no scope, and the caller derives one as before
|
|
140
|
+
* (`deriveScope`): none is chosen (you belong to organizations and none is named), the
|
|
141
|
+
* command is a preview, the workspace is a unit without a handle, or the account has no
|
|
142
|
+
* handle (a service account).
|
|
143
|
+
*
|
|
144
|
+
* @param {{ workspace?: string|null, source?: string, refused?: boolean }} ws -
|
|
145
|
+
* `resolveWorkspace`'s answer
|
|
146
|
+
* @param {string|null} accountHandle - the account's own handle (`GET /dev/orgs`), read
|
|
147
|
+
* only for the personal workspace
|
|
148
|
+
* @returns {string|null} `@handle`
|
|
149
|
+
*/
|
|
150
|
+
export function scopeOfWorkspace(ws, accountHandle) {
|
|
151
|
+
if (!ws || ws.refused || ws.source === 'offline') return null
|
|
152
|
+
if (typeof ws.workspace === 'string') return ws.workspace.startsWith('@') ? ws.workspace : null
|
|
153
|
+
const h = bareHandle(accountHandle || '')
|
|
154
|
+
return h && !validateHandle(h) ? `@${h}` : null
|
|
155
|
+
}
|
|
156
|
+
|
|
126
157
|
/**
|
|
127
158
|
* The workspace a login works in — asked once, at `uniweb login`, and stored with the
|
|
128
159
|
* session.
|
|
@@ -166,11 +197,17 @@ export async function chooseWorkspace({ apiBase, token, args = [] }) {
|
|
|
166
197
|
}
|
|
167
198
|
}
|
|
168
199
|
|
|
169
|
-
const { isNonInteractive } = await import('../utils/interactive.js')
|
|
200
|
+
const { isNonInteractive, getCliPrefix } = await import('../utils/interactive.js')
|
|
201
|
+
// ⭐ How to choose, said whole: the session is stored already (`registry-auth.js`, the
|
|
202
|
+
// login methods), so the login that finishes this signs nobody in again.
|
|
203
|
+
const others = [...mine.slice(1).map((h) => `--org ${h}`), '--personal']
|
|
204
|
+
const howToChoose =
|
|
205
|
+
`Choose one: ${loginCommand(apiBase, getCliPrefix())} --org ${mine[0]} (or ${others.join(', ')}) — ` +
|
|
206
|
+
'it will not ask you to sign in again.'
|
|
170
207
|
if (isNonInteractive(args)) {
|
|
171
208
|
return {
|
|
172
209
|
refused: true,
|
|
173
|
-
reason: `You belong to organizations
|
|
210
|
+
reason: `You belong to organizations, so no workspace is assumed. ${howToChoose}`
|
|
174
211
|
}
|
|
175
212
|
}
|
|
176
213
|
const prompts = (await import('prompts')).default
|
|
@@ -188,12 +225,10 @@ export async function chooseWorkspace({ apiBase, token, args = [] }) {
|
|
|
188
225
|
],
|
|
189
226
|
initial: 0
|
|
190
227
|
},
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
196
|
-
}
|
|
228
|
+
// ⛔ A cancelled pick is NOT a cancelled login: the session is stored already, so the
|
|
229
|
+
// login says so and how to finish it (`finishLogin`), and exits 2. Until 2026-10-06 this
|
|
230
|
+
// printed "Cancelled." and exited 0 — with the new session in place of the old one.
|
|
231
|
+
{ onCancel: () => false }
|
|
197
232
|
)
|
|
198
|
-
return choice ? { choice } : { refused: true, reason:
|
|
233
|
+
return choice ? { choice } : { refused: true, reason: `No workspace chosen. ${howToChoose}` }
|
|
199
234
|
}
|
package/src/commands/publish.js
CHANGED
|
@@ -492,8 +492,9 @@ export async function publish(args = []) {
|
|
|
492
492
|
cliBin: process.argv[1]
|
|
493
493
|
})
|
|
494
494
|
} catch (err) {
|
|
495
|
-
|
|
496
|
-
say.
|
|
495
|
+
// A scope you are not a member of is said as that, with the ways on (`explainReleaseFailure`).
|
|
496
|
+
say.err(err.notMember ? err.message : `Foundation release failed: ${err.message}`)
|
|
497
|
+
for (const line of err.notMember ? err.ways : ['Fix the foundation, then re-run `uniweb publish`.']) say.dim(line)
|
|
497
498
|
return { exitCode: 1 }
|
|
498
499
|
}
|
|
499
500
|
|
|
@@ -512,8 +513,8 @@ export async function publish(args = []) {
|
|
|
512
513
|
cliBin: process.argv[1]
|
|
513
514
|
})
|
|
514
515
|
} catch (err) {
|
|
515
|
-
say.err(`Extension release failed: ${err.message}`)
|
|
516
|
-
|
|
516
|
+
say.err(err.notMember ? err.message : `Extension release failed: ${err.message}`)
|
|
517
|
+
for (const line of err.notMember ? err.ways : ['Fix the extension, then re-run `uniweb publish`.']) say.dim(line)
|
|
517
518
|
return { exitCode: 1 }
|
|
518
519
|
}
|
|
519
520
|
if (!ext.proceed) return { exitCode: 1 }
|
package/src/commands/push.js
CHANGED
|
@@ -37,6 +37,9 @@
|
|
|
37
37
|
* uniweb push --foundation <dir> Use this local foundation for the Model schema
|
|
38
38
|
* uniweb push --all Send every record (bypass the changed-only cache)
|
|
39
39
|
* uniweb push --force Overwrite upstream changes (drop the staleness gate)
|
|
40
|
+
* uniweb push --no-release Send content against the foundation already
|
|
41
|
+
* released; release nothing
|
|
42
|
+
* uniweb push --bump Release above a newer registered version
|
|
40
43
|
*
|
|
41
44
|
* Pushes are GATED by default: each entity carries the backend `version` this clone
|
|
42
45
|
* last saw (a top-level `base_version` on the manifest entry), and the backend refuses the whole package
|
|
@@ -274,8 +277,9 @@ export async function push(args = [], deps = {}) {
|
|
|
274
277
|
verb: 'push'
|
|
275
278
|
})
|
|
276
279
|
} catch (err) {
|
|
277
|
-
|
|
278
|
-
|
|
280
|
+
// A scope you are not a member of is said as that, with the ways on (`explainReleaseFailure`).
|
|
281
|
+
error(err.notMember ? err.message : `Foundation release failed: ${err.message}`)
|
|
282
|
+
for (const line of err.notMember ? err.ways : ['Fix the foundation, then re-run `uniweb push`.']) note(line)
|
|
279
283
|
return { exitCode: 1 }
|
|
280
284
|
}
|
|
281
285
|
if (!fnd.proceed) return { exitCode: 1 }
|
package/src/commands/register.js
CHANGED
|
@@ -88,8 +88,9 @@ import {
|
|
|
88
88
|
computeFoundationDigest,
|
|
89
89
|
readRuntimePin
|
|
90
90
|
} from '../utils/code-upload.js'
|
|
91
|
-
import { deriveScope, publishScope } from '../utils/registry-orgs.js'
|
|
91
|
+
import { deriveScope, publishScope, belongsToScope } from '../utils/registry-orgs.js'
|
|
92
92
|
import { BackendClient } from '../backend/client.js'
|
|
93
|
+
import { resolveWorkspace, scopeOfWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
|
|
93
94
|
import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
|
|
94
95
|
import {
|
|
95
96
|
findWorkspaceRoot,
|
|
@@ -316,8 +317,10 @@ export async function settleFoundationName(targetDir, { args, isPreview }) {
|
|
|
316
317
|
* A preview (`--dry-run`, `-o`) writes nothing: a bare name previews under `--scope`,
|
|
317
318
|
* or unscoped.
|
|
318
319
|
*
|
|
319
|
-
* @returns {Promise<{ scope: string|null, source: string|null } |
|
|
320
|
-
* null when refused
|
|
320
|
+
* @returns {Promise<{ scope: string|null, source: string|null } | null>}
|
|
321
|
+
* null when refused, the reason printed — no scope chosen included, since a name with
|
|
322
|
+
* no scope cannot register. ⛔ Until 2026-10-06 "no scope chosen" was `cancelled`, and
|
|
323
|
+
* register exited 0 having registered nothing.
|
|
321
324
|
*/
|
|
322
325
|
export async function settleFoundationScope(targetDir, { args, isPreview, flagScope, client }) {
|
|
323
326
|
let read
|
|
@@ -351,7 +354,7 @@ export async function settleFoundationScope(targetDir, { args, isPreview, flagSc
|
|
|
351
354
|
let source = flagScope ? '--scope' : null
|
|
352
355
|
if (!scope) {
|
|
353
356
|
scope = await deriveScopeFromLogin(client, args)
|
|
354
|
-
if (!scope) return
|
|
357
|
+
if (!scope) return null
|
|
355
358
|
source = 'login'
|
|
356
359
|
}
|
|
357
360
|
|
|
@@ -375,11 +378,35 @@ export async function settleFoundationScope(targetDir, { args, isPreview, flagSc
|
|
|
375
378
|
}
|
|
376
379
|
|
|
377
380
|
/**
|
|
378
|
-
*
|
|
379
|
-
*
|
|
381
|
+
* The scope a bare name registers under when `--scope` names none — as `@handle`, or null
|
|
382
|
+
* when none was chosen.
|
|
383
|
+
*
|
|
384
|
+
* ⭐ THE WORKSPACE THIS COMMAND WORKS IN *[Diego, 2026-10-06]* — an organization's handle,
|
|
385
|
+
* or your own for your personal workspace — said, not asked (`scopeOfWorkspace`). A team
|
|
386
|
+
* working in `@acme` registers `@acme/…`, which any of its members can release.
|
|
387
|
+
* ⛔ Until 2026-10-06 the default was your personal scope wherever you worked, so a team's
|
|
388
|
+
* foundation registered as `@jane/…` — releasable by Jane alone, and fixed only by
|
|
389
|
+
* renaming it into a new foundation.
|
|
390
|
+
*
|
|
391
|
+
* When the workspace names no scope — none is chosen, or it is a unit without a handle —
|
|
392
|
+
* the scope is derived from your orgs as before (`deriveScope`): your personal scope when
|
|
393
|
+
* you belong to none, else a pick at a terminal, your personal scope without one.
|
|
380
394
|
*/
|
|
381
395
|
async function deriveScopeFromLogin(client, args) {
|
|
382
396
|
const token = await client.token()
|
|
397
|
+
const ws = await resolveWorkspace({ client, args })
|
|
398
|
+
const personal = !ws.refused && ws.source !== 'offline' && ws.workspace === null
|
|
399
|
+
const accountHandle = personal ? (await client.fetchOrgs()).account_handle : null
|
|
400
|
+
const fromWorkspace = scopeOfWorkspace(ws, accountHandle)
|
|
401
|
+
if (fromWorkspace) {
|
|
402
|
+
const named = `${colors.bright}${fromWorkspace}${colors.reset}`
|
|
403
|
+
console.error(
|
|
404
|
+
personal
|
|
405
|
+
? `Registering under your personal scope ${named} — you work in your personal workspace (${SOURCE_LABEL[ws.source]}). For an org: --scope @org.`
|
|
406
|
+
: `Registering under ${named} — the workspace you work in (${SOURCE_LABEL[ws.source]}). For another scope: --scope @org.`
|
|
407
|
+
)
|
|
408
|
+
return fromWorkspace
|
|
409
|
+
}
|
|
383
410
|
const sess = await readRegistryAuth(client.origin)
|
|
384
411
|
const derived = await deriveScope({
|
|
385
412
|
apiBase: client.origin,
|
|
@@ -585,11 +612,13 @@ async function runRegister(args = []) {
|
|
|
585
612
|
: await resolveFoundationDir(args)
|
|
586
613
|
|
|
587
614
|
// Scope. ⭐ A FOUNDATION's is the one in its name (`name: '@acme/marketing'` in
|
|
588
|
-
// main.js), settled below before the build; a bare name takes --scope, else
|
|
589
|
-
//
|
|
590
|
-
// A SCHEMAS-ONLY package has no name to carry one:
|
|
591
|
-
// `uniweb.scope`, else (real submit only)
|
|
592
|
-
// spelling, `@acme` or `acme`, and from here on the one
|
|
615
|
+
// main.js), settled below before the build; a bare name takes --scope, else the
|
|
616
|
+
// workspace the command works in (`deriveScopeFromLogin`), and register writes it
|
|
617
|
+
// into the name (2026-09-22). A SCHEMAS-ONLY package has no name to carry one:
|
|
618
|
+
// --scope, else package.json `uniweb.scope`, else (real submit only) the workspace
|
|
619
|
+
// the command works in. Either spelling, `@acme` or `acme`, and from here on the one
|
|
620
|
+
// form: `@acme` (publishScope). ⛔ This said "derived from your orgs" / "from login
|
|
621
|
+
// membership" until 2026-10-06, read as the personal-scope default that was replaced.
|
|
593
622
|
const pkgScope = standalone ? readPkgScope(targetDir) : null
|
|
594
623
|
const givenScope = scopeFlag || pkgScope
|
|
595
624
|
let scope = publishScope(givenScope)
|
|
@@ -640,7 +669,6 @@ async function runRegister(args = []) {
|
|
|
640
669
|
client
|
|
641
670
|
})
|
|
642
671
|
if (!settled) return { exitCode: 2 }
|
|
643
|
-
if (settled.cancelled) return { exitCode: 0 }
|
|
644
672
|
scope = settled.scope
|
|
645
673
|
scopeSource = settled.source
|
|
646
674
|
// Build-if-stale (`foundationNeedsBuild`): a missing or stale dist/ gets
|
|
@@ -691,12 +719,12 @@ async function runRegister(args = []) {
|
|
|
691
719
|
}
|
|
692
720
|
}
|
|
693
721
|
|
|
694
|
-
// A schemas-only package with no scope, on a real submit →
|
|
695
|
-
//
|
|
722
|
+
// A schemas-only package with no scope, on a real submit → the workspace you work in,
|
|
723
|
+
// else derived from your orgs (`deriveScopeFromLogin`), and persisted to package.json.
|
|
696
724
|
// (A foundation's was settled before its build — `settleFoundationScope`.)
|
|
697
725
|
if (standalone && !scope && !isPreview) {
|
|
698
726
|
const derived = await deriveScopeFromLogin(client, args)
|
|
699
|
-
if (!derived) return { exitCode:
|
|
727
|
+
if (!derived) return { exitCode: 2 }
|
|
700
728
|
scope = derived
|
|
701
729
|
scopeSource = 'login'
|
|
702
730
|
try {
|
|
@@ -852,10 +880,27 @@ async function runRegister(args = []) {
|
|
|
852
880
|
`${colors.dim}Schema for this version is already registered — resuming code delivery.${colors.reset}`
|
|
853
881
|
)
|
|
854
882
|
} else {
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
883
|
+
// ⭐ NOT A MEMBER OF THE SCOPE IS SAID AS THAT *[Diego, 2026-10-06]*. The registry
|
|
884
|
+
// decides who may release into a scope — its account, or an org's members — and
|
|
885
|
+
// when the orgs read says you are neither, that is the refusal to name. ⛔ Until then
|
|
886
|
+
// a 403 here printed "log in again", the wrong advice for a scope that is not yours.
|
|
887
|
+
// Read to explain a refusal, never to gate one; a 401 is the credential itself.
|
|
888
|
+
const member =
|
|
889
|
+
res.status === 401 ? null : belongsToScope(scope, await client.fetchOrgs().catch(() => null))
|
|
890
|
+
if (member === false) {
|
|
891
|
+
const what = standalone
|
|
892
|
+
? `data schemas under ${scope}`
|
|
893
|
+
: doc.entities.find((e) => e.model === '@uniweb/foundation-schema')?.info?.name || `a foundation under ${scope}`
|
|
894
|
+
error(`You can't release ${what}: you're not a member of ${scope}.`)
|
|
895
|
+
log(
|
|
896
|
+
` ${colors.dim}A member of ${scope} can release it. (The registry answered HTTP ${res.status}.)${colors.reset}`
|
|
897
|
+
)
|
|
898
|
+
} else {
|
|
899
|
+
error(
|
|
900
|
+
`Registry rejected the submission: HTTP ${res.status} ${res.statusText}`
|
|
901
|
+
)
|
|
902
|
+
}
|
|
903
|
+
if (member !== false && (res.status === 401 || res.status === 403)) {
|
|
859
904
|
log(
|
|
860
905
|
` ${colors.dim}The registry didn't accept your credentials — it may use different ones than \`uniweb login\`.${colors.reset}`
|
|
861
906
|
)
|
package/src/commands/status.js
CHANGED
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Usage:
|
|
16
16
|
* uniweb status Sync identity + unpushed content + foundation ref (local)
|
|
17
|
-
* uniweb status --remote Also: draft-vs-live
|
|
18
|
-
*
|
|
17
|
+
* uniweb status --remote Also: whether the backend still holds the site, draft-vs-live,
|
|
18
|
+
* and a newer-registered-foundation check
|
|
19
|
+
* uniweb status --json One JSON line (adds a `remote` object under --remote, whose
|
|
20
|
+
* `site_state` is live / gone / unknown — `gone` only on the
|
|
21
|
+
* backend's own word that it holds no such site)
|
|
19
22
|
*
|
|
20
23
|
* Run from a site, or a workspace with one site.
|
|
21
24
|
*/
|
|
@@ -119,6 +122,9 @@ export async function status(args = []) {
|
|
|
119
122
|
// Remote signals — opt-in (`--remote`). May prompt for login. Degrades to null
|
|
120
123
|
// on 404 / any failure, so a backend without the endpoints just shows local.
|
|
121
124
|
let site = null
|
|
125
|
+
// Whether the backend holds the site this binding names — live / gone / unknown, never
|
|
126
|
+
// folded (`client.siteState`). Null when there is no binding to ask about.
|
|
127
|
+
let siteState = null
|
|
122
128
|
let fdnLatest = null
|
|
123
129
|
let foundationFresh = null // true/false when both digests are known; else null
|
|
124
130
|
let localFoundationVersion = null
|
|
@@ -134,7 +140,14 @@ export async function status(args = []) {
|
|
|
134
140
|
const ws = await resolveWorkspace({ client, args })
|
|
135
141
|
if (ws.refused) throw Object.assign(new Error(ws.reason), { status: 409 })
|
|
136
142
|
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
137
|
-
if (uuid)
|
|
143
|
+
if (uuid) {
|
|
144
|
+
siteState = await client.siteState(uuid)
|
|
145
|
+
if (siteState.state === 'live') {
|
|
146
|
+
site = siteState.site || null
|
|
147
|
+
// In another workspace: it exists, and the refusal says where — report it.
|
|
148
|
+
if (siteState.status === 409) remoteError = siteState.detail
|
|
149
|
+
}
|
|
150
|
+
}
|
|
138
151
|
// Foundation freshness: prefer the LOCAL foundation's scoped name (so a
|
|
139
152
|
// local-foundation site can be checked too); fall back to a scoped
|
|
140
153
|
// site.yml ref. The digest compare is read-only — it never builds, so it
|
|
@@ -153,6 +166,8 @@ export async function status(args = []) {
|
|
|
153
166
|
// backend does not work on this site from, or the deployment has another. Saying
|
|
154
167
|
// nothing would read as "fine".
|
|
155
168
|
if (err instanceof WorkspaceMismatchError || err?.status === 409) remoteError = err.message
|
|
169
|
+
// Not asked, or not answered: that is not knowing, never `gone`.
|
|
170
|
+
if (uuid && !siteState) siteState = { state: 'unknown', status: null, detail: err?.message || null }
|
|
156
171
|
}
|
|
157
172
|
}
|
|
158
173
|
|
|
@@ -168,6 +183,10 @@ export async function status(args = []) {
|
|
|
168
183
|
...(remote
|
|
169
184
|
? {
|
|
170
185
|
remote: {
|
|
186
|
+
// ⭐ live · gone · unknown — `gone` only on the backend's own word about
|
|
187
|
+
// this site; null when this directory names no site on this backend.
|
|
188
|
+
site_state: siteState ? siteState.state : null,
|
|
189
|
+
site_state_detail: siteState?.detail ?? null,
|
|
171
190
|
site,
|
|
172
191
|
foundation_latest: fdnLatest?.latest_version ?? null,
|
|
173
192
|
foundation_fresh: foundationFresh,
|
|
@@ -221,6 +240,16 @@ export async function status(args = []) {
|
|
|
221
240
|
// Remote signals
|
|
222
241
|
if (remote) {
|
|
223
242
|
if (remoteError) say.warn(remoteError)
|
|
243
|
+
if (siteState?.state === 'gone') {
|
|
244
|
+
say.warn(`The backend has no site ${uuid} — it was deleted there, or the backend was rebuilt.`)
|
|
245
|
+
say.dim(
|
|
246
|
+
`To push this as a new site: uniweb forget --backend ${probeBackend}, then uniweb push.`
|
|
247
|
+
)
|
|
248
|
+
} else if (siteState?.state === 'unknown') {
|
|
249
|
+
say.dim(
|
|
250
|
+
`Could not tell whether the backend holds this site${siteState.detail ? ` (${siteState.detail})` : ''}.`
|
|
251
|
+
)
|
|
252
|
+
}
|
|
224
253
|
if (site) {
|
|
225
254
|
if (site.draft_dirty) {
|
|
226
255
|
say.info(
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-06T02:16:00.292Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
6
|
"version": "0.6.10",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
]
|
|
44
44
|
},
|
|
45
45
|
"@uniweb/frame-bridge": {
|
|
46
|
-
"version": "0.3.
|
|
46
|
+
"version": "0.3.2",
|
|
47
47
|
"path": "framework/frame-bridge",
|
|
48
48
|
"deps": []
|
|
49
49
|
},
|
package/src/index.js
CHANGED
|
@@ -917,10 +917,12 @@ async function main() {
|
|
|
917
917
|
// Handle login command — the backend (username/password · paste a token ·
|
|
918
918
|
// --token <bearer>). ⭐ `--backend <url>` names it; without the flag it is the DEFAULT
|
|
919
919
|
// backend — UNIWEB_REGISTER_URL, else ~/.uniweb/config.json, else https://uniweb.app —
|
|
920
|
-
// and never the project's backend
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
//
|
|
920
|
+
// and never the project's backend *[Diego, 2026-09-21: "the default backend for login, if
|
|
921
|
+
// not specified, is uniweb.app"]*. ⭐ Except a WORKSPACE SWITCH — `--org` / `--personal`
|
|
922
|
+
// and no way of signing in — which acts on the backend you are logged in to *[Diego,
|
|
923
|
+
// 2026-10-06]* (`resolveLoginOrigin`). The backend logged in to becomes CURRENT, and
|
|
924
|
+
// every backend command goes there (only UNIWEB_REGISTER_URL outranks it) — which is why
|
|
925
|
+
// this is the one place a backend is chosen.
|
|
924
926
|
//
|
|
925
927
|
// ⛔ Until 2026-09-21 a bare login went to the backend of the project in the cwd, and
|
|
926
928
|
// asked when the machine knew several backends. Both are gone: the default is the
|
|
@@ -932,7 +934,7 @@ async function main() {
|
|
|
932
934
|
const { resolveLoginOrigin } = await import('./utils/config.js')
|
|
933
935
|
let apiBase
|
|
934
936
|
try {
|
|
935
|
-
apiBase = resolveLoginOrigin(readFlagValue(loginArgs, '--backend'))
|
|
937
|
+
apiBase = resolveLoginOrigin(readFlagValue(loginArgs, '--backend'), loginArgs)
|
|
936
938
|
} catch (err) {
|
|
937
939
|
console.error(
|
|
938
940
|
`\x1b[31m✗\x1b[0m ${err.message} — e.g. uniweb login --backend http://localhost:8080`
|
|
@@ -1729,8 +1731,8 @@ Auto-detects what you run it in:
|
|
|
1729
1731
|
|
|
1730
1732
|
A foundation's scope is part of its name — name: '@scope/<name>' in main.js. A scope
|
|
1731
1733
|
is a namespace: your personal one (@<your handle>, no org needed) or an org's. A bare
|
|
1732
|
-
name takes --scope, else
|
|
1733
|
-
register writes it into the name.
|
|
1734
|
+
name takes --scope, else the workspace you work in (an org's scope, or your personal
|
|
1735
|
+
one), and register writes it into the name.
|
|
1734
1736
|
|
|
1735
1737
|
Schema scopes:
|
|
1736
1738
|
@/name your own schema, in the foundation's scope (@/x -> @org/x)
|
|
@@ -1742,6 +1744,9 @@ ${colors.bright}Options:${colors.reset}
|
|
|
1742
1744
|
org's — and write it into the name (refused when the name has another
|
|
1743
1745
|
scope). A schemas-only package: publish under @scope; default:
|
|
1744
1746
|
package.json uniweb.scope
|
|
1747
|
+
--org @org Work in @org for this command: a bare name registers under it
|
|
1748
|
+
--personal Work in your personal workspace: a bare name registers under your
|
|
1749
|
+
personal scope
|
|
1745
1750
|
--dry-run Print the .uwx; submit nothing
|
|
1746
1751
|
-o, --output <f> Write the .uwx to a file; submit nothing
|
|
1747
1752
|
--non-interactive Fail with usage info instead of prompting
|
|
@@ -1798,11 +1803,12 @@ ${colors.bright}The workspace you work in.${colors.reset} A login works in ONE w
|
|
|
1798
1803
|
personal one, or an organization's — and every push, pull and publish works in it:
|
|
1799
1804
|
a site it creates is created there, and a site kept in another workspace is refused.
|
|
1800
1805
|
With no organization it is your personal workspace; with organizations you are asked,
|
|
1801
|
-
or name it. Already logged in, \`uniweb login --org @other\`
|
|
1802
|
-
in again.
|
|
1806
|
+
or name it. Already logged in, \`uniweb login --org @other\` (or \`--personal\`) switches
|
|
1807
|
+
the workspace on the backend you are logged in to, without logging in again.
|
|
1803
1808
|
|
|
1804
1809
|
${colors.bright}Options:${colors.reset}
|
|
1805
|
-
--backend <url> The backend to log in to
|
|
1810
|
+
--backend <url> The backend to log in to (without it: the default backend — or, for
|
|
1811
|
+
--org / --personal alone, the one you are logged in to)
|
|
1806
1812
|
--org @org Work in @org (an organization you belong to)
|
|
1807
1813
|
--personal Work in your personal workspace
|
|
1808
1814
|
--token <bearer> Seed + verify a session from a bearer token (non-interactive)
|
|
@@ -1813,6 +1819,8 @@ ${colors.bright}Options:${colors.reset}
|
|
|
1813
1819
|
In non-interactive mode (no TTY — an agent, a script), pass \`--token <bearer>\` with
|
|
1814
1820
|
\`--org @org\` or \`--personal\`, or set \`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`, or set
|
|
1815
1821
|
\`UNIWEB_TOKEN\` (per process, not stored) with \`UNIWEB_WORKSPACE=@org\` or \`personal\`.
|
|
1822
|
+
A login with organizations that names no workspace signs you in but exits 2;
|
|
1823
|
+
\`uniweb login --org @org\` (or \`--personal\`) finishes it without signing in again.
|
|
1816
1824
|
\`uniweb logout\` logs you out.
|
|
1817
1825
|
`,
|
|
1818
1826
|
refresh: `
|
|
@@ -2089,6 +2097,16 @@ ${colors.bright}Global Options:${colors.reset}
|
|
|
2089
2097
|
a script can aim and authenticate one process with UNIWEB_REGISTER_URL and
|
|
2090
2098
|
UNIWEB_TOKEN instead.
|
|
2091
2099
|
|
|
2100
|
+
${colors.bright}Push Options:${colors.reset}
|
|
2101
|
+
--dry-run Report what would be pushed; send nothing (-o <file> writes the .uwx)
|
|
2102
|
+
--no-release Send content against the already-released code; release nothing
|
|
2103
|
+
--bump Release above a newer registered foundation version
|
|
2104
|
+
--force Overwrite upstream changes (drop the staleness gate)
|
|
2105
|
+
--all Send every record (bypass the changed-only cache)
|
|
2106
|
+
--org @org Work in @org for this push, not your login's workspace
|
|
2107
|
+
--personal Work in your personal workspace for this push
|
|
2108
|
+
--no-validate Skip the content-conformance check (it stops a push)
|
|
2109
|
+
|
|
2092
2110
|
${colors.bright}Publish Options:${colors.reset}
|
|
2093
2111
|
--dry-run Resolve everything; release/sync/POST nothing
|
|
2094
2112
|
--yes Skip confirmations (CI); never block on a prompt
|
package/src/utils/config.js
CHANGED
|
@@ -107,32 +107,87 @@ export function getDefaultBackendOrigin() {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
|
-
* The
|
|
110
|
+
* The `uniweb login` that SWITCHES the workspace on `origin` — the caller appends `--org @x`
|
|
111
|
+
* or `--personal` — with `--backend` unless a switch without it reaches `origin`
|
|
112
|
+
* (`resolveLoginOrigin`): the backend you are logged in to, or, logged in nowhere, the
|
|
113
|
+
* default backend.
|
|
114
|
+
*
|
|
115
|
+
* ⚠️ *Measured 2026-10-06 against a local backend, before switches acted on the session:*
|
|
116
|
+
* `uniweb login --org @acme` went to https://uniweb.app and began a new login there — the
|
|
117
|
+
* reason a hint about any other backend names it.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} origin - the backend the hint is about
|
|
120
|
+
* @param {string} [prefix='uniweb'] - how the user runs the CLI (`getCliPrefix`)
|
|
121
|
+
* @returns {string} e.g. `uniweb login`, or `uniweb login --backend http://localhost:8080`
|
|
122
|
+
*/
|
|
123
|
+
export function loginCommand(origin, prefix = 'uniweb') {
|
|
124
|
+
const o = originOrNull(origin)
|
|
125
|
+
const reached = originOrNull(loggedInOrigin()) || getDefaultBackendOrigin()
|
|
126
|
+
return o && o !== reached ? `${prefix} login --backend ${o}` : `${prefix} login`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The flags with which `uniweb login` SIGNS IN — a method, or a credential. */
|
|
130
|
+
const SIGN_IN_FLAGS = ['--token', '--browser', '--password', '--token-paste']
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether a `uniweb login` is a WORKSPACE SWITCH: `--org` or `--personal`, and nothing that
|
|
134
|
+
* signs in (`SIGN_IN_FLAGS`).
|
|
135
|
+
*
|
|
136
|
+
* @param {string[]} [args]
|
|
137
|
+
* @returns {boolean}
|
|
138
|
+
*/
|
|
139
|
+
export function isWorkspaceSwitch(args = []) {
|
|
140
|
+
const names = args.map((a) => String(a).split('=')[0])
|
|
141
|
+
return (
|
|
142
|
+
(names.includes('--org') || names.includes('--personal')) &&
|
|
143
|
+
!SIGN_IN_FLAGS.some((f) => names.includes(f))
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The backend `uniweb login` logs in to: `--backend`; else, for a workspace switch, the
|
|
149
|
+
* backend you are logged in to; else the default backend.
|
|
150
|
+
*
|
|
151
|
+
* ⭐ A SWITCH ACTS ON YOUR SESSION *[Diego, 2026-10-06: "`uniweb login --org @x` or
|
|
152
|
+
* `--personal`, with no `--backend`, should switch to the workspace on the backend you're
|
|
153
|
+
* already logged in to so we can fullfil the promise 'switches without logging in again'"]*.
|
|
154
|
+
* Logged in nowhere, it is a first login, and goes where any first login goes. A login that
|
|
155
|
+
* signs in — a method or a credential named (`isWorkspaceSwitch`) — still goes to the
|
|
156
|
+
* default backend *[Diego, 2026-09-21: "the default backend for login, if not specified, is
|
|
157
|
+
* uniweb.app"]*. ⛔ Until 2026-10-06 a switch went to the default backend too, so on any
|
|
158
|
+
* other backend it began a new login there, logging you out of the one you were on.
|
|
111
159
|
*
|
|
112
160
|
* ⛔ A mistyped `--backend` is an error, never a fallback — it would log you in, and so
|
|
113
161
|
* point every command, somewhere you did not name.
|
|
114
162
|
*
|
|
115
163
|
* @param {string|null|undefined} flag - `readFlagValue`'s answer: undefined when the
|
|
116
164
|
* flag is absent, null when it was given with no value
|
|
165
|
+
* @param {string[]} [args] - the login's argv, to tell a switch from a sign-in
|
|
117
166
|
* @returns {string}
|
|
118
167
|
* @throws {Error} when --backend was given and is not a URL
|
|
119
168
|
*/
|
|
120
|
-
export function resolveLoginOrigin(flag) {
|
|
121
|
-
if (flag === undefined)
|
|
169
|
+
export function resolveLoginOrigin(flag, args = []) {
|
|
170
|
+
if (flag === undefined) {
|
|
171
|
+
const session = isWorkspaceSwitch(args) ? originOrNull(loggedInOrigin()) : null
|
|
172
|
+
return session || getDefaultBackendOrigin()
|
|
173
|
+
}
|
|
122
174
|
const origin = originOrNull(flag)
|
|
123
175
|
if (!origin) throw new Error(flag ? `Not a URL: ${flag}` : '--backend needs a URL')
|
|
124
176
|
return origin
|
|
125
177
|
}
|
|
126
178
|
|
|
127
179
|
/**
|
|
128
|
-
* **The backend a command talks to**
|
|
129
|
-
*
|
|
180
|
+
* **The backend a command talks to** — the base of every `/dev/*` route (`register`
|
|
181
|
+
* POSTs to {origin}/dev/registry/register, and so on).
|
|
130
182
|
*
|
|
131
183
|
* `UNIWEB_REGISTER_URL` > **the backend the user is logged in to** > the default
|
|
132
184
|
* (`getDefaultBackendOrigin`). ⭐ Nothing talks to a backend the user is not logged in
|
|
133
185
|
* to *[Diego, 2026-09-21]*: when this falls through to the default, the command's first
|
|
134
186
|
* request asks for that login — so the default is where the login goes, never a
|
|
135
|
-
* backend reached without one. `resolveBackendOrigin`
|
|
187
|
+
* backend reached without one. `resolveBackendOrigin` (backend/client.js) returns this
|
|
188
|
+
* unchanged: no command takes a `--backend`. ⛔ *Until 2026-10-05 this said "when no
|
|
189
|
+
* `--backend` is given" and that `resolveBackendOrigin` put `--backend` on top — read as
|
|
190
|
+
* a per-command flag outranking the login. The verbs lost that flag on 2026-09-21.*
|
|
136
191
|
*
|
|
137
192
|
* @returns {string}
|
|
138
193
|
*/
|
|
@@ -98,7 +98,8 @@ export function getRegistryAuthPath() {
|
|
|
98
98
|
* always logs you out of the one backend you may be logged in"]*. A login REPLACES the
|
|
99
99
|
* file — once it succeeds, so a cancelled or failed login leaves you where you were —
|
|
100
100
|
* and a logout deletes it. So "the backend you are logged in to" is exactly one thing
|
|
101
|
-
* or nothing, and logout needs no selector.
|
|
101
|
+
* or nothing, and logout needs no selector. ⚠️ One exception, and it says so: a login that
|
|
102
|
+
* signs in but cannot choose a workspace keeps the session and exits 2 (`finishLogin`).
|
|
102
103
|
*
|
|
103
104
|
* ⚖️ The v2 map is kept, holding one entry: the file then has one reader
|
|
104
105
|
* (utils/session-file.js), and it still understands what came before — a v1 flat
|
|
@@ -720,7 +721,12 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
|
|
|
720
721
|
if (wants || !session.workspace) {
|
|
721
722
|
const settled = await settleWorkspace(session, args)
|
|
722
723
|
if (settled.refused) {
|
|
723
|
-
|
|
724
|
+
// Say what the session works in now: unchanged, or — one from before workspaces —
|
|
725
|
+
// nothing yet (`finishLogin`).
|
|
726
|
+
const where = session.workspace
|
|
727
|
+
? `${await workspaceTail(session)} — unchanged`
|
|
728
|
+
: ' — with no workspace chosen yet'
|
|
729
|
+
console.error(`\x1b[32m✓\x1b[0m Logged in to ${key}${who ? ` as \x1b[1m${who}\x1b[0m` : ''}${where}.`)
|
|
724
730
|
console.error(`\x1b[31m✗\x1b[0m ${settled.refused}`)
|
|
725
731
|
process.exit(2)
|
|
726
732
|
}
|
|
@@ -839,15 +845,22 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
|
|
|
839
845
|
|
|
840
846
|
/**
|
|
841
847
|
* A login succeeded and its session is stored: choose its workspace, and say where the
|
|
842
|
-
* login works.
|
|
843
|
-
*
|
|
848
|
+
* login works.
|
|
849
|
+
*
|
|
850
|
+
* ⭐ WITHOUT A WORKSPACE THE SESSION STAYS, AND THE LOGIN SAYS SO *[Diego, 2026-10-06]*. No
|
|
851
|
+
* terminal, organizations, neither `--org` nor `--personal` — or a pick cancelled at a
|
|
852
|
+
* terminal: you are logged in, with no workspace, and the login exits 2 naming the one
|
|
853
|
+
* command that finishes it without signing in again. The credential was good, and a
|
|
854
|
+
* second sign-in can be a second browser round trip. ⚠️ So this is the one login that
|
|
855
|
+
* exits non-zero having replaced your session — the reason it says so in as many words.
|
|
844
856
|
*/
|
|
845
857
|
async function finishLogin(record, apiBase, args) {
|
|
846
858
|
const settled = await settleWorkspace(record, args)
|
|
847
859
|
const who = settled.record.username ? ` as \x1b[1m${settled.record.username}\x1b[0m` : ''
|
|
848
860
|
if (settled.refused) {
|
|
849
|
-
console.error(`\x1b[32m✓\x1b[0m Logged in${
|
|
861
|
+
console.error(`\x1b[32m✓\x1b[0m Logged in to ${normOrigin(apiBase)}${who} — with no workspace chosen yet.`)
|
|
850
862
|
console.error(`\x1b[31m✗\x1b[0m ${settled.refused}`)
|
|
863
|
+
console.error('\x1b[2m Until one is chosen, the commands that work on a site refuse.\x1b[0m')
|
|
851
864
|
process.exit(2)
|
|
852
865
|
}
|
|
853
866
|
console.error(`\x1b[32m✓\x1b[0m Logged in${who} (${apiBase})${await workspaceTail(settled.record)}.`)
|
|
@@ -81,6 +81,25 @@ export function publishScope(value) {
|
|
|
81
81
|
return handle ? `@${handle}` : null
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Whether the orgs read (`GET /dev/orgs`) says this account may release into `scope`:
|
|
86
|
+
* the account's own handle, or an org it belongs to. True or false; null when there is
|
|
87
|
+
* nothing to say (no scope, no read).
|
|
88
|
+
*
|
|
89
|
+
* ⛔ For EXPLAINING a refusal only, never to gate a release: the registry decides who
|
|
90
|
+
* may publish into a scope, and this knows only the memberships that read lists.
|
|
91
|
+
*
|
|
92
|
+
* @param {string|null} scope - `@acme`, `acme` or `@acme/…`
|
|
93
|
+
* @param {{ account_handle?: string|null, orgs?: Array<{ handle?: string }> }|null} envelope
|
|
94
|
+
* @returns {boolean|null}
|
|
95
|
+
*/
|
|
96
|
+
export function belongsToScope(scope, envelope) {
|
|
97
|
+
const h = bareHandle(scope)
|
|
98
|
+
if (!h || !envelope || typeof envelope !== 'object') return null
|
|
99
|
+
if (bareHandle(envelope.account_handle || '') === h) return true
|
|
100
|
+
return (Array.isArray(envelope.orgs) ? envelope.orgs : []).some((o) => bareHandle(o?.handle) === h)
|
|
101
|
+
}
|
|
102
|
+
|
|
84
103
|
/**
|
|
85
104
|
* Validate a handle's GRAMMAR client-side (reserved names are the server's
|
|
86
105
|
* call — a 409 carries the verdict). Returns an error string, or null.
|
|
@@ -157,15 +176,22 @@ export async function createOrg({ apiBase, token, handle }) {
|
|
|
157
176
|
}
|
|
158
177
|
|
|
159
178
|
/**
|
|
160
|
-
* The scope to register under, from the login, when none was named
|
|
161
|
-
*
|
|
162
|
-
* foundation's name, or a
|
|
179
|
+
* The scope to register under, from the login's orgs, when none was named and the
|
|
180
|
+
* workspace the command works in names none either — a bare handle, or null when none
|
|
181
|
+
* was chosen. Persists nothing; the caller records it (in the foundation's name, or a
|
|
182
|
+
* schemas-only package's `package.json`).
|
|
183
|
+
*
|
|
184
|
+
* ⚠️ The FALLBACK since 2026-10-06: a bare name first takes the workspace the command
|
|
185
|
+
* works in (`register.js::deriveScopeFromLogin`, `workspace.js::scopeOfWorkspace`). This
|
|
186
|
+
* answers only when no workspace is chosen — you belong to orgs and named none — or the
|
|
187
|
+
* one chosen is a unit without a handle.
|
|
163
188
|
*
|
|
164
189
|
* ⭐ A SCOPE IS A NAMESPACE (2026-09-23): your account's own, `@<handle>`, needs no org.
|
|
165
190
|
*
|
|
166
191
|
* no org → your personal scope — said, never asked, in CI too
|
|
167
192
|
* orgs → pick: your personal scope first, then each org;
|
|
168
|
-
* non-interactive,
|
|
193
|
+
* non-interactive, REFUSED — choose a workspace, or --scope
|
|
194
|
+
* (until 2026-10-06: your personal scope, said)
|
|
169
195
|
* no account handle → (a service account) its one org, said; several are asked,
|
|
170
196
|
* or refused in CI; none is a pointer to `--scope`
|
|
171
197
|
*
|
|
@@ -220,10 +246,20 @@ export async function deriveScope({
|
|
|
220
246
|
)
|
|
221
247
|
return null
|
|
222
248
|
}
|
|
249
|
+
// ⛔ NO DEFAULT FOR A MEMBER OF ORGANIZATIONS *[Diego, 2026-10-06: "someone in an org,
|
|
250
|
+
// with no workspace chosen must choose one. we should not default to personal"]*. Only
|
|
251
|
+
// reached when the workspace the command works in names no scope — none is chosen — so
|
|
252
|
+
// the choice is theirs. ⚠️ Until then this answered your personal scope, said: a team's
|
|
253
|
+
// foundation registered as one member's.
|
|
254
|
+
const { getCliPrefix } = await import('./interactive.js')
|
|
255
|
+
const { loginCommand } = await import('./config.js')
|
|
256
|
+
const first = orgs[0].handle
|
|
223
257
|
console.error(
|
|
224
|
-
|
|
258
|
+
`\x1b[31m✗\x1b[0m You belong to organizations (${orgs.map((o) => `@${o.handle}`).join(', ')}), so no scope is assumed.\n` +
|
|
259
|
+
` Choose the workspace you work in — ${loginCommand(apiBase, getCliPrefix())} --org @${first} (or --personal) — and the name takes its scope;\n` +
|
|
260
|
+
` or name the scope here: --scope @${first}, or --scope @${personal} for your own.`
|
|
225
261
|
)
|
|
226
|
-
return
|
|
262
|
+
return null
|
|
227
263
|
}
|
|
228
264
|
const prompts = (await import('prompts')).default
|
|
229
265
|
const { choice } = await prompts(
|
|
@@ -249,5 +285,6 @@ export async function deriveScope({
|
|
|
249
285
|
}
|
|
250
286
|
}
|
|
251
287
|
)
|
|
288
|
+
if (!choice) console.error('\x1b[31m✗\x1b[0m No scope chosen.')
|
|
252
289
|
return choice || null
|
|
253
290
|
}
|