uniweb 0.12.26 → 0.12.27
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 +3 -0
- package/src/backend/client.js +339 -0
- package/src/commands/clone.js +18 -32
- package/src/commands/deploy.js +218 -1783
- package/src/commands/handoff.js +9 -246
- package/src/commands/invite.js +10 -318
- package/src/commands/org.js +6 -8
- package/src/commands/publish.js +128 -1153
- package/src/commands/pull.js +22 -36
- package/src/commands/push.js +43 -101
- package/src/commands/register.js +184 -39
- package/src/commands/runtime.js +141 -0
- package/src/commands/template.js +13 -221
- package/src/framework-index.json +17 -6
- package/src/index.js +74 -100
- package/src/utils/asset-upload.js +162 -0
- package/src/utils/code-upload.js +245 -0
- package/src/utils/config.js +11 -44
- package/src/utils/registry-auth.js +35 -1
- package/src/utils/registry-orgs.js +141 -73
- package/src/utils/runtime-upload.js +163 -0
- package/src/commands/login.js +0 -230
- package/src/utils/auth.js +0 -212
- package/src/utils/registry.js +0 -466
package/src/commands/publish.js
CHANGED
|
@@ -1,1201 +1,176 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* uniweb publish — make a SYNCED site's current backend state live (CMS publish).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Three "publish-ish" verbs, three jobs — don't conflate them:
|
|
5
|
+
* - `uniweb deploy` hosts the CLI's FILE-BUILT payload (POST /dev/deploy).
|
|
6
|
+
* - `uniweb publish` publishes a SITE that already lives on the backend as a
|
|
7
|
+
* `@uniweb/site-content` entity (synced via `uniweb push`) — POST /dev/site/publish.
|
|
8
|
+
* - `uniweb register` registers a FOUNDATION (+ the data schemas it renders).
|
|
9
|
+
* (Foundation publishing used to be `uniweb publish`; it is now `register`.)
|
|
7
10
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* flow for the "this foundation only powers this one site" case.
|
|
11
|
+
* `publish` makes the site's CURRENT backend state live — including edits made
|
|
12
|
+
* through the app since the last push. It does NOT push local files (run
|
|
13
|
+
* `uniweb push` first if you want your local edits live, then `publish`). The two
|
|
14
|
+
* are deliberately separate steps, mirroring the directional sync primitives.
|
|
13
15
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* - Bare `uniweb publish` (no explicit name) is no longer accepted.
|
|
18
|
-
* The user must provide a deliberate name via --name, --namespace,
|
|
19
|
-
* a sigil-scoped package.json::name, or package.json::uniweb.id.
|
|
20
|
-
* - Catalog confirmation is required: interactive runs prompt; CI
|
|
21
|
-
* runs need --catalog to skip the prompt.
|
|
22
|
-
* - Both gates are skipped for --local (local mock, no public
|
|
23
|
-
* consequences).
|
|
16
|
+
* `{uuid}` is the site-content uuid (`site.yml::$uuid`, written by `uniweb push`).
|
|
17
|
+
* A site that was never pushed 404s — push it first, or use `uniweb deploy` for a
|
|
18
|
+
* file-only site.
|
|
24
19
|
*
|
|
25
20
|
* Usage:
|
|
26
|
-
* uniweb publish
|
|
27
|
-
* uniweb publish --
|
|
28
|
-
* uniweb publish
|
|
29
|
-
* uniweb publish --
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
21
|
+
* uniweb publish Publish the synced site's current state
|
|
22
|
+
* uniweb publish --backend <url> Override the backend origin
|
|
23
|
+
* uniweb publish --token <bearer> Auth bearer (skips `uniweb login`)
|
|
24
|
+
* uniweb publish --dry-run Resolve everything; POST nothing
|
|
25
|
+
*
|
|
26
|
+
* Backend: BackendClient → POST /dev/site/publish/{uuid}. Origin from
|
|
27
|
+
* --backend/--registry > UNIWEB_REGISTER_URL > default. Auth: --token >
|
|
28
|
+
* UNIWEB_TOKEN > `uniweb login`.
|
|
34
29
|
*/
|
|
35
30
|
|
|
36
31
|
import { existsSync } from 'node:fs'
|
|
37
|
-
import { readFile
|
|
38
|
-
import {
|
|
39
|
-
import
|
|
32
|
+
import { readFile } from 'node:fs/promises'
|
|
33
|
+
import { join } from 'node:path'
|
|
34
|
+
import yaml from 'js-yaml'
|
|
40
35
|
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
44
|
-
import { getRegistryUrl, getBackendUrl } from '../utils/config.js'
|
|
45
|
-
import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
|
|
46
|
-
import { findWorkspaceRoot, findFoundations, findSites, promptSelect } from '../utils/workspace.js'
|
|
47
|
-
import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
|
|
36
|
+
import { BackendClient } from '../backend/client.js'
|
|
37
|
+
import { resolveSiteDir } from './deploy.js'
|
|
38
|
+
import { readFlagValue } from '../utils/args.js'
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
bright: '\x1b[1m',
|
|
53
|
-
dim: '\x1b[2m',
|
|
54
|
-
cyan: '\x1b[36m',
|
|
55
|
-
green: '\x1b[32m',
|
|
56
|
-
yellow: '\x1b[33m',
|
|
57
|
-
red: '\x1b[31m',
|
|
40
|
+
const c = {
|
|
41
|
+
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
42
|
+
cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m',
|
|
58
43
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
console.log(`${
|
|
44
|
+
const say = {
|
|
45
|
+
ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
|
|
46
|
+
info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
|
|
47
|
+
warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
|
|
48
|
+
err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
|
|
49
|
+
dim: (m) => console.log(` ${c.dim}${m}${c.reset}`),
|
|
62
50
|
}
|
|
63
51
|
|
|
64
|
-
|
|
65
|
-
|
|
52
|
+
// Highest installed runtime from the backend's /dev/config list (numeric-aware
|
|
53
|
+
// sort). Mirrors deploy.js's resolver. Null when the list is empty.
|
|
54
|
+
function pickHighestRuntime(installed) {
|
|
55
|
+
if (!Array.isArray(installed) || installed.length === 0) return null
|
|
56
|
+
return [...installed].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true }))[0]
|
|
66
57
|
}
|
|
67
58
|
|
|
68
|
-
|
|
69
|
-
|
|
59
|
+
// Origin-relative serve path → clickable absolute URL (self-serve default).
|
|
60
|
+
function absolutizeServeUrl(origin, url) {
|
|
61
|
+
if (!url || typeof url !== 'string') return null
|
|
62
|
+
if (/^https?:\/\//.test(url)) return url
|
|
63
|
+
return `${origin.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`
|
|
70
64
|
}
|
|
71
65
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
* @param {string[]} args
|
|
82
|
-
* @returns {Promise<string>} Absolute path to the foundation directory
|
|
83
|
-
*/
|
|
84
|
-
async function resolveFoundationDir(args) {
|
|
85
|
-
const cwd = process.cwd()
|
|
86
|
-
const prefix = getCliPrefix()
|
|
87
|
-
|
|
88
|
-
// Check if current directory is a foundation
|
|
89
|
-
const type = classifyPackage(cwd)
|
|
90
|
-
if (type === 'foundation') {
|
|
91
|
-
return cwd
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Check workspace
|
|
95
|
-
const workspaceRoot = findWorkspaceRoot(cwd)
|
|
96
|
-
if (workspaceRoot) {
|
|
97
|
-
const foundations = await findFoundations(workspaceRoot)
|
|
98
|
-
|
|
99
|
-
if (foundations.length === 1) {
|
|
100
|
-
return resolve(workspaceRoot, foundations[0])
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (foundations.length > 1) {
|
|
104
|
-
if (isNonInteractive(args)) {
|
|
105
|
-
error('Multiple foundations found. Specify which one to publish.')
|
|
106
|
-
console.log('')
|
|
107
|
-
for (const f of foundations) {
|
|
108
|
-
console.log(` ${colors.cyan}cd ${f} && ${prefix} publish${colors.reset}`)
|
|
109
|
-
}
|
|
110
|
-
process.exit(1)
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const choice = await promptSelect('Which foundation?', foundations)
|
|
114
|
-
if (!choice) {
|
|
115
|
-
console.log('\nPublish cancelled.')
|
|
116
|
-
process.exit(0)
|
|
117
|
-
}
|
|
118
|
-
return resolve(workspaceRoot, choice)
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// No foundation found — educational error
|
|
123
|
-
error('No foundation found in this workspace.')
|
|
124
|
-
console.log('')
|
|
125
|
-
console.log(` ${colors.dim}\`publish\` registers your foundation so clients you invite can${colors.reset}`)
|
|
126
|
-
console.log(` ${colors.dim}create and manage their own sites with it.${colors.reset}`)
|
|
127
|
-
console.log('')
|
|
128
|
-
console.log(` ${colors.dim}To publish, run this command from a foundation directory, or from a${colors.reset}`)
|
|
129
|
-
console.log(` ${colors.dim}workspace root that contains a foundation.${colors.reset}`)
|
|
130
|
-
process.exit(1)
|
|
66
|
+
// Locale set from site.yml — source/default first, then declared locales.
|
|
67
|
+
// Tolerant of the shapes site.yml uses (i18n.locales, languages[]). Null when
|
|
68
|
+
// single-locale, so the body omits `languages` and the backend defaults.
|
|
69
|
+
function extractLanguages(siteYml) {
|
|
70
|
+
const def = siteYml.defaultLanguage || siteYml.lang || 'en'
|
|
71
|
+
const locales = siteYml.i18n?.locales || siteYml.languages
|
|
72
|
+
if (!Array.isArray(locales) || locales.length === 0) return null
|
|
73
|
+
const norm = locales.map((l) => (typeof l === 'string' ? l : l?.value || l?.code)).filter(Boolean)
|
|
74
|
+
return [def, ...norm.filter((l) => l !== def)]
|
|
131
75
|
}
|
|
132
76
|
|
|
133
|
-
/**
|
|
134
|
-
* Parse --registry <url> from args.
|
|
135
|
-
* @param {string[]} args
|
|
136
|
-
* @returns {string|null}
|
|
137
|
-
*/
|
|
138
|
-
function parseRegistryUrl(args) {
|
|
139
|
-
const idx = args.indexOf('--registry')
|
|
140
|
-
if (idx === -1 || !args[idx + 1]) return null
|
|
141
|
-
return args[idx + 1]
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Parse --namespace <handle> from args.
|
|
146
|
-
* @param {string[]} args
|
|
147
|
-
* @returns {string|null}
|
|
148
|
-
*/
|
|
149
|
-
function parseNamespace(args) {
|
|
150
|
-
const idx = args.indexOf('--namespace')
|
|
151
|
-
if (idx === -1 || !args[idx + 1]) return null
|
|
152
|
-
return args[idx + 1]
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Parse --name <id> from args.
|
|
157
|
-
* The publish-time "id" — the bare-name segment in the registry name.
|
|
158
|
-
* Distinct from `package.json::name` (a workspace concern). Persisted
|
|
159
|
-
* to `package.json::uniweb.id` after the first successful publish so
|
|
160
|
-
* it doesn't need to be supplied again.
|
|
161
|
-
* @param {string[]} args
|
|
162
|
-
* @returns {string|null}
|
|
163
|
-
*/
|
|
164
|
-
function parseName(args) {
|
|
165
|
-
const idx = args.indexOf('--name')
|
|
166
|
-
if (idx === -1 || !args[idx + 1]) return null
|
|
167
|
-
return args[idx + 1]
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Parse --edit-access <policy> from args.
|
|
172
|
-
* @param {string[]} args
|
|
173
|
-
* @returns {'open'|'restricted'|null}
|
|
174
|
-
*/
|
|
175
|
-
function parseEditAccess(args) {
|
|
176
|
-
const idx = args.indexOf('--edit-access')
|
|
177
|
-
if (idx === -1 || !args[idx + 1]) return null
|
|
178
|
-
const value = args[idx + 1]
|
|
179
|
-
if (value !== 'open' && value !== 'restricted') {
|
|
180
|
-
error(`Invalid --edit-access value: "${value}". Must be "open" or "restricted".`)
|
|
181
|
-
process.exit(1)
|
|
182
|
-
}
|
|
183
|
-
return value
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Main publish command handler
|
|
188
|
-
*/
|
|
189
77
|
export async function publish(args = []) {
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// --catalog confirms the user understands they're publishing to the
|
|
199
|
-
// public catalog. Phase 3 of the CLI ergonomics overhaul: in
|
|
200
|
-
// interactive mode, missing --catalog triggers a confirmation prompt;
|
|
201
|
-
// in non-interactive mode, it's required (otherwise fatal). Skipped
|
|
202
|
-
// entirely for --local (local mock) and --dry-run (no writes).
|
|
203
|
-
const isCatalog = args.includes('--catalog')
|
|
204
|
-
const registryUrl = parseRegistryUrl(args)
|
|
205
|
-
const editAccess = parseEditAccess(args)
|
|
206
|
-
const namespaceFlag = parseNamespace(args)
|
|
207
|
-
const nameFlag = parseName(args)
|
|
208
|
-
|
|
209
|
-
// 1. Resolve foundation directory
|
|
210
|
-
const foundationDir = await resolveFoundationDir(args)
|
|
211
|
-
|
|
212
|
-
// Verify it's actually a foundation (canonical classifier checks
|
|
213
|
-
// package.json::main, then main.js, then legacy foundation.js).
|
|
214
|
-
if (classifyPackage(foundationDir) !== 'foundation') {
|
|
215
|
-
error(`Not a foundation directory: ${foundationDir}`)
|
|
216
|
-
process.exit(1)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// 2. Auto-build if dist/ is missing OR stale.
|
|
220
|
-
//
|
|
221
|
-
// "Stale" means the schema fingerprint baked into
|
|
222
|
-
// `dist/meta/schema.json::_self.version` doesn't match the user's
|
|
223
|
-
// current `package.json::version`. That happens when the user bumps
|
|
224
|
-
// the version and runs `uniweb publish` without rebuilding — the
|
|
225
|
-
// artifact in dist/ encodes the OLD version, but the publish
|
|
226
|
-
// intends the NEW one. Without rebuilding we'd ship inconsistent
|
|
227
|
-
// bytes (schema says one version, registry record says another).
|
|
228
|
-
const distDir = join(foundationDir, 'dist')
|
|
229
|
-
// @uniweb/build@0.14.0+ emits dist/entry.js (Phase 5 of CDN migration);
|
|
230
|
-
// older builds emitted dist/foundation.js. Accept either so a single CLI
|
|
231
|
-
// works against both old and new foundations during the rollout window.
|
|
232
|
-
const entryJs = join(distDir, 'entry.js')
|
|
233
|
-
const foundationJs = join(distDir, 'foundation.js')
|
|
234
|
-
const hasMainArtifact = () => existsSync(entryJs) || existsSync(foundationJs)
|
|
235
|
-
const schemaJson = join(distDir, 'meta', 'schema.json')
|
|
236
|
-
|
|
237
|
-
// Pre-read package.json so we can compare its version against the
|
|
238
|
-
// schema before deciding whether to rebuild.
|
|
239
|
-
const pkgPath = join(foundationDir, 'package.json')
|
|
240
|
-
let earlyPkg
|
|
241
|
-
try {
|
|
242
|
-
earlyPkg = JSON.parse(await readFile(pkgPath, 'utf8'))
|
|
243
|
-
} catch (err) {
|
|
244
|
-
error(`Failed to read package.json: ${err.message}`)
|
|
245
|
-
process.exit(1)
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// 1b. Phase 4e: catalog-publish gate.
|
|
249
|
-
//
|
|
250
|
-
// `uniweb publish` is for cataloging a foundation as a product —
|
|
251
|
-
// deliberate `@org/{name}` name, version-pinnable, discoverable.
|
|
252
|
-
// Site-bound foundations go through `uniweb deploy` instead, which
|
|
253
|
-
// uploads them to `sites/{siteId}/_src/...` automatically.
|
|
254
|
-
//
|
|
255
|
-
// The gate rejects two shapes:
|
|
256
|
-
// (a) No explicit name at all — running `uniweb publish` from a
|
|
257
|
-
// fresh scaffold would otherwise register `src` (or whatever
|
|
258
|
-
// the workspace name is) as a catalog entry.
|
|
259
|
-
// (b) `~user/...` or personal-UUID scopes — Phase 4e retired the
|
|
260
|
-
// personal scope; site-bound foundations use deploy, catalog
|
|
261
|
-
// uses `@org/`. There is no "personal catalog" any more.
|
|
262
|
-
//
|
|
263
|
-
// `--local` skips the gate (local mock registry, no public consequences).
|
|
264
|
-
const hasExplicitName = !!(
|
|
265
|
-
nameFlag ||
|
|
266
|
-
namespaceFlag ||
|
|
267
|
-
/^[@~]/.test(earlyPkg.name || '') ||
|
|
268
|
-
earlyPkg.uniweb?.id ||
|
|
269
|
-
earlyPkg.uniweb?.namespace
|
|
270
|
-
)
|
|
271
|
-
if (!hasExplicitName && !isLocal) {
|
|
272
|
-
error('uniweb publish needs a deliberate foundation name.')
|
|
273
|
-
console.log('')
|
|
274
|
-
console.log(` ${colors.bright}If this foundation only powers one site, use ${colors.cyan}uniweb deploy${colors.reset}${colors.bright} instead.${colors.reset}`)
|
|
275
|
-
console.log(` ${colors.dim}Deploy uploads your foundation alongside the site's assets — no name ceremony.${colors.reset}`)
|
|
276
|
-
console.log('')
|
|
277
|
-
console.log(` ${colors.bright}If you're cataloging this foundation as a product, name it explicitly:${colors.reset}`)
|
|
278
|
-
console.log(` ${colors.cyan}uniweb publish @your-org/foundation-name${colors.reset}`)
|
|
279
|
-
console.log('')
|
|
280
|
-
console.log(` ${colors.dim}For local development, ${colors.reset}${colors.cyan}--local${colors.reset}${colors.dim} skips this gate.${colors.reset}`)
|
|
281
|
-
process.exit(1)
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// 1b'. Phase 4e: reject `~`-scoped names. Site-bound foundations don't
|
|
285
|
-
// go through publish at all.
|
|
286
|
-
if (!isLocal) {
|
|
287
|
-
const candidateName = nameFlag || earlyPkg.name || earlyPkg.uniweb?.id || ''
|
|
288
|
-
const candidateNamespace = namespaceFlag || earlyPkg.uniweb?.namespace || ''
|
|
289
|
-
if (candidateName.startsWith('~') || candidateNamespace.startsWith('~')) {
|
|
290
|
-
error('uniweb publish is for cataloged foundations only.')
|
|
291
|
-
console.log('')
|
|
292
|
-
console.log(` ${colors.dim}The personal-UUID scope (${colors.reset}~uuid/name${colors.dim}) is no longer accepted.${colors.reset}`)
|
|
293
|
-
console.log(` ${colors.dim}Site-bound foundations are uploaded automatically by ${colors.reset}${colors.cyan}uniweb deploy${colors.reset}${colors.dim} — they live with site assets, not in the catalog.${colors.reset}`)
|
|
294
|
-
console.log('')
|
|
295
|
-
console.log(` ${colors.bright}For a catalog product, use an org scope:${colors.reset}`)
|
|
296
|
-
console.log(` ${colors.cyan}uniweb publish @your-org/foundation-name${colors.reset}`)
|
|
297
|
-
console.log('')
|
|
298
|
-
console.log(` ${colors.dim}No org yet? The CLI will offer to claim one for you the first time you publish to a handle you don't own.${colors.reset}`)
|
|
299
|
-
process.exit(1)
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// 1c. Phase 3 catalog confirmation gate.
|
|
304
|
-
//
|
|
305
|
-
// Cataloging a foundation has consequences (visible in the catalog,
|
|
306
|
-
// other developers may pin to versions, propagation system tracks
|
|
307
|
-
// it). Require explicit confirmation:
|
|
308
|
-
// - Interactive: prompt unless --catalog passed.
|
|
309
|
-
// - Non-interactive: fatal unless --catalog passed.
|
|
310
|
-
// - Skipped for --local and --dry-run (no public consequences).
|
|
311
|
-
if (hasExplicitName && !isLocal && !isDryRun && !isCatalog) {
|
|
312
|
-
if (isNonInteractive(process.argv)) {
|
|
313
|
-
error('uniweb publish to the catalog needs --catalog confirmation.')
|
|
314
|
-
console.log('')
|
|
315
|
-
console.log(` ${colors.dim}Catalog publishes are public — other developers can pin to your versions.${colors.reset}`)
|
|
316
|
-
console.log(` ${colors.dim}Pass ${colors.reset}${colors.cyan}--catalog${colors.reset}${colors.dim} to confirm:${colors.reset}`)
|
|
317
|
-
console.log(` ${colors.cyan}uniweb publish ${colors.reset}${colors.dim}<args>${colors.reset} ${colors.cyan}--catalog${colors.reset}`)
|
|
318
|
-
console.log('')
|
|
319
|
-
console.log(` ${colors.dim}For site-bound foundations, use ${colors.reset}${colors.cyan}uniweb deploy${colors.reset}${colors.dim} instead.${colors.reset}`)
|
|
320
|
-
process.exit(1)
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
const prompts = (await import('prompts')).default
|
|
324
|
-
console.log('')
|
|
325
|
-
console.log(`${colors.dim}You're publishing this foundation to the public catalog.${colors.reset}`)
|
|
326
|
-
console.log(`${colors.dim}Other developers will be able to find it and pin to its versions.${colors.reset}`)
|
|
327
|
-
console.log(`${colors.dim}For site-bound foundations, ${colors.reset}${colors.cyan}uniweb deploy${colors.reset}${colors.dim} is the right command.${colors.reset}`)
|
|
328
|
-
console.log('')
|
|
329
|
-
const confirm = await prompts({
|
|
330
|
-
type: 'confirm',
|
|
331
|
-
name: 'go',
|
|
332
|
-
message: 'Continue with catalog publish?',
|
|
333
|
-
initial: false,
|
|
334
|
-
}, {
|
|
335
|
-
onCancel: () => { console.log(''); console.log('Publish cancelled.'); process.exit(0) },
|
|
336
|
-
})
|
|
337
|
-
if (!confirm.go) {
|
|
338
|
-
console.log('')
|
|
339
|
-
console.log(`${colors.dim}Cancelled. Use ${colors.reset}${colors.cyan}uniweb deploy${colors.reset}${colors.dim} for site-bound foundations.${colors.reset}`)
|
|
340
|
-
process.exit(0)
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
let needsBuild = !hasMainArtifact() || !existsSync(schemaJson)
|
|
345
|
-
let buildReason = needsBuild ? 'no dist/ found' : null
|
|
346
|
-
|
|
347
|
-
if (!needsBuild) {
|
|
78
|
+
const dryRun = args.includes('--dry-run')
|
|
79
|
+
const siteDir = await resolveSiteDir(args, 'publish')
|
|
80
|
+
|
|
81
|
+
// The site-content uuid lives in site.yml::$uuid (written by `uniweb push`).
|
|
82
|
+
// No uuid → the site was never synced; publish has nothing to make live.
|
|
83
|
+
const siteYmlPath = join(siteDir, 'site.yml')
|
|
84
|
+
let siteYml = {}
|
|
85
|
+
if (existsSync(siteYmlPath)) {
|
|
348
86
|
try {
|
|
349
|
-
|
|
350
|
-
if (peekSchema?._self?.version && earlyPkg.version && peekSchema._self.version !== earlyPkg.version) {
|
|
351
|
-
needsBuild = true
|
|
352
|
-
buildReason = `package.json::version (${earlyPkg.version}) differs from dist/meta/schema.json::_self.version (${peekSchema._self.version})`
|
|
353
|
-
}
|
|
87
|
+
siteYml = yaml.load(await readFile(siteYmlPath, 'utf8')) || {}
|
|
354
88
|
} catch {
|
|
355
|
-
|
|
356
|
-
needsBuild = true
|
|
357
|
-
buildReason = 'dist/meta/schema.json could not be parsed'
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// --dry-run gate. Must come BEFORE the pre-flight registry check (which
|
|
362
|
-
// may persist `uniweb.id` to package.json on the matching-sha path) and
|
|
363
|
-
// BEFORE the build (which writes to dist/). Earlier the dry-run check
|
|
364
|
-
// sat after both, which violated the zero-writes contract.
|
|
365
|
-
if (isDryRun) {
|
|
366
|
-
const previewName = quickResolveCanonicalName(earlyPkg, { namespaceFlag, nameFlag })
|
|
367
|
-
|| earlyPkg.name
|
|
368
|
-
|| '(unresolved)'
|
|
369
|
-
const target = isLocal ? 'local registry' : `remote registry (${registryUrl || getRegistryUrl()})`
|
|
370
|
-
console.log('')
|
|
371
|
-
info(`Would publish ${colors.bright}${previewName}@${earlyPkg.version}${colors.reset} to ${target}`)
|
|
372
|
-
if (needsBuild) {
|
|
373
|
-
console.log(` ${colors.dim}Would build first: ${buildReason}${colors.reset}`)
|
|
374
|
-
} else {
|
|
375
|
-
console.log(` ${colors.dim}Source: ${distDir}${colors.reset}`)
|
|
376
|
-
}
|
|
377
|
-
return
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// 2b. Pre-flight registry check — runs BEFORE the build so we don't
|
|
381
|
-
// burn vite cycles on a foundation we already know we can't (or
|
|
382
|
-
// don't need to) publish.
|
|
383
|
-
//
|
|
384
|
-
// Two outcomes short-circuit the build:
|
|
385
|
-
//
|
|
386
|
-
// a. The registry already has `<canonicalName>@<version>`
|
|
387
|
-
// published from the CURRENT git sha (per-foundation last
|
|
388
|
-
// commit). The artifact upstream is correct; refresh the
|
|
389
|
-
// local receipt and exit. (Same outcome as the post-build
|
|
390
|
-
// duplicate check, just earlier — saves a build.)
|
|
391
|
-
//
|
|
392
|
-
// b. The registry has the version published from a DIFFERENT
|
|
393
|
-
// sha. The user has unpublished changes against an already-
|
|
394
|
-
// published version → "bump the version" error before any
|
|
395
|
-
// build work. Was the eval skill's pp-03 row.
|
|
396
|
-
//
|
|
397
|
-
// If the pre-flight can't determine the canonical name from
|
|
398
|
-
// pkg.json + flags + auth alone (e.g., needs a TTY prompt for
|
|
399
|
-
// the foundation id), it falls through silently to the existing
|
|
400
|
-
// post-build path. No-build-saved is still the existing behavior.
|
|
401
|
-
if (!isLocal) {
|
|
402
|
-
const preflightName = quickResolveCanonicalName(earlyPkg, { namespaceFlag, nameFlag })
|
|
403
|
-
const preflightVersion = earlyPkg.version
|
|
404
|
-
if (preflightName && preflightVersion) {
|
|
405
|
-
try {
|
|
406
|
-
const auth = await readAuth()
|
|
407
|
-
if (auth?.token) {
|
|
408
|
-
const claims = decodeJwtPayload(auth.token)
|
|
409
|
-
const memberUuid = claims?.memberUuid
|
|
410
|
-
// Empty-scope publishes are server-rewritten to ~<memberUuid>/<id>.
|
|
411
|
-
// Mirror that here so getVersionEntry queries the canonical key.
|
|
412
|
-
const lookupName = preflightName.startsWith('@') || preflightName.startsWith('~')
|
|
413
|
-
? preflightName
|
|
414
|
-
: memberUuid ? `~${memberUuid}/${preflightName}` : null
|
|
415
|
-
if (lookupName) {
|
|
416
|
-
const registryUrlPre = registryUrl || getRegistryUrl()
|
|
417
|
-
const registryPre = new RemoteRegistry(registryUrlPre, auth.token)
|
|
418
|
-
const existing = await registryPre.getVersionEntry(lookupName, preflightVersion)
|
|
419
|
-
if (existing) {
|
|
420
|
-
const { gitSha } = readGitState(foundationDir)
|
|
421
|
-
if (gitSha && existing.publishedFromGitSha === gitSha) {
|
|
422
|
-
// Already published from this exact source — nothing to do.
|
|
423
|
-
console.log('')
|
|
424
|
-
success(`${colors.bright}${lookupName}@${preflightVersion}${colors.reset} already published from ${gitSha.slice(0, 7)}.`)
|
|
425
|
-
return
|
|
426
|
-
}
|
|
427
|
-
// Sha mismatch (or no provenance recorded for the existing
|
|
428
|
-
// entry). Clean error before any build work.
|
|
429
|
-
console.log('')
|
|
430
|
-
error(`Foundation source has changed since the last publish, but ${colors.bright}${lookupName}@${preflightVersion}${colors.reset} is already published.`)
|
|
431
|
-
console.log('')
|
|
432
|
-
console.log(` Bump ${colors.cyan}package.json::version${colors.reset} to publish an update:`)
|
|
433
|
-
console.log(` ${colors.dim}"version": "${bumpPatch(preflightVersion)}"${colors.reset}`)
|
|
434
|
-
process.exit(1)
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
} catch {
|
|
439
|
-
// Network down, malformed auth, etc. — fall through to the
|
|
440
|
-
// existing post-build flow. No-build-saved is still the same
|
|
441
|
-
// behavior the user got before this pre-flight existed.
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
if (needsBuild) {
|
|
447
|
-
console.log(`${colors.yellow}⚠${colors.reset} ${buildReason}. Building foundation...`)
|
|
448
|
-
console.log('')
|
|
449
|
-
execSync('npx uniweb build --target foundation', {
|
|
450
|
-
cwd: foundationDir,
|
|
451
|
-
stdio: 'inherit',
|
|
452
|
-
})
|
|
453
|
-
console.log('')
|
|
454
|
-
|
|
455
|
-
if (!hasMainArtifact() || !existsSync(schemaJson)) {
|
|
456
|
-
error('Build did not produce dist/entry.js (or legacy dist/foundation.js) and dist/meta/schema.json')
|
|
457
|
-
process.exit(1)
|
|
89
|
+
siteYml = {}
|
|
458
90
|
}
|
|
459
91
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
// output than to redo here. `version` is sourced from package.json
|
|
466
|
-
// directly; the version-skew check above already ensured the
|
|
467
|
-
// schema and package.json agree.
|
|
468
|
-
let schema
|
|
469
|
-
try {
|
|
470
|
-
schema = JSON.parse(await readFile(schemaJson, 'utf8'))
|
|
471
|
-
} catch (err) {
|
|
472
|
-
error(`Failed to read dist/meta/schema.json: ${err.message}`)
|
|
473
|
-
process.exit(1)
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
const rawName = schema._self?.name
|
|
477
|
-
const version = earlyPkg.version
|
|
478
|
-
|
|
479
|
-
if (!rawName || !version) {
|
|
480
|
-
error('Foundation missing name or version')
|
|
481
|
-
console.log(`${colors.dim} Ensure your package.json has "name" and "version" fields,${colors.reset}`)
|
|
482
|
-
console.log(`${colors.dim} and that the build has produced dist/meta/schema.json with _self.name.${colors.reset}`)
|
|
483
|
-
process.exit(1)
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// 3b. Resolve scope and foundation id.
|
|
487
|
-
//
|
|
488
|
-
// The publish-time identity is two pieces: a SCOPE (org `@`, personal `~`,
|
|
489
|
-
// or empty → server-resolved personal) and an ID (the bare name segment).
|
|
490
|
-
// They live in different places and get different defaults.
|
|
491
|
-
//
|
|
492
|
-
// Scope priority:
|
|
493
|
-
// 1. --namespace <handle> CLI flag → forces `@<handle>` org scope
|
|
494
|
-
// 2. Sigil in `package.json::name`:
|
|
495
|
-
// - `@org/x` → `@org`
|
|
496
|
-
// - `~user/x` → `~user` (personal alias)
|
|
497
|
-
// 3. `package.json::uniweb.namespace` → legacy explicit org field
|
|
498
|
-
// 4. (none) → empty scope; server attaches
|
|
499
|
-
// the publisher's personal
|
|
500
|
-
// scope at upload time
|
|
501
|
-
//
|
|
502
|
-
// ID priority (the bare name segment):
|
|
503
|
-
// 1. --name <id> CLI flag → override
|
|
504
|
-
// 2. Sigil-stripped `package.json::name` → @org/<id> or ~user/<id>
|
|
505
|
-
// 3. `package.json::uniweb.id` → persisted publish-id
|
|
506
|
-
// 4. Interactive prompt → and write back to
|
|
507
|
-
// `package.json::uniweb.id`
|
|
508
|
-
// so future publishes don't
|
|
509
|
-
// re-prompt.
|
|
510
|
-
// 5. Non-interactive without a usable id → fail with guidance.
|
|
511
|
-
//
|
|
512
|
-
// Note: a bare `package.json::name` (e.g. the scaffold default `src`)
|
|
513
|
-
// is intentionally NOT used as a fallback id. The workspace name is for
|
|
514
|
-
// pnpm linking and the file: dependency in site/package.json — using it
|
|
515
|
-
// as the publish id would couple the registry identity to the workspace,
|
|
516
|
-
// exactly what `uniweb.id` exists to prevent. Users who want their
|
|
517
|
-
// workspace name to be the publish id pass `--name <pkg-name>` once;
|
|
518
|
-
// it persists.
|
|
519
|
-
//
|
|
520
|
-
// Why two storage locations for an ID? `package.json::name` is a
|
|
521
|
-
// workspace concern — pnpm uses it to link packages, sites reference
|
|
522
|
-
// it via `file:` deps and `site.yml::foundation`. Renaming it cascades
|
|
523
|
-
// through several files. `uniweb.id` is publish-only — changing it
|
|
524
|
-
// affects only the registry identity, never the workspace. Most users
|
|
525
|
-
// benefit from leaving `package.json::name` as the scaffold default
|
|
526
|
-
// (`src`) and putting the published-as id in `uniweb.id`.
|
|
527
|
-
// pkgPath was declared earlier (during the rebuild-stale-dist check).
|
|
528
|
-
// Reuse the already-loaded `earlyPkg` rather than re-reading from disk.
|
|
529
|
-
const pkg = earlyPkg
|
|
530
|
-
const uniwebNamespace = pkg.uniweb?.namespace
|
|
531
|
-
const uniwebId = pkg.uniweb?.id
|
|
532
|
-
const orgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
|
|
533
|
-
const personalScopeMatch = (pkg.name || '').match(/^~([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
|
|
534
|
-
|
|
535
|
-
// Resolve the SCOPE.
|
|
536
|
-
let scopeSigil = null
|
|
537
|
-
let scopeName = null
|
|
538
|
-
if (namespaceFlag) {
|
|
539
|
-
scopeSigil = '@'
|
|
540
|
-
scopeName = namespaceFlag
|
|
541
|
-
} else if (orgScopeMatch) {
|
|
542
|
-
scopeSigil = '@'
|
|
543
|
-
scopeName = orgScopeMatch[1]
|
|
544
|
-
} else if (personalScopeMatch) {
|
|
545
|
-
scopeSigil = '~'
|
|
546
|
-
scopeName = personalScopeMatch[1]
|
|
547
|
-
} else if (uniwebNamespace) {
|
|
548
|
-
scopeSigil = '@'
|
|
549
|
-
scopeName = uniwebNamespace
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
// Resolve the ID.
|
|
553
|
-
const ID_RE = /^[a-z0-9_-]+$/
|
|
554
|
-
let foundationName = null
|
|
555
|
-
let writeBackId = false
|
|
556
|
-
if (nameFlag) {
|
|
557
|
-
foundationName = nameFlag
|
|
558
|
-
// Persist the flag's value when it differs from what's already in
|
|
559
|
-
// `uniweb.id`. This makes rename a one-shot:
|
|
560
|
-
// $ uniweb publish --name new-name
|
|
561
|
-
// From here on, `uniweb publish` (no flag) keeps using `new-name`.
|
|
562
|
-
// No-op when --name matches the existing id.
|
|
563
|
-
if (nameFlag !== uniwebId) writeBackId = true
|
|
564
|
-
} else if (orgScopeMatch) {
|
|
565
|
-
foundationName = orgScopeMatch[2]
|
|
566
|
-
} else if (personalScopeMatch) {
|
|
567
|
-
foundationName = personalScopeMatch[2]
|
|
568
|
-
} else if (uniwebId) {
|
|
569
|
-
foundationName = uniwebId
|
|
570
|
-
}
|
|
571
|
-
if (!foundationName) {
|
|
572
|
-
// No id resolvable from any field. Build a set of suggestions
|
|
573
|
-
// contextual to this workspace, then either prompt (TTY) or print
|
|
574
|
-
// them as guidance (CI). The bare `pkg.name` is intentionally NOT
|
|
575
|
-
// a suggestion when it equals the scaffold default `src` — picking
|
|
576
|
-
// that name would couple the registry id to a generic placeholder
|
|
577
|
-
// that future renames couldn't undo.
|
|
578
|
-
const workspaceRoot = findWorkspaceRoot(foundationDir) || foundationDir
|
|
579
|
-
const suggestions = await buildIdSuggestions({ foundationDir, workspaceRoot, pkg })
|
|
580
|
-
|
|
581
|
-
if (isNonInteractive(process.argv)) {
|
|
582
|
-
// CI: when there's a high-confidence signal — the workspace
|
|
583
|
-
// package.json's name (the user typed it via `uniweb create
|
|
584
|
-
// <name>`) — auto-derive and persist. This unblocks first-deploy
|
|
585
|
-
// CI flows (pp-01 etc.) where stopping to ask isn't an option.
|
|
586
|
-
// Other suggestion sources (sibling-site name, M-code) are NOT
|
|
587
|
-
// auto-picked because they're ambiguous in multi-package
|
|
588
|
-
// workspaces; they remain available via the error message
|
|
589
|
-
// when no high-confidence signal exists.
|
|
590
|
-
const autoId = await pickAutoDerivedId({ workspaceRoot, foundationDir })
|
|
591
|
-
if (autoId) {
|
|
592
|
-
info(`Auto-deriving ${colors.bright}uniweb.id: "${autoId}"${colors.reset} ${colors.dim}(matches workspace name; persisted to package.json)${colors.reset}`)
|
|
593
|
-
foundationName = autoId
|
|
594
|
-
writeBackId = true
|
|
595
|
-
} else {
|
|
596
|
-
error('Foundation id is required for publishing.')
|
|
597
|
-
console.log('')
|
|
598
|
-
if (suggestions.length > 0) {
|
|
599
|
-
console.log(` ${colors.bright}Suggestions for your workspace:${colors.reset}`)
|
|
600
|
-
for (const { id, why } of suggestions) {
|
|
601
|
-
console.log(` ${colors.cyan}${id}${colors.reset} ${colors.dim}${why}${colors.reset}`)
|
|
602
|
-
}
|
|
603
|
-
console.log('')
|
|
604
|
-
}
|
|
605
|
-
console.log(` ${colors.dim}Use one of:${colors.reset}`)
|
|
606
|
-
const example = suggestions[0]?.id || '<id>'
|
|
607
|
-
console.log(` ${colors.cyan}uniweb publish --name ${example}${colors.reset}`)
|
|
608
|
-
console.log(` ${colors.dim}Add ${colors.reset}"uniweb": { "id": "<your-id>" }${colors.dim} to package.json${colors.reset}`)
|
|
609
|
-
console.log(` ${colors.dim}Or use a scoped name in package.json: ${colors.reset}"name": "@org/<id>"${colors.reset}`)
|
|
610
|
-
process.exit(1)
|
|
611
|
-
}
|
|
612
|
-
} else {
|
|
613
|
-
|
|
614
|
-
const prompts = (await import('prompts')).default
|
|
615
|
-
console.log('')
|
|
616
|
-
console.log(`${colors.dim}This is the first publish of this foundation. Pick a name${colors.reset}`)
|
|
617
|
-
console.log(`${colors.dim}for the registry — what your foundation will be known as.${colors.reset}`)
|
|
618
|
-
console.log('')
|
|
619
|
-
|
|
620
|
-
let chosen
|
|
621
|
-
if (suggestions.length > 0) {
|
|
622
|
-
// Surface contextual suggestions first (sibling site, workspace name,
|
|
623
|
-
// M-code series). Always include "Type a different name…" so the
|
|
624
|
-
// user is never trapped in a list.
|
|
625
|
-
const choices = [
|
|
626
|
-
...suggestions.map(s => ({ title: s.id, description: s.why, value: s.id })),
|
|
627
|
-
{ title: 'Type a different name…', value: '__custom__' },
|
|
628
|
-
]
|
|
629
|
-
const pickResp = await prompts({
|
|
630
|
-
type: 'select',
|
|
631
|
-
name: 'pick',
|
|
632
|
-
message: 'Foundation name',
|
|
633
|
-
choices,
|
|
634
|
-
initial: 0,
|
|
635
|
-
}, {
|
|
636
|
-
onCancel: () => { console.log(''); console.log('Publish cancelled.'); process.exit(0) },
|
|
637
|
-
})
|
|
638
|
-
if (!pickResp.pick) process.exit(0)
|
|
639
|
-
chosen = pickResp.pick
|
|
640
|
-
} else {
|
|
641
|
-
chosen = '__custom__'
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
if (chosen === '__custom__') {
|
|
645
|
-
const folderName = workspaceRoot === foundationDir
|
|
646
|
-
? null
|
|
647
|
-
: foundationDir.replace(workspaceRoot + '/', '').split('/')[0]
|
|
648
|
-
const suggestion =
|
|
649
|
-
suggestions[0]?.id ||
|
|
650
|
-
(folderName ? folderName.replace(/-src$/, '') : null) ||
|
|
651
|
-
''
|
|
652
|
-
const textResp = await prompts({
|
|
653
|
-
type: 'text',
|
|
654
|
-
name: 'id',
|
|
655
|
-
message: 'Foundation name',
|
|
656
|
-
initial: suggestion,
|
|
657
|
-
validate: (v) => {
|
|
658
|
-
if (!v) return 'Required'
|
|
659
|
-
if (!ID_RE.test(v)) return 'Lowercase letters, digits, hyphens, underscores only'
|
|
660
|
-
return true
|
|
661
|
-
},
|
|
662
|
-
}, {
|
|
663
|
-
onCancel: () => { console.log(''); console.log('Publish cancelled.'); process.exit(0) },
|
|
664
|
-
})
|
|
665
|
-
if (!textResp.id) process.exit(0)
|
|
666
|
-
chosen = textResp.id
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
foundationName = chosen
|
|
670
|
-
writeBackId = true
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
// Validate the resolved id (may have come from any source).
|
|
675
|
-
if (!ID_RE.test(foundationName)) {
|
|
676
|
-
error(`Invalid foundation name: "${foundationName}"`)
|
|
677
|
-
console.log(` ${colors.dim}Names must be lowercase letters, digits, hyphens, or underscores.${colors.reset}`)
|
|
678
|
-
process.exit(1)
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
// Persist the id so future publishes don't re-prompt.
|
|
682
|
-
if (writeBackId) {
|
|
683
|
-
pkg.uniweb = pkg.uniweb || {}
|
|
684
|
-
pkg.uniweb.id = foundationName
|
|
685
|
-
await writeJsonPreservingStyleAsync(pkgPath, pkg)
|
|
686
|
-
info(`Wrote ${colors.cyan}uniweb.id: "${foundationName}"${colors.reset} to ${colors.dim}package.json${colors.reset}`)
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// The registry name. Three cases:
|
|
690
|
-
//
|
|
691
|
-
// 1. Explicit scope (`@org/x` or `~user/x`) → `<sigil><name>/<base>`.
|
|
692
|
-
// 2. Empty-scope, --local → synthesize a
|
|
693
|
-
// personal-scope form `~<loginName-or-sub-or-'me'>/<base>` so the
|
|
694
|
-
// local index mirrors what production will write. This is the
|
|
695
|
-
// local mock's stand-in for the server-side memberId resolution.
|
|
696
|
-
// 3. Empty-scope, remote → send the bare
|
|
697
|
-
// name. The Worker attaches the personal scope server-side
|
|
698
|
-
// (anchoring to the `sub` claim), and the publish response
|
|
699
|
-
// carries the canonical URL back to the CLI for the receipt.
|
|
700
|
-
let name
|
|
701
|
-
if (scopeSigil) {
|
|
702
|
-
name = `${scopeSigil}${scopeName}/${foundationName}`
|
|
703
|
-
} else if (isLocal) {
|
|
704
|
-
const localAuth = await readAuth()
|
|
705
|
-
const personalSeed = localAuth?.loginName || localAuth?.sub || 'me'
|
|
706
|
-
name = `~${personalSeed}/${foundationName}`
|
|
707
|
-
} else {
|
|
708
|
-
name = foundationName
|
|
92
|
+
const uuid = siteYml.$uuid
|
|
93
|
+
if (!uuid) {
|
|
94
|
+
say.err('This site has no $uuid in site.yml — it was never synced to the backend.')
|
|
95
|
+
say.dim('Run `uniweb push` first (publish makes the synced site live), or use `uniweb deploy` for a file-only site.')
|
|
96
|
+
return { exitCode: 1 }
|
|
709
97
|
}
|
|
710
98
|
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
// (b) Handle is already in `namespaces` → proceed.
|
|
718
|
-
// (c) Handle is NOT in `namespaces` → call POST /api/orgs/{handle}.
|
|
719
|
-
// Confirm-and-claim if available; hard-fail if taken; refresh
|
|
720
|
-
// the cached token on success and proceed with publish.
|
|
721
|
-
//
|
|
722
|
-
// Skipped for `--local` (no auth, no org system).
|
|
723
|
-
const claimOrgFlag = args.includes('--claim-org')
|
|
724
|
-
if (!isLocal && scopeSigil === '@') {
|
|
725
|
-
const auth = await readAuth()
|
|
726
|
-
if (!Array.isArray(auth?.namespaces)) {
|
|
727
|
-
// Old token, predates org support.
|
|
728
|
-
error('Your authentication token doesn\'t carry organization claims.')
|
|
729
|
-
console.log('')
|
|
730
|
-
console.log(` ${colors.dim}Run ${colors.reset}${colors.cyan}uniweb login${colors.reset}${colors.dim} to refresh your session, then retry.${colors.reset}`)
|
|
731
|
-
process.exit(1)
|
|
732
|
-
}
|
|
733
|
-
if (!auth.namespaces.includes(scopeName)) {
|
|
734
|
-
// Need to claim. Confirm interactively unless --claim-org was passed.
|
|
735
|
-
if (isNonInteractive(process.argv) && !claimOrgFlag) {
|
|
736
|
-
error(`You don't own ${colors.bright}@${scopeName}${colors.reset} yet.`)
|
|
737
|
-
console.log('')
|
|
738
|
-
console.log(` ${colors.dim}In CI, pass ${colors.reset}${colors.cyan}--claim-org${colors.reset}${colors.dim} to claim available handles automatically.${colors.reset}`)
|
|
739
|
-
console.log(` ${colors.dim}Interactive mode prompts for confirmation.${colors.reset}`)
|
|
740
|
-
process.exit(1)
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
if (!claimOrgFlag) {
|
|
744
|
-
const prompts = (await import('prompts')).default
|
|
745
|
-
console.log('')
|
|
746
|
-
console.log(`${colors.dim}You don't own ${colors.reset}${colors.bright}@${scopeName}${colors.reset}${colors.dim} yet.${colors.reset}`)
|
|
747
|
-
console.log(`${colors.dim}Org handles are global and permanent — only the claiming account can publish under them.${colors.reset}`)
|
|
748
|
-
console.log('')
|
|
749
|
-
const confirm = await prompts({
|
|
750
|
-
type: 'confirm',
|
|
751
|
-
name: 'go',
|
|
752
|
-
message: `Claim @${scopeName} for your account?`,
|
|
753
|
-
initial: false,
|
|
754
|
-
}, {
|
|
755
|
-
onCancel: () => { console.log(''); console.log('Publish cancelled.'); process.exit(0) },
|
|
756
|
-
})
|
|
757
|
-
if (!confirm.go) {
|
|
758
|
-
console.log('')
|
|
759
|
-
console.log(`${colors.dim}Cancelled. Publish under a handle you already own, or pick a different one.${colors.reset}`)
|
|
760
|
-
process.exit(0)
|
|
761
|
-
}
|
|
762
|
-
}
|
|
99
|
+
const client = new BackendClient({
|
|
100
|
+
originFlag: readFlagValue(args, '--backend') || readFlagValue(args, '--registry'),
|
|
101
|
+
token: readFlagValue(args, '--token') || undefined,
|
|
102
|
+
args,
|
|
103
|
+
command: 'Publishing',
|
|
104
|
+
})
|
|
763
105
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
backendUrl: getBackendUrl(),
|
|
771
|
-
})
|
|
772
|
-
if (claimed.taken) {
|
|
773
|
-
error(`@${scopeName} is already claimed by another account.`)
|
|
774
|
-
console.log('')
|
|
775
|
-
console.log(` ${colors.dim}Pick a different handle. Org names are global and exclusive.${colors.reset}`)
|
|
776
|
-
process.exit(1)
|
|
777
|
-
}
|
|
778
|
-
// Swap the cached token for the refreshed one (now carries the new
|
|
779
|
-
// namespace claim). Subsequent publish calls in this run see it via
|
|
780
|
-
// a fresh `readAuth()` and the worker accepts the upload.
|
|
781
|
-
await writeAuth({
|
|
782
|
-
token: claimed.token,
|
|
783
|
-
email: auth.email,
|
|
784
|
-
expiresAt: auth.expiresAt,
|
|
785
|
-
})
|
|
786
|
-
if (claimed.created) {
|
|
787
|
-
success(`Claimed ${colors.bright}@${scopeName}${colors.reset} for your account.`)
|
|
788
|
-
} else {
|
|
789
|
-
info(`Refreshed your token; ${colors.bright}@${scopeName}${colors.reset} is yours.`)
|
|
790
|
-
}
|
|
791
|
-
console.log('')
|
|
792
|
-
}
|
|
106
|
+
// Discover + resolve the runtime exactly like deploy: explicit site.yml::runtime,
|
|
107
|
+
// else the highest installed (the /dev/config source). Fail closed otherwise.
|
|
108
|
+
const config = await client.discover()
|
|
109
|
+
if (config?.delivery && config.delivery.publish === false) {
|
|
110
|
+
say.err(`Backend at ${client.origin} does not offer the publish lane (delivery.publish=false).`)
|
|
111
|
+
return { exitCode: 1 }
|
|
793
112
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
if (isLocal) {
|
|
800
|
-
registry = createLocalRegistry(foundationDir)
|
|
801
|
-
} else {
|
|
802
|
-
// Remote publish — ensure authenticated (inline login if needed)
|
|
803
|
-
const token = await ensureAuth({ command: 'Publishing', args })
|
|
804
|
-
|
|
805
|
-
const url = registryUrl || getRegistryUrl()
|
|
806
|
-
registry = new RemoteRegistry(url, token)
|
|
113
|
+
const installed = Array.isArray(config?.runtime?.installed) ? config.runtime.installed : []
|
|
114
|
+
if (siteYml.runtime && installed.length && !installed.includes(siteYml.runtime)) {
|
|
115
|
+
say.err(`Runtime ${siteYml.runtime} (from site.yml) is not installed on the backend.`)
|
|
116
|
+
say.dim(`Installed: ${installed.join(', ') || '(none)'} — pin one of these in site.yml (\`runtime:\`), or have it installed on the backend.`)
|
|
117
|
+
return { exitCode: 1 }
|
|
807
118
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
// publish payload.
|
|
814
|
-
const { gitSha, gitDirty } = readGitState(foundationDir)
|
|
815
|
-
|
|
816
|
-
// Compute the canonical name the server stores under. Empty-scope
|
|
817
|
-
// (bare-name) publishes go to the registry as `<name>` but are
|
|
818
|
-
// server-side rewritten to `~<memberUuid>/<name>`. The duplicate
|
|
819
|
-
// check below queries the registry's index, which uses the canonical
|
|
820
|
-
// form as the key — so we have to mirror the rewrite locally.
|
|
821
|
-
// Org / personal-scope publishes skip this (their `name` is already
|
|
822
|
-
// canonical).
|
|
823
|
-
let lookupName = name
|
|
824
|
-
if (!scopeSigil && !isLocal) {
|
|
825
|
-
try {
|
|
826
|
-
const localAuth = await readAuth()
|
|
827
|
-
const claims = decodeJwtPayload(localAuth?.token)
|
|
828
|
-
if (claims?.memberUuid) {
|
|
829
|
-
lookupName = `~${claims.memberUuid}/${foundationName}`
|
|
830
|
-
}
|
|
831
|
-
} catch {
|
|
832
|
-
// No usable auth — fall back to the bare name. The publish call
|
|
833
|
-
// itself will fail later with an auth error if a token is needed.
|
|
834
|
-
}
|
|
119
|
+
const runtimeVersion = siteYml.runtime || pickHighestRuntime(installed)
|
|
120
|
+
if (!runtimeVersion) {
|
|
121
|
+
say.err('Could not resolve a runtime version.')
|
|
122
|
+
say.dim('Pin one with `runtime:` in site.yml, or install one on the backend so /dev/config reports it.')
|
|
123
|
+
return { exitCode: 1 }
|
|
835
124
|
}
|
|
836
125
|
|
|
837
|
-
|
|
838
|
-
// version recorded as published from the current commit, treat it
|
|
839
|
-
// as a fresh-checkout no-op — the artifact upstream is already
|
|
840
|
-
// correct; there's nothing to upload.
|
|
841
|
-
const existingEntry = await registry.getVersionEntry(lookupName, version)
|
|
842
|
-
if (existingEntry) {
|
|
843
|
-
if (gitSha && existingEntry.publishedFromGitSha === gitSha) {
|
|
844
|
-
// Persist uniweb.id BEFORE the early return when an auto-derive
|
|
845
|
-
// or prompt-resolved id was set in this run. Without this, the
|
|
846
|
-
// next run wouldn't know the id and would have to re-derive
|
|
847
|
-
// from scratch — which means the pre-flight registry check at
|
|
848
|
-
// the top of publish() can't fire either (it relies on a
|
|
849
|
-
// resolvable id from pkg.json alone). Persisting here closes
|
|
850
|
-
// that loop so future deploys hit the pre-flight bail and skip
|
|
851
|
-
// the build entirely.
|
|
852
|
-
if (writeBackId) {
|
|
853
|
-
pkg.uniweb = pkg.uniweb || {}
|
|
854
|
-
pkg.uniweb.id = foundationName
|
|
855
|
-
await writeJsonPreservingStyleAsync(pkgPath, pkg)
|
|
856
|
-
info(`Wrote ${colors.cyan}uniweb.id: "${foundationName}"${colors.reset} to ${colors.dim}package.json${colors.reset}`)
|
|
857
|
-
}
|
|
858
|
-
console.log('')
|
|
859
|
-
success(`${colors.bright}${lookupName}@${version}${colors.reset} already published from ${gitSha.slice(0, 7)}.`)
|
|
860
|
-
return
|
|
861
|
-
}
|
|
862
|
-
console.log('')
|
|
863
|
-
error(`Foundation source has changed since the last publish, but ${colors.bright}${name}@${version}${colors.reset} is already published.`)
|
|
864
|
-
console.log('')
|
|
865
|
-
console.log(` Bump ${colors.cyan}package.json::version${colors.reset} to publish an update:`)
|
|
866
|
-
console.log(` ${colors.dim}"version": "${bumpPatch(version)}"${colors.reset}`)
|
|
867
|
-
process.exit(1)
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
// 6. Publish
|
|
871
|
-
info(`Publishing ${colors.bright}${name}@${version}${colors.reset} to ${registryLabel}...`)
|
|
126
|
+
const languages = extractLanguages(siteYml)
|
|
872
127
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
...(gitSha ? { publishedFromGitSha: gitSha } : {}),
|
|
881
|
-
...(typeof gitDirty === 'boolean' ? { publishedFromGitDirty: gitDirty } : {}),
|
|
882
|
-
}
|
|
883
|
-
if (editAccess) {
|
|
884
|
-
publishMetadata.editAccess = editAccess
|
|
128
|
+
if (dryRun) {
|
|
129
|
+
say.info('Dry run — would publish the synced site (its current backend state):')
|
|
130
|
+
say.dim(`Backend : ${client.origin}`)
|
|
131
|
+
say.dim(`Site uuid : ${uuid}`)
|
|
132
|
+
say.dim(`Runtime : ${runtimeVersion}${siteYml.runtime ? '' : ' (highest installed)'}`)
|
|
133
|
+
if (languages) say.dim(`Languages : ${languages.join(', ')}`)
|
|
134
|
+
return { exitCode: 0 }
|
|
885
135
|
}
|
|
886
136
|
|
|
137
|
+
say.info(`Publishing the synced site to ${c.dim}${client.origin}${c.reset} …`)
|
|
138
|
+
say.dim('Publishes the CURRENT backend state (incl. app-side edits) — run `uniweb push` first to include local edits.')
|
|
139
|
+
let res
|
|
887
140
|
try {
|
|
888
|
-
await
|
|
141
|
+
res = await client.publishSite(uuid, { runtimeVersion, ...(languages ? { languages } : {}) })
|
|
889
142
|
} catch (err) {
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
process.exit(1)
|
|
894
|
-
}
|
|
895
|
-
if (err.code === 'UNAUTHORIZED') {
|
|
896
|
-
error('Authentication failed.')
|
|
897
|
-
console.log(` Run ${colors.cyan}${getCliPrefix()} login${colors.reset} to refresh your credentials.`)
|
|
898
|
-
process.exit(1)
|
|
899
|
-
}
|
|
900
|
-
throw err
|
|
143
|
+
say.err(`Could not reach the backend at ${client.origin}: ${err.message}`)
|
|
144
|
+
say.dim('Set the origin with --backend <url> or UNIWEB_REGISTER_URL.')
|
|
145
|
+
return { exitCode: 1 }
|
|
901
146
|
}
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
if (editAccess) {
|
|
908
|
-
console.log(` ${colors.dim}Edit access: ${editAccess}${colors.reset}`)
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
// Cross-promotion: working with clients (remote only), deploy (if workspace has a site)
|
|
912
|
-
if (isRemote) {
|
|
913
|
-
console.log('')
|
|
914
|
-
if (isExtension) {
|
|
915
|
-
console.log(` ${colors.bright}Authorize a client to use this extension:${colors.reset}`)
|
|
916
|
-
console.log(` ${colors.bright}${prefix} invite <email>${colors.reset} Client adds this extension to their site`)
|
|
917
|
-
} else {
|
|
918
|
-
console.log(` ${colors.bright}Working with clients:${colors.reset}`)
|
|
919
|
-
console.log(` ${colors.bright}${prefix} invite <email>${colors.reset} Client creates their own site with your foundation`)
|
|
920
|
-
console.log(` ${colors.bright}${prefix} handoff <email>${colors.reset} Create a web or local site and hand it off to a client`)
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
const workspaceRoot = findWorkspaceRoot(foundationDir)
|
|
924
|
-
if (workspaceRoot) {
|
|
925
|
-
const sites = await findSites(workspaceRoot)
|
|
926
|
-
if (sites.length > 0) {
|
|
927
|
-
console.log('')
|
|
928
|
-
console.log(` ${colors.dim}Tip: Run \`${prefix} deploy\` for a conventional static bundle deployment.${colors.reset}`)
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
/**
|
|
934
|
-
* Bump the patch version of a semver string.
|
|
935
|
-
* @param {string} version - e.g. "1.0.0"
|
|
936
|
-
* @returns {string} - e.g. "1.0.1"
|
|
937
|
-
*/
|
|
938
|
-
/**
|
|
939
|
-
* Quickly compute the canonical foundation name from `package.json` +
|
|
940
|
-
* CLI flags alone, without prompting and without reading the build's
|
|
941
|
-
* `dist/meta/schema.json`. Used by the pre-flight registry check so we
|
|
942
|
-
* can short-circuit the build when the registry already has this
|
|
943
|
-
* version published.
|
|
944
|
-
*
|
|
945
|
-
* Returns null when resolution would need a prompt or auto-derive
|
|
946
|
-
* (caller falls through to the existing post-build resolution path,
|
|
947
|
-
* which handles those cases). The returned string is one of:
|
|
948
|
-
* - `@<scope>/<id>` (org scope, full canonical form)
|
|
949
|
-
* - `~<handle>/<id>` (personal alias scope)
|
|
950
|
-
* - `<id>` (bare; caller may prepend `~<memberUuid>/`
|
|
951
|
-
* from the JWT for the actual lookup)
|
|
952
|
-
*
|
|
953
|
-
* The full resolution at line 313+ is the canonical implementation;
|
|
954
|
-
* this helper is a strict subset that mirrors the high-confidence
|
|
955
|
-
* paths only. If they diverge, the helper is the one that should
|
|
956
|
-
* stay conservative (return null on uncertainty).
|
|
957
|
-
*/
|
|
958
|
-
function quickResolveCanonicalName(pkg, { namespaceFlag, nameFlag } = {}) {
|
|
959
|
-
if (!pkg) return null
|
|
960
|
-
const orgScopeMatch = (pkg.name || '').match(/^@([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
|
|
961
|
-
const personalScopeMatch = (pkg.name || '').match(/^~([a-z0-9_-]+)\/([a-z0-9_-]+)$/)
|
|
962
|
-
const uniwebNamespace = pkg.uniweb?.namespace
|
|
963
|
-
const uniwebId = pkg.uniweb?.id
|
|
964
|
-
|
|
965
|
-
// Scope precedence mirrors the full resolution.
|
|
966
|
-
let scopeSigil = null
|
|
967
|
-
let scopeName = null
|
|
968
|
-
if (namespaceFlag) {
|
|
969
|
-
scopeSigil = '@'
|
|
970
|
-
scopeName = namespaceFlag
|
|
971
|
-
} else if (orgScopeMatch) {
|
|
972
|
-
scopeSigil = '@'
|
|
973
|
-
scopeName = orgScopeMatch[1]
|
|
974
|
-
} else if (personalScopeMatch) {
|
|
975
|
-
scopeSigil = '~'
|
|
976
|
-
scopeName = personalScopeMatch[1]
|
|
977
|
-
} else if (uniwebNamespace) {
|
|
978
|
-
scopeSigil = '@'
|
|
979
|
-
scopeName = uniwebNamespace
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
// Id precedence mirrors the full resolution but stops at "no-prompt"
|
|
983
|
-
// sources. Auto-derive and TTY prompts both happen post-build so the
|
|
984
|
-
// user sees suggestions in context; the pre-flight only fires when
|
|
985
|
-
// the id is already determined.
|
|
986
|
-
let id = null
|
|
987
|
-
if (nameFlag) id = nameFlag
|
|
988
|
-
else if (orgScopeMatch) id = orgScopeMatch[2]
|
|
989
|
-
else if (personalScopeMatch) id = personalScopeMatch[2]
|
|
990
|
-
else if (uniwebId) id = uniwebId
|
|
991
|
-
else return null
|
|
992
|
-
|
|
993
|
-
if (scopeSigil) return `${scopeSigil}${scopeName}/${id}`
|
|
994
|
-
return id
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
function bumpPatch(version) {
|
|
998
|
-
const parts = version.split('.')
|
|
999
|
-
if (parts.length !== 3) return version
|
|
1000
|
-
parts[2] = String(Number(parts[2]) + 1)
|
|
1001
|
-
return parts.join('.')
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
/**
|
|
1005
|
-
* High-confidence auto-derive for non-interactive (CI) first publishes.
|
|
1006
|
-
*
|
|
1007
|
-
* Diego's principle: never silently take a generic scaffold default like
|
|
1008
|
-
* `src` or `site` as the registry id (those are placeholders, not user
|
|
1009
|
-
* intent). But when the user has typed a real name elsewhere — most
|
|
1010
|
-
* unambiguously the workspace package.json's `name` (set by
|
|
1011
|
-
* `uniweb create <name>`) — picking that in CI is the obvious right
|
|
1012
|
-
* answer and stopping to ask just breaks the CI run.
|
|
1013
|
-
*
|
|
1014
|
-
* Auto-derive set is intentionally NARROW:
|
|
1015
|
-
* 1. Workspace package.json::name, when it's a clean id and not a
|
|
1016
|
-
* generic placeholder.
|
|
1017
|
-
*
|
|
1018
|
-
* Other suggestion sources from `buildIdSuggestions` (sibling-site
|
|
1019
|
-
* name, M-code series) are NOT auto-picked: they're ambiguous in
|
|
1020
|
-
* multi-package or multi-foundation workspaces. They remain visible
|
|
1021
|
-
* in the CI error message when no high-confidence signal exists, so
|
|
1022
|
-
* the user can pick one explicitly via `--name <id>`.
|
|
1023
|
-
*
|
|
1024
|
-
* Returns the id string, or null when no high-confidence signal is
|
|
1025
|
-
* available (caller falls through to the existing error-with-
|
|
1026
|
-
* suggestions guidance).
|
|
1027
|
-
*/
|
|
1028
|
-
async function pickAutoDerivedId({ workspaceRoot, foundationDir }) {
|
|
1029
|
-
const ID_RE = /^[a-z0-9_-]+$/
|
|
1030
|
-
const PLACEHOLDERS = new Set(['src', 'site', 'foundation', 'workspace', 'project'])
|
|
1031
|
-
const isHighConfidence = s => typeof s === 'string' && ID_RE.test(s) && !PLACEHOLDERS.has(s)
|
|
1032
|
-
|
|
1033
|
-
if (!workspaceRoot || workspaceRoot === foundationDir) return null
|
|
1034
|
-
try {
|
|
1035
|
-
const wsPkg = JSON.parse(await readFile(join(workspaceRoot, 'package.json'), 'utf8'))
|
|
1036
|
-
const wsName = typeof wsPkg.name === 'string'
|
|
1037
|
-
? wsPkg.name.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^-+|-+$/g, '')
|
|
1038
|
-
: null
|
|
1039
|
-
if (isHighConfidence(wsName)) return wsName
|
|
1040
|
-
} catch { /* no workspace package.json — skip */ }
|
|
1041
|
-
return null
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
/**
|
|
1045
|
-
* Build a list of contextual `uniweb.id` suggestions for first-time publishes.
|
|
1046
|
-
*
|
|
1047
|
-
* The CLI never auto-picks an id (Diego's principle: a bare folder name like
|
|
1048
|
-
* "src" is wrong, and silently committing to it would couple the registry
|
|
1049
|
-
* id to scaffold noise the user can't easily undo). Instead, suggest names
|
|
1050
|
-
* derived from signals the workspace already exposes:
|
|
1051
|
-
*
|
|
1052
|
-
* - **Sibling site name.** When exactly one site exists in the workspace,
|
|
1053
|
-
* the user's mental model is "this foundation is FOR that site" — so
|
|
1054
|
-
* the site's name (or "<site>-foundation" if it would collide with the
|
|
1055
|
-
* site's own package name) is a natural pick.
|
|
1056
|
-
* - **Workspace name.** A workspace package.json often carries a name
|
|
1057
|
-
* more meaningful than the foundation folder ("acme-marketing" vs "src").
|
|
1058
|
-
* - **Folder name minus `-src`.** Foundations placed under
|
|
1059
|
-
* `<name>-src/` strongly suggest `<name>` as the publish id (this
|
|
1060
|
-
* is the existing default; preserved here for back-compat).
|
|
1061
|
-
* - **Code-based fallback (M1, M2, …).** When the workspace already has
|
|
1062
|
-
* other foundations (i.e., the user manages a category of similar
|
|
1063
|
-
* foundations across sites/projects), suggest the next code in series.
|
|
1064
|
-
*
|
|
1065
|
-
* Returns deduplicated `{ id, why }` entries — `why` is shown next to the
|
|
1066
|
-
* id in both the CI guidance message and the TTY select prompt so the
|
|
1067
|
-
* user can tell at a glance which signal each suggestion comes from.
|
|
1068
|
-
*
|
|
1069
|
-
* The bare scaffold default `pkg.name === 'src'` is excluded by design.
|
|
1070
|
-
* Likewise any non-conforming shape (uppercase, dots, etc.) is filtered
|
|
1071
|
-
* out so users only ever see valid candidates.
|
|
1072
|
-
*/
|
|
1073
|
-
async function buildIdSuggestions({ foundationDir, workspaceRoot, pkg }) {
|
|
1074
|
-
const ID_RE = /^[a-z0-9_-]+$/
|
|
1075
|
-
const sanitize = s => (typeof s === 'string' ? s.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^-+|-+$/g, '') : null)
|
|
1076
|
-
const isValid = s => typeof s === 'string' && ID_RE.test(s) && s !== 'src' && s !== 'site'
|
|
1077
|
-
|
|
1078
|
-
const seen = new Set()
|
|
1079
|
-
const out = []
|
|
1080
|
-
const push = (id, why) => {
|
|
1081
|
-
if (!isValid(id) || seen.has(id)) return
|
|
1082
|
-
seen.add(id)
|
|
1083
|
-
out.push({ id, why })
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
// 1. Sibling-site suggestion. Only fires when there's exactly one site
|
|
1087
|
-
// in the workspace, because that's the unambiguous "for X" case.
|
|
1088
|
-
try {
|
|
1089
|
-
const sites = await findSites(workspaceRoot)
|
|
1090
|
-
if (sites.length === 1) {
|
|
1091
|
-
const sitePath = sites[0]
|
|
1092
|
-
try {
|
|
1093
|
-
const sitePkg = JSON.parse(await readFile(join(workspaceRoot, sitePath, 'package.json'), 'utf8'))
|
|
1094
|
-
const siteName = sanitize(sitePkg.name)
|
|
1095
|
-
if (siteName) {
|
|
1096
|
-
push(siteName, `matches your site "${siteName}"`)
|
|
1097
|
-
push(`${siteName}-foundation`, `derived from your site "${siteName}"`)
|
|
1098
|
-
}
|
|
1099
|
-
} catch { /* missing or malformed site package.json — skip */ }
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
if (res.status === 404) {
|
|
149
|
+
say.err(`Site ${uuid} not found on the backend (404).`)
|
|
150
|
+
say.dim('Sync it first with `uniweb push`, or use `uniweb deploy` for a file-only site.')
|
|
151
|
+
return { exitCode: 1 }
|
|
1100
152
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
// the user's chosen project identity; if it's a clean id, suggest it.
|
|
1105
|
-
try {
|
|
1106
|
-
if (workspaceRoot && workspaceRoot !== foundationDir) {
|
|
1107
|
-
const wsPkg = JSON.parse(await readFile(join(workspaceRoot, 'package.json'), 'utf8'))
|
|
1108
|
-
const wsName = sanitize(wsPkg.name)
|
|
1109
|
-
if (wsName) push(wsName, `matches your workspace "${wsName}"`)
|
|
153
|
+
say.err(`Publish rejected: HTTP ${res.status} ${res.statusText}`)
|
|
154
|
+
if (res.status === 401 || res.status === 403) {
|
|
155
|
+
say.dim("Credentials weren't accepted — run `uniweb login` (or pass --token <bearer>).")
|
|
1110
156
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
// suggestion now rather than the auto-pick.
|
|
1115
|
-
if (workspaceRoot && foundationDir !== workspaceRoot) {
|
|
1116
|
-
const folderName = foundationDir.replace(workspaceRoot + '/', '').split('/')[0]
|
|
1117
|
-
const stripped = sanitize(folderName?.replace(/-src$/, ''))
|
|
1118
|
-
if (stripped) push(stripped, `derived from the folder "${folderName}"`)
|
|
157
|
+
const body = await res.text().catch(() => '')
|
|
158
|
+
if (body) say.dim(body.slice(0, 800))
|
|
159
|
+
return { exitCode: 1 }
|
|
1119
160
|
}
|
|
1120
|
-
|
|
1121
|
-
// 4. Code-based fallback. Only suggested when the workspace already has
|
|
1122
|
-
// multiple foundations — the case Diego flagged (publishers managing
|
|
1123
|
-
// a category like M1, M2, M3 across sites/projects).
|
|
1124
|
-
try {
|
|
1125
|
-
const foundations = await findFoundations(workspaceRoot)
|
|
1126
|
-
if (foundations.length >= 2) {
|
|
1127
|
-
// Find the next M-number not already used by a sibling foundation's id.
|
|
1128
|
-
const usedCodes = new Set()
|
|
1129
|
-
for (const fp of foundations) {
|
|
1130
|
-
try {
|
|
1131
|
-
const fp_pkg = JSON.parse(await readFile(join(workspaceRoot, fp, 'package.json'), 'utf8'))
|
|
1132
|
-
const id = fp_pkg.uniweb?.id
|
|
1133
|
-
const m = typeof id === 'string' && id.match(/^m(\d+)$/i)
|
|
1134
|
-
if (m) usedCodes.add(parseInt(m[1], 10))
|
|
1135
|
-
} catch { /* skip */ }
|
|
1136
|
-
}
|
|
1137
|
-
let n = 1
|
|
1138
|
-
while (usedCodes.has(n)) n++
|
|
1139
|
-
push(`m${n}`, `next in your "M-code" series`)
|
|
1140
|
-
}
|
|
1141
|
-
} catch { /* findFoundations failed — skip */ }
|
|
1142
|
-
|
|
1143
|
-
return out
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
/**
|
|
1147
|
-
* Per-directory git state. Mirrors `deploy.js::readGitState` exactly —
|
|
1148
|
-
* scopes the sha + dirty check to `dir` rather than reading the whole
|
|
1149
|
-
* repo's HEAD. Publish records this in registry metadata; deploy
|
|
1150
|
-
* compares against it for staleness. Both sides must read the same
|
|
1151
|
-
* shape or the staleness check drifts.
|
|
1152
|
-
*/
|
|
1153
|
-
function readGitState(dir) {
|
|
161
|
+
let result
|
|
1154
162
|
try {
|
|
1155
|
-
|
|
1156
|
-
cwd: dir,
|
|
1157
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1158
|
-
}).toString().trim()
|
|
1159
|
-
const status = execSync('git status --porcelain -- .', {
|
|
1160
|
-
cwd: dir,
|
|
1161
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1162
|
-
}).toString()
|
|
1163
|
-
return { gitSha: sha || null, gitDirty: status.length > 0 }
|
|
163
|
+
result = await res.json()
|
|
1164
164
|
} catch {
|
|
1165
|
-
|
|
165
|
+
result = {}
|
|
1166
166
|
}
|
|
1167
|
-
}
|
|
1168
167
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
* { taken: true } — claimed by someone else
|
|
1176
|
-
*
|
|
1177
|
-
* Other failures throw.
|
|
1178
|
-
*/
|
|
1179
|
-
async function claimOrgHandle({ handle, token, backendUrl }) {
|
|
1180
|
-
const url = `${backendUrl.replace(/\/$/, '')}/api/orgs/${encodeURIComponent(handle)}`
|
|
1181
|
-
const res = await fetch(url, {
|
|
1182
|
-
method: 'POST',
|
|
1183
|
-
headers: {
|
|
1184
|
-
'Content-Type': 'application/json',
|
|
1185
|
-
Authorization: `Bearer ${token}`,
|
|
1186
|
-
},
|
|
1187
|
-
})
|
|
1188
|
-
if (res.status === 409) return { taken: true }
|
|
1189
|
-
if (!res.ok) {
|
|
1190
|
-
let detail = `HTTP ${res.status}`
|
|
1191
|
-
try {
|
|
1192
|
-
const j = await res.json()
|
|
1193
|
-
detail = j.error || detail
|
|
1194
|
-
} catch { /* non-JSON body */ }
|
|
1195
|
-
throw new Error(`Org claim failed: ${detail}`)
|
|
1196
|
-
}
|
|
1197
|
-
const body = await res.json()
|
|
1198
|
-
return { created: !!body.created, token: body.token }
|
|
168
|
+
const serveUrl = absolutizeServeUrl(client.origin, result.url)
|
|
169
|
+
console.log('')
|
|
170
|
+
say.ok(`Published ${c.bold}${uuid}${c.reset}${result.status ? ` (${result.status})` : ''}`)
|
|
171
|
+
if (serveUrl) console.log(` ${c.cyan}${serveUrl}${c.reset}`)
|
|
172
|
+
if (result.deploy_uuid) say.dim(`deploy: ${result.deploy_uuid}`)
|
|
173
|
+
return { exitCode: 0 }
|
|
1199
174
|
}
|
|
1200
175
|
|
|
1201
176
|
export default publish
|