uniweb 0.59.2 → 0.60.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
@@ -805,6 +805,15 @@ async function main() {
805
805
  process.exit(result?.exitCode ?? 0)
806
806
  }
807
807
 
808
+ // Handle forget — remove one backend's local records (sync.json, cache, deploy
809
+ // records), or with --all everything a copied project inherited. Local only: every
810
+ // site still exists on its backend. Needs @uniweb/build (the store).
811
+ if (command === 'forget') {
812
+ const { forget } = await importProjectCommand('./commands/forget.js')
813
+ const result = await forget(args.slice(1))
814
+ process.exit(result?.exitCode ?? 0)
815
+ }
816
+
808
817
  // Handle status command (dynamic import — offline emit via @uniweb/build)
809
818
  if (command === 'status') {
810
819
  const { status } = await importProjectCommand('./commands/status.js')
@@ -879,70 +888,59 @@ async function main() {
879
888
  }
880
889
 
881
890
  // Handle login command — the backend (username/password · paste a token ·
882
- // --token <bearer>). Origin from --backend > UNIWEB_REGISTER_URL >
883
- // default, the SAME resolver register/push/pull/deploy use, so a session and
884
- // the commands that reuse it always target one backend.
891
+ // --token <bearer>). ⭐ `--backend <url>` names it; without the flag it is the DEFAULT
892
+ // backend — UNIWEB_REGISTER_URL, else ~/.uniweb/config.json, else https://uniweb.app —
893
+ // and never the project's backend or the current session *[Diego, 2026-09-21: "the
894
+ // default backend for login, if not specified, is uniweb.app"]*. The backend logged in
895
+ // to becomes CURRENT, and every backend command goes there (only UNIWEB_REGISTER_URL
896
+ // outranks it) — which is why this is the one place a backend is chosen.
897
+ //
898
+ // ⛔ Until 2026-09-21 a bare login went to the backend of the project in the cwd, and
899
+ // asked when the machine knew several backends. Both are gone: the default is the
900
+ // default, and any other backend is named.
885
901
  if (command === 'login') {
886
902
  const loginArgs = args.slice(1)
887
- const { resolveBackendOrigin } = await import('./backend/client.js')
888
903
  const { readFlagValue } = await import('./utils/args.js')
889
904
  const { runRegistryLogin } = await import('./utils/registry-auth.js')
890
- const originFlag =
891
- readFlagValue(loginArgs, '--backend')
892
- const apiBase = resolveBackendOrigin(originFlag)
893
-
894
- // ⭐ The project says where it belongs — say so BEFORE authenticating elsewhere.
895
- //
896
- // `login` is deliberately NOT given the `site.yml::$backend` tier the site verbs
897
- // get (see resolveBackendOrigin): the session it writes is machine-wide, so letting
898
- // cwd pick the account you log into would be a silent surprise. But staying silent
899
- // does not remove the failure, it MOVES it — you log into the default, and the next
900
- // push/pull/publish resolves to `$backend` and warns about the mismatch. That is the
901
- // routed-not-nagged case `$backend` was added for, missing at the one command a
902
- // teammate runs FIRST after cloning.
903
- //
904
- // ⛔ Silent when the origin was named explicitly (--backend /
905
- // UNIWEB_REGISTER_URL). A deliberate aim is not a mistake to warn about; a genuinely
906
- // wrong one is still caught by the session-mismatch guard in BackendClient.token().
907
- if (!originFlag && !process.env.UNIWEB_REGISTER_URL) {
908
- try {
909
- const { findNearbySiteBackend } = await import(
910
- './utils/site-identity.js'
911
- )
912
- const nearby = findNearbySiteBackend(process.cwd())
913
- if (nearby && nearby.backend !== apiBase) {
914
- console.error(
915
- `\x1b[33m⚠\x1b[0m This project syncs with ${nearby.backend} (site.yml::$backend), but login is targeting ${apiBase}.`
916
- )
917
- console.error(
918
- ` Log in where the project belongs: uniweb login --backend ${nearby.backend}`
919
- )
920
- console.error(
921
- ` Continuing with ${apiBase} — a session is machine-wide, so this is a heads-up, not a block.\n`
922
- )
923
- }
924
- } catch {
925
- // Advisory only. A malformed site.yml, an unreadable directory or anything else
926
- // here must never be the reason someone cannot log in.
927
- }
905
+ const { resolveLoginOrigin } = await import('./utils/config.js')
906
+ let apiBase
907
+ try {
908
+ apiBase = resolveLoginOrigin(readFlagValue(loginArgs, '--backend'))
909
+ } catch (err) {
910
+ console.error(
911
+ `\x1b[31m✗\x1b[0m ${err.message} — e.g. uniweb login --backend http://localhost:8080`
912
+ )
913
+ process.exit(2)
928
914
  }
929
-
930
915
  await runRegistryLogin({ apiBase, args: loginArgs })
931
916
  return
932
917
  }
933
918
 
934
- // Handle logout command — clear the stored backend session.
919
+ // Handle logout — THE session. ⭐ There is at most one *[Diego, 2026-09-21: "`uniweb
920
+ // logout` always logs you out of the one backend you may be logged in"]*, so logout
921
+ // takes no options: `--backend` and `--all` existed only while sessions were kept per
922
+ // backend. ⚠️ Local only — the token is forgotten here, not revoked on the backend.
935
923
  if (command === 'logout') {
936
- const { clearRegistryAuth, getRegistryAuthPath } =
937
- await import('./utils/registry-auth.js')
938
- const { existsSync } = await import('node:fs')
939
- const had = existsSync(getRegistryAuthPath())
940
- await clearRegistryAuth()
941
- console.log(
942
- had
943
- ? '\x1b[32m✓\x1b[0m Logged out (cleared the stored session).'
944
- : 'Not logged in — nothing to clear.'
945
- )
924
+ const stray = args.slice(1).find((a) => a.startsWith('-') && a !== '--non-interactive')
925
+ if (stray) {
926
+ console.error(
927
+ `\x1b[31m✗\x1b[0m \`uniweb logout\` takes no options (got ${stray}) — it logs you out of the backend you are logged in to.`
928
+ )
929
+ process.exit(2)
930
+ }
931
+ const { clearRegistryAuth } = await import('./utils/registry-auth.js')
932
+ const { loggedInOrigin } = await import('./utils/config.js')
933
+ const origin = loggedInOrigin()
934
+ const cleared = await clearRegistryAuth()
935
+ if (!cleared.length) {
936
+ console.log('Not logged in — nothing to clear.')
937
+ return
938
+ }
939
+ console.log(`\x1b[32m✓\x1b[0m Logged out${origin ? ` of ${origin}` : ''}.`)
940
+ // A file from before sessions were single can hold several; all of them go.
941
+ const others = cleared.filter((o) => o !== origin)
942
+ if (others.length)
943
+ console.log(`\x1b[2mAlso removed stored sessions for: ${others.join(', ')}\x1b[0m`)
946
944
  return
947
945
  }
948
946
 
@@ -1392,7 +1390,7 @@ ${colors.bright}Options:${colors.reset}
1392
1390
  --host <name> The host to ship to (no value → the wizard, TTY only)
1393
1391
  --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
1394
1392
  --dry-run Resolve the target + adapter; print summary; upload nothing
1395
- --no-save Skip the auto-save of lastDeploy in deploy.yml
1393
+ --no-save Do not record this deploy in deploy.yml
1396
1394
  --no-validate Skip the content-conformance check (it only warns)
1397
1395
  --non-interactive Fail with usage info instead of prompting
1398
1396
 
@@ -1429,7 +1427,7 @@ ${colors.bright}Options:${colors.reset}
1429
1427
  when only the content should go live. Refused if the
1430
1428
  foundation has never been released — there is nothing to
1431
1429
  bind to, and the app cannot open such a site.
1432
- --no-save Skip the deploy.yml lastDeploy auto-save
1430
+ --no-save Do not record this deploy in deploy.yml
1433
1431
  --no-validate Skip the content-conformance check (it only warns)
1434
1432
  --org @org Publish under @org (membership-gated; alias: --as-org). Read
1435
1433
  only on a site's FIRST publish — that create decides which org
@@ -1439,8 +1437,6 @@ ${colors.bright}Options:${colors.reset}
1439
1437
  --personal Create the site under your personal account, deliberately.
1440
1438
  Only needed on a first publish, and only to answer the owner
1441
1439
  question without a prompt (CI, agents, scripts).
1442
- --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
1443
- --token <bearer> Auth bearer (skips \`uniweb login\`)
1444
1440
  `,
1445
1441
  create: `
1446
1442
  ${colors.cyan}${colors.bright}uniweb create${colors.reset} ${colors.dim}— Create a new project${colors.reset}
@@ -1691,7 +1687,7 @@ ${colors.bright}Usage:${colors.reset}
1691
1687
  uniweb release [options] ${colors.dim}(synonym — reads naturally for updates)${colors.reset}
1692
1688
 
1693
1689
  Builds one \`.uwx\` document and submits it to the registry over HTTP. Run
1694
- \`uniweb login\` first (or pass \`--token\`). \`register\`/\`release\` are for
1690
+ \`uniweb login\` first. \`register\`/\`release\` are for
1695
1691
  FOUNDATIONS (and schemas) — code only; \`uniweb publish\` makes a synced SITE
1696
1692
  live (and brings its foundation along); \`uniweb deploy\` hosts on a third-party
1697
1693
  host. \`register\` and \`release\` are the same act: \`register\` reads naturally
@@ -1711,8 +1707,6 @@ ${colors.bright}Options:${colors.reset}
1711
1707
  --scope @org Publish under @org (resolves @/x -> @org/x); default: package.json uniweb.scope
1712
1708
  --dry-run Print the .uwx; submit nothing
1713
1709
  -o, --output <f> Write the .uwx to a file; submit nothing
1714
- --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or a local URL)
1715
- --token <bearer> Submit with this bearer; skips \`uniweb login\` (or set UNIWEB_TOKEN)
1716
1710
  --non-interactive Fail with usage info instead of prompting
1717
1711
 
1718
1712
  Run from a foundation, a schemas-only package, or a workspace with a single foundation.
@@ -1753,19 +1747,26 @@ ${colors.cyan}${colors.bright}uniweb login${colors.reset} ${colors.dim}— Log i
1753
1747
  ${colors.bright}Usage:${colors.reset}
1754
1748
  uniweb login [options]
1755
1749
 
1756
- Authenticates with the backend and stores a session at
1757
- ~/.uniweb/registry-auth.json. Every backend command (register, push, pull,
1758
- clone, deploy) reuses it. Interactively, pick username/password or paste a token.
1750
+ Authenticates with a backend and stores its session in
1751
+ ~/.uniweb/registry-auth.json. ${colors.bright}One backend at a time:${colors.reset} logging in to another one
1752
+ logs you out of the first (once the new login succeeds). The backend you are
1753
+ logged in to is where the backend commands go (push, pull, publish, status,
1754
+ register, clone) — so this is how you switch. Already logged in to it, login
1755
+ does nothing; add --password, --browser, --token-paste or --token to log in again.
1756
+
1757
+ Without --backend: https://uniweb.app (or \$UNIWEB_REGISTER_URL). No command talks
1758
+ to a backend you are not logged in to — run one before logging in and it asks first.
1759
1759
 
1760
1760
  ${colors.bright}Options:${colors.reset}
1761
- --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
1761
+ --backend <url> The backend to log in to
1762
1762
  --token <bearer> Seed + verify a session from a bearer token (non-interactive)
1763
+ --browser Force the browser method
1763
1764
  --password Force the username/password method
1764
1765
  --token-paste Force the paste-a-token prompt
1765
1766
 
1766
1767
  In non-interactive mode (CI / no TTY), pass \`--token <bearer>\`, or set
1767
1768
  \`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`, or set \`UNIWEB_TOKEN\` (used per-command,
1768
- not stored). Run \`uniweb logout\` to clear the stored session.
1769
+ not stored). \`uniweb logout\` logs you out.
1769
1770
  `,
1770
1771
  refresh: `
1771
1772
  ${colors.cyan}${colors.bright}uniweb refresh${colors.reset} ${colors.dim}— Catch up with teammates AND app authors${colors.reset}
@@ -1784,8 +1785,6 @@ reflexively. Exits non-zero if a merge leaves conflicts.
1784
1785
  ${colors.bright}Options:${colors.reset}
1785
1786
  --no-git Skip the git remote; backend only
1786
1787
  --no-backend Skip the backend; git only
1787
- --backend <url> Override the backend origin
1788
- --token <bearer> Read with this bearer; skips \`uniweb login\`
1789
1788
 
1790
1789
  \`--force\` and \`--merge\` are NOT accepted here: this verb builds the delegated
1791
1790
  pull's arguments itself, and to pull \`--force\` means "discard my local work".
@@ -1813,11 +1812,32 @@ ${colors.bright}Options:${colors.reset}
1813
1812
 
1814
1813
  --no-git Skip the git remote half of the refresh
1815
1814
  --force Overwrite upstream changes (reaches the PUSH half only)
1816
- --backend <url> Override the backend origin
1817
- --token <bearer> Auth bearer; skips \`uniweb login\`
1818
1815
 
1819
1816
  \`--force\` means "overwrite upstream" to push but "discard my local work" to pull,
1820
1817
  so it is deliberately kept away from the refresh half.
1818
+ `,
1819
+ forget: `
1820
+ ${colors.cyan}${colors.bright}uniweb forget${colors.reset} ${colors.dim}— Remove what this project recorded about where it synced${colors.reset}
1821
+
1822
+ ${colors.bright}Usage:${colors.reset}
1823
+ uniweb forget --backend <url>
1824
+ uniweb forget --all
1825
+
1826
+ Local files only. Every site stays where it is on its backend.
1827
+
1828
+ ${colors.bright}Options:${colors.reset}
1829
+ --backend <url> Forget one backend: its entries in sync.json and in the local
1830
+ cache, and its deploy records in deploy.yml. Its targets stay,
1831
+ so the next publish there creates a new site.
1832
+ --all For a COPY of a project that should become a new one. Deletes
1833
+ sync.json, deploy.yml and the local cache — everything naming
1834
+ the original's sites and destinations. Without it, the copy's
1835
+ first push updates the original's site.
1836
+
1837
+ Record files keep their \`$uuid\`: it is the record's own id, not a backend's.
1838
+
1839
+ To duplicate a project:
1840
+ cp -r my-site my-site-2 && cd my-site-2 && uniweb forget --all
1821
1841
  `,
1822
1842
  invite: `
1823
1843
  ${colors.cyan}${colors.bright}uniweb invite${colors.reset} ${colors.dim}— (reserved; not available on the new backend yet)${colors.reset}
@@ -1986,6 +2006,7 @@ ${colors.bright}Commands:${colors.reset}
1986
2006
  refresh Catch up with the git remote AND the backend (never pushes)
1987
2007
  sync Catch up, then push (refresh + push)
1988
2008
  status Show a site's sync state (unpushed content, foundation)
2009
+ forget Forget a backend, or --all to make a copied project a new one
1989
2010
  inspect <path> Inspect parsed content shape of a markdown file or folder
1990
2011
  docs Generate component documentation
1991
2012
  families List the standard section families (for meta.js family:)
@@ -1994,7 +2015,7 @@ ${colors.bright}Commands:${colors.reset}
1994
2015
  update Align workspace deps + AGENTS.md to the running CLI
1995
2016
  i18n <cmd> Internationalization (extract, sync, status)
1996
2017
  login Log in to your Uniweb account
1997
- logout Clear the stored session
2018
+ logout Log out of the backend you are logged in to
1998
2019
 
1999
2020
  ${colors.bright}Create Options:${colors.reset}
2000
2021
  --template <type> Project template (default: starter)
@@ -2015,16 +2036,20 @@ ${colors.bright}Global Options:${colors.reset}
2015
2036
  --non-interactive Fail with usage info instead of prompting
2016
2037
  Auto-detected when CI=true or no TTY (pipes, agents)
2017
2038
 
2039
+ Backend commands (push, pull, publish, status, register, clone) go to the
2040
+ backend you are logged in to, with its session. Switch with
2041
+ \`uniweb login --backend <url>\` (add \`--token <bearer>\` to sign in with a token);
2042
+ a script can aim and authenticate one process with UNIWEB_REGISTER_URL and
2043
+ UNIWEB_TOKEN instead.
2044
+
2018
2045
  ${colors.bright}Publish Options:${colors.reset}
2019
2046
  --dry-run Resolve everything; release/sync/POST nothing
2020
2047
  --yes Skip confirmations (CI); never block on a prompt
2021
2048
  --no-release Ship content against the already-released code; release nothing
2022
2049
  --org @org Publish under @org (first publish only; then remembered)
2023
2050
  --personal Own the new site personally, deliberately (first publish only)
2024
- --no-save Skip the deploy.yml lastDeploy auto-save
2051
+ --no-save Do not record this deploy in deploy.yml
2025
2052
  --no-validate Skip the content-conformance check (it only warns)
2026
- --backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
2027
- --token <bearer> Auth bearer (skips \`uniweb login\`)
2028
2053
 
2029
2054
  uniweb publish is the smart Uniweb-hosting path: it brings the site's
2030
2055
  foundation along, syncs, and goes live. To register a foundation on its own
@@ -2037,7 +2062,7 @@ ${colors.bright}Deploy Options:${colors.reset}
2037
2062
  s3-cloudfront, or \`uniweb\` (delegates to \`uniweb publish\`).
2038
2063
  --target <name> Pick a target from deploy.yml (default: deploy.yml's \`default:\`)
2039
2064
  --dry-run Resolve the target + adapter; print summary; upload nothing
2040
- --no-save Skip the auto-save of lastDeploy in deploy.yml
2065
+ --no-save Do not record this deploy in deploy.yml
2041
2066
  --no-validate Skip the content-conformance check (it only warns)
2042
2067
  To deploy on every push instead, see \`uniweb add ci --help\`.
2043
2068
 
package/src/utils/args.js CHANGED
@@ -47,10 +47,9 @@ export function readOrgFlag(args) {
47
47
  *
48
48
  * The CLI reads flags by scanning argv for a literal, so an unrecognized flag is
49
49
  * not an error — it is *invisible*, and whatever it was meant to change silently
50
- * keeps its default. The sharp case is `--backend`: mistype it and the origin
51
- * ladder falls through to the session, the saved config, or `https://uniweb.app`,
52
- * so a command aimed at localhost can reach production. `--token` degrades the
53
- * same way, to a stored session belonging to someone else.
50
+ * keeps its default. The sharp cases were `--backend` and `--token`, which aimed a
51
+ * command and chose its identity; both left the backend commands on 2026-09-21
52
+ * (the login does both now), and the guard names the login when either is passed.
54
53
  *
55
54
  * Scanning rules, chosen to avoid false positives (a wrong rejection is worse than
56
55
  * a missed one — it breaks a working command):
@@ -19,11 +19,24 @@
19
19
  * its id+ext for the rewrite. Absent `present` ⇒ false — an older
20
20
  * backend without the flag uploads everything (today's behavior).
21
21
  *
22
- * Assets are GLOBAL + content-addressed: identical bytes → same id → dedup
23
- * across sites and idempotent re-deploys (a re-PUT is a cheap no-op). This
24
- * mirrors the foundation code lane (utils/code-upload.js); the one structural
25
- * difference is that the backend MINTS the per-asset id, so the plan response is
26
- * what the deploy step rewrites content references to.
22
+ * Assets are content-addressed and global WITHIN ONE BACKEND: identical bytes →
23
+ * same id → dedup across that backend's sites and idempotent re-deploys (a
24
+ * re-PUT is a cheap no-op). This mirrors the foundation code lane
25
+ * (utils/code-upload.js); the one structural difference is that the backend
26
+ * MINTS the per-asset id, so the plan response is what the deploy step rewrites
27
+ * content references to.
28
+ *
29
+ * ⛔ **"Global" is the DEDUP SCOPE, not a claim about other backends.** An id,
30
+ * and the presence of the bytes behind it, are facts about the backend that
31
+ * answered the PLAN and about nothing else — where a backend keeps them is its
32
+ * own business and not a shape this lane models. ⚠️ This said only "GLOBAL" until
33
+ * 2026-09-20 and was read as "the same id works on any backend", which made
34
+ * `assets.json` look portable in a multi-backend design. It is not: `sync.json`
35
+ * keeps asset ids per backend (`backends.<origin>.assets`), and the backend ORIGIN
36
+ * is the segregation value the CLI has.
37
+ *
38
+ * ⚠️ Do not infer portability from `id` looking like the sha256 either — we read
39
+ * it from the response precisely so the derivation is never load-bearing here.
27
40
  */
28
41
 
29
42
  import { createHash } from 'node:crypto'
@@ -21,6 +21,7 @@ import { homedir } from 'node:os'
21
21
  import yaml from 'js-yaml'
22
22
  import { filterCmd } from './pm.js'
23
23
  import { writeJsonPreservingStyleAsync } from './json-file.js'
24
+ import { loggedInOriginOf, readSessionFileSync } from './session-file.js'
24
25
 
25
26
  // ── Backend origin ─────────────────────────────────────────────
26
27
 
@@ -57,64 +58,100 @@ function readCliConfig() {
57
58
  * to keep this module off the optional-peer / import-cycle path.
58
59
  * @returns {string|null}
59
60
  */
60
- function readSessionOrigin() {
61
+ /**
62
+ * **The backend the user is logged in to** — the one session's backend — or null.
63
+ *
64
+ * ⛔ This read `~/.uniweb/registry-auth.json`'s top-level `origin` itself until
65
+ * 2026-09-21: the v1 shape. The file had become one session per backend, which has no
66
+ * such field, so this answered null for every new login and the session tier below
67
+ * silently fell through to the default backend. It reads through the file's one reader
68
+ * now (utils/session-file.js).
69
+ *
70
+ * @returns {string|null}
71
+ */
72
+ export function loggedInOrigin() {
73
+ return loggedInOriginOf(readSessionFileSync(DEFAULT_BACKEND_ORIGIN))
74
+ }
75
+
76
+ /** A value's bare origin, or null when it is not a URL. */
77
+ function originOrNull(value) {
78
+ if (!value) return null
61
79
  try {
62
- const p = join(homedir(), '.uniweb', 'registry-auth.json')
63
- if (!existsSync(p)) return null
64
- const o = JSON.parse(readFileSync(p, 'utf8'))?.origin
65
- return o || null
80
+ // ⛔ http(s) only: `new URL('localhost:8080')` PARSES — as the scheme `localhost:` —
81
+ // and its origin is the string "null", which is truthy. A bare host:port is the
82
+ // easy slip, and it must not become a backend.
83
+ const u = new URL(value)
84
+ return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
66
85
  } catch {
67
86
  return null
68
87
  }
69
88
  }
70
89
 
71
90
  /**
72
- * Get the backend's API base origin. `register` POSTs to
73
- * {origin}/dev/registry/register, `login` to {origin}/dev/auth/login, etc.
74
- * (BackendClient.resolveBackendOrigin layers the --backend flag and a site's
75
- * deploy.yml backend on top of this.)
91
+ * **The default backend** — where a bare `uniweb login` goes, and where a backend command
92
+ * goes when nobody is logged in (the login it asks for is then this one).
93
+ *
94
+ * `UNIWEB_REGISTER_URL`, else `~/.uniweb/config.json` `registryApiUrl`, else
95
+ * https://uniweb.app. ⛔ **Never the current session** *[Diego, 2026-09-21: "the default
96
+ * backend for login, if not specified, is uniweb.app"]* — a bare `uniweb login` means the
97
+ * default backend, not "the one I am already on".
98
+ *
99
+ * @returns {string}
100
+ */
101
+ export function getDefaultBackendOrigin() {
102
+ return (
103
+ originOrNull(process.env.UNIWEB_REGISTER_URL) ||
104
+ originOrNull(readCliConfig().registryApiUrl) ||
105
+ DEFAULT_BACKEND_ORIGIN
106
+ )
107
+ }
108
+
109
+ /**
110
+ * The backend `uniweb login` logs in to: `--backend`, else the default backend.
111
+ *
112
+ * ⛔ A mistyped `--backend` is an error, never a fallback — it would log you in, and so
113
+ * point every command, somewhere you did not name.
114
+ *
115
+ * @param {string|null|undefined} flag - `readFlagValue`'s answer: undefined when the
116
+ * flag is absent, null when it was given with no value
117
+ * @returns {string}
118
+ * @throws {Error} when --backend was given and is not a URL
119
+ */
120
+ export function resolveLoginOrigin(flag) {
121
+ if (flag === undefined) return getDefaultBackendOrigin()
122
+ const origin = originOrNull(flag)
123
+ if (!origin) throw new Error(flag ? `Not a URL: ${flag}` : '--backend needs a URL')
124
+ return origin
125
+ }
126
+
127
+ /**
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).
130
+ *
131
+ * `UNIWEB_REGISTER_URL` > **the backend the user is logged in to** > the default
132
+ * (`getDefaultBackendOrigin`). ⭐ Nothing talks to a backend the user is not logged in
133
+ * to *[Diego, 2026-09-21]*: when this falls through to the default, the command's first
134
+ * 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.
76
136
  *
77
- * Priority: UNIWEB_REGISTER_URL's origin > the logged-in session origin >
78
- * ~/.uniweb/config.json registryApiUrl > the default (uniweb.app). Local dev
79
- * points at a local backend EXPLICITLY (--backend / UNIWEB_REGISTER_URL /
80
- * `uniweb login --backend …`), rather than the default being localhost.
81
137
  * @returns {string}
82
138
  */
83
139
  export function getRegistryApiBaseUrl() {
84
- const fromEnv = process.env.UNIWEB_REGISTER_URL
85
- if (fromEnv) {
86
- try {
87
- return new URL(fromEnv).origin
88
- } catch {
89
- /* fall through */
90
- }
91
- }
92
- const fromSession = readSessionOrigin()
93
- if (fromSession) {
94
- try {
95
- return new URL(fromSession).origin
96
- } catch {
97
- return fromSession
98
- }
99
- }
100
- const fromCfg = readCliConfig().registryApiUrl
101
- if (fromCfg) {
102
- try {
103
- return new URL(fromCfg).origin
104
- } catch {
105
- return fromCfg
106
- }
107
- }
108
- return DEFAULT_BACKEND_ORIGIN
140
+ return (
141
+ originOrNull(process.env.UNIWEB_REGISTER_URL) ||
142
+ originOrNull(loggedInOrigin()) ||
143
+ getDefaultBackendOrigin()
144
+ )
109
145
  }
110
146
 
111
147
  /**
112
148
  * The built-in backend origin — the last tier of the ladder, and the 98% case.
113
149
  *
114
- * Exported because `site.yml::$backend` is written ONLY when the backend is NOT this
115
- * (`recordSiteBackend`, utils/site-identity.js). That test needs the *built-in* default,
116
- * which `getRegistryApiBaseUrl()` cannot give it — that function returns whatever tier
117
- * won, so on a machine with a session or a saved config it returns something else
150
+ * Exported because several readers need the *built-in* default, not the resolved one:
151
+ * a v1 session file with no origin stamp, `login`'s list of known backends, and a
152
+ * deploy.yml `host: uniweb` target with no `backend:` (its documented meaning).
153
+ * `getRegistryApiBaseUrl()` cannot give it — that function returns whatever tier won,
154
+ * so on a machine with a session or a saved config it returns something else
118
155
  * entirely. Two copies of the literal would silently disagree exactly there.
119
156
  */
120
157
  export const DEFAULT_BACKEND_ORIGIN = 'https://uniweb.app'