uniweb 0.88.1 → 0.89.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/package.json +6 -6
- package/partials/agents.md +1 -1
- package/src/commands/add.js +60 -47
- package/src/commands/build.js +24 -16
- package/src/commands/docs.js +1 -1
- package/src/commands/doctor.js +4 -34
- package/src/framework-index.json +2 -2
- package/src/index.js +11 -5
- package/src/templates/fetchers/github.js +48 -22
- package/src/templates/fetchers/release.js +33 -126
- package/src/utils/names.js +4 -21
- package/src/utils/placement.js +14 -0
- package/src/utils/registry-auth.js +133 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.89.0",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,17 +41,17 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
+
"@uniweb/runtime": "^0.29.5",
|
|
44
45
|
"@uniweb/core": "^0.38.0",
|
|
45
|
-
"@uniweb/content-writer": "^0.3.4",
|
|
46
|
-
"@uniweb/semantic-parser": "^1.5.1",
|
|
47
46
|
"@uniweb/kit": "^0.20.0",
|
|
47
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
48
48
|
"@uniweb/schemas": "^0.13.0",
|
|
49
|
-
"@uniweb/
|
|
49
|
+
"@uniweb/semantic-parser": "^1.5.1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
+
"@uniweb/content-reader": "^1.2.8",
|
|
52
53
|
"@uniweb/build": "^0.80.3",
|
|
53
|
-
"@uniweb/semantic-parser": "^1.5.1"
|
|
54
|
-
"@uniweb/content-reader": "^1.2.8"
|
|
54
|
+
"@uniweb/semantic-parser": "^1.5.1"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -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
|
|
package/src/commands/add.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
732
|
-
await addWorkspaceGlob(rootDir,
|
|
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
|
-
|
|
746
|
-
|
|
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 ${
|
|
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
|
-
//
|
|
1145
|
-
|
|
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
|
}
|
package/src/commands/build.js
CHANGED
|
@@ -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
|
|
212
|
+
// 1. Discover section types
|
|
209
213
|
log('')
|
|
210
|
-
info('Discovering
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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('
|
|
964
|
-
log(' foundation/
|
|
965
|
-
log(' site/
|
|
966
|
-
log('
|
|
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
|
|
package/src/commands/docs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
-
//
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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) {
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-09T22:44:36.037Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
6
|
"version": "0.7.0"
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"version": "0.1.2"
|
|
55
55
|
},
|
|
56
56
|
"@uniweb/templates": {
|
|
57
|
-
"version": "0.18.
|
|
57
|
+
"version": "0.18.7"
|
|
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>
|
|
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 ./
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 }) //
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
25
|
-
* @param {string}
|
|
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
|
|
30
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
93
|
-
const manifest = await fetchManifest({ version, onProgress })
|
|
52
|
+
onProgress?.(`Downloading ${name} template (${version})...`)
|
|
94
53
|
|
|
95
|
-
|
|
96
|
-
|
|
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 (!
|
|
114
|
-
if (
|
|
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(`
|
|
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
|
-
//
|
|
136
|
-
//
|
|
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
|
|
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
|
package/src/utils/names.js
CHANGED
|
@@ -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
|
|
package/src/utils/placement.js
CHANGED
|
@@ -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
|
|
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 —
|
|
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
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
+
}
|