uniweb 0.59.2 → 0.61.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.
@@ -30,12 +30,11 @@
30
30
  * uniweb register --json Porcelain: ONE compact JSON line on stdout
31
31
  * ({ok,scope,origin,entities:[{name,uuid,version,unchanged}]}),
32
32
  * all human output to stderr — for scripted callers
33
- * uniweb register --backend <url> Override the backend origin
34
- * uniweb register --token <bearer> Submit with this bearer; skips `uniweb login`
35
33
  *
36
- * Endpoint resolution: --backend <url> > UNIWEB_REGISTER_URL >
37
- * the logged-in session origin > ~/.uniweb/config.json > the default (uniweb.app).
38
- * Auth (submit only): --token <bearer> > UNIWEB_TOKEN > `uniweb login` session.
34
+ * Endpoint: UNIWEB_REGISTER_URL > the backend you are logged in to > the default
35
+ * (~/.uniweb/config.json, else uniweb.app) — resolveBackendOrigin. No `--backend`:
36
+ * switching is `uniweb login --backend <url>`.
37
+ * Auth (submit only): UNIWEB_TOKEN > the stored session > `uniweb login`.
39
38
  */
40
39
 
41
40
  // DEFERRED foundation-registration capabilities (the legacy `uniweb publish` had
@@ -78,7 +77,7 @@ import {
78
77
  computeFoundationDigest,
79
78
  readRuntimePin
80
79
  } from '../utils/code-upload.js'
81
- import { deriveScope } from '../utils/registry-orgs.js'
80
+ import { deriveScope, publishScope } from '../utils/registry-orgs.js'
82
81
  import { BackendClient } from '../backend/client.js'
83
82
  import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
