uniweb 1.0.0 → 1.0.2
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/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* uniweb register — submit a foundation (and the data schemas it renders), or a
|
|
3
|
+
* standalone schemas package (the data schemas alone, no foundation), to the
|
|
4
|
+
* registry as one names-only `.uwx` document (uwx-format.md §5).
|
|
5
|
+
*
|
|
6
|
+
* `uniweb login && uniweb register`. Distinct from `uniweb publish`, which ships a
|
|
7
|
+
* SITE — and releases the site's local foundation by running this command
|
|
8
|
+
* (`backend/foundation-bring-along.js`).
|
|
9
|
+
*
|
|
10
|
+
* If the foundation's `dist/` is missing or stale — no build, a version bump
|
|
11
|
+
* since it, a source file newer than it, no SSR bundle (`foundationNeedsBuild`)
|
|
12
|
+
* — `register` builds it first. Preview paths (`--dry-run`, `-o`) never write to
|
|
13
|
+
* `dist/`; they require a pre-built foundation.
|
|
14
|
+
*
|
|
15
|
+
* Run from a foundation, or from a schemas-only package — a package that exports
|
|
16
|
+
* schemas (e.g. `@uniweb/schemas`, any `@org/schemas`) or a bare `schemas/*.yml`
|
|
17
|
+
* folder. The schemas-only package is auto-detected and submits its data schemas
|
|
18
|
+
* standalone (foundation-less); same flags, `--scope` names them.
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* uniweb register Build the .uwx, submit it, then deliver
|
|
22
|
+
* the foundation's dist/ code (plan +
|
|
23
|
+
* upload — see utils/code-upload.js)
|
|
24
|
+
* uniweb register --scope @org A foundation whose name is bare: register it under
|
|
25
|
+
* @org (resolves @/x -> @org/x) and write the scope
|
|
26
|
+
* into its name in main.js. Refused when the name
|
|
27
|
+
* already carries another scope — a foundation's
|
|
28
|
+
* scope is the one in its name.
|
|
29
|
+
* A schemas-only package: publish under @org.
|
|
30
|
+
* Default: its package.json "uniweb.scope".
|
|
31
|
+
* uniweb register --schema-only Skip the code delivery (schemas land, no dist upload)
|
|
32
|
+
* uniweb register --dry-run Print the .uwx + the code file plan; submit nothing
|
|
33
|
+
* uniweb register -o foundation.uwx Write the .uwx to a file; submit nothing
|
|
34
|
+
* uniweb register --json Porcelain: ONE compact JSON line on stdout
|
|
35
|
+
* ({ok,scope,origin,entities:[{name,uuid,version,unchanged}]}),
|
|
36
|
+
* all human output to stderr — for scripted callers
|
|
37
|
+
*
|
|
38
|
+
* Endpoint: UNIWEB_SERVER > the backend you are logged in to > the default
|
|
39
|
+
* (~/.uniweb/config.json, else uniweb.app) — resolveBackendOrigin. No backend flag:
|
|
40
|
+
* switching is `uniweb login --server <url>`.
|
|
41
|
+
* Auth (submit only): UNIWEB_TOKEN > the stored session > `uniweb login`.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
// DEFERRED foundation-registration capabilities (the legacy `uniweb publish` had
|
|
45
|
+
// these; the new backend doesn't yet — captured here so the design intent isn't
|
|
46
|
+
// lost, and the legacy code could be removed). Implement as `register` flags (or
|
|
47
|
+
// backend-side policy) when the need is real:
|
|
48
|
+
//
|
|
49
|
+
// • ACCESS POLICY — legacy `--edit-access open|restricted`. On the old registry
|
|
50
|
+
// this gated who could act on the foundation. Its meaning is
|
|
51
|
+
// unclear for the new model: there is no editing of a foundation's code or
|
|
52
|
+
// schema, so it is most likely a LICENSING / access-control concern (who may
|
|
53
|
+
// use/reference the foundation), not "editing". Revisit as `--access` (or an
|
|
54
|
+
// org/licensing policy on the backend) when foundation licensing lands.
|
|
55
|
+
//
|
|
56
|
+
// • VERSION PROPAGATION — legacy `--propagate`. Opts a newly-registered version
|
|
57
|
+
// into the registry's version-update walk: trusting sites whose policy allows
|
|
58
|
+
// the jump (e.g. auto-patch) adopt it with no rebuild; default was "silent"
|
|
59
|
+
// (stored, nothing moves). Implement once the backend has a
|
|
60
|
+
// version-update/propagation policy; until then every register is silent.
|
|
61
|
+
|
|
62
|
+
import {
|
|
63
|
+
existsSync,
|
|
64
|
+
readFileSync,
|
|
65
|
+
writeFileSync,
|
|
66
|
+
readdirSync,
|
|
67
|
+
statSync
|
|
68
|
+
} from 'node:fs'
|
|
69
|
+
import { execSync } from 'node:child_process'
|
|
70
|
+
import { resolve, join, relative } from 'node:path'
|
|
71
|
+
import prompts from 'prompts'
|
|
72
|
+
import { buildRegistryPackage, buildSchemaOnlyPackage } from '@uniweb/build/uwx'
|
|
73
|
+
import {
|
|
74
|
+
classifyPackage,
|
|
75
|
+
isSchemasPackage,
|
|
76
|
+
collectStandaloneSchemas,
|
|
77
|
+
readFoundationName,
|
|
78
|
+
checkFoundationName,
|
|
79
|
+
splitFoundationName
|
|
80
|
+
} from '@uniweb/build'
|
|
81
|
+
import {
|
|
82
|
+
suggestFoundationName,
|
|
83
|
+
writeFoundationName
|
|
84
|
+
} from '../utils/foundation-name.js'
|
|
85
|
+
import { readRegistryAuth } from '../utils/registry-auth.js'
|
|
86
|
+
import {
|
|
87
|
+
collectDistFiles,
|
|
88
|
+
computeFoundationDigest,
|
|
89
|
+
readRuntimePin
|
|
90
|
+
} from '../utils/code-upload.js'
|
|
91
|
+
import { deriveScope, publishScope, belongsToScope } from '../utils/registry-orgs.js'
|
|
92
|
+
import { BackendClient } from '../backend/client.js'
|
|
93
|
+
import { resolveWorkspace, scopeOfWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
|
|
94
|
+
import { writeJsonPreservingStyleAsync } from '../utils/json-file.js'
|
|
95
|
+
import {
|
|
96
|
+
findWorkspaceRoot,
|
|
97
|
+
findFoundations,
|
|
98
|
+
promptSelect
|
|
99
|
+
} from '../utils/workspace.js'
|
|
100
|
+
import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
|
|
101
|
+
import { checkFlags } from '../utils/flag-guard.js'
|
|
102
|
+
|
|
103
|
+
const colors = {
|
|
104
|
+
reset: '\x1b[0m',
|
|
105
|
+
bright: '\x1b[1m',
|
|
106
|
+
dim: '\x1b[2m',
|
|
107
|
+
red: '\x1b[31m',
|
|
108
|
+
green: '\x1b[32m',
|
|
109
|
+
yellow: '\x1b[33m',
|
|
110
|
+
blue: '\x1b[36m'
|
|
111
|
+
}
|
|
112
|
+
// Porcelain (`--json`) mode: stdout carries ONLY the final compact JSON line, so
|
|
113
|
+
// all human/colored output diverts to stderr. `emitJson` writes to the REAL
|
|
114
|
+
// stdout (bypassing the redirect). `jsonMode`/`jsonEmitted`/`lastError` are reset
|
|
115
|
+
// per run by the exported `register` wrapper.
|
|
116
|
+
let jsonMode = false
|
|
117
|
+
let jsonEmitted = false
|
|
118
|
+
let lastError = null
|
|
119
|
+
const log = (...a) => (jsonMode ? console.error(...a) : console.log(...a))
|
|
120
|
+
const success = (m) => log(`${colors.green}✓${colors.reset} ${m}`)
|
|
121
|
+
const error = (m) => {
|
|
122
|
+
lastError = String(m)
|
|
123
|
+
console.error(`${colors.red}✗${colors.reset} ${m}`)
|
|
124
|
+
}
|
|
125
|
+
const info = (m) => log(`${colors.blue}→${colors.reset} ${m}`)
|
|
126
|
+
const emitJson = (obj) => {
|
|
127
|
+
jsonEmitted = true
|
|
128
|
+
process.stdout.write(JSON.stringify(obj) + '\n')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function flagValue(args, name) {
|
|
132
|
+
const eq = args.find((a) => a.startsWith(`${name}=`))
|
|
133
|
+
if (eq) return eq.slice(name.length + 1)
|
|
134
|
+
const i = args.indexOf(name)
|
|
135
|
+
if (i !== -1 && args[i + 1] && !args[i + 1].startsWith('-'))
|
|
136
|
+
return args[i + 1]
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The uniweb CLI version, for the `.uwx` exporter envelope. Safe fallback if the
|
|
141
|
+
// package.json isn't reachable.
|
|
142
|
+
function cliVersion() {
|
|
143
|
+
try {
|
|
144
|
+
return JSON.parse(
|
|
145
|
+
readFileSync(new URL('../../package.json', import.meta.url), 'utf8')
|
|
146
|
+
).version
|
|
147
|
+
} catch {
|
|
148
|
+
return '0.0.0'
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A SCHEMAS-ONLY package's recorded publish org, from its package.json `uniweb.scope`
|
|
153
|
+
// (`{ "uniweb": { "scope": "@acme" } }`) — the default when `--scope` is absent. It
|
|
154
|
+
// has no main.js whose name could carry it. ⛔ A foundation's scope is part of its name
|
|
155
|
+
// (2026-09-22); its `uniweb.scope` is refused by `readFoundationName`.
|
|
156
|
+
function readPkgScope(foundationDir) {
|
|
157
|
+
try {
|
|
158
|
+
const pkg = JSON.parse(
|
|
159
|
+
readFileSync(join(foundationDir, 'package.json'), 'utf8')
|
|
160
|
+
)
|
|
161
|
+
return pkg?.uniweb?.scope || null
|
|
162
|
+
} catch {
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Record the chosen publish scope in a schemas-only package's package.json so it
|
|
168
|
+
// travels with the package (read back by readPkgScope on the next register). Preserves
|
|
169
|
+
// the file's existing JSON style. A foundation's goes into its name instead
|
|
170
|
+
// (`settleFoundationScope`).
|
|
171
|
+
async function writePkgScope(foundationDir, scope) {
|
|
172
|
+
const path = join(foundationDir, 'package.json')
|
|
173
|
+
const pkg = JSON.parse(readFileSync(path, 'utf8'))
|
|
174
|
+
pkg.uniweb = { ...(pkg.uniweb || {}), scope }
|
|
175
|
+
await writeJsonPreservingStyleAsync(path, pkg)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The package's name from its package.json, for display. The standalone schemas
|
|
179
|
+
// register has no foundation `_self` to name, so it labels by package name.
|
|
180
|
+
function readPkgName(dir) {
|
|
181
|
+
try {
|
|
182
|
+
return (
|
|
183
|
+
JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))?.name || null
|
|
184
|
+
)
|
|
185
|
+
} catch {
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve which foundation to register: the cwd if it's a foundation, else the
|
|
192
|
+
* single foundation in the workspace, else prompt (or error in non-interactive).
|
|
193
|
+
* Mirrors `uniweb publish`.
|
|
194
|
+
*/
|
|
195
|
+
async function resolveFoundationDir(args) {
|
|
196
|
+
const cwd = process.cwd()
|
|
197
|
+
if (classifyPackage(cwd) === 'foundation') return cwd
|
|
198
|
+
|
|
199
|
+
const workspaceRoot = findWorkspaceRoot(cwd)
|
|
200
|
+
if (workspaceRoot) {
|
|
201
|
+
const foundations = await findFoundations(workspaceRoot)
|
|
202
|
+
if (foundations.length === 1) return resolve(workspaceRoot, foundations[0])
|
|
203
|
+
if (foundations.length > 1) {
|
|
204
|
+
if (isNonInteractive(args)) {
|
|
205
|
+
error('Multiple foundations found. Run register from the one you mean.')
|
|
206
|
+
for (const f of foundations)
|
|
207
|
+
log(
|
|
208
|
+
` ${colors.cyan || ''}cd ${f} && ${getCliPrefix()} register${colors.reset}`
|
|
209
|
+
)
|
|
210
|
+
process.exit(1)
|
|
211
|
+
}
|
|
212
|
+
const choice = await promptSelect('Which foundation?', foundations)
|
|
213
|
+
if (!choice) {
|
|
214
|
+
log('\nRegister cancelled.')
|
|
215
|
+
process.exit(0)
|
|
216
|
+
}
|
|
217
|
+
return resolve(workspaceRoot, choice)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
error(
|
|
222
|
+
'No foundation found. Run register from a foundation directory or a workspace that has one.'
|
|
223
|
+
)
|
|
224
|
+
process.exit(1)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The foundation's name, settled before anything is built or sent.
|
|
229
|
+
*
|
|
230
|
+
* A foundation registers as `@org/<name>`, and sites pin it by that name. The name
|
|
231
|
+
* is `main.js`'s `name`, else package.json's — `readFoundationName`, the rule the
|
|
232
|
+
* build reads for the schema this command submits. ⛔ `src` and `foundation` are
|
|
233
|
+
* the folder the code sits in, not the code: a project scaffolded before the name
|
|
234
|
+
* moved to main.js has package name `src`, and every such project in an org would
|
|
235
|
+
* register the same `@org/src`. So a name that cannot register is ASKED FOR here,
|
|
236
|
+
* once, and kept in main.js — or, with nobody to ask, refused with the line to add.
|
|
237
|
+
* Before the build, so a written name is built into the schema like any edit.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} targetDir
|
|
240
|
+
* @param {{ args: string[], isPreview: boolean }} o
|
|
241
|
+
* @returns {Promise<boolean>} false → stop; the reason is printed
|
|
242
|
+
*/
|
|
243
|
+
export async function settleFoundationName(targetDir, { args, isPreview }) {
|
|
244
|
+
let read
|
|
245
|
+
try {
|
|
246
|
+
read = await readFoundationName(targetDir)
|
|
247
|
+
} catch (err) {
|
|
248
|
+
error(err.message)
|
|
249
|
+
return false
|
|
250
|
+
}
|
|
251
|
+
const problem = checkFoundationName(read.name)
|
|
252
|
+
if (!problem) return true
|
|
253
|
+
|
|
254
|
+
const where = relative(process.cwd(), read.mainFile) || read.mainFile
|
|
255
|
+
const suggestion = suggestFoundationName(targetDir)
|
|
256
|
+
const reason = `${problem[0].toUpperCase()}${problem.slice(1)}.`
|
|
257
|
+
|
|
258
|
+
// Nobody to ask — a script, a preview (which writes nothing), or --json (whose
|
|
259
|
+
// stdout is a data channel a prompt would corrupt). Name the fix exactly.
|
|
260
|
+
if (isPreview || jsonMode || isNonInteractive(args)) {
|
|
261
|
+
error(`This foundation has no name it can register under. ${reason}`)
|
|
262
|
+
log(` Name it in ${colors.bright}${where}${colors.reset}, in the default export:`)
|
|
263
|
+
log(` ${colors.bright}name: '${suggestion || '<name>'}',${colors.reset}`)
|
|
264
|
+
log(` ${colors.dim}It registers as @<org>/<name>, and sites pin that name.${colors.reset}`)
|
|
265
|
+
return false
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
info(`This foundation needs a name to register under. ${reason}`)
|
|
269
|
+
const { name } = await prompts(
|
|
270
|
+
{
|
|
271
|
+
type: 'text',
|
|
272
|
+
name: 'name',
|
|
273
|
+
message: 'Foundation name (it registers as @<org>/<name>):',
|
|
274
|
+
initial: suggestion || '',
|
|
275
|
+
validate: (v) => checkFoundationName(String(v).trim()) || true
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
onCancel: () => {
|
|
279
|
+
log('\nRegister cancelled.')
|
|
280
|
+
process.exit(0)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
)
|
|
284
|
+
const chosen = String(name || '').trim()
|
|
285
|
+
if (!chosen) return false
|
|
286
|
+
|
|
287
|
+
const written = writeFoundationName(read.mainFile, chosen, {
|
|
288
|
+
replace: read.source === 'main.js' ? read.name : null
|
|
289
|
+
})
|
|
290
|
+
if (!written.ok) {
|
|
291
|
+
error(`Could not write the name: ${written.reason}.`)
|
|
292
|
+
log(` Add it to the default export in ${colors.bright}${where}${colors.reset}: name: '${chosen}',`)
|
|
293
|
+
return false
|
|
294
|
+
}
|
|
295
|
+
// Read it back through the build's own rule — the text edit is only a means.
|
|
296
|
+
const again = await readFoundationName(targetDir).catch(() => null)
|
|
297
|
+
if (again?.name !== chosen) {
|
|
298
|
+
error(`${where} now names the foundation '${chosen}', but it still reads as "${again?.name}".`)
|
|
299
|
+
return false
|
|
300
|
+
}
|
|
301
|
+
success(`Named the foundation ${colors.bright}${chosen}${colors.reset} — kept in ${where}.`)
|
|
302
|
+
return true
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Settle the scope a foundation registers under — the one in its name.
|
|
307
|
+
*
|
|
308
|
+
* ⭐ THE SCOPE IS PART OF THE NAME (2026-09-22): `name: '@acme/marketing'` in main.js.
|
|
309
|
+
* A scoped name decides it, and a `--scope` naming another org is refused — which org
|
|
310
|
+
* was meant is not ours to guess, and today's alternative was a split identity (the
|
|
311
|
+
* foundation under one org, its data schemas under the other). A bare name has not
|
|
312
|
+
* registered yet: it takes `--scope`, else one derived from your orgs, and the chosen
|
|
313
|
+
* scope is WRITTEN INTO THE NAME, so it is chosen once and travels with the code.
|
|
314
|
+
* ⛔ Until then it was written to `package.json::uniweb.scope`, which is refused now
|
|
315
|
+
* (`readFoundationName`, naming the line to write).
|
|
316
|
+
*
|
|
317
|
+
* A preview (`--dry-run`, `-o`) writes nothing: a bare name previews under `--scope`,
|
|
318
|
+
* or unscoped.
|
|
319
|
+
*
|
|
320
|
+
* @returns {Promise<{ scope: string|null, source: string|null } | null>}
|
|
321
|
+
* null when refused, the reason printed — no scope chosen included, since a name with
|
|
322
|
+
* no scope cannot register. ⛔ Until 2026-10-06 "no scope chosen" was `cancelled`, and
|
|
323
|
+
* register exited 0 having registered nothing.
|
|
324
|
+
*/
|
|
325
|
+
export async function settleFoundationScope(targetDir, { args, isPreview, flagScope, client }) {
|
|
326
|
+
let read
|
|
327
|
+
try {
|
|
328
|
+
read = await readFoundationName(targetDir)
|
|
329
|
+
} catch (err) {
|
|
330
|
+
error(err.message)
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
333
|
+
const where = relative(process.cwd(), read.mainFile) || read.mainFile
|
|
334
|
+
const { scope: nameScope, bare } = splitFoundationName(read.name)
|
|
335
|
+
|
|
336
|
+
if (nameScope) {
|
|
337
|
+
if (flagScope && flagScope !== nameScope) {
|
|
338
|
+
error(
|
|
339
|
+
`${where} names this foundation ${read.name}, so it registers under ${nameScope} — ` +
|
|
340
|
+
`--scope ${flagScope} names another org.`
|
|
341
|
+
)
|
|
342
|
+
log(
|
|
343
|
+
` ${colors.dim}To register it under ${flagScope}, change its name in ${where}: ` +
|
|
344
|
+
`name: '${flagScope}/${bare}'${colors.reset}`
|
|
345
|
+
)
|
|
346
|
+
return null
|
|
347
|
+
}
|
|
348
|
+
return { scope: nameScope, source: 'main.js' }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (isPreview) return { scope: flagScope, source: flagScope ? '--scope' : null }
|
|
352
|
+
|
|
353
|
+
let scope = flagScope
|
|
354
|
+
let source = flagScope ? '--scope' : null
|
|
355
|
+
if (!scope) {
|
|
356
|
+
scope = await deriveScopeFromLogin(client, args)
|
|
357
|
+
if (!scope) return null
|
|
358
|
+
source = 'login'
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const scoped = `${scope}/${bare}`
|
|
362
|
+
const written = writeFoundationName(read.mainFile, scoped, {
|
|
363
|
+
replace: read.source === 'main.js' ? read.name : null
|
|
364
|
+
})
|
|
365
|
+
if (!written.ok) {
|
|
366
|
+
error(`Could not write the scope into the foundation's name: ${written.reason}.`)
|
|
367
|
+
log(` Name it in the default export in ${colors.bright}${where}${colors.reset}: name: '${scoped}',`)
|
|
368
|
+
return null
|
|
369
|
+
}
|
|
370
|
+
// Read it back through the build's own rule — the text edit is only a means.
|
|
371
|
+
const again = await readFoundationName(targetDir).catch(() => null)
|
|
372
|
+
if (again?.name !== scoped) {
|
|
373
|
+
error(`${where} now names the foundation '${scoped}', but it still reads as "${again?.name}".`)
|
|
374
|
+
return null
|
|
375
|
+
}
|
|
376
|
+
success(`This foundation registers as ${colors.bright}${scoped}${colors.reset} — kept in ${where}.`)
|
|
377
|
+
return { scope, source }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The scope a bare name registers under when `--scope` names none — as `@handle`, or null
|
|
382
|
+
* when none was chosen.
|
|
383
|
+
*
|
|
384
|
+
* ⭐ THE WORKSPACE THIS COMMAND WORKS IN *[Diego, 2026-10-06]* — an organization's handle,
|
|
385
|
+
* or your own for your personal workspace — said, not asked (`scopeOfWorkspace`). A team
|
|
386
|
+
* working in `@acme` registers `@acme/…`, which any of its members can release.
|
|
387
|
+
* ⛔ Until 2026-10-06 the default was your personal scope wherever you worked, so a team's
|
|
388
|
+
* foundation registered as `@jane/…` — releasable by Jane alone, and fixed only by
|
|
389
|
+
* renaming it into a new foundation.
|
|
390
|
+
*
|
|
391
|
+
* When the workspace names no scope — none is chosen, or it is a unit without a handle —
|
|
392
|
+
* the scope is derived from your orgs as before (`deriveScope`): your personal scope when
|
|
393
|
+
* you belong to none, else a pick at a terminal, your personal scope without one.
|
|
394
|
+
*/
|
|
395
|
+
async function deriveScopeFromLogin(client, args) {
|
|
396
|
+
const token = await client.token()
|
|
397
|
+
const ws = await resolveWorkspace({ client, args })
|
|
398
|
+
const personal = !ws.refused && ws.source !== 'offline' && ws.workspace === null
|
|
399
|
+
const accountHandle = personal ? (await client.fetchOrgs()).account_handle : null
|
|
400
|
+
const fromWorkspace = scopeOfWorkspace(ws, accountHandle)
|
|
401
|
+
if (fromWorkspace) {
|
|
402
|
+
const named = `${colors.bright}${fromWorkspace}${colors.reset}`
|
|
403
|
+
console.error(
|
|
404
|
+
personal
|
|
405
|
+
? `Registering under your personal scope ${named} — you work in your personal workspace (${SOURCE_LABEL[ws.source]}). For an org: --scope @org.`
|
|
406
|
+
: `Registering under ${named} — the workspace you work in (${SOURCE_LABEL[ws.source]}). For another scope: --scope @org.`
|
|
407
|
+
)
|
|
408
|
+
return fromWorkspace
|
|
409
|
+
}
|
|
410
|
+
const sess = await readRegistryAuth(client.origin)
|
|
411
|
+
const derived = await deriveScope({
|
|
412
|
+
apiBase: client.origin,
|
|
413
|
+
token,
|
|
414
|
+
accountHandle: sess?.handle || null,
|
|
415
|
+
args
|
|
416
|
+
})
|
|
417
|
+
return derived ? `@${derived}` : null
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Directories that are never foundation source. `dist` is the output we compare
|
|
421
|
+
// against; the rest are tooling. Dotted entries are skipped wholesale.
|
|
422
|
+
const NON_SOURCE_DIRS = new Set(['dist', 'node_modules'])
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The newest mtime under `dir`, skipping build output and tooling. `0` when the
|
|
426
|
+
* tree is unreadable — which reads as "no source is newer", i.e. it degrades to
|
|
427
|
+
* the pre-existing behaviour rather than forcing a build on an fs error.
|
|
428
|
+
*
|
|
429
|
+
* @param {string} dir
|
|
430
|
+
* @returns {number} epoch ms
|
|
431
|
+
*/
|
|
432
|
+
function newestSourceMtime(dir) {
|
|
433
|
+
let newest = 0
|
|
434
|
+
const walk = (d) => {
|
|
435
|
+
let entries
|
|
436
|
+
try {
|
|
437
|
+
entries = readdirSync(d, { withFileTypes: true })
|
|
438
|
+
} catch {
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
for (const e of entries) {
|
|
442
|
+
if (e.name.startsWith('.') || NON_SOURCE_DIRS.has(e.name)) continue
|
|
443
|
+
const p = join(d, e.name)
|
|
444
|
+
if (e.isDirectory()) {
|
|
445
|
+
walk(p)
|
|
446
|
+
continue
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
const m = statSync(p).mtimeMs
|
|
450
|
+
if (m > newest) newest = m
|
|
451
|
+
} catch {
|
|
452
|
+
/* unreadable file — ignore */
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
walk(dir)
|
|
457
|
+
return newest
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Does the foundation's `dist/` need a (re)build before we can register it?
|
|
462
|
+
*
|
|
463
|
+
* Three staleness signals:
|
|
464
|
+
* - MISSING: no `dist/entry.js` (or the legacy `dist/foundation.js`), or no
|
|
465
|
+
* `dist/meta/schema.json` — nothing built yet.
|
|
466
|
+
* - STALE VERSION: the version baked into `dist/meta/schema.json::_self.version`
|
|
467
|
+
* differs from `package.json::version` — a version bump without a rebuild,
|
|
468
|
+
* so the artifact encodes the OLD version while the register intends the
|
|
469
|
+
* NEW one (we'd otherwise submit a schema whose version disagrees with the
|
|
470
|
+
* code we deliver).
|
|
471
|
+
* - STALE SOURCE: a source file is newer than `dist/entry.js`.
|
|
472
|
+
*
|
|
473
|
+
* ⛔ **The source signal was missing until 2026-08-16, and its absence shipped the
|
|
474
|
+
* PREVIOUS bundle silently.** Edit a component's rendered output, re-run
|
|
475
|
+
* `register` with a `dist/` present, and the version signal says fresh (nothing
|
|
476
|
+
* bumped), the artifact signal says fresh (files exist) — so the old bytes upload
|
|
477
|
+
* and the content digest legitimately does not move. The developer's freshness
|
|
478
|
+
* indicator reports "unchanged" and it is telling the truth about an input that
|
|
479
|
+
* never changed. Found by end-to-end testing against a live backend, which is
|
|
480
|
+
* the only place the two halves meet: a unit test on this predicate agrees with
|
|
481
|
+
* it, and the digest agrees with the bytes.
|
|
482
|
+
*
|
|
483
|
+
* ⚠️ **This header used to claim it "mirrors `uniweb publish`'s build-if-stale".
|
|
484
|
+
* It does not, and that sentence is why nobody looked.** `publish` does not do
|
|
485
|
+
* build-if-stale at all — `backend/foundation-bring-along.js` builds
|
|
486
|
+
* UNCONDITIONALLY before digesting, precisely so the digest reflects current
|
|
487
|
+
* source. So publish was already correct and register was not, while a docblock
|
|
488
|
+
* asserted they matched.
|
|
489
|
+
*
|
|
490
|
+
* ⛔ **And do NOT "fix" this by building unconditionally to match publish.**
|
|
491
|
+
* `publish` builds and then shells out to THIS command
|
|
492
|
+
* (`foundation-bring-along.js` → `execFileSync(cliBin, ['register', …])`), so an
|
|
493
|
+
* unconditional build here makes every publish build twice. The mtime signal is
|
|
494
|
+
* what keeps both paths right: publish's fresh `dist/` is newer than its source,
|
|
495
|
+
* so this correctly skips.
|
|
496
|
+
*
|
|
497
|
+
* Returns `{ needs: false }` or `{ needs: true, reason }`.
|
|
498
|
+
*
|
|
499
|
+
* @param {string} targetDir the foundation directory
|
|
500
|
+
*/
|
|
501
|
+
export function foundationNeedsBuild(targetDir) {
|
|
502
|
+
const distDir = join(targetDir, 'dist')
|
|
503
|
+
const schemaPath = join(distDir, 'meta', 'schema.json')
|
|
504
|
+
// @uniweb/build emits dist/entry.js; older builds emitted dist/foundation.js.
|
|
505
|
+
const hasArtifact =
|
|
506
|
+
existsSync(join(distDir, 'entry.js')) ||
|
|
507
|
+
existsSync(join(distDir, 'foundation.js'))
|
|
508
|
+
if (!hasArtifact || !existsSync(schemaPath))
|
|
509
|
+
return { needs: true, reason: 'no dist/ found' }
|
|
510
|
+
// The SSR bundle is required by any host that server-renders, and a dist can
|
|
511
|
+
// lack one for two reasons that both reach here looking complete: it was left
|
|
512
|
+
// by a `uniweb dev` session (which skips that sub-build), or it was built by
|
|
513
|
+
// a toolchain older than @uniweb/build@0.14.25, which did not emit it at all.
|
|
514
|
+
// Shipping either produces a site that renders client-side at HTTP 200 with
|
|
515
|
+
// nothing reporting why, so treat it as stale and rebuild.
|
|
516
|
+
if (!existsSync(join(distDir, 'entry-ssr.js')))
|
|
517
|
+
return { needs: true, reason: 'dist/ has no entry-ssr.js (SSR bundle)' }
|
|
518
|
+
let pkgVersion = null
|
|
519
|
+
try {
|
|
520
|
+
pkgVersion =
|
|
521
|
+
JSON.parse(readFileSync(join(targetDir, 'package.json'), 'utf8'))
|
|
522
|
+
?.version || null
|
|
523
|
+
} catch {
|
|
524
|
+
// No readable package.json version — fall through; a present schema with no
|
|
525
|
+
// version to compare is treated as fresh (the submit path validates names).
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
const peek = JSON.parse(readFileSync(schemaPath, 'utf8'))
|
|
529
|
+
if (
|
|
530
|
+
peek?._self?.version &&
|
|
531
|
+
pkgVersion &&
|
|
532
|
+
peek._self.version !== pkgVersion
|
|
533
|
+
) {
|
|
534
|
+
return {
|
|
535
|
+
needs: true,
|
|
536
|
+
reason: `package.json version (${pkgVersion}) differs from built schema (${peek._self.version})`
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
} catch {
|
|
540
|
+
return { needs: true, reason: 'dist/meta/schema.json could not be parsed' }
|
|
541
|
+
}
|
|
542
|
+
// STALE SOURCE — the signal whose absence shipped the previous bundle. Compared
|
|
543
|
+
// against the built entry rather than the schema: the schema is emitted by a
|
|
544
|
+
// later plugin hook, so it is always the newer of the two and using it would
|
|
545
|
+
// widen the window in which an edit reads as already-built.
|
|
546
|
+
//
|
|
547
|
+
// Ties count as fresh (`>`, not `>=`). A build writes its output after reading
|
|
548
|
+
// its input, so equal mtimes mean a same-millisecond build, not a missed edit —
|
|
549
|
+
// and on a fresh clone, where whole trees share a timestamp, `>=` would rebuild
|
|
550
|
+
// every foundation once for nothing.
|
|
551
|
+
try {
|
|
552
|
+
const entryPath = existsSync(join(distDir, 'entry.js'))
|
|
553
|
+
? join(distDir, 'entry.js')
|
|
554
|
+
: join(distDir, 'foundation.js')
|
|
555
|
+
const builtAt = statSync(entryPath).mtimeMs
|
|
556
|
+
if (newestSourceMtime(targetDir) > builtAt)
|
|
557
|
+
return { needs: true, reason: 'a source file is newer than dist/entry.js' }
|
|
558
|
+
} catch {
|
|
559
|
+
// Can't compare — leave the other signals to decide rather than forcing a
|
|
560
|
+
// build on an fs error.
|
|
561
|
+
}
|
|
562
|
+
return { needs: false }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export async function register(args = []) {
|
|
566
|
+
// See utils/flag-guard.js — an unrecognized flag is invisible to a
|
|
567
|
+
// literal scan, so it silently keeps the default (production, for --backend).
|
|
568
|
+
const badFlag = checkFlags('register', args)
|
|
569
|
+
if (badFlag) {
|
|
570
|
+
error(badFlag.message)
|
|
571
|
+
return { exitCode: 2 }
|
|
572
|
+
}
|
|
573
|
+
jsonMode = args.includes('--json')
|
|
574
|
+
jsonEmitted = false
|
|
575
|
+
lastError = null
|
|
576
|
+
const result = await runRegister(args)
|
|
577
|
+
// Guarantee a porcelain line on stdout for every --json exit: the success path
|
|
578
|
+
// emits its own; here we cover the error / early-return paths so a scripted
|
|
579
|
+
// caller can always JSON.parse(stdout).
|
|
580
|
+
if (jsonMode && !jsonEmitted) {
|
|
581
|
+
emitJson(
|
|
582
|
+
result?.exitCode === 0
|
|
583
|
+
? { ok: true, entities: [] }
|
|
584
|
+
: {
|
|
585
|
+
ok: false,
|
|
586
|
+
error:
|
|
587
|
+
lastError || `register failed (exit ${result?.exitCode ?? 1})`
|
|
588
|
+
}
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
return result
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function runRegister(args = []) {
|
|
595
|
+
const dryRun = args.includes('--dry-run')
|
|
596
|
+
const output = flagValue(args, '-o') || flagValue(args, '--output')
|
|
597
|
+
const scopeFlag = flagValue(args, '--scope')
|
|
598
|
+
// The backend you are logged in to (UNIWEB_SERVER overrides it for a script) —
|
|
599
|
+
// resolveBackendOrigin, like every backend command.
|
|
600
|
+
const client = new BackendClient({
|
|
601
|
+
args,
|
|
602
|
+
command: 'Registering'
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
// Target: a schemas-only package (standalone data-schema register) or a
|
|
606
|
+
// foundation (foundation + the schemas it renders). A schemas package is only
|
|
607
|
+
// detected when the cwd isn't a foundation, so the foundation path — including
|
|
608
|
+
// its workspace-scan + prompt (resolveFoundationDir) — is unchanged.
|
|
609
|
+
const standalone = isSchemasPackage(process.cwd())
|
|
610
|
+
const targetDir = standalone
|
|
611
|
+
? process.cwd()
|
|
612
|
+
: await resolveFoundationDir(args)
|
|
613
|
+
|
|
614
|
+
// Scope. ⭐ A FOUNDATION's is the one in its name (`name: '@acme/marketing'` in
|
|
615
|
+
// main.js), settled below before the build; a bare name takes --scope, else the
|
|
616
|
+
// workspace the command works in (`deriveScopeFromLogin`), and register writes it
|
|
617
|
+
// into the name (2026-09-22). A SCHEMAS-ONLY package has no name to carry one:
|
|
618
|
+
// --scope, else package.json `uniweb.scope`, else (real submit only) the workspace
|
|
619
|
+
// the command works in. Either spelling, `@acme` or `acme`, and from here on the one
|
|
620
|
+
// form: `@acme` (publishScope). ⛔ This said "derived from your orgs" / "from login
|
|
621
|
+
// membership" until 2026-10-06, read as the personal-scope default that was replaced.
|
|
622
|
+
const pkgScope = standalone ? readPkgScope(targetDir) : null
|
|
623
|
+
const givenScope = scopeFlag || pkgScope
|
|
624
|
+
let scope = publishScope(givenScope)
|
|
625
|
+
let scopeSource = scopeFlag
|
|
626
|
+
? '--scope'
|
|
627
|
+
: pkgScope
|
|
628
|
+
? 'package.json uniweb.scope'
|
|
629
|
+
: null
|
|
630
|
+
// A scope that was GIVEN but names no org (`--scope @`) is refused, never read as
|
|
631
|
+
// absent: absent derives one from the login, which would register under an org the
|
|
632
|
+
// caller did not name.
|
|
633
|
+
if (givenScope && !scope) {
|
|
634
|
+
error(`Not an org scope: ${givenScope} (from ${scopeSource})`)
|
|
635
|
+
log(` ${colors.dim}Pass --scope @org.${colors.reset}`)
|
|
636
|
+
return { exitCode: 2 }
|
|
637
|
+
}
|
|
638
|
+
const isPreview = !!output || dryRun
|
|
639
|
+
|
|
640
|
+
// Each path supplies a different schema source: the standalone path discovers
|
|
641
|
+
// the package's own schemas; the foundation path reads its built schema.json.
|
|
642
|
+
let schema = null
|
|
643
|
+
let schemas = null
|
|
644
|
+
if (standalone) {
|
|
645
|
+
try {
|
|
646
|
+
schemas = await collectStandaloneSchemas(targetDir)
|
|
647
|
+
} catch (err) {
|
|
648
|
+
error(`Could not read the schemas package: ${err.message}`)
|
|
649
|
+
return { exitCode: 2 }
|
|
650
|
+
}
|
|
651
|
+
if (!schemas || Object.keys(schemas).length === 0) {
|
|
652
|
+
error('No data schemas found in this package.')
|
|
653
|
+
log(
|
|
654
|
+
` ${colors.dim}Expected a package that exports schemas (getSchema / schemas), or a schemas/ directory of *.yml files.${colors.reset}`
|
|
655
|
+
)
|
|
656
|
+
return { exitCode: 2 }
|
|
657
|
+
}
|
|
658
|
+
} else {
|
|
659
|
+
if (!(await settleFoundationName(targetDir, { args, isPreview }))) {
|
|
660
|
+
return { exitCode: 2 }
|
|
661
|
+
}
|
|
662
|
+
// ⭐ BEFORE THE BUILD, so the build embeds the name the foundation registers as.
|
|
663
|
+
// Settled after it, a scope written into main.js would leave dist/ describing the
|
|
664
|
+
// bare name — stale the moment it was built.
|
|
665
|
+
const settled = await settleFoundationScope(targetDir, {
|
|
666
|
+
args,
|
|
667
|
+
isPreview,
|
|
668
|
+
flagScope: scope,
|
|
669
|
+
client
|
|
670
|
+
})
|
|
671
|
+
if (!settled) return { exitCode: 2 }
|
|
672
|
+
scope = settled.scope
|
|
673
|
+
scopeSource = settled.source
|
|
674
|
+
// Build-if-stale (`foundationNeedsBuild`): a missing or stale dist/ gets
|
|
675
|
+
// (re)built before we read its schema. Preview paths
|
|
676
|
+
// (--dry-run / -o) must not write to dist/, so they require a pre-built
|
|
677
|
+
// foundation and say so instead of building.
|
|
678
|
+
const { needs, reason } = foundationNeedsBuild(targetDir)
|
|
679
|
+
if (needs) {
|
|
680
|
+
if (isPreview) {
|
|
681
|
+
error(`No usable build (${reason}).`)
|
|
682
|
+
log(
|
|
683
|
+
` Build the foundation first: ${colors.bright}uniweb build${colors.reset}`
|
|
684
|
+
)
|
|
685
|
+
return { exitCode: 2 }
|
|
686
|
+
}
|
|
687
|
+
info(`${reason} — building the foundation first …`)
|
|
688
|
+
try {
|
|
689
|
+
execSync('npx uniweb build --target foundation', {
|
|
690
|
+
cwd: targetDir,
|
|
691
|
+
// ⛔ `inherit` wires the DELEGATE's stdout to ours, so under --json the
|
|
692
|
+
// builder's progress lands on stdout ahead of the JSON line and
|
|
693
|
+
// `JSON.parse(stdout)` throws — on exactly the cold path a fresh
|
|
694
|
+
// checkout always takes. register's own output is already redirected to
|
|
695
|
+
// stderr (see `log` above); this child is the one stream it did not own,
|
|
696
|
+
// so --json was porcelain only when a build was NOT needed.
|
|
697
|
+
//
|
|
698
|
+
// fd 2 is our stderr: progress stays visible, stdout stays parseable.
|
|
699
|
+
// Found by a harness that had to work around it by taking the last
|
|
700
|
+
// JSON line off stdout.
|
|
701
|
+
stdio: jsonMode ? ['inherit', 2, 'inherit'] : 'inherit',
|
|
702
|
+
// A step of register: the build's "Share with clients: uniweb register" hint
|
|
703
|
+
// would tell someone running register to run register.
|
|
704
|
+
env: { ...process.env, UNIWEB_BUILD_STEP: '1' }
|
|
705
|
+
})
|
|
706
|
+
} catch (err) {
|
|
707
|
+
error(`Build failed: ${err.message}`)
|
|
708
|
+
return { exitCode: 2 }
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
const schemaPath = join(targetDir, 'dist', 'meta', 'schema.json')
|
|
712
|
+
if (!existsSync(schemaPath)) {
|
|
713
|
+
error('No built schema found (dist/meta/schema.json) after build.')
|
|
714
|
+
log(` Build the foundation: ${colors.bright}uniweb build${colors.reset}`)
|
|
715
|
+
return { exitCode: 2 }
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
schema = JSON.parse(readFileSync(schemaPath, 'utf8'))
|
|
719
|
+
} catch (err) {
|
|
720
|
+
error(`Could not read ${schemaPath}: ${err.message}`)
|
|
721
|
+
return { exitCode: 2 }
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// A schemas-only package with no scope, on a real submit → the workspace you work in,
|
|
726
|
+
// else derived from your orgs (`deriveScopeFromLogin`), and persisted to package.json.
|
|
727
|
+
// (A foundation's was settled before its build — `settleFoundationScope`.)
|
|
728
|
+
if (standalone && !scope && !isPreview) {
|
|
729
|
+
const derived = await deriveScopeFromLogin(client, args)
|
|
730
|
+
if (!derived) return { exitCode: 2 }
|
|
731
|
+
scope = derived
|
|
732
|
+
scopeSource = 'login'
|
|
733
|
+
try {
|
|
734
|
+
await writePkgScope(targetDir, scope)
|
|
735
|
+
info(
|
|
736
|
+
`Saved ${colors.bright}${scope}${colors.reset} as this package's publish scope (package.json).`
|
|
737
|
+
)
|
|
738
|
+
} catch {
|
|
739
|
+
log(
|
|
740
|
+
` ${colors.dim}(Could not save the scope to package.json — pass --scope ${scope} next time.)${colors.reset}`
|
|
741
|
+
)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Content digest (foundation path only) — the freshness fingerprint over what
|
|
746
|
+
// register ships (shipping-model.md §4.1). Rides in the foundation-schema
|
|
747
|
+
// entity's info.digest; the backend stores it opaque and returns it so
|
|
748
|
+
// publish/status can detect "code changed since release" with no local state.
|
|
749
|
+
const digest = standalone
|
|
750
|
+
? null
|
|
751
|
+
: computeFoundationDigest(join(targetDir, 'dist'))
|
|
752
|
+
|
|
753
|
+
// Compatibility floor (foundation path only) — the @uniweb/runtime version
|
|
754
|
+
// this build linked against. Rides in info.runtime beside info.digest.
|
|
755
|
+
//
|
|
756
|
+
// Until this shipped, `dist/runtime-pin.json` was written by the build and
|
|
757
|
+
// read by NOTHING, so a floor a foundation stated could never reach anyone
|
|
758
|
+
// able to act on it. It cannot be acted on here either: a site's floor is
|
|
759
|
+
// max() over its primary foundation AND its extensions, and this command
|
|
760
|
+
// publishes one foundation without sight of the others. So the producer
|
|
761
|
+
// states its own floor and whoever resolves a whole site does the arithmetic.
|
|
762
|
+
const runtime = standalone ? null : readRuntimePin(join(targetDir, 'dist'))
|
|
763
|
+
|
|
764
|
+
const exporter = { tool: 'uniweb', version: cliVersion(), instance: 'build' }
|
|
765
|
+
let doc
|
|
766
|
+
try {
|
|
767
|
+
doc = standalone
|
|
768
|
+
? buildSchemaOnlyPackage({ schemas, scope, exporter })
|
|
769
|
+
: buildRegistryPackage({
|
|
770
|
+
schema,
|
|
771
|
+
foundationDir: targetDir,
|
|
772
|
+
scope,
|
|
773
|
+
exporter,
|
|
774
|
+
digest,
|
|
775
|
+
runtime
|
|
776
|
+
})
|
|
777
|
+
} catch (err) {
|
|
778
|
+
error(`Could not assemble the .uwx: ${err.message}`)
|
|
779
|
+
return { exitCode: 2 }
|
|
780
|
+
}
|
|
781
|
+
const json = JSON.stringify(doc, null, 2)
|
|
782
|
+
|
|
783
|
+
const defined = doc.entities
|
|
784
|
+
.filter((e) => e.model === '@uniweb/data-schema')
|
|
785
|
+
.map((e) => e.name)
|
|
786
|
+
log('')
|
|
787
|
+
if (standalone) {
|
|
788
|
+
info(
|
|
789
|
+
`${colors.bright}${readPkgName(targetDir) || 'schemas'}${colors.reset} ${colors.dim}(schemas-only — no foundation)${colors.reset}`
|
|
790
|
+
)
|
|
791
|
+
} else {
|
|
792
|
+
info(
|
|
793
|
+
`${colors.bright}${schema._self.name}@${schema._self.version}${colors.reset}`
|
|
794
|
+
)
|
|
795
|
+
}
|
|
796
|
+
log(
|
|
797
|
+
` ${colors.dim}data schemas ${standalone ? 'registered' : 'defined'}: ${defined.length ? defined.join(', ') : '(none)'}${colors.reset}`
|
|
798
|
+
)
|
|
799
|
+
if (scope)
|
|
800
|
+
log(` ${colors.dim}scope: ${scope} (${scopeSource})${colors.reset}`)
|
|
801
|
+
if (digest) log(` ${colors.dim}digest: ${digest}${colors.reset}`)
|
|
802
|
+
if (runtime) log(` ${colors.dim}runtime floor: ${runtime}${colors.reset}`)
|
|
803
|
+
|
|
804
|
+
// Preview paths — no submit, no auth needed.
|
|
805
|
+
if (output) {
|
|
806
|
+
writeFileSync(resolve(output), json)
|
|
807
|
+
success(`Wrote ${output} (${doc.entities.length} entities) — not submitted`)
|
|
808
|
+
return { exitCode: 0 }
|
|
809
|
+
}
|
|
810
|
+
if (dryRun) {
|
|
811
|
+
log('')
|
|
812
|
+
log(json)
|
|
813
|
+
log('')
|
|
814
|
+
info(`Dry run — would submit to ${client.origin}`)
|
|
815
|
+
if (!standalone && !args.includes('--schema-only')) {
|
|
816
|
+
const distFiles = collectDistFiles(join(targetDir, 'dist'))
|
|
817
|
+
log('')
|
|
818
|
+
info(
|
|
819
|
+
`Would then deliver ${distFiles.length} code file(s) (meta/ excluded):`
|
|
820
|
+
)
|
|
821
|
+
for (const f of distFiles) {
|
|
822
|
+
log(
|
|
823
|
+
` ${colors.dim}${f.path} ${f.size} bytes ${f.content_type}${colors.reset}`
|
|
824
|
+
)
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return { exitCode: 0 }
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Submit requires a concrete scope — the registry rejects @/… fail-closed.
|
|
831
|
+
if (!scope) {
|
|
832
|
+
error(
|
|
833
|
+
standalone
|
|
834
|
+
? 'No publish scope — set "uniweb.scope" in package.json, or pass --scope @org.'
|
|
835
|
+
: "No publish scope — name the foundation '@org/<name>' in main.js, or pass --scope @org."
|
|
836
|
+
)
|
|
837
|
+
log(
|
|
838
|
+
` ${colors.dim}Without a scope, names stay @/… and the registry rejects them.${colors.reset}`
|
|
839
|
+
)
|
|
840
|
+
return { exitCode: 2 }
|
|
841
|
+
}
|
|
842
|
+
// Submit — the client carries the bearer (UNIWEB_TOKEN › stored
|
|
843
|
+
// session › login), resolved lazily on this first authed call.
|
|
844
|
+
info(`Submitting to ${colors.dim}${client.origin}${colors.reset} …`)
|
|
845
|
+
let res
|
|
846
|
+
try {
|
|
847
|
+
res = await client.register(json)
|
|
848
|
+
} catch (err) {
|
|
849
|
+
error(`Could not reach the registry at ${client.origin}: ${err.message}`)
|
|
850
|
+
log(
|
|
851
|
+
` ${colors.dim}Is that the backend you meant? Switch with: uniweb login --server <url>${colors.reset}`
|
|
852
|
+
)
|
|
853
|
+
return { exitCode: 2 }
|
|
854
|
+
}
|
|
855
|
+
// Read the response body once: the --json success path needs it for the minted
|
|
856
|
+
// entity ids; the error path shows it.
|
|
857
|
+
const rawBody = await res.text().catch(() => '')
|
|
858
|
+
let parsedBody = null
|
|
859
|
+
try {
|
|
860
|
+
parsedBody = rawBody ? JSON.parse(rawBody) : null
|
|
861
|
+
} catch {
|
|
862
|
+
parsedBody = null
|
|
863
|
+
}
|
|
864
|
+
let alreadyRegistered = false
|
|
865
|
+
if (!res.ok) {
|
|
866
|
+
// Resume path: a registered version is immutable, so re-running after a
|
|
867
|
+
// partial code delivery hits the duplicate rejection here — a STRUCTURED
|
|
868
|
+
// 409 (problem+json, title "Conflict", naming the `version` it refused) — and
|
|
869
|
+
// proceeds to phase 2 (the code-uploads plan authorizes against the REGISTERED
|
|
870
|
+
// version; files it already stores come back `present` and are skipped).
|
|
871
|
+
//
|
|
872
|
+
// ⛔ ONE STATUS, MORE THAN ONE MEANING. A 409 that names no version is another
|
|
873
|
+
// refusal — a data schema changed where the backend holds records of it
|
|
874
|
+
// (`reason: "destructive_republish"`) — and until 2026-09-26 it was read as a
|
|
875
|
+
// resume too: "already registered — resuming code delivery", then a 404 for a
|
|
876
|
+
// version that was never registered, and the refusal's own sentence never shown.
|
|
877
|
+
// A body with no shape to read resumes, as it always did.
|
|
878
|
+
const isDuplicate =
|
|
879
|
+
!standalone && res.status === 409 && (!parsedBody || typeof parsedBody !== 'object' || parsedBody.version !== undefined)
|
|
880
|
+
if (isDuplicate) {
|
|
881
|
+
alreadyRegistered = true
|
|
882
|
+
info(
|
|
883
|
+
`${colors.dim}Schema for this version is already registered — resuming code delivery.${colors.reset}`
|
|
884
|
+
)
|
|
885
|
+
} else {
|
|
886
|
+
// ⭐ NOT A MEMBER OF THE SCOPE IS SAID AS THAT *[Diego, 2026-10-06]*. The registry
|
|
887
|
+
// decides who may release into a scope — its account, or an org's members — and
|
|
888
|
+
// when the orgs read says you are neither, that is the refusal to name. ⛔ Until then
|
|
889
|
+
// a 403 here printed "log in again", the wrong advice for a scope that is not yours.
|
|
890
|
+
// Read to explain a refusal, never to gate one; a 401 is the credential itself.
|
|
891
|
+
const member =
|
|
892
|
+
res.status === 401 ? null : belongsToScope(scope, await client.fetchOrgs().catch(() => null))
|
|
893
|
+
if (member === false) {
|
|
894
|
+
const what = standalone
|
|
895
|
+
? `data schemas under ${scope}`
|
|
896
|
+
: doc.entities.find((e) => e.model === '@uniweb/foundation-schema')?.info?.name || `a foundation under ${scope}`
|
|
897
|
+
error(`You can't release ${what}: you're not a member of ${scope}.`)
|
|
898
|
+
log(
|
|
899
|
+
` ${colors.dim}A member of ${scope} can release it. (The registry answered HTTP ${res.status}.)${colors.reset}`
|
|
900
|
+
)
|
|
901
|
+
} else {
|
|
902
|
+
error(
|
|
903
|
+
`Registry rejected the submission: HTTP ${res.status} ${res.statusText}`
|
|
904
|
+
)
|
|
905
|
+
}
|
|
906
|
+
if (member !== false && (res.status === 401 || res.status === 403)) {
|
|
907
|
+
log(
|
|
908
|
+
` ${colors.dim}The registry didn't accept your credentials — it may use different ones than \`uniweb login\`.${colors.reset}`
|
|
909
|
+
)
|
|
910
|
+
log(
|
|
911
|
+
` ${colors.dim}Log in again (uniweb login --server <url>), or check UNIWEB_TOKEN — an existing one may be wrong or expired.${colors.reset}`
|
|
912
|
+
)
|
|
913
|
+
}
|
|
914
|
+
// ⭐ **Surface `detail` as a sentence, not as a JSON dump.** The registry
|
|
915
|
+
// answers with problem+json (RFC 9457), and its `detail` is written for a
|
|
916
|
+
// human — it carries the actual next step, which for a rejected
|
|
917
|
+
// re-register is "bump the version". Printing the whole body dimmed put
|
|
918
|
+
// that sentence inside a blob, at the end of a 500-char truncation, in the
|
|
919
|
+
// one moment the reader most needs it.
|
|
920
|
+
//
|
|
921
|
+
// ⚖️ Keyed on the SHAPE, not on any particular code: a new refusal the CLI
|
|
922
|
+
// has never seen explains itself the day the backend ships it, with no
|
|
923
|
+
// release here. That is the property worth having — this branch exists
|
|
924
|
+
// because a 422 the CLI did not know about arrived exactly that way.
|
|
925
|
+
if (parsedBody?.detail) {
|
|
926
|
+
log(` ${parsedBody.detail}`)
|
|
927
|
+
const code = parsedBody.code ?? parsedBody.reason
|
|
928
|
+
if (code) {
|
|
929
|
+
log(` ${colors.dim}(${code})${colors.reset}`)
|
|
930
|
+
}
|
|
931
|
+
} else if (rawBody) {
|
|
932
|
+
log(` ${colors.dim}${rawBody.slice(0, 500)}${colors.reset}`)
|
|
933
|
+
}
|
|
934
|
+
return { exitCode: 1 }
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
// The foundation's name as the registry holds it (`@org/name`) — read from the
|
|
938
|
+
// document just submitted, never composed again. See phase 2.
|
|
939
|
+
const registeredName = standalone
|
|
940
|
+
? null
|
|
941
|
+
: doc.entities.find((e) => e.model === '@uniweb/foundation-schema')?.info
|
|
942
|
+
?.name
|
|
943
|
+
if (!alreadyRegistered) {
|
|
944
|
+
success(
|
|
945
|
+
standalone
|
|
946
|
+
? `Registered ${defined.length} data schema(s)${scope ? ` under ${scope}` : ''}`
|
|
947
|
+
: `Registered ${registeredName}@${schema._self.version}${defined.length ? ` + ${defined.length} data schema(s)` : ''}`
|
|
948
|
+
)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Phase 2 — deliver the foundation's code (plan → PUT-per-file, entry
|
|
952
|
+
// last; contract: foundation-code-upload.md). Schemas-
|
|
953
|
+
// only packages have no dist; --schema-only skips deliberately.
|
|
954
|
+
if (!standalone && !args.includes('--schema-only')) {
|
|
955
|
+
const distDir = join(targetDir, 'dist')
|
|
956
|
+
// ⭐ The plan authorizes against the REGISTERED (name, version), so the name here
|
|
957
|
+
// is the one phase 1 registered — the `.uwx`'s own `info.name`, where a scoped
|
|
958
|
+
// package name passes through and a bare one gets the scope. ⛔ Not composed a
|
|
959
|
+
// second time: this line composed it from the raw scope until 2026-09-21, so
|
|
960
|
+
// `--scope std` registered `@std/src` and then asked to deliver `std/src`, which
|
|
961
|
+
// the registry refuses. One name, read from the one place it was decided.
|
|
962
|
+
const name = registeredName
|
|
963
|
+
const version = schema._self.version
|
|
964
|
+
info(
|
|
965
|
+
`Delivering code for ${colors.bright}${name}@${version}${colors.reset} …`
|
|
966
|
+
)
|
|
967
|
+
try {
|
|
968
|
+
const result = await client.uploadFoundationCode({
|
|
969
|
+
name,
|
|
970
|
+
version,
|
|
971
|
+
distDir,
|
|
972
|
+
onProgress: (m) => log(` ${colors.dim}${m}${colors.reset}`)
|
|
973
|
+
})
|
|
974
|
+
if (result.failed.length) {
|
|
975
|
+
error(`${result.failed.length} file(s) failed to upload:`)
|
|
976
|
+
for (const f of result.failed) {
|
|
977
|
+
log(
|
|
978
|
+
` ${colors.red}${f.path}${colors.reset} ${colors.dim}HTTP ${f.status} ${f.detail}${colors.reset}`
|
|
979
|
+
)
|
|
980
|
+
}
|
|
981
|
+
log(
|
|
982
|
+
` ${colors.dim}Re-run \`uniweb register\` to resume — files already stored are skipped.${colors.reset}`
|
|
983
|
+
)
|
|
984
|
+
return { exitCode: 1 }
|
|
985
|
+
}
|
|
986
|
+
// "4 files" on a first upload; "1 uploaded, 3 already stored" on a resume.
|
|
987
|
+
const delivered = result.stored?.length
|
|
988
|
+
? `${result.uploaded.length} uploaded, ${result.stored.length} already stored`
|
|
989
|
+
: `${result.uploaded.length} files`
|
|
990
|
+
if (result.verified === true) {
|
|
991
|
+
// serveBase is guaranteed here — it is what gated the check.
|
|
992
|
+
success(
|
|
993
|
+
`Code delivered (${delivered}) — entry verified live at ${colors.dim}${result.serveBase}${colors.reset}`
|
|
994
|
+
)
|
|
995
|
+
} else if (result.verified === false) {
|
|
996
|
+
error(
|
|
997
|
+
'Code uploaded but the entry verification fetch did not match — investigate before using this version.'
|
|
998
|
+
)
|
|
999
|
+
return { exitCode: 1 }
|
|
1000
|
+
} else {
|
|
1001
|
+
success(`Code delivered (${delivered}, ${result.mode} mode)`)
|
|
1002
|
+
// Say WHY nothing was checked, rather than skipping in silence. The CLI
|
|
1003
|
+
// never reconstructs a serve URL (see utils/code-upload.js), so a plan
|
|
1004
|
+
// without `serve_base` means the location is not ours to know — name it
|
|
1005
|
+
// as a backend gap so it surfaces there instead of looking like a pass.
|
|
1006
|
+
if (!result.serveBase) {
|
|
1007
|
+
log(
|
|
1008
|
+
` ${colors.dim}Not verified: the backend reported no serve location for this version.${colors.reset}`
|
|
1009
|
+
)
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
// ⚖️ A 4xx is the registry REFUSING the plan — e.g. a stored file
|
|
1014
|
+
// declared at a different size (`version_content_changed`). Running the
|
|
1015
|
+
// same command again gets the same answer, so do not suggest it: print
|
|
1016
|
+
// the refusal's own sentence, which says what does help.
|
|
1017
|
+
if (err.status >= 400 && err.status < 500 && err.detail) {
|
|
1018
|
+
error(`Code delivery refused: HTTP ${err.status}`)
|
|
1019
|
+
log(` ${err.detail}`)
|
|
1020
|
+
if (err.code) log(` ${colors.dim}(${err.code})${colors.reset}`)
|
|
1021
|
+
return { exitCode: 1 }
|
|
1022
|
+
}
|
|
1023
|
+
error(`Code delivery failed: ${err.message}`)
|
|
1024
|
+
log(
|
|
1025
|
+
` ${colors.dim}The schema registration above succeeded; re-run \`uniweb register\` to deliver the code.${colors.reset}`
|
|
1026
|
+
)
|
|
1027
|
+
return { exitCode: 1 }
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
// Registering ≠ delivering the file. A registered schema is a content type
|
|
1031
|
+
// authors use in the app; a FOUNDATION build still resolves '@scope/name' from
|
|
1032
|
+
// disk (a package or a routed folder). This is the moment that belief forms —
|
|
1033
|
+
// you just registered it — so correct it here, not in a doc nobody's reading.
|
|
1034
|
+
if (!jsonMode && defined.length > 0 && scope) {
|
|
1035
|
+
log('')
|
|
1036
|
+
info(
|
|
1037
|
+
'Registered schemas are content types authors can use in the Uniweb app.'
|
|
1038
|
+
)
|
|
1039
|
+
log(
|
|
1040
|
+
` ${colors.dim}Delivery is separate: a foundation that binds ${scope}/<name> resolves it from${colors.reset}`
|
|
1041
|
+
)
|
|
1042
|
+
log(
|
|
1043
|
+
` ${colors.dim}disk — an ${scope}/schemas package, or a folder routed in schemas.config.js.${colors.reset}`
|
|
1044
|
+
)
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
if (jsonMode) {
|
|
1048
|
+
// Join my authoritative submitted names with the backend's minted ids. Each
|
|
1049
|
+
// response entry is `{ registered: { name, version, payload_model_uuid, … },
|
|
1050
|
+
// unchanged }` (symmetric on the new-version + unchanged branches); a flat
|
|
1051
|
+
// shape is tolerated as a fallback. Names from doc.entities are the spine, so
|
|
1052
|
+
// the porcelain always reports WHICH names landed even if a field is absent.
|
|
1053
|
+
const minted = {}
|
|
1054
|
+
const addMint = (e) => {
|
|
1055
|
+
if (!e || typeof e !== 'object') return
|
|
1056
|
+
const reg = e.registered ?? e
|
|
1057
|
+
if (reg.name)
|
|
1058
|
+
minted[reg.name] = {
|
|
1059
|
+
uuid: reg.payload_model_uuid ?? null,
|
|
1060
|
+
version: reg.version ?? null,
|
|
1061
|
+
unchanged: e.unchanged === true
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
if (parsedBody && typeof parsedBody === 'object') {
|
|
1065
|
+
if (Array.isArray(parsedBody.data_schemas))
|
|
1066
|
+
parsedBody.data_schemas.forEach(addMint)
|
|
1067
|
+
if (parsedBody.foundation_schema) addMint(parsedBody.foundation_schema)
|
|
1068
|
+
}
|
|
1069
|
+
const names = [
|
|
1070
|
+
...doc.entities
|
|
1071
|
+
.filter((e) => e.model === '@uniweb/data-schema')
|
|
1072
|
+
.map((e) => e.name),
|
|
1073
|
+
// The foundation-schema entity carries its `@scope/name` under `info`, not a
|
|
1074
|
+
// top-level `name` (that's the foundation-schema shape).
|
|
1075
|
+
...doc.entities
|
|
1076
|
+
.filter((e) => e.model === '@uniweb/foundation-schema')
|
|
1077
|
+
.map((e) => e.info?.name ?? e.name)
|
|
1078
|
+
].filter(Boolean)
|
|
1079
|
+
const entities = names.map((name) => ({
|
|
1080
|
+
name,
|
|
1081
|
+
...(minted[name] || { uuid: null, version: null, unchanged: false })
|
|
1082
|
+
}))
|
|
1083
|
+
emitJson({
|
|
1084
|
+
ok: true,
|
|
1085
|
+
scope: scope || null,
|
|
1086
|
+
origin: client.origin,
|
|
1087
|
+
digest: digest || null,
|
|
1088
|
+
entities
|
|
1089
|
+
})
|
|
1090
|
+
}
|
|
1091
|
+
return { exitCode: 0 }
|
|
1092
|
+
}
|