uniweb 0.63.2 → 0.64.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.63.2",
3
+ "version": "0.64.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,17 +41,17 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
+ "@uniweb/runtime": "^0.27.4",
45
+ "@uniweb/schemas": "^0.4.0",
46
+ "@uniweb/core": "^0.29.8",
44
47
  "@uniweb/kit": "^0.19.4",
45
48
  "@uniweb/semantic-parser": "^1.4.1",
46
- "@uniweb/schemas": "^0.3.4",
47
- "@uniweb/content-writer": "^0.3.4",
48
- "@uniweb/core": "^0.29.8",
49
- "@uniweb/runtime": "^0.27.4"
49
+ "@uniweb/content-writer": "^0.3.4"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/content-reader": "^1.2.5",
53
52
  "@uniweb/semantic-parser": "^1.4.1",
54
- "@uniweb/build": "^0.59.0"
53
+ "@uniweb/content-reader": "^1.2.5",
54
+ "@uniweb/build": "^0.59.1"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@uniweb/build": {
@@ -2422,9 +2422,18 @@ component that never needed one.
2422
2422
  ### Reading
2423
2423
 
2424
2424
  ```jsx
2425
- const { status, records } = useRecords({ schema: '@/session' })
2425
+ const { status, records } = useRecords({ schema: '@/track' }) // summaries: record.brief, no items
2426
+ const { entity } = useEntity({ schema: '@/track', uuid: record.uuid })
2427
+ const sessions = entity?.hydrated?.items.filter((item) => item.section === 'sessions')
2426
2428
  ```
2427
2429
 
2430
+ ⛔ **A listed record is a summary, not the entity.** `record.brief` holds the brief
2431
+ section's fields (`record.brief.name`, not `record.name`), and a record carries **no
2432
+ items**. Read one entity for its items. Each item carries `section`, its section's name
2433
+ (the word a write takes), and `id`, which is what the writer takes to update, move or
2434
+ remove it. `scope: 'mine'` lists only the viewer's own records. Without it, the operator
2435
+ also gets everyone else's.
2436
+
2428
2437
  ⭐ **`absent` and an empty `ready` are different answers, and confusing them is the
2429
2438
  mistake to avoid.** `absent` = there is no live source (no `api` service, or nobody signed
2430
2439
  in) → render the site's authored content. `ready` with `records: []` = the service
@@ -2455,15 +2464,19 @@ await writer.remove(itemId)
2455
2464
 
2456
2465
  ### ⛔ Permissions are the SERVER'S, and your UI is only a courtesy
2457
2466
 
2458
- Gate your controls on what the viewer may do — `viewer.actingUnitId`, `viewer.roles` —
2459
- but **never rely on that for safety**. A foundation runs with exactly the viewer's
2460
- own authority, so hiding a button hides a button. The rule belongs in the data
2461
- schema, where the store enforces it:
2467
+ Gate your controls on what the viewer may do — `viewer.roles` (the site's operator
2468
+ holds `system_admin`), or a record's own `can_edit` from a single-entity read — but
2469
+ **never rely on that for safety**. A foundation runs with exactly the viewer's own
2470
+ authority, so hiding a button hides a button.
2462
2471
 
2463
- ```yaml
2464
- # foundation/schemas/session.yml
2465
- creatable_by: unit_members # only members of the owning unit may create these
2472
+ What protects content is the **entry**: a member writes their own entries and not
2473
+ another's, and the store enforces it. **Models are open** — anyone signed in may
2474
+ create entries of any Model, so a data schema does not restrict who creates
2475
+ (`creatable_by` is retired and refused). What a schema can protect is how an item
2476
+ changes:
2466
2477
 
2478
+ ```yaml
2479
+ # foundation/schemas/attendance.yml
2467
2480
  sections:
2468
2481
  checkins:
2469
2482
  many: true