84
83
  import {
@@ -388,12 +387,9 @@ async function runRegister(args = []) {
388
387
  const dryRun = args.includes('--dry-run')
389
388
  const output = flagValue(args, '-o') || flagValue(args, '--output')
390
389
  const scopeFlag = flagValue(args, '--scope')
391
- const tokenFlag = flagValue(args, '--token')
392
- // Origin: --backend (matches deploy/publish + the
393
- // origin-selection convention); either overrides UNIWEB_REGISTER_URL / default.
390
+ // The backend you are logged in to (UNIWEB_REGISTER_URL overrides it for a script) —
391
+ // resolveBackendOrigin, like every backend command.
394
392
  const client = new BackendClient({
395
- originFlag: flagValue(args, '--backend'),
396
- token: tokenFlag,
397
393
  args,
398
394
  command: 'Registering'
399
395
  })
@@ -408,14 +404,24 @@ async function runRegister(args = []) {
408
404
  : await resolveFoundationDir(args)
409
405
 
410
406
  // Scope: --scope flag, else package.json `uniweb.scope`, else (real submit
411
- // only) derived from login membership in the bootstrap below.
407
+ // only) derived from login membership in the bootstrap below. Either spelling,
408
+ // `@acme` or `acme`, and from here on the one form: `@acme` (publishScope).
412
409
  const pkgScope = readPkgScope(targetDir)
413
- let scope = scopeFlag || pkgScope
410
+ const givenScope = scopeFlag || pkgScope
411
+ let scope = publishScope(givenScope)
414
412
  let scopeSource = scopeFlag
415
413
  ? '--scope'
416
414
  : pkgScope
417
415
  ? 'package.json uniweb.scope'
418
416
  : null
417
+ // A scope that was GIVEN but names no org (`--scope @`) is refused, never read as
418
+ // absent: absent derives one from the login, which would register under an org the
419
+ // caller did not name.
420
+ if (givenScope && !scope) {
421
+ error(`Not an org scope: ${givenScope} (from ${scopeSource})`)
422
+ log(` ${colors.dim}Pass --scope @org.${colors.reset}`)
423
+ return { exitCode: 2 }
424
+ }
419
425
  const isPreview = !!output || dryRun
420
426
 
421
427
  // Each path supplies a different schema source: the standalone path discovers
@@ -489,7 +495,7 @@ async function runRegister(args = []) {
489
495
  // 0 create / N pick), persist to package.json, and reuse the session token.
490
496
  if (!scope && !isPreview) {
491
497
  const token = await client.token()
492
- const sess = await readRegistryAuth()
498
+ const sess = await readRegistryAuth(client.origin)
493
499
  const derived = await deriveScope({
494
500
  apiBase: client.origin,
495
501
  token,
@@ -606,7 +612,7 @@ async function runRegister(args = []) {
606
612
  )
607
613
  return { exitCode: 2 }
608
614
  }
609
- // Submit — the client carries the bearer (--token › UNIWEB_TOKEN › stored
615
+ // Submit — the client carries the bearer (UNIWEB_TOKEN › stored
610
616
  // session › login), resolved lazily on this first authed call.
611
617
  info(`Submitting to ${colors.dim}${client.origin}${colors.reset} …`)
612
618
  let res
@@ -615,7 +621,7 @@ async function runRegister(args = []) {
615
621
  } catch (err) {
616
622
  error(`Could not reach the registry at ${client.origin}: ${err.message}`)
617
623
  log(
618
- ` ${colors.dim}Set the endpoint with --backend <url> or UNIWEB_REGISTER_URL.${colors.reset}`
624
+ ` ${colors.dim}Is that the backend you meant? Switch with: uniweb login --backend <url>${colors.reset}`
619
625
  )
620
626
  return { exitCode: 2 }
621
627
  }
@@ -650,7 +656,7 @@ async function runRegister(args = []) {
650
656
  ` ${colors.dim}The registry didn't accept your credentials — it may use different ones than \`uniweb login\`.${colors.reset}`
651
657
  )
652
658
  log(
653
- ` ${colors.dim}Supply a registry bearer with --token <bearer> (or UNIWEB_TOKEN); an existing one may be wrong or expired.${colors.reset}`
659
+ ` ${colors.dim}Log in again (uniweb login --backend <url>), or check UNIWEB_TOKEN — an existing one may be wrong or expired.${colors.reset}`
654
660
  )
655
661
  }
656
662
  // ⭐ **Surface `detail` as a sentence, not as a JSON dump.** The registry
@@ -675,11 +681,17 @@ async function runRegister(args = []) {
675
681
  return { exitCode: 1 }
676
682
  }
677
683
  }
684
+ // The foundation's name as the registry holds it (`@org/name`) — read from the
685
+ // document just submitted, never composed again. See phase 2.
686
+ const registeredName = standalone
687
+ ? null
688
+ : doc.entities.find((e) => e.model === '@uniweb/foundation-schema')?.info
689
+ ?.name
678
690
  if (!alreadyRegistered) {
679
691
  success(
680
692
  standalone
681
693
  ? `Registered ${defined.length} data schema(s)${scope ? ` under ${scope}` : ''}`
682
- : `Registered ${schema._self.name}@${schema._self.version}${defined.length ? ` + ${defined.length} data schema(s)` : ''}`
694
+ : `Registered ${registeredName}@${schema._self.version}${defined.length ? ` + ${defined.length} data schema(s)` : ''}`
683
695
  )
684
696
  }
685
697
 
@@ -688,11 +700,13 @@ async function runRegister(args = []) {
688
700
  // only packages have no dist; --schema-only skips deliberately.
689
701
  if (!standalone && !args.includes('--schema-only')) {
690
702
  const distDir = join(targetDir, 'dist')
691
- // The registry's vocabulary is the SCOPED name (`@org/name`). A scoped
692
- // package name passes through; a bare one gets the chosen scope — the
693
- // same resolution the .uwx submission applied.
694
- const bareName = schema._self.name
695
- const name = bareName.startsWith('@') ? bareName : `${scope}/${bareName}`
703
+ // ⭐ The plan authorizes against the REGISTERED (name, version), so the name here
704
+ // is the one phase 1 registered — the `.uwx`'s own `info.name`, where a scoped
705
+ // package name passes through and a bare one gets the scope. ⛔ Not composed a
706
+ // second time: this line composed it from the raw scope until 2026-09-21, so
707
+ // `--scope std` registered `@std/src` and then asked to deliver `std/src`, which
708
+ // the registry refuses. One name, read from the one place it was decided.
709
+ const name = registeredName
696
710
  const version = schema._self.version
697
711
  info(
698
712
  `Delivering code for ${colors.bright}${name}@${version}${colors.reset} …`
@@ -24,14 +24,13 @@ import { existsSync, readFileSync } from 'node:fs'
24
24
  import { join } from 'node:path'
25
25
  import yaml from 'js-yaml'
26
26
 
27
- import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
27
+ import { resolveSiteDir } from './deploy.js'
28
28
  import { probeUnpushed } from '../backend/site-sync.js'
29
- import { BackendClient } from '../backend/client.js'
30
- import { readFlagValue } from '../utils/args.js'
29
+ import { BackendClient, resolveBackendOrigin } from '../backend/client.js'
30
+ import { readBackendState } from '@uniweb/build/uwx'
31
31
  import { resolveLocalFoundation } from '../backend/foundation-bring-along.js'
32
32
  import { computeFoundationDigest } from '../utils/code-upload.js'
33
33
  import { checkFlags } from '../utils/flag-guard.js'
34
- import { readSiteIdentity } from '../utils/site-identity.js'
35
34
 
36
35
  const c = {
37
36
  reset: '\x1b[0m',
@@ -87,15 +86,23 @@ export async function status(args = []) {
87
86
  const remote = args.includes('--remote')
88
87
  const siteDir = await resolveSiteDir(args, 'status')
89
88
  const siteYml = readSiteYml(siteDir)
90
- const uuid = siteYml.$uuid || null
91
89
  const fnd = foundationRef(siteYml)
92
90
  const { scope: fndScope, version: fndVersion } = splitFoundationRef(fnd)
93
91
 
94
92
  // Local content diff — builds the sync packages, never authenticates.
93
+ //
94
+ // ⭐ The ORIGIN is resolved here rather than taken from the client below, because
95
+ // this probe is deliberately offline and the client may never be built. It decides
96
+ // whose asset ids the comparison reads: against the wrong backend every media ref
97
+ // reads as changed.
98
+ const probeBackend = resolveBackendOrigin()
99
+ // ⭐ Identity is this backend's, from sync.json. It read `site.yml::$uuid`, so after
100
+ // step 4 moved the key every project reported itself as never synced.
101
+ const uuid = readBackendState(siteDir, probeBackend).site?.uuid || null
95
102
  let probe = null
96
103
  let probeErr = null
97
104
  try {
98
- probe = await probeUnpushed(siteDir)
105
+ probe = await probeUnpushed(siteDir, { backend: probeBackend })
99
106
  } catch (err) {
100
107
  probeErr = err.message
101
108
  }
@@ -108,11 +115,6 @@ export async function status(args = []) {
108
115
  if (remote) {
109
116
  try {
110
117
  const client = new BackendClient({
111
- originFlag:
112
- readFlagValue(args, '--backend'),
113
- siteScope: readSiteIdentity(siteDir).backend,
114
- siteBackend: await resolveSiteBackend(siteDir),
115
- token: readFlagValue(args, '--token') || undefined,
116
118
  args,
117
119
  command: 'Status'
118
120
  })
@@ -35,7 +35,6 @@
35
35
  * uniweb sync refresh, then push
36
36
  * uniweb sync --no-git skip the git remote half of the refresh
37
37
  * uniweb sync --force forwarded to push (overwrite upstream changes)
38
- * uniweb sync --backend <url> override the backend origin
39
38
  */
40
39
 
41
40
  import { checkFlags } from '../utils/flag-guard.js'
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-20T19:01:50.411Z",
3
+ "generatedAt": "2026-09-21T21:47:05.925Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.4.1",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.55.1",
13
+ "version": "0.57.0",
14
14
  "path": "framework/build",
15
15
  "deps": [
16
16
  "@uniweb/content-reader",
@@ -35,7 +35,7 @@
35
35
  "deps": []
36
36
  },
37
37
  "@uniweb/core": {
38
- "version": "0.29.6",
38
+ "version": "0.29.7",
39
39
  "path": "framework/core",
40
40
  "deps": [
41
41
  "@uniweb/semantic-parser",
@@ -55,7 +55,7 @@
55
55
  ]
56
56
  },
57
57
  "@uniweb/kit": {
58
- "version": "0.19.3",
58
+ "version": "0.19.4",
59
59
  "path": "framework/kit",
60
60
  "deps": [
61
61
  "@uniweb/core",
@@ -83,7 +83,7 @@
83
83
  ]
84
84
  },
85
85
  "@uniweb/runtime": {
86
- "version": "0.27.2",
86
+ "version": "0.27.3",
87
87
  "path": "framework/runtime",
88
88
  "deps": [
89
89
  "@uniweb/core",
@@ -116,7 +116,7 @@
116
116
  "deps": []
117
117
  },
118
118
  "@uniweb/templates": {
119
- "version": "0.14.8",
119
+ "version": "0.15.0",
120
120
  "path": "framework/templates",
121
121
  "deps": []
122
122
  },
@@ -126,7 +126,7 @@
126
126
  "deps": []
127
127
  },
128
128
  "@uniweb/unipress": {
129
- "version": "0.9.26",
129
+ "version": "0.10.0",
130
130
  "path": "framework/unipress",
131
131
  "deps": [
132
132
  "@uniweb/build",
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,18 +1427,16 @@ ${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
1436
1434
  owns the site, and whose storage its assets are charged to.
1437
- Recorded as site.yml \$org and replayed, so it is never
1435
+ Recorded in sync.json and replayed, so it is never
1438
1436
  re-typed. Without it, you are asked once.
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'