uniweb 0.82.2 → 0.84.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/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 or the current session *[Diego, 2026-09-21: "the
921
- // default backend for login, if not specified, is uniweb.app"]*. The backend logged in
922
- // to becomes CURRENT, and every backend command goes there (only UNIWEB_REGISTER_URL
923
- // outranks it) — which is why this is the one place a backend is chosen.
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 your personal scope (or a pick, if you belong to orgs), and
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\` switches without logging
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
@@ -107,32 +107,87 @@ export function getDefaultBackendOrigin() {
107
107
  }
108
108
 
109
109
  /**
110
- * The backend `uniweb login` logs in to: `--backend`, else the default backend.
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) return getDefaultBackendOrigin()
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** when no `--backend` is given — the base of every
129
- * `/dev/*` route (`register` POSTs to {origin}/dev/registry/register, and so on).
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` puts `--backend` on top.
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
- console.error(`\x1b[32m✓\x1b[0m Logged in to ${key}${who ? ` as \x1b[1m${who}\x1b[0m` : ''}.`)
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. Without a workspace (no terminal, organizations, no flag) the session
843
- * stays, the reason is printed, and the login exits 2 — every command would refuse.
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${who} (${apiBase}).`)
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 — a bare handle,
161
- * or null when none was chosen. Persists nothing; the caller records it (in the
162
- * foundation's name, or a schemas-only package's `package.json`).
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, your personal scope, said
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
- `Registering under your personal scope ${bold(personal)} (non-interactive). Pass --scope @org for an org.`
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 personal
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
  }