@@ -2489,8 +2502,8 @@ export default createMockBackend({ seed }).fetch
2489
2502
  ```
2490
2503
 
2491
2504
  `uniweb dev` mounts it at your `api:` address, same-origin, so cookies and your
2492
- site's configuration behave exactly as they will in production. It **enforces**
2493
- `creatable_by` and `append_only`, so a permission you are relying on fails on your
2505
+ site's configuration behave exactly as they will in production. It **enforces** who
2506
+ may edit an entry and `append_only`, so a permission you are relying on fails on your
2494
2507
  machine rather than in front of a user. State is in memory; restart to reset.
2495
2508
 
2496
2509
  `uniweb create my-event --template conference` is a worked example of all of this.
@@ -2515,9 +2528,9 @@ uniweb add ci --target foundation # Publish a foundation for free at permanent v
2515
2528
  uniweb push / pull / clone / status # Git-style content sync with the Uniweb backend
2516
2529
  uniweb refresh / sync # Catch up (git + backend, never pushes) / catch up, then push
2517
2530
  uniweb push --org @acme # First push/publish of a site: who owns it (see below)
2518
- uniweb register [--scope @org] # Register a foundation + its data schemas to the registry
2531
+ uniweb register [--scope @scope] # Register a foundation + its data schemas to the registry
2519
2532
  uniweb login / logout # One backend at a time: log in (--backend <url>) or out
2520
- uniweb org list / create <handle> # Publish orgs you belong to — the @org in a scoped ref
2533
+ uniweb org list / create <handle> # Your personal scope, and the orgs you belong to
2521
2534
  uniweb content export [dir] # Package a site (or a built foundation's schema) as .uwx
2522
2535
 
2523
2536
  uniweb rename <foundation|site|extension> <old> <new> # Rename across the whole workspace
@@ -2634,7 +2647,7 @@ Platform-specific configuration that doesn't belong in npm-standard fields. All
2634
2647
  |---|---|---|---|
2635
2648
  | `runtimePolicy` | `dist/runtime-pin.json` | unset | Declares how far past the recorded runtime version a host may move a site. |
2636
2649
 
2637
- **The foundation's name is not here — it is `name` in `main.js`** (else `package.json`'s `name`), **and so is the org it registers under: the scope is part of the name**, `name: '@acme/marketing'`. A name with no scope has not been registered yet: the first `uniweb register` takes `--scope @org` (else one of your orgs) and writes it into the name, and a `--scope` naming another org 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`.)
2650
+ **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 your personal scope (asking only if you belong to orgs), 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`.)
2638
2651
 
2639
2652
  **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.
2640
2653
 
@@ -569,7 +569,7 @@ export class BackendClient {
569
569
 
570
570
  // ── Orgs ──────────────────────────────────────────────────────────────────────
571
571
 
572
- /** GET /dev/orgs → { account_handle, personal_org_exists, orgs[] }. */
572
+ /** GET /dev/orgs → { account_handle, orgs[] }. */
573
573
  async fetchOrgs() {
574
574
  return fetchOrgsImpl({ apiBase: this.origin, token: await this.token() })
575
575
  }
@@ -787,10 +787,10 @@ export const EXPLICIT_OWNER = new Set(['flag', 'personal', 'picked'])
787
787
  * they never named.
788
788
  *
789
789
  * `register` resolves a foundation's scope in the same order — recorded (the scope
790
- * in its name, `@acme/marketing` in main.js), else explicit (`--scope`), else asked
791
- * (`deriveScope`) — with one difference: non-interactive, it PICKS the one org you
792
- * belong to, else your personal org, else the primary, and says which. This refuses
793
- * instead, because a site's owner is the one-shot decision above. ⭐ And the two are
790
+ * in its name, `@acme/marketing` in main.js), else explicit (`--scope`), else derived
791
+ * (`deriveScope`) — with one difference: non-interactive, it takes your personal
792
+ * scope and says so. This refuses instead, because a site's owner is the one-shot
793
+ * decision above, and a scope is only a namespace (2026-09-23). ⭐ And the two are
794
794
  * separate decisions: a foundation may be registered under any org its author
795
795
  * belongs to, whoever owns the site (2026-09-22) — a site's `@/x` refs take the
796
796
  * FOUNDATION's scope, never this one. ⚠️ Until 2026-09-21 this said
@@ -861,10 +861,9 @@ export async function resolveSiteOrgForCreate({
861
861
  }
862
862
  }
863
863
 
864
- // Interactive: offer the real choice. Deliberately NOT `deriveScope` — that one
865
- // serves the foundation lane, where every answer is an org and 0-orgs means
866
- // "claim your personal org". Here "personal" must stay reachable as *no org*,
867
- // and reusing deriveScope would quietly turn it into an org creation.
864
+ // Interactive: offer the real choice. Deliberately NOT `deriveScope` — that one picks
865
+ // a NAMESPACE for a foundation, where `@<your handle>` is your personal scope. Here
866
+ // the answer is an OWNER: your personal workspace (named by naming none) or an org.
868
867
  const { fetchOrgs, createOrg, validateHandle, bareHandle } =
869
868
  await import('../utils/registry-orgs.js')
870
869
  let envelope
@@ -1,11 +1,14 @@
1
1
  /**
2
- * uniweb org — manage publish orgs on the new backend.
2
+ * uniweb org — the orgs you belong to, on the backend you are logged in to.
3
3
  *
4
- * uniweb org list List the orgs you're a member of.
4
+ * uniweb org list Your personal scope, and the orgs you're a member of.
5
5
  * uniweb org create <handle> Create an org; you become a member.
6
6
  *
7
- * Auth: the new-backend session (`uniweb login`) / UNIWEB_TOKEN. Distinct from
8
- * the legacy platform (publish/deploy); this talks to the registry backend.
7
+ * ⭐ You need no org to register a foundation: `@<your handle>` is your personal scope
8
+ * (2026-09-23). An org is for a scope — and sites — you share, and it needs a handle
9
+ * of its own: a backend refuses one named after an account, yours included.
10
+ *
11
+ * Auth: the session of the backend you are logged in to (`uniweb login`) / UNIWEB_TOKEN.
9
12
  */
10
13
 
11
14
  import { BackendClient } from '../backend/client.js'
@@ -26,10 +29,17 @@ export async function org(args = []) {
26
29
 
27
30
  if (sub === 'list') {
28
31
  const client = new BackendClient({ args, command: 'Listing orgs' })
29
- const { orgs } = await client.fetchOrgs()
32
+ const { account_handle: mine, orgs: all } = await client.fetchOrgs()
33
+ // An org named after you — made before 2026-09-23 — is your personal scope's `@`.
34
+ const orgs = all.filter((u) => u.handle !== mine)
35
+ if (mine) {
36
+ console.log(
37
+ `Your personal scope: ${colors.bright}@${mine}${colors.reset}${colors.dim} — register under it with no org${colors.reset}`
38
+ )
39
+ }
30
40
  if (!orgs.length) {
31
41
  console.log(
32
- 'You have no orgs yet. Create one: uniweb org create <handle>'
42
+ `You belong to no org. To share a scope and sites with others: uniweb org create <handle>`
33
43
  )
34
44
  return { exitCode: 0 }
35
45
  }
@@ -55,12 +65,20 @@ export async function org(args = []) {
55
65
  }
56
66
  const client = new BackendClient({ args, command: 'Creating an org' })
57
67
  try {
68
+ // Named after you, it is your personal scope already — and a backend refuses it.
69
+ const { account_handle: mine } = await client.fetchOrgs()
70
+ if (mine && handle === mine) {
71
+ error(
72
+ `@${handle} is your personal scope already — you can register under it with no org. An org needs a handle of its own.`
73
+ )
74
+ return { exitCode: 2 }
75
+ }
58
76
  const org = await client.createOrg(handle)
59
77
  success(
60
78
  `Created ${colors.bright}@${org.handle}${colors.reset} — you're a member${org.is_primary ? ' (primary)' : ''}.`
