uniweb 0.88.1 → 0.89.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.88.1",
3
+ "version": "0.89.1",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,16 +41,16 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/core": "^0.38.0",
45
- "@uniweb/content-writer": "^0.3.4",
46
- "@uniweb/semantic-parser": "^1.5.1",
44
+ "@uniweb/core": "^0.38.1",
47
45
  "@uniweb/kit": "^0.20.0",
48
- "@uniweb/schemas": "^0.13.0",
49
- "@uniweb/runtime": "^0.29.5"
46
+ "@uniweb/runtime": "^0.29.6",
47
+ "@uniweb/semantic-parser": "^1.5.1",
48
+ "@uniweb/content-writer": "^0.3.4",
49
+ "@uniweb/schemas": "^0.13.0"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.80.3",
53
52
  "@uniweb/semantic-parser": "^1.5.1",
53
+ "@uniweb/build": "^0.80.3",
54
54
  "@uniweb/content-reader": "^1.2.8"
55
55
  },
56
56
  "peerDependenciesMeta": {
@@ -194,7 +194,7 @@ The rest of this guide explains how Uniweb works. This part is what to do *first
194
194
  | `src`, `site` | **single** (the default) | foundation in `src/`, site in `site/` |
195
195
  | `foundations/*`, `sites/*` | **segregated** | several sites may share one foundation — a foundation edit hits all of them |
196
196
  | `*/src`, `*/site` | **co-located projects** | one self-contained pair per project: `docs/src` + `docs/site` |
197
- | `extensions/*` (alongside any of the above) | extensions present | extra section types, runtime-loaded — see step 2 |
197
+ | a package whose `main.js` says `extension: true` — `effects`, `extensions/*` (alongside any of the above) | extensions present | extra section types, runtime-loaded — see step 2 |
198
198
 
199
199
  Those names are convention; the globs are the truth. Full guide with the wiring for each: `development/project-structures.md`.
200
200
 
@@ -2510,8 +2510,8 @@ const sessions = entity?.hydrated?.items.filter((item) => item.section === 'sess
2510
2510
  section's fields (`record.brief.name`, not `record.name`), and a record carries **no
2511
2511
  items**. Read one entity for its items. Each item carries `section`, its section's name
2512
2512
  (the word a write takes), and `id`, which is what the writer takes to update, move or
2513
- remove it. `scope: 'mine'` lists only the viewer's own records. Without it, the operator
2514
- also gets everyone else's.
2513
+ remove it. `scope: 'mine'` lists only the viewer's own records; without it, a list also
2514
+ holds what was shared with the viewer.
2515
2515
 
2516
2516
  ⭐ **`absent` and an empty `ready` are different answers, and confusing them is the
2517
2517
  mistake to avoid.** `absent` = there is no live source (no `backend` service, or nobody signed
@@ -2543,10 +2543,12 @@ await writer.remove(itemId)
2543
2543
 
2544
2544
  ### ⛔ Permissions are the SERVER'S, and your UI is only a courtesy
2545
2545
 
2546
- Gate your controls on what the viewer may do — `viewer.roles` (the site's operator
2547
- holds `system_admin`), or a record's own `can_edit` from a single-entity read — but
2548
- **never rely on that for safety**. A foundation runs with exactly the viewer's own
2549
- authority, so hiding a button hides a button.
2546
+ Gate your controls on what the viewer may do — a record's own `can_edit` from a
2547
+ single-entity read — but **never rely on that for safety**. A foundation runs with
2548
+ exactly the viewer's own authority, so hiding a button hides a button. ⛔ **Not on
2549
+ `viewer.roles`:** a session signed in on a site holds a member's rights — the operator's
2550
+ too — so its roles never name an administrator, and a gate on `system_admin` shows the
2551
+ operator nothing.
2550
2552
 
2551
2553
  What protects content is the **entry**: a member writes their own entries and not
2552
2554
  another's, and the store enforces it. **Models are open** — anyone signed in may
@@ -2565,6 +2567,19 @@ sections:
2565
2567
  `append_only` holds against the item's own author. That is the difference between a
2566
2568
  permission model and a CSS one.
2567
2569
 
2570
+ ### The Admin link
2571
+
2572
+ ```jsx
2573
+ const { adminHref } = useSession()
2574
+ {adminHref && <a href={adminHref}>Admin</a>}
2575
+ ```
2576
+
2577
+ `adminHref` is set only for a signed-in account the backend's administration admits —
2578
+ the operator, and whoever else administers it — and is `null` for everyone else. It
2579
+ takes them there already signed in. ⛔ **Navigate to it, never `fetch` it**: the answer is
2580
+ a redirect carrying a one-time credential no script on the site is to hold. It decides
2581
+ what to offer, not what is allowed — on the site the operator acts as a member.
2582
+
2568
2583
  ### A backend on your machine
2569
2584
 
2570
2585
  You do not need a live backend to build against one. In `site.yml`:
@@ -27,7 +27,8 @@ import {
27
27
  import {
28
28
  resolvePlacement,
29
29
  SITE_KIND,
30
- FOUNDATION_KIND
30
+ FOUNDATION_KIND,
31
+ EXTENSION_KIND
31
32
  } from '../utils/placement.js'
32
33
  import {
33
34
  readWorkspaceConfig,
@@ -648,17 +649,8 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
648
649
  let name = opts.name
649
650
  const existingNames = await getExistingPackageNames(rootDir)
650
651
 
651
- // Reject reserved names (format + reserved check only — collisions handled at package name level)
652
- if (name) {
653
- const valid = validatePackageName(name)
654
- if (valid !== true) {
655
- error(valid)
656
- process.exit(1)
657
- }
658
- }
659
-
660
- // Interactive name prompt when name not provided
661
- if (!name) {
652
+ // A name is asked for unless one is given, or `--path` names the folder.
653
+ if (!name && !opts.path) {
662
654
  if (isNonInteractive(process.argv)) {
663
655
  error(`Missing extension name.\n`)
664
656
  log(`Usage: ${getCliPrefix()} add extension <name>`)
@@ -682,23 +674,41 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
682
674
  name = response.name
683
675
  }
684
676
 
677
+ // ⭐ Placed by the rule `add foundation` follows (utils/placement.js): the folder
678
+ // you name is the folder created — `effects` → effects/, `extensions/effects` →
679
+ // extensions/effects/ — and `--path` is the folder it goes in.
680
+ const placement = resolvePlacement(rootDir, name, opts, EXTENSION_KIND)
681
+ if (placement.outsideRoot) {
682
+ error(`--path must name a folder inside the workspace: ${placement.outsideRoot}`)
683
+ log('')
684
+ log(`The workspace root is ${colors.bright}${rootDir}${colors.reset}.`)
685
+ log(`A package outside it cannot be a workspace member.`)
686
+ process.exit(1)
687
+ }
688
+ const { relativePath } = placement
689
+ // The extension's own name: the last segment of the folder.
690
+ name = placement.packageName
691
+
692
+ // Reject reserved names (format + reserved check only — collisions handled at package name level)
693
+ const valid = validatePackageName(name)
694
+ if (valid !== true) {
695
+ error(valid)
696
+ process.exit(1)
697
+ }
698
+
685
699
  // Auto-suffix package name if it collides with an existing package
686
700
  const extensionPackageName = existingNames.has(name)
687
701
  ? resolveUniqueName(name, '-ext', existingNames)
688
702
  : name
689
703
 
690
- // Determine target
691
- let target
692
- if (opts.path) {
693
- target = opts.path
694
- } else {
695
- target = `extensions/${name}`
696
- }
697
-
698
- const fullPath = join(rootDir, target)
704
+ const fullPath = join(rootDir, relativePath)
699
705
 
700
706
  if (existsSync(fullPath)) {
701
- error(`Directory already exists: ${target}`)
707
+ error(
708
+ `Cannot create extension: ${colors.bright}${relativePath}/${colors.reset} already exists.`
709
+ )
710
+ log('')
711
+ log(`Pick a different name, or pass --path to choose a different folder.`)
702
712
  process.exit(1)
703
713
  }
704
714
 
@@ -728,8 +738,8 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
728
738
  ensureFoundationName(fullPath, registryName)
729
739
  }
730
740
 
731
- // Update workspace globs
732
- await addWorkspaceGlob(rootDir, 'extensions/*')
741
+ // Register the folder it is in, as add foundation does
742
+ await addWorkspaceGlob(rootDir, relativePath)
733
743
 
734
744
  // Wire extension to site:
735
745
  // - --site <name>: explicit, wire it.
@@ -742,17 +752,22 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
742
752
  // - no sites: print a note and exit 0.
743
753
  let wiredSite = null
744
754
  let unwiredReason = null
745
- if (opts.site) {
746
- wiredSite = await wireExtensionToSite(rootDir, opts.site, name, target)
755
+ // A site loads a workspace extension from `/<name>/entry.js`, which the build
756
+ // finds in `<name>/` or `extensions/<name>/` (wireExtensionToSite). An entry
757
+ // pointing anywhere else would build with a warning and 404 at runtime, so
758
+ // none is written.
759
+ const reachable = relativePath === name || relativePath === `extensions/${name}`
760
+ if (!reachable) {
761
+ unwiredReason =
762
+ `A site loads a workspace extension from /${name}/entry.js, which the build finds in ` +
763
+ `${name}/ or extensions/${name}/ — not ${relativePath}/. Move the folder to one of those, ` +
764
+ `or list a URL where the built extension is served under <site>/site.yml::extensions:.`
765
+ } else if (opts.site) {
766
+ wiredSite = await wireExtensionToSite(rootDir, opts.site, relativePath)
747
767
  } else {
748
768
  const sites = await discoverSites(rootDir)
749
769
  if (sites.length === 1) {
750
- wiredSite = await wireExtensionToSite(
751
- rootDir,
752
- sites[0].name,
753
- name,
754
- target
755
- )
770
+ wiredSite = await wireExtensionToSite(rootDir, sites[0].name, relativePath)
756
771
  } else if (sites.length > 1) {
757
772
  if (isNonInteractive(process.argv)) {
758
773
  unwiredReason = `Multiple sites in workspace; extension not wired. Re-run with --site <name>, or edit <site>/site.yml::extensions: manually.`
@@ -779,12 +794,7 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
779
794
  }
780
795
  )
781
796
  if (response.site) {
782
- wiredSite = await wireExtensionToSite(
783
- rootDir,
784
- response.site,
785
- name,
786
- target
787
- )
797
+ wiredSite = await wireExtensionToSite(rootDir, response.site, relativePath)
788
798
  }
789
799
  }
790
800
  } else {
@@ -796,7 +806,7 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
796
806
  const sites = await discoverSites(rootDir)
797
807
  await updateRootScripts(rootDir, sites, pm)
798
808
 
799
- let msg = `Created extension '${name}' at ${target}/`
809
+ let msg = `Created extension '${name}' at ${relativePath}/`
800
810
  if (wiredSite) {
801
811
  msg += ` → wired to site '${wiredSite}'`
802
812
  }
@@ -1117,12 +1127,7 @@ async function applyFromTemplate(
1117
1127
  /**
1118
1128
  * Wire an extension URL to a site's site.yml
1119
1129
  */
1120
- async function wireExtensionToSite(
1121
- rootDir,
1122
- siteName,
1123
- extensionName,
1124
- extensionPath
1125
- ) {
1130
+ async function wireExtensionToSite(rootDir, siteName, extensionFolder) {
1126
1131
  // Find the site directory
1127
1132
  const sites = await discoverSites(rootDir)
1128
1133
  const site = sites.find((s) => s.name === siteName)
@@ -1141,8 +1146,16 @@ async function wireExtensionToSite(
1141
1146
  const content = await readFile(siteYmlPath, 'utf-8')
1142
1147
  const config = yaml.load(content) || {}
1143
1148
 
1144
- // Add extension URL
1145
- const extensionUrl = `/${extensionPath}/dist/entry.js`
1149
+ // ⭐ The site loads it from its own origin at `/<name>/entry.js`, `<name>`
1150
+ // being the extension's folder: the build finds that extension's build in
1151
+ // `<name>/` or `extensions/<name>/`, copies it into the site's output there,
1152
+ // and prerender imports it from the same place (`@uniweb/build`,
1153
+ // resolveExtensionDist) — the form the `extensions` template declares.
1154
+ // ⛔ Until 2026-10-08 this wrote `/<folder>/dist/entry.js`, which that lookup
1155
+ // reads as an extension named after the folder's FIRST segment —
1156
+ // `/extensions/effects/dist/entry.js` as one named `extensions` — so the
1157
+ // build warned it found no build, and the site 404'd on the file.
1158
+ const extensionUrl = `/${basename(extensionFolder)}/entry.js`
1146
1159
  if (!config.extensions) {
1147
1160
  config.extensions = []
1148
1161
  }
@@ -47,7 +47,7 @@
47
47
  */
48
48
 
49
49
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
50
- import { resolve, join, dirname, basename } from 'node:path'
50
+ import { resolve, join, dirname, basename, relative } from 'node:path'
51
51
  import { spawn } from 'node:child_process'
52
52
  import { writeFile, mkdir } from 'node:fs/promises'
53
53
  import { createRequire } from 'node:module'
@@ -94,6 +94,10 @@ function info(message) {
94
94
  console.log(`${colors.cyan}→${colors.reset} ${message}`)
95
95
  }
96
96
 
97
+ function warn(message) {
98
+ console.log(`${colors.yellow}⚠${colors.reset} ${message}`)
99
+ }
100
+
97
101
  /**
98
102
  * Detect project type based on directory contents
99
103
  *
@@ -205,24 +209,29 @@ async function buildFoundation(projectDir, options = {}) {
205
209
 
206
210
  info('Building foundation...')
207
211
 
208
- // 1. Discover components
212
+ // 1. Discover section types
209
213
  log('')
210
- info('Discovering components...')
214
+ info('Discovering section types...')
211
215
  const components = await discoverComponents(srcDir)
212
216
  const componentNames = Object.keys(components)
213
217
 
218
+ // ⭐ A foundation with no section types yet builds — the state
219
+ // `create --template none` makes on purpose, and one `uniweb dev` and
220
+ // @uniweb/build's entry generation already accept. ⛔ Until 2026-10-08 this
221
+ // stopped the build, saying section types belonged in `components/[Name]/`
222
+ // with a meta.js — where they lived in January — so a fresh `none` project
223
+ // failed its first build, pointed at the wrong folder.
214
224
  if (componentNames.length === 0) {
215
- error('No components found with meta.js files')
216
- error(
217
- `Make sure components are in ${srcDir}/components/[Name]/ with a meta.js file`
225
+ const sectionsDir = relative(process.cwd(), join(srcDir, 'sections')) || 'sections'
226
+ warn(`No section types yet: ${sectionsDir}/ is empty.`)
227
+ log(
228
+ ` ${colors.dim}Add one: ${colors.reset}uniweb add section Hero${colors.dim} — a PascalCase file or folder there is one.${colors.reset}`
218
229
  )
219
- process.exit(1)
230
+ } else {
231
+ const n = componentNames.length
232
+ success(`Found ${n} section type${n === 1 ? '' : 's'}: ${componentNames.join(', ')}`)
220
233
  }
221
234
 
222
- success(
223
- `Found ${componentNames.length} components: ${componentNames.join(', ')}`
224
- )
225
-
226
235
  // 2. Generate entry point
227
236
  log('')
228
237
  info('Generating entry point...')
@@ -960,11 +969,10 @@ async function buildWorkspace(workspaceDir, options = {}) {
960
969
  ) {
961
970
  error('No foundations, extensions, or sites found in workspace')
962
971
  log('')
963
- log('Expected structure (matching pnpm-workspace.yaml globs):')
964
- log(' foundation/ or foundations/*/')
965
- log(' site/ or sites/*/')
966
- log(' */foundation + */site (co-located)')
967
- log(' extensions/*/')
972
+ log('Packages are the folders pnpm-workspace.yaml lists. Add one with:')
973
+ log(' uniweb add foundation [folder] (src/ when none is named)')
974
+ log(' uniweb add site [folder] (site/ when none is named)')
975
+ log(' uniweb add extension <folder>')
968
976
  process.exit(1)
969
977
  }
970
978
 
@@ -170,7 +170,7 @@ ${colors.dim}Schema: https://raw.githubusercontent.com/uniweb/cli/main/schemas/p
170
170
  const META_REFERENCE = `
171
171
  ${colors.cyan}${colors.bright}Component meta.js Reference${colors.reset}
172
172
 
173
- Component metadata in <foundation>/components/[Name]/meta.js (or sections/[Name]/meta.js)
173
+ A section type's metadata, in <foundation>/sections/[Name]/meta.js
174
174
 
175
175
  ${colors.bright}Identity:${colors.reset}
176
176
  ${colors.cyan}title${colors.reset} Display name in editor
@@ -80,27 +80,6 @@ function isFoundation(dir) {
80
80
  return classifyPackage(dir) === 'foundation'
81
81
  }
82
82
 
83
- /**
84
- * Load the foundation's authored declarations file (main.js or legacy
85
- * foundation.js) and return a minimal summary used by the doctor.
86
- * Returns null if no declarations file is found.
87
- */
88
- function loadFoundationJs(dir) {
89
- const srcDir = resolveFoundationSrcPath(dir)
90
- for (const name of ['main.js', 'foundation.js']) {
91
- const filePath = join(srcDir, name)
92
- if (existsSync(filePath)) {
93
- try {
94
- const content = readFileSync(filePath, 'utf8')
95
- return { extension: /extension\s*:\s*true/.test(content) }
96
- } catch {
97
- return null
98
- }
99
- }
100
- }
101
- return null
102
- }
103
-
104
83
  /**
105
84
  * Load a foundation's built schema.json.
106
85
  *
@@ -1634,21 +1613,12 @@ export async function doctor(args = []) {
1634
1613
  log('')
1635
1614
  info(`Checking extension: ${ext.name}`)
1636
1615
 
1637
- // Check if it declares extension: true
1638
- if (!isExtensionPackage(ext.path)) {
1639
- issues.push({
1640
- type: 'warn',
1641
- message: `Extension "${ext.name}" in extensions/ doesn't declare extension: true in foundation.js`
1642
- })
1643
- warn(`Missing extension identity`)
1644
- log(` ${colors.dim}Add to src/foundation.js:${colors.reset}`)
1645
- log(` export default { extension: true }`)
1646
- } else {
1647
- success(`Extension identity: extension: true`)
1648
- }
1616
+ // An extension is a package that declares `extension: true` — that is how it
1617
+ // was told apart from the foundations above, wherever its folder is. (A check
1618
+ // here that it declares it could never fail, and was removed 2026-10-08.)
1619
+ success(`Extension identity: extension: true`)
1649
1620
 
1650
1621
  // Check for vars or layouts
1651
- const config = loadFoundationJs(ext.path)
1652
1622
  const schema = loadSchemaJson(ext.path)
1653
1623
 
1654
1624
  if (schema?._self?.vars && Object.keys(schema._self.vars).length > 0) {
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-10-09T02:55:55.375Z",
3
+ "generatedAt": "2026-10-11T15:34:07.453Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
- "version": "0.7.0"
6
+ "version": "0.8.0"
7
7
  },
8
8
  "@uniweb/build": {
9
9
  "version": "0.80.3"
@@ -15,7 +15,7 @@
15
15
  "version": "0.3.4"
16
16
  },
17
17
  "@uniweb/core": {
18
- "version": "0.38.0"
18
+ "version": "0.38.1"
19
19
  },
20
20
  "@uniweb/frame-bridge": {
21
21
  "version": "0.3.2"
@@ -36,7 +36,7 @@
36
36
  "version": "0.7.15"
37
37
  },
38
38
  "@uniweb/runtime": {
39
- "version": "0.29.5"
39
+ "version": "0.29.6"
40
40
  },
41
41
  "@uniweb/scene": {
42
42
  "version": "0.1.3"
@@ -54,7 +54,7 @@
54
54
  "version": "0.1.2"
55
55
  },
56
56
  "@uniweb/templates": {
57
- "version": "0.18.6"
57
+ "version": "0.18.8"
58
58
  },
59
59
  "@uniweb/theming": {
60
60
  "version": "0.2.0"
package/src/index.js CHANGED
@@ -1627,7 +1627,7 @@ ${colors.bright}Usage:${colors.reset}
1627
1627
 
1628
1628
  ${colors.bright}Common Options:${colors.reset}
1629
1629
  --from <template> Apply content from a template after scaffolding
1630
- --path <dir> Custom directory for the package
1630
+ --path <dir> The folder to put the package in
1631
1631
  --non-interactive Fail with usage info instead of prompting
1632
1632
 
1633
1633
  ${colors.bright}Foundation Options:${colors.reset}
@@ -1675,7 +1675,8 @@ ${colors.bright}Examples:${colors.reset}
1675
1675
  uniweb add foundation foundations/ui # Create ./foundations/ui/
1676
1676
  uniweb add site # Create ./site/ at root
1677
1677
  uniweb add site blog --foundation marketing # Create ./blog/ wired to marketing
1678
- uniweb add extension effects --site site # Create ./extensions/effects/
1678
+ uniweb add extension effects --site site # Create ./effects/ — the folder you name
1679
+ uniweb add extension extensions/effects # Create ./extensions/effects/
1679
1680
  uniweb add section Hero # Create Hero section type
1680
1681
  uniweb add section Hero --foundation ui # Target specific foundation
1681
1682
  uniweb add section Hero --starter # Starter content for an existing Hero
@@ -1849,11 +1850,16 @@ ${colors.bright}Options:${colors.reset}
1849
1850
  --token-paste Force the paste-a-token prompt
1850
1851
 
1851
1852
  In non-interactive mode (no TTY — an agent, a script), pass \`--token <bearer>\` with
1852
- \`--org @org\` or \`--personal\`, or set \`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`, or set
1853
- \`UNIWEB_TOKEN\` (per process, not stored) with \`UNIWEB_WORKSPACE=@org\` or \`personal\`.
1853
+ \`--org @org\` or \`--personal\`, or set \`UNIWEB_USERNAME\` + \`UNIWEB_PASSWORD\`.
1854
1854
  A login with organizations that names no workspace signs you in but exits 2;
1855
1855
  \`uniweb login --org @org\` (or \`--personal\`) finishes it without signing in again.
1856
1856
  \`uniweb logout\` logs you out.
1857
+
1858
+ ${colors.bright}Or skip the login.${colors.reset} With \`UNIWEB_TOKEN\` set — and \`UNIWEB_WORKSPACE=@org\` or
1859
+ \`personal\` — every backend command in that process uses the token, ahead of any
1860
+ session, and nothing is stored. \`uniweb login\` there only checks the token with the
1861
+ backend those commands go to — \`UNIWEB_SERVER\`, else the one you are logged in to,
1862
+ else the default — and says who and where they work as.
1857
1863
  `,
1858
1864
  refresh: `
1859
1865
  ${colors.cyan}${colors.bright}uniweb refresh${colors.reset} ${colors.dim}— Catch up with teammates AND app authors${colors.reset}
@@ -2216,7 +2222,7 @@ ${colors.bright}Examples:${colors.reset}
2216
2222
  uniweb add project docs --from academic # Co-located pair + academic content
2217
2223
  uniweb add foundation marketing # Add marketing/ at root
2218
2224
  uniweb add site blog --foundation marketing # Add blog/ wired to marketing
2219
- uniweb add extension effects --site site # Add extensions/effects/
2225
+ uniweb add extension effects --site site # Add effects/
2220
2226
 
2221
2227
  uniweb build
2222
2228
  uniweb build --target src # Build src/ package
@@ -1,5 +1,14 @@
1
1
  /**
2
- * GitHub fetcher - downloads templates from GitHub repositories
2
+ * GitHub fetcher — downloads a template from a GitHub repository
3
+ * (`github:owner/repo[#ref]`).
4
+ *
5
+ * ⭐ Without a token it downloads from GitHub's archive host —
6
+ * `codeload.github.com/<owner>/<repo>/tar.gz/<ref>`, `HEAD` naming the default
7
+ * branch — which is not the REST API, so the API's anonymous limit (60 requests an
8
+ * hour per address) does not apply. With GITHUB_TOKEN set it uses the API's
9
+ * tarball endpoint, which a private repository needs and which then counts
10
+ * against the token's own, higher limit. ⛔ Until 2026-10-08 every download went
11
+ * through the API, token or not.
3
12
  */
4
13
 
5
14
  import { pipeline } from 'node:stream/promises'
@@ -10,6 +19,32 @@ import { join } from 'node:path'
10
19
  import * as tar from 'tar'
11
20
  import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.js'
12
21
 
22
+ /**
23
+ * Where to download a repository's tarball from, and with which headers.
24
+ *
25
+ * @param {string} owner
26
+ * @param {string} repo
27
+ * @param {string} [ref='HEAD'] - Branch, tag, or commit
28
+ * @param {string|null} [token] - GITHUB_TOKEN, when set
29
+ * @returns {{ url: string, headers: Record<string, string> }}
30
+ */
31
+ export function githubTarballRequest(owner, repo, ref = 'HEAD', token = null) {
32
+ if (token) {
33
+ return {
34
+ url: `https://api.github.com/repos/${owner}/${repo}/tarball/${ref}`,
35
+ headers: {
36
+ Accept: 'application/vnd.github+json',
37
+ 'User-Agent': 'uniweb-cli',
38
+ Authorization: `Bearer ${token}`
39
+ }
40
+ }
41
+ }
42
+ return {
43
+ url: `https://codeload.github.com/${owner}/${repo}/tar.gz/${ref}`,
44
+ headers: { 'User-Agent': 'uniweb-cli' }
45
+ }
46
+ }
47
+
13
48
  /**
14
49
  * Fetch a template from a GitHub repository
15
50
  *
@@ -27,35 +62,26 @@ export async function fetchGitHubTemplate(owner, repo, options = {}) {
27
62
  onProgress?.(`Fetching ${owner}/${repo}@${displayRef} from GitHub...`)
28
63
 
29
64
  // GitHub provides tarballs without requiring git
30
- const tarballUrl = `https://api.github.com/repos/${owner}/${repo}/tarball/${ref}`
65
+ const token = process.env.GITHUB_TOKEN || null
66
+ const { url, headers } = githubTarballRequest(owner, repo, ref, token)
31
67
 
32
68
  const tempDir = await mkdtemp(join(tmpdir(), 'uniweb-template-'))
33
69
 
34
70
  try {
35
- const response = await fetchWithRetry(tarballUrl, {
36
- headers: {
37
- Accept: 'application/vnd.github+json',
38
- 'User-Agent': 'uniweb-cli',
39
- // Support private repos if GITHUB_TOKEN is set
40
- ...(process.env.GITHUB_TOKEN && {
41
- Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
42
- })
43
- }
44
- })
71
+ const response = await fetchWithRetry(url, { headers })
45
72
 
46
73
  if (!response.ok) {
47
74
  if (response.status === 404) {
48
- throw new Error(`Repository not found: ${owner}/${repo}`)
75
+ const at = ref === 'HEAD' ? '' : ` at ${ref}`
76
+ throw new Error(
77
+ `Repository not found: ${owner}/${repo}${at}` +
78
+ (token ? '' : ' — a private repository needs GITHUB_TOKEN')
79
+ )
49
80
  }
50
- if (response.status === 403) {
51
- const remaining = response.headers.get('x-ratelimit-remaining')
52
- if (remaining === '0') {
53
- throw new Error(
54
- 'GitHub API rate limit exceeded. Set GITHUB_TOKEN environment variable for higher limits.'
55
- )
56
- }
81
+ if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') {
82
+ throw new Error('GitHub API rate limit exceeded for this GITHUB_TOKEN.')
57
83
  }
58
- throw new Error(`GitHub API error: ${response.status}`)
84
+ throw new Error(`GitHub download failed: HTTP ${response.status}`)
59
85
  }
60
86
 
61
87
  onProgress?.(`Downloading and extracting...`)
@@ -63,7 +89,7 @@ export async function fetchGitHubTemplate(owner, repo, options = {}) {
63
89
  await pipeline(
64
90
  response.body,
65
91
  createGunzip(),
66
- tar.extract({ cwd: tempDir, strip: 1 }) // strip 'owner-repo-sha/' prefix
92
+ tar.extract({ cwd: tempDir, strip: 1 }) // the archive's one top folder: 'repo-ref/' (codeload) or 'owner-repo-sha/' (API)
67
93
  )
68
94
 
69
95
  onProgress?.(`Extracted to ${tempDir}`)
@@ -1,5 +1,18 @@
1
1
  /**
2
- * GitHub Release fetcher - downloads official templates from GitHub releases
2
+ * Release fetcher — downloads an official template from the release of the
3
+ * templates repo this CLI was published with.
4
+ *
5
+ * ⭐ ONE REQUEST, AND NOT TO GITHUB'S API. The release is pinned
6
+ * (templates/resolver.js, OFFICIAL_TEMPLATES_RELEASE) and a release's files sit
7
+ * at fixed URLs, so the template's tarball is downloaded from its release URL
8
+ * directly. The name was checked against this CLI's own roster before anything
9
+ * is fetched, and a tarball the release lacks answers 404.
10
+ *
11
+ * ⛔ Until 2026-10-08 a `create` first asked the REST API for the release, to find
12
+ * its manifest.json, then downloaded the manifest to check the name, then the
13
+ * tarball: three requests, the first counted against the API's anonymous limit of
14
+ * 60 an hour per address — which a shared network or a CI runner can spend, and
15
+ * `create` then failed with "rate limit exceeded".
3
16
  */
4
17
 
5
18
  import { pipeline } from 'node:stream/promises'
@@ -12,69 +25,15 @@ import { fetchWithRetry as sharedFetchWithRetry } from '../../utils/fetch-retry.
12
25
 
13
26
  // GitHub repository for official templates
14
27
  const TEMPLATES_REPO = 'uniweb/templates'
15
- const GITHUB_API = 'https://api.github.com'
16
28
 
17
29
  /**
18
- * Fetch manifest.json from one release of the official templates.
19
- *
20
- * ⛔ Always a named release — the one the CLI was published with — and never
21
- * `latest`: the latest templates can be newer than the packages this CLI pins
22
- * (templates/resolver.js, OFFICIAL_TEMPLATES_RELEASE).
30
+ * The URL a release serves one template's tarball at.
23
31
  *
24
- * @param {Object} options - Fetch options
25
- * @param {string} options.version - The release tag, e.g. `v0.18.4`
26
- * @param {Function} options.onProgress - Progress callback
27
- * @returns {Promise<Object>} { version, templates, downloadUrlBase }
32
+ * @param {string} name - Template name (e.g., 'marketing')
33
+ * @param {string} version - The release tag, e.g. `v0.18.6`
28
34
  */
29
- export async function fetchManifest(options = {}) {
30
- const { version, onProgress } = options
31
- if (!version) throw new Error('fetchManifest needs the release tag to read')
32
-
33
- onProgress?.('Fetching template manifest...')
34
-
35
- const releaseResponse = await fetchWithRetry(
36
- `${GITHUB_API}/repos/${TEMPLATES_REPO}/releases/tags/${version}`,
37
- { headers: getGitHubHeaders() }
38
- )
39
-
40
- if (!releaseResponse.ok) {
41
- if (releaseResponse.status === 404) {
42
- throw new Error(`Release ${version} not found for ${TEMPLATES_REPO}`)
43
- }
44
- await handleGitHubError(releaseResponse)
45
- }
46
-
47
- const release = await releaseResponse.json()
48
-
49
- // Find manifest.json asset
50
- const manifestAsset = release.assets?.find((a) => a.name === 'manifest.json')
51
- if (!manifestAsset) {
52
- throw new Error(
53
- `Release ${release.tag_name} does not contain manifest.json. ` +
54
- `This may be an older release format.`
55
- )
56
- }
57
-
58
- // Download manifest
59
- const manifestResponse = await fetchWithRetry(
60
- manifestAsset.browser_download_url,
61
- {
62
- headers: getGitHubHeaders()
63
- }
64
- )
65
-
66
- if (!manifestResponse.ok) {
67
- throw new Error(`Failed to download manifest: ${manifestResponse.status}`)
68
- }
69
-
70
- const manifest = await manifestResponse.json()
71
-
72
- return {
73
- version: release.tag_name,
74
- templates: manifest.templates || {},
75
- // Base URL for downloading template tarballs
76
- downloadUrlBase: `https://github.com/${TEMPLATES_REPO}/releases/download/${release.tag_name}`
77
- }
35
+ export function officialTemplateUrl(name, version) {
36
+ return `https://github.com/${TEMPLATES_REPO}/releases/download/${version}/${name}.tar.gz`
78
37
  }
79
38
 
80
39
  /**
@@ -82,42 +41,25 @@ export async function fetchManifest(options = {}) {
82
41
  *
83
42
  * @param {string} name - Template name (e.g., 'marketing')
84
43
  * @param {Object} options - Fetch options
85
- * @param {string} options.version - The release tag, e.g. `v0.18.4`
44
+ * @param {string} options.version - The release tag, e.g. `v0.18.6`
86
45
  * @param {Function} options.onProgress - Progress callback
87
- * @returns {Promise<Object>} { tempDir, version, metadata }
46
+ * @returns {Promise<Object>} { tempDir, baseTempDir, version }
88
47
  */
89
48
  export async function fetchOfficialTemplate(name, options = {}) {
90
49
  const { version, onProgress } = options
50
+ if (!version) throw new Error('fetchOfficialTemplate needs the release tag to download from')
91
51
 
92
- // Get manifest first
93
- const manifest = await fetchManifest({ version, onProgress })
52
+ onProgress?.(`Downloading ${name} template (${version})...`)
94
53
 
95
- // Check if template exists
96
- const templateInfo = manifest.templates[name]
97
- if (!templateInfo) {
98
- const available = Object.keys(manifest.templates).join(', ')
99
- throw new Error(
100
- `Template "${name}" not found in release ${manifest.version}.\n` +
101
- `Available templates: ${available || 'none'}`
102
- )
103
- }
104
-
105
- onProgress?.(`Downloading ${name} template (${manifest.version})...`)
106
-
107
- // Download template tarball
108
- const tarballUrl = `${manifest.downloadUrlBase}/${name}.tar.gz`
109
- const tarballResponse = await fetchWithRetry(tarballUrl, {
110
- headers: getGitHubHeaders()
54
+ const response = await fetchWithRetry(officialTemplateUrl(name, version), {
55
+ headers: { 'User-Agent': 'uniweb-cli' }
111
56
  })
112
57
 
113
- if (!tarballResponse.ok) {
114
- if (tarballResponse.status === 404) {
115
- throw new Error(
116
- `Template tarball not found: ${name}.tar.gz\n` +
117
- `The release may be incomplete or corrupted.`
118
- )
58
+ if (!response.ok) {
59
+ if (response.status === 404) {
60
+ throw new Error(`Release ${version} of ${TEMPLATES_REPO} has no template "${name}".`)
119
61
  }
120
- throw new Error(`Failed to download template: ${tarballResponse.status}`)
62
+ throw new Error(`Could not download the ${name} template (HTTP ${response.status}).`)
121
63
  }
122
64
 
123
65
  // Extract to temp directory
@@ -126,19 +68,14 @@ export async function fetchOfficialTemplate(name, options = {}) {
126
68
  try {
127
69
  onProgress?.('Extracting template...')
128
70
 
129
- await pipeline(
130
- tarballResponse.body,
131
- createGunzip(),
132
- tar.extract({ cwd: tempDir, strip: 0 })
133
- )
71
+ await pipeline(response.body, createGunzip(), tar.extract({ cwd: tempDir, strip: 0 }))
134
72
 
135
- // Tarball contains template in a subdirectory named after the template
136
- // e.g., marketing.tar.gz extracts to marketing/template.json
73
+ // The tarball holds the template in a folder named after it:
74
+ // marketing.tar.gz extracts to marketing/template.json
137
75
  return {
138
76
  tempDir: join(tempDir, name),
139
77
  baseTempDir: tempDir, // For cleanup
140
- version: manifest.version,
141
- metadata: templateInfo
78
+ version
142
79
  }
143
80
  } catch (err) {
144
81
  // Clean up on error
@@ -147,36 +84,6 @@ export async function fetchOfficialTemplate(name, options = {}) {
147
84
  }
148
85
  }
149
86
 
150
- /**
151
- * Get GitHub API headers
152
- */
153
- function getGitHubHeaders() {
154
- return {
155
- Accept: 'application/vnd.github+json',
156
- 'User-Agent': 'uniweb-cli',
157
- // Support private repos or higher rate limits if GITHUB_TOKEN is set
158
- ...(process.env.GITHUB_TOKEN && {
159
- Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
160
- })
161
- }
162
- }
163
-
164
- /**
165
- * Handle GitHub API errors
166
- */
167
- async function handleGitHubError(response) {
168
- if (response.status === 403) {
169
- const remaining = response.headers.get('x-ratelimit-remaining')
170
- if (remaining === '0') {
171
- throw new Error(
172
- 'GitHub API rate limit exceeded.\n' +
173
- 'Set GITHUB_TOKEN environment variable for higher limits.'
174
- )
175
- }
176
- }
177
- throw new Error(`GitHub API error: ${response.status}`)
178
- }
179
-
180
87
  // Retry + timeout live in one place now (`../../utils/fetch-retry.js`).
181
88
  // This file carried its own copy, as did the other two fetchers — three
182
89
  // byte-identical implementations, and none reachable from the upload paths
@@ -5,11 +5,7 @@
5
5
  * and detects collisions with existing workspace packages.
6
6
  */
7
7
 
8
- import { readWorkspaceConfig, resolveGlob } from './config.js'
9
8
  import { discoverFoundations, discoverSites } from './discover.js'
10
- import { existsSync } from 'node:fs'
11
- import { readFile } from 'node:fs/promises'
12
- import { join } from 'node:path'
13
9
 
14
10
  /**
15
11
  * Names that must not be used as package names.
@@ -65,26 +61,13 @@ export async function getExistingPackageNames(rootDir) {
65
61
  const foundations = await discoverFoundations(rootDir)
66
62
  const sites = await discoverSites(rootDir)
67
63
 
64
+ // An extension is a foundation that declares `extension: true`, so the
65
+ // discovery above already has it, wherever its folder is. (A second scan of
66
+ // `extensions/*` stood here until 2026-10-08, when extensions stopped being
67
+ // placed there by default.)
68
68
  for (const f of foundations) names.add(f.name)
69
69
  for (const s of sites) names.add(s.name)
70
70
 
71
- // Extensions (foundations with @uniweb/runtime absent — already captured above)
72
- // Also scan extensions/* if it exists
73
- const extensionsDir = join(rootDir, 'extensions')
74
- if (existsSync(extensionsDir)) {
75
- const dirs = await resolveGlob(rootDir, 'extensions/*')
76
- for (const dir of dirs) {
77
- const pkgPath = join(rootDir, dir, 'package.json')
78
- if (!existsSync(pkgPath)) continue
79
- try {
80
- const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
81
- if (pkg.name) names.add(pkg.name)
82
- } catch {
83
- // skip
84
- }
85
- }
86
- }
87
-
88
71
  return names
89
72
  }
90
73
 
@@ -48,6 +48,20 @@ export const SITE_KIND = {
48
48
  projectSub: 'site'
49
49
  }
50
50
 
51
+ /**
52
+ * Extension placement: no defaults — `add extension` always has a name, or a
53
+ * `--path` naming the folder, so neither a default folder nor the co-located
54
+ * `--project` form is reached. ⛔ Until 2026-10-08 an extension went to
55
+ * `extensions/<name>/` whatever was asked, `--path` named the folder itself
56
+ * rather than the one it goes in, and the workspace glob added was always
57
+ * `extensions/*` — so `--path elsewhere/fx` made a package pnpm never saw.
58
+ */
59
+ export const EXTENSION_KIND = {
60
+ defaultDir: null,
61
+ defaultPkg: null,
62
+ projectSub: null
63
+ }
64
+
51
65
  /**
52
66
  * Resolve where a foundation or site should be placed, given the user's input.
53
67
  *
@@ -366,7 +366,7 @@ async function loginViaPassword({ apiBase, nonInteractive }) {
366
366
  if (!username || !password) {
367
367
  if (nonInteractive) {
368
368
  throw new Error(
369
- 'username/password login needs a terminal — set UNIWEB_USERNAME + UNIWEB_PASSWORD (or UNIWEB_TOKEN).'
369
+ 'username/password login needs a terminal, or UNIWEB_USERNAME + UNIWEB_PASSWORD.'
370
370
  )
371
371
  }
372
372
  const prompts = (await import('prompts')).default
@@ -402,7 +402,7 @@ async function loginViaPassword({ apiBase, nonInteractive }) {
402
402
  // Paste a token — verified + identified via /me before it's stored.
403
403
  async function loginViaTokenPaste({ apiBase, nonInteractive }) {
404
404
  if (nonInteractive) {
405
- throw new Error('token paste needs a terminal — set UNIWEB_TOKEN instead.')
405
+ throw new Error('token paste needs a terminal — pass the token instead: uniweb login --token <bearer>.')
406
406
  }
407
407
  const prompts = (await import('prompts')).default
408
408
  const { token } = await prompts(
@@ -678,7 +678,15 @@ async function workspaceTail(record) {
678
678
  * browser/social (default, once available) · username+password · paste a token.
679
679
  * Force a method with --browser / --password / --token-paste (skips the menu).
680
680
  * No TTY → no menu; falls back to the non-browser path (env UNIWEB_USERNAME/
681
- * PASSWORD; UNIWEB_TOKEN is handled earlier by ensureRegistryAuth).
681
+ * PASSWORD, or `--token <bearer>`).
682
+ *
683
+ * ⭐ UNIWEB_TOKEN IS NOT A LOGIN METHOD. It is the one-process bearer each backend
684
+ * command reads first, ahead of any stored session (ensureRegistryAuth), and it is
685
+ * never stored — so in a process that has it, login only checks it and says so
686
+ * (reportTokenProcess). ⛔ This note used to say "UNIWEB_TOKEN is handled earlier by
687
+ * ensureRegistryAuth", true of the backend commands and not of login, which never
688
+ * calls it; read as covering login, it put "set UNIWEB_TOKEN" into login's own
689
+ * refusal — which, with the variable set, refused the same way (measured on 0.88.0).
682
690
  *
683
691
  * @param {Object} o
684
692
  * @param {string} o.apiBase - new-backend origin
@@ -689,7 +697,7 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
689
697
  // For THIS backend. It read "the" session and printed `apiBase` beside it, so on a
690
698
  // machine logged into another backend it announced "Already logged in … (this origin)"
691
699
  // about a session belonging to a different one — and then offered to replace it.
692
- const { isNonInteractive } = await import('./interactive.js')
700
+ const { isNonInteractive, getCliPrefix } = await import('./interactive.js')
693
701
  const nonInteractive = isNonInteractive(args)
694
702
 
695
703
  const key = normOrigin(apiBase)
@@ -701,6 +709,22 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
701
709
  args.includes('--browser') ||
702
710
  args.includes('--password') ||
703
711
  args.includes('--token-paste')
712
+
713
+ // A process with UNIWEB_TOKEN is authenticated by it, ahead of any session: a login
714
+ // that asks for nothing has nothing to do but check it and say so. One that asks for
715
+ // something — a way of signing in, or a workspace switch — still does it, for the stored
716
+ // session, after saying that this process does not use that session.
717
+ const envToken = process.env.UNIWEB_TOKEN
718
+ if (envToken) {
719
+ if (!forced && !readOrgFlag(args) && !args.includes('--personal')) {
720
+ const named = args.some((a) => a === '--server' || a.startsWith('--server='))
721
+ return reportTokenProcess({ named: named ? key : null, token: envToken })
722
+ }
723
+ console.error(
724
+ `\x1b[2mUNIWEB_TOKEN is set, so this process's backend commands use it (and UNIWEB_WORKSPACE) — not the session or workspace this login stores.\x1b[0m\n`
725
+ )
726
+ }
727
+
704
728
  if (existing?.token && !isExpired(existing)) {
705
729
  const who =
706
730
  existing.username ||
@@ -791,13 +815,21 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
791
815
  if (process.env.UNIWEB_USERNAME && process.env.UNIWEB_PASSWORD) {
792
816
  method = 'password'
793
817
  } else {
818
+ // Only what works without a terminal: `--password` and `--token-paste` prompt. The
819
+ // command keeps the backend named, or re-run as printed it would go to the default.
820
+ const server = args.some((a) => a === '--server' || a.startsWith('--server='))
821
+ ? ` --server ${key}`
822
+ : ''
823
+ console.error(
824
+ '\x1b[31m✗\x1b[0m Without a terminal, a login needs a way to sign in that asks nothing:'
825
+ )
794
826
  console.error(
795
- '\x1b[31m✗\x1b[0m Cannot log in non-interactively without a method.'
827
+ ` ${getCliPrefix()} login${server} --token <bearer> (with --org @org or --personal)`
796
828
  )
829
+ console.error(' UNIWEB_USERNAME + UNIWEB_PASSWORD, set for the login')
797
830
  console.error(
798
- ' Set UNIWEB_USERNAME + UNIWEB_PASSWORD, set UNIWEB_TOKEN, or run `uniweb login` in a terminal.'
831
+ ' Or skip the login: with UNIWEB_TOKEN set, every backend command in that process uses it, and nothing is stored.'
799
832
  )
800
- console.error(' Or force one: --password / --token-paste.')
801
833
  process.exit(1)
802
834
  }
803
835
  } else {
@@ -866,3 +898,97 @@ async function finishLogin(record, apiBase, args) {
866
898
  console.error(`\x1b[32m✓\x1b[0m Logged in${who} (${apiBase})${await workspaceTail(settled.record)}.`)
867
899
  return settled.record
868
900
  }
901
+
902
+ /**
903
+ * `uniweb login` in a process that has UNIWEB_TOKEN, asking for nothing: there is no login
904
+ * to do — every backend command here sends the token, ahead of any stored session — so
905
+ * check it with the backend those commands go to, and say who and where they work as.
906
+ *
907
+ * ⭐ CHECKED WHERE IT IS SENT. Those commands go to `getRegistryApiBaseUrl()` — UNIWEB_SERVER,
908
+ * else the backend logged in to, else the default — while a login goes where `--server`
909
+ * says. A login naming another backend is refused rather than checked there: a yes from a
910
+ * backend this process never reaches is a false answer, and the next command would show
911
+ * it as a 401 from the other one.
912
+ *
913
+ * Exits 1 when the token is refused or unreachable, 2 when it is good and its workspace is
914
+ * not settled (as `finishLogin` does); returns nothing, since nothing is stored.
915
+ *
916
+ * @param {object} o
917
+ * @param {string|null} o.named - the origin `--server` named, or null
918
+ * @param {string} o.token
919
+ */
920
+ async function reportTokenProcess({ named, token }) {
921
+ const { getRegistryApiBaseUrl } = await import('./config.js')
922
+ const { getCliPrefix } = await import('./interactive.js')
923
+ const origin = normOrigin(getRegistryApiBaseUrl())
924
+ if (named && named !== origin) {
925
+ console.error(
926
+ `\x1b[31m✗\x1b[0m UNIWEB_TOKEN is set, and this process's backend commands send it to ${origin} — not ${named}.`
927
+ )
928
+ console.error(` To use the token with ${named}: UNIWEB_SERVER=${named}`)
929
+ console.error(` To log in to ${named} instead, unset UNIWEB_TOKEN: ${getCliPrefix()} login --server ${named}`)
930
+ process.exit(1)
931
+ }
932
+
933
+ let account
934
+ try {
935
+ account = await fetchMe({ apiBase: origin, token })
936
+ } catch (err) {
937
+ console.error(
938
+ err.status
939
+ ? `\x1b[31m✗\x1b[0m ${origin} refuses UNIWEB_TOKEN (HTTP ${err.status}).`
940
+ : `\x1b[31m✗\x1b[0m Could not check UNIWEB_TOKEN with ${origin}: ${err.message}`
941
+ )
942
+ process.exit(1)
943
+ }
944
+ const who = account?.username || account?.handle || (account?.uuid ? `account ${account.uuid}` : null)
945
+ console.error(
946
+ `\x1b[32m✓\x1b[0m No login needed: this process's backend commands use UNIWEB_TOKEN, which ${origin} accepts${who ? ` as \x1b[1m${who}\x1b[0m` : ''}.`
947
+ )
948
+ console.error(
949
+ '\x1b[2m It comes before any stored session and is never stored. To store a session, name a way to sign in: --password, --browser, --token-paste or --token <bearer>.\x1b[0m'
950
+ )
951
+
952
+ // Their workspace is UNIWEB_WORKSPACE's — never the stored session's — and, with none
953
+ // named, personal for an account in no organization (resolveWorkspace, workspace.js).
954
+ const { WORKSPACE_ENV, PERSONAL, parseWorkspaceChoice, headerOf, describeWorkspace } =
955
+ await import('../backend/workspace.js')
956
+ const unsettled = (reason, { commandsRefuse }) => {
957
+ console.error(`\x1b[31m✗\x1b[0m ${reason}`)
958
+ if (commandsRefuse) {
959
+ console.error(`\x1b[2m Until a workspace is named, the commands that work on a site refuse.\x1b[0m`)
960
+ }
961
+ process.exit(2)
962
+ }
963
+ const raw = process.env[WORKSPACE_ENV]
964
+ const choice = parseWorkspaceChoice(raw)
965
+ if (raw && !choice) {
966
+ unsettled(`${WORKSPACE_ENV}=${JSON.stringify(raw)} names no workspace — give @org, or personal.`, {
967
+ commandsRefuse: true
968
+ })
969
+ }
970
+ let orgs
971
+ try {
972
+ orgs = (await (await import('./registry-orgs.js')).fetchOrgs({ apiBase: origin, token })).orgs
973
+ } catch (err) {
974
+ console.error(`\x1b[33m⚠\x1b[0m ${err.message} — the workspace is not checked.`)
975
+ return
976
+ }
977
+ const mine = orgs.map((o) => `@${o.handle}`)
978
+ if (choice && choice !== PERSONAL && !mine.includes(choice)) {
979
+ // As a login refuses `--org` for one (chooseWorkspace).
980
+ unsettled(`${WORKSPACE_ENV}=${raw} is not one of this account's organizations on ${origin}.`, {
981
+ commandsRefuse: false
982
+ })
983
+ }
984
+ if (!choice && mine.length) {
985
+ unsettled(
986
+ `The account belongs to organizations, so no workspace is assumed: set ${WORKSPACE_ENV}=${mine[0]} (or another, or personal).`,
987
+ { commandsRefuse: true }
988
+ )
989
+ }
990
+ const where = describeWorkspace(headerOf(choice || PERSONAL))
991
+ console.error(
992
+ ` They work in \x1b[1m${where}\x1b[0m${choice ? ` (${WORKSPACE_ENV})` : ' — the account belongs to no organization'}.`
993
+ )
994
+ }