61
79
  )
62
80
  console.log(
63
- `${colors.dim}Publish under it: uniweb register --scope @${org.handle}${colors.reset}`
81
+ `${colors.dim}Register under it: uniweb register --scope @${org.handle}${colors.reset}`
64
82
  )
65
83
  return { exitCode: 0 }
66
84
  } catch (err) {
@@ -70,7 +88,7 @@ export async function org(args = []) {
70
88
  }
71
89
 
72
90
  console.log('uniweb org <command>')
73
- console.log(' list List orgs you belong to')
91
+ console.log(' list Your personal scope, and the orgs you belong to')
74
92
  console.log(' create <handle> Create an org (you become a member)')
75
93
  return { exitCode: sub ? 2 : 0 }
76
94
  }
@@ -376,8 +376,8 @@ export async function settleFoundationScope(targetDir, { args, isPreview, flagSc
376
376
  }
377
377
 
378
378
  /**
379
- * A publish scope derived from the login's org memberships (list → 1 use / 0 create /
380
- * N pick), as `@handle` — or null when none was chosen.
379
+ * A scope derived from the login (`deriveScope`): your personal scope when you belong
380
+ * to no org, else a pick — as `@handle`, or null when none was chosen.
381
381
  */
382
382
  async function deriveScopeFromLogin(client, args) {
383
383
  const token = await client.token()
@@ -692,8 +692,8 @@ async function runRegister(args = []) {
692
692
  }
693
693
  }
694
694
 
695
- // A schemas-only package with no scope, on a real submit → derive it from login
696
- // membership (list → 1 use / 0 create / N pick) and persist it to package.json.
695
+ // A schemas-only package with no scope, on a real submit → derive it from the login
696
+ // (your personal scope, or a pick among it and your orgs) and persist it to package.json.
697
697
  // (A foundation's was settled before its build — `settleFoundationScope`.)
698
698
  if (standalone && !scope && !isPreview) {
699
699
  const derived = await deriveScopeFromLogin(client, args)
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-23T15:39:38.377Z",
3
+ "generatedAt": "2026-09-23T16:44:03.282Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.5.1",
6
+ "version": "0.6.1",
7
7
  "path": "framework/api",
8
8
  "deps": [
9
9
  "@uniweb/core"
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.59.0",
13
+ "version": "0.59.1",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -96,7 +96,7 @@
96
96
  "deps": []
97
97
  },
98
98
  "@uniweb/schemas": {
99
- "version": "0.3.4",
99
+ "version": "0.4.0",
100
100
  "path": "framework/schemas",
101
101
  "deps": []
102
102
  },
@@ -116,7 +116,7 @@
116
116
  "deps": []
117
117
  },
118
118
  "@uniweb/templates": {
119
- "version": "0.16.1",
119
+ "version": "0.16.3",
120
120
  "path": "framework/templates",
121
121
  "deps": []
122
122
  },
package/src/index.js CHANGED
@@ -1726,8 +1726,10 @@ Auto-detects what you run it in:
1726
1726
  • a schemas-only pkg just its data schemas, no foundation — e.g. @uniweb/schemas,
1727
1727
  any @org/schemas package, or a bare schemas/*.yml folder
1728
1728
 
1729
- A foundation's scope is part of its name — name: '@org/<name>' in main.js. A bare
1730
- name takes --scope (or one of your orgs), and register writes it into the name.
1729
+ A foundation's scope is part of its name — name: '@scope/<name>' in main.js. A scope
1730
+ is a namespace: your personal one (@<your handle>, no org needed) or an org's. A bare
1731
+ name takes --scope, else your personal scope (or a pick, if you belong to orgs), and
1732
+ register writes it into the name.
1731
1733
 
1732
1734
  Schema scopes:
1733
1735
  @/name your own schema, in the foundation's scope (@/x -> @org/x)
@@ -1735,9 +1737,10 @@ Schema scopes:
1735
1737
  @org/name another org's published schema, referenced by name
1736
1738
 
1737
1739
  ${colors.bright}Options:${colors.reset}
1738
- --scope @org A foundation with a bare name: register under @org, and write it into
1739
- the name (refused when the name has another scope). A schemas-only
1740
- package: publish under @org; default: package.json uniweb.scope
1740
+ --scope @scope A foundation with a bare name: register under @scope — yours or an
1741
+ org's — and write it into the name (refused when the name has another
1742
+ scope). A schemas-only package: publish under @scope; default:
1743
+ package.json uniweb.scope
1741
1744
  --dry-run Print the .uwx; submit nothing
1742
1745
  -o, --output <f> Write the .uwx to a file; submit nothing
1743
1746
  --non-interactive Fail with usage info instead of prompting
@@ -26,17 +26,19 @@
26
26
  *
27
27
  * GET /dev/orgs → {
28
28
  * account_handle: string|null, // the caller's account handle
29
- * personal_org_exists: bool, // does @<account_handle> exist as an org
30
29
  * orgs: [{ handle, is_primary }] // memberships, primary first;
31
30
  * } // handle-less units filtered server-side
32
31
  *
33
32
  * POST /dev/orgs { handle } → { handle, uuid, is_primary }
34
33
  *
35
- * The PERSONAL org needs no flag: handles live in ONE global namespace across
36
- * accounts + orgs and only the owner can create the org matching their account
37
- * handle — so `org.handle === account_handle` is a sound derivation, and the
38
- * lazy personal claim is just a create. Org creation requires NO second factor
39
- * on any lane (the 2FA gate lives at escalation points, not here).
34
+ * ⭐ A SCOPE IS A NAMESPACE, NOT AN ORG (2026-09-23). `@<account handle>` is the
35
+ * account's own scope and needs no org; an org's scope is published into by its
36
+ * members. Handles live in ONE global namespace across accounts and orgs, so an org
37
+ * needs a handle of its own — a backend refuses an org named after an account, its
38
+ * owner's included. ⛔ Until 2026-09-23 the CLI created a "personal org" `@jane` for
39
+ * account `jane` to have a scope at all; `personal_org_exists` served that and is no
40
+ * longer read. Org creation requires NO second factor on any lane (the 2FA gate lives
41
+ * at escalation points, not here).
40
42
  *
41
43
  * Failure shapes (branch on STATUS; details are human display, not contract):
42
44
  * 422 = handle grammar; 409 = taken / reserved / belongs to another account
@@ -95,7 +97,7 @@ export function validateHandle(handle) {
95
97
  /**
96
98
  * The picker read: memberships + the caller's account handle + whether the
97
99
  * personal org already exists.
98
- * @returns {Promise<{account_handle: string|null, personal_org_exists: boolean, orgs: Array<{handle: string, is_primary: boolean}>}>}
100
+ * @returns {Promise<{account_handle: string|null, orgs: Array<{handle: string, is_primary: boolean}>}>}
99
101
  */
100
102
  export async function fetchOrgs({ apiBase, token }) {
101
103
  const res = await fetch(`${apiBase.replace(/\/$/, '')}${ORGS_PATH}`, {
@@ -108,7 +110,6 @@ export async function fetchOrgs({ apiBase, token }) {
108
110
  const data = await res.json().catch(() => null)
109
111
  return {
110
112
  account_handle: data?.account_handle ?? null,
111
- personal_org_exists: data?.personal_org_exists === true,
112
113
  orgs: Array.isArray(data?.orgs) ? data.orgs : []
113
114
  }
114
115
  }
@@ -119,8 +120,8 @@ export async function listOrgs(opts) {
119
120
  }
120
121
 
121
122
  /**
122
- * Create an org (the lazy personal claim is exactly this create — the
123
- * owner-exception admits `handle === account_handle` for the owner).
123
+ * Create an org. Its handle must be its own: a backend refuses an account's handle,
124
+ * your own included — your personal scope needs no org.
124
125
  * @returns {Promise<{handle: string, uuid?: string, is_primary?: boolean}>}
125
126
  */
126
127
  export async function createOrg({ apiBase, token, handle }) {
@@ -156,11 +157,23 @@ export async function createOrg({ apiBase, token, handle }) {
156
157
  }
157
158
 
158
159
  /**
159
- * Derive the publish scope from login membership when none was supplied:
160
- * ≥1 org → confirm (1) or pick (N), personal org labeled + first
161
- * 0 orgs → the first-publish picker (personal default, lazily claimed)
162
- * Returns the chosen bare handle (no `@`), or null if cancelled. Persists
163
- * nothing — the caller records it. Bails in non-interactive mode.
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`).
163
+ *
164
+ * ⭐ A SCOPE IS A NAMESPACE (2026-09-23): your account's own, `@<handle>`, needs no org.
165
+ *
166
+ * no org → your personal scope — said, never asked, in CI too
167
+ * orgs → pick: your personal scope first, then each org;
168
+ * non-interactive, your personal scope, said
169
+ * no account handle → (a service account) its one org, said; several are asked,
170
+ * or refused in CI; none is a pointer to `--scope`
171
+ *
172
+ * ⛔ Until 2026-09-23 a login with no org was offered a "personal org" `@jane`, created
173
+ * on the spot — a scope was taken to need an org — and refused in CI.
174
+ *
175
+ * An org named after the account — a personal org made before then — is the same
176
+ * `@jane`, and is listed once, as your personal scope.
164
177
  *
165
178
  * @param {Object} p
166
179
  * @param {string} p.apiBase
@@ -177,161 +190,56 @@ export async function deriveScope({
177
190
  args = []
178
191
  }) {
179
192
  const envelope = await fetchOrgs({ apiBase, token })
180
- const { orgs } = envelope
181
- const personal =
182
- envelope.account_handle ||
183
- (accountHandle ? bareHandle(accountHandle) : null)
184
- const personalOrgExists = envelope.personal_org_exists
193
+ const handle = bareHandle(envelope.account_handle || accountHandle || '')
194
+ const personal = handle && !validateHandle(handle) ? handle : null
195
+ const orgs = envelope.orgs.filter((o) => o.handle !== personal)
185
196
  const { isNonInteractive } = await import('./interactive.js')
186
197
  const nonInteractive = isNonInteractive(args)
187
- const isPersonal = (h) => personal && h === personal
198
+ const bold = (h) => `\x1b[1m@${h}\x1b[0m`
188
199
 
189
- if (orgs.length === 1) {
190
- const h = orgs[0].handle
191
- const label = isPersonal(h) ? `your personal org @${h}` : `your org @${h}`
192
- if (nonInteractive) {
193
- console.error(
194
- `Publishing under ${label.replace(`@${h}`, `\x1b[1m@${h}\x1b[0m`)}.`
195
- )
196
- return h
200
+ if (personal && !orgs.length) {
201
+ console.error(`Registering under your personal scope ${bold(personal)}.`)
202
+ return personal
203
+ }
204
+ if (!personal && orgs.length <= 1) {
205
+ if (orgs.length === 1) {
206
+ console.error(`Registering under your org ${bold(orgs[0].handle)}.`)
207
+ return orgs[0].handle
197
208
  }
198
- const prompts = (await import('prompts')).default
199
- const { ok } = await prompts(
200
- {
201
- type: 'confirm',
202
- name: 'ok',
203
- message: `Publish under ${label}?`,
204
- initial: true
205
- },
206
- {
207
- onCancel: () => {
208
- console.error('\nCancelled.')
209
- process.exit(0)
210
- }
211
- }
209
+ console.error(
210
+ "\x1b[31m✗\x1b[0m This account has no handle (service accounts don't get one), so it has no personal scope, and it belongs to no org.\n" +
211
+ ' Pass --scope @org, or create one with `uniweb org create <handle>`.'
212
212
  )
213
- if (!ok) {
214
- console.error(
215
- 'Pass --scope @org, or create another with `uniweb org create <handle>`.'
216
- )
217
- return null
218
- }
219
- return h
213
+ return null
220
214
  }
221
215
 
222
- if (orgs.length > 1) {
223
- // Personal org first; the rest in server order (primary-first).
224
- const ordered = [...orgs].sort(
225
- (a, b) => (isPersonal(b.handle) ? 1 : 0) - (isPersonal(a.handle) ? 1 : 0)
226
- )
227
- if (nonInteractive) {
228
- const pick =
229
- ordered.find((u) => isPersonal(u.handle)) ||
230
- orgs.find((u) => u.is_primary) ||
231
- orgs[0]
216
+ if (nonInteractive) {
217
+ if (!personal) {
232
218
  console.error(
233
- `Multiple orgs; using \x1b[1m@${pick.handle}\x1b[0m (non-interactive).`
219
+ '\x1b[31m✗\x1b[0m This account belongs to several orgs and has no personal scope. Pass --scope @org.'
234
220
  )
235
- return pick.handle
221
+ return null
236
222
  }
237
- const prompts = (await import('prompts')).default
238
- const { choice } = await prompts(
239
- {
240
- type: 'select',
241
- name: 'choice',
242
- message: 'Publish under which org?',
243
- choices: ordered.map((u) => ({
244
- title: `@${u.handle}${isPersonal(u.handle) ? ' — your personal org' : u.is_primary ? ' (primary)' : ''}`,
245
- value: u.handle
246
- })),
247
- initial: 0
248
- },
249
- {
250
- onCancel: () => {
251
- console.error('\nCancelled.')
252
- process.exit(0)
253
- }
254
- }
255
- )
256
- return choice || null
257
- }
258
-
259
- // 0 orgs → the first-publish picker.
260
- if (nonInteractive) {
261
223
  console.error(
262
- '\x1b[31m✗\x1b[0m You have no org to publish under. Create one with `uniweb org create <handle>`, or pass --scope @org.'
224
+ `Registering under your personal scope ${bold(personal)} (non-interactive). Pass --scope @org for an org.`
263
225
  )
264
- process.exit(1)
226
+ return personal
265
227
  }
266
- return offerCreateOrg({
267
- apiBase,
268
- token,
269
- accountHandle: personal,
270
- personalOrgExists
271
- })
272
- }
273
-
274
- /**
275
- * Cold-start (0 orgs) — the first-publish org choice. Every account handle
276
- * is a reserved, ready-to-go org handle (globally unique across accounts +
277
- * orgs; only the owner can claim it), so the default is one keystroke:
278
- *
279
- * ? Publish under which org?
280
- * ❯ @jane — your personal org (created on first publish)
281
- * A new organization…
282
- *
283
- * The personal org is materialized LAZILY here — at first publish, never at
284
- * signup. Two guards:
285
- * - no account handle (Service/System accounts — real signups always mint
286
- * one) → a crisp pointer instead of a prompt;
287
- * - the personal org exists but the caller is no longer a member
288
- * (created-then-left) → the lazy claim would 409; don't offer it.
289
- * Returns the chosen bare handle, or null if cancelled/failed.
290
- */
291
- export async function offerCreateOrg({
292
- apiBase,
293
- token,
294
- accountHandle = null,
295
- personalOrgExists = false
296
- }) {
297
228
  const prompts = (await import('prompts')).default
298
- const personal =
299
- accountHandle && !validateHandle(accountHandle)
300
- ? bareHandle(accountHandle)
301
- : null
302
-
303
- if (!personal) {
304
- console.error(
305
- "\x1b[31m✗\x1b[0m This account has no handle (service accounts don't get one), so there is no ready-to-go org.\n" +
306
- ' Log in with a personal account or set a handle in the app — or pass --scope @org / `uniweb org create <handle>`.'
307
- )
308
- return null
309
- }
310
-
311
- const canClaimPersonal = !personalOrgExists
312
- const choices = [
313
- ...(canClaimPersonal
314
- ? [
315
- {
316
- title: `@${personal} — your personal org (created on first publish)`,
317
- value: personal
318
- }
319
- ]
320
- : []),
321
- { title: 'A new organization…', value: ':new' }
322
- ]
323
- if (!canClaimPersonal) {
324
- console.error(
325
- `\x1b[2m@${personal} exists but you're not a member of it — ask its admin, or create another org.\x1b[0m`
326
- )
327
- }
328
-
329
229
  const { choice } = await prompts(
330
230
  {
331
231
  type: 'select',
332
232
  name: 'choice',
333
- message: 'Publish under which org?',
334
- choices,
233
+ message: 'Register under which scope?',
234
+ choices: [
235
+ ...(personal
236
+ ? [{ title: `@${personal} — your personal scope`, value: personal }]
237
+ : []),
238
+ ...orgs.map((o) => ({
239
+ title: `@${o.handle}${o.is_primary ? ' (primary org)' : ' (org)'}`,
240
+ value: o.handle
241
+ }))
242
+ ],
335
243
  initial: 0
336
244
  },
337
245
  {
@@ -341,36 +249,5 @@ export async function offerCreateOrg({
341
249
  }
342
250
  }
343
251
  )
344
- if (!choice) return null
345
-
346
- let handle = choice
347
- if (choice === ':new') {
348
- const answer = await prompts(
349
- {
350
- type: 'text',
351
- name: 'handle',
352
- message: 'Org handle (e.g. acme):',
353
- validate: (v) => validateHandle(v) || true
354
- },
355
- {
356
- onCancel: () => {
357
- console.error('\nCancelled.')
358
- process.exit(0)
359
- }
360
- }
361
- )
362
- if (!answer.handle) return null
363
- handle = answer.handle
364
- }
365
-
366
- try {
367
- const org = await createOrg({ apiBase, token, handle })
368
- console.error(
369
- `\x1b[32m✓\x1b[0m Created \x1b[1m@${org.handle}\x1b[0m — you're a member${org.is_primary ? ' (primary)' : ''}.`
370
- )
371
- return org.handle
372
- } catch (err) {
373
- console.error(`\x1b[31m✗\x1b[0m ${err.message}`)
374
- return null
375
- }
252
+ return choice || null
376
253
  }