uniweb 0.64.1 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/partials/agents.md +19 -19
- package/src/backend/client.js +61 -51
- package/src/backend/foundation-bring-along.js +154 -97
- package/src/backend/site-sync.js +237 -243
- package/src/backend/workspace.js +199 -0
- package/src/commands/clone.js +20 -30
- package/src/commands/deploy.js +9 -7
- package/src/commands/publish.js +37 -43
- package/src/commands/pull.js +232 -81
- package/src/commands/push.js +36 -45
- package/src/commands/status.js +24 -15
- package/src/commands/validate.js +16 -12
- package/src/framework-index.json +4 -4
- package/src/index.js +39 -27
- package/src/utils/conformance.js +68 -10
- package/src/utils/flag-guard.js +12 -9
- package/src/utils/git.js +48 -1
- package/src/utils/pull-written.js +5 -0
- package/src/utils/registry-auth.js +75 -16
- package/src/utils/registry-orgs.js +2 -2
- package/src/utils/semver-precedence.js +23 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE WORKSPACE A COMMAND WORKS IN — one at a time, chosen with the login.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ **[Diego, 2026-09-23]** — *"when a user has workspace in the backend, I expected them
|
|
5
|
+
* to explicitly login into one"*, and a site the backend keeps in ANOTHER workspace is
|
|
6
|
+
* refused rather than worked on anyway: *"that's the intent of the workspace concept."*
|
|
7
|
+
* The app keeps one workspace per tab; the CLI keeps one per login, and every site
|
|
8
|
+
* request names it (`x-uniweb-workspace`, `client.js`). A site outside it answers `409
|
|
9
|
+
* wrong_workspace`, and the command stops, saying how to switch — nothing is adopted.
|
|
10
|
+
*
|
|
11
|
+
* Where a command's workspace comes from — the first answer wins:
|
|
12
|
+
*
|
|
13
|
+
* 1. `--org @acme` / `--personal` this command only
|
|
14
|
+
* 2. `UNIWEB_WORKSPACE=@acme|personal` a process logged in with `UNIWEB_TOKEN`, which has
|
|
15
|
+
* no `uniweb login` to choose one (a template
|
|
16
|
+
* installer, an agent driving several backends)
|
|
17
|
+
* 3. the one chosen at `uniweb login` the session's
|
|
18
|
+
* 4. none chosen your personal workspace when you belong to no
|
|
19
|
+
* organization; otherwise refused, naming 1–3
|
|
20
|
+
*
|
|
21
|
+
* New sites are created in it: a create names the workspace, and the workspace is the
|
|
22
|
+
* site's owner.
|
|
23
|
+
*
|
|
24
|
+
* ⛔ **The personal workspace is named by naming NONE.** `@<your handle>` is your personal
|
|
25
|
+
* SCOPE — a registry namespace for foundations and data schemas — not a workspace.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readOrgFlag } from '../utils/args.js'
|
|
29
|
+
import { fetchOrgs } from '../utils/registry-orgs.js'
|
|
30
|
+
import { readRegistryAuth } from '../utils/registry-auth.js'
|
|
31
|
+
import { workspaceHandle, describeWorkspace } from './client.js'
|
|
32
|
+
|
|
33
|
+
export { describeWorkspace }
|
|
34
|
+
|
|
35
|
+
/** The personal workspace, as a login stores the choice (a handle is stored with its `@`). */
|
|
36
|
+
export const PERSONAL = 'personal'
|
|
37
|
+
|
|
38
|
+
/** Names a process's workspace when it is logged in with `UNIWEB_TOKEN`. */
|
|
39
|
+
export const WORKSPACE_ENV = 'UNIWEB_WORKSPACE'
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A workspace choice as it may be written — `@acme`, `acme`, `personal` — or null when
|
|
43
|
+
* the value names none.
|
|
44
|
+
*
|
|
45
|
+
* @param {unknown} value
|
|
46
|
+
* @returns {string|null} `@acme`, `PERSONAL`, or null
|
|
47
|
+
*/
|
|
48
|
+
export function parseWorkspaceChoice(value) {
|
|
49
|
+
if (typeof value !== 'string' || !value.trim()) return null
|
|
50
|
+
const v = value.trim()
|
|
51
|
+
return v === PERSONAL ? PERSONAL : workspaceHandle(v)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A choice in the header's form: `@acme`, or null for the personal workspace. */
|
|
55
|
+
export function headerOf(choice) {
|
|
56
|
+
return choice === PERSONAL ? null : choice
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What a source is called in a sentence — "(… )" after the workspace. */
|
|
60
|
+
export const SOURCE_LABEL = {
|
|
61
|
+
flag: 'named on this command',
|
|
62
|
+
env: WORKSPACE_ENV,
|
|
63
|
+
login: 'your login',
|
|
64
|
+
personal: 'you belong to no organization',
|
|
65
|
+
offline: 'not resolved in a preview'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const CHOOSE = [
|
|
69
|
+
'Choose the workspace you work in:',
|
|
70
|
+
' uniweb login --org @acme (or --personal) — for every command after',
|
|
71
|
+
' --org @acme / --personal — for this command only',
|
|
72
|
+
` ${WORKSPACE_ENV}=@acme — for a process logged in with UNIWEB_TOKEN`
|
|
73
|
+
].join('\n')
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The workspace this command works in.
|
|
77
|
+
*
|
|
78
|
+
* @param {object} p
|
|
79
|
+
* @param {import('./client.js').BackendClient} p.client - its origin and token
|
|
80
|
+
* @param {string[]} [p.args]
|
|
81
|
+
* @param {boolean} [p.offline=false] - a preview (`--dry-run`, `-o`): never authenticates,
|
|
82
|
+
* so step 4 answers "not resolved" instead of asking the backend
|
|
83
|
+
* @returns {Promise<{ workspace: string|null, source: string } | { refused: true, reason: string }>}
|
|
84
|
+
* `workspace` in the header's form — `@acme`, or null for the personal workspace
|
|
85
|
+
*/
|
|
86
|
+
export async function resolveWorkspace({ client, args = [], offline = false }) {
|
|
87
|
+
const flag = readOrgFlag(args)
|
|
88
|
+
if (flag) return { workspace: workspaceHandle(flag), source: 'flag' }
|
|
89
|
+
if (args.includes('--personal')) return { workspace: null, source: 'flag' }
|
|
90
|
+
|
|
91
|
+
const raw = process.env[WORKSPACE_ENV]
|
|
92
|
+
if (raw !== undefined && raw !== '') {
|
|
93
|
+
const env = parseWorkspaceChoice(raw)
|
|
94
|
+
if (!env) {
|
|
95
|
+
return {
|
|
96
|
+
refused: true,
|
|
97
|
+
reason: `${WORKSPACE_ENV}=${JSON.stringify(raw)} names no workspace — give @org, or ${PERSONAL}.`
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { workspace: headerOf(env), source: 'env' }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ⛔ A process logged in with UNIWEB_TOKEN is not the session's login: the token may be
|
|
104
|
+
// another account's, so the session's workspace is not its to use.
|
|
105
|
+
const useSession = !process.env.UNIWEB_TOKEN
|
|
106
|
+
const fromSession = async () =>
|
|
107
|
+
useSession ? parseWorkspaceChoice((await readRegistryAuth(client.origin))?.workspace) : null
|
|
108
|
+
|
|
109
|
+
const chosen = await fromSession()
|
|
110
|
+
if (chosen) return { workspace: headerOf(chosen), source: 'login' }
|
|
111
|
+
if (offline) return { workspace: null, source: 'offline' }
|
|
112
|
+
|
|
113
|
+
// Authenticate — a login this triggers chooses a workspace, so ask the session again.
|
|
114
|
+
await client.token()
|
|
115
|
+
const now = await fromSession()
|
|
116
|
+
if (now) return { workspace: headerOf(now), source: 'login' }
|
|
117
|
+
|
|
118
|
+
const { orgs } = await client.fetchOrgs()
|
|
119
|
+
if (!orgs.length) return { workspace: null, source: 'personal' }
|
|
120
|
+
return {
|
|
121
|
+
refused: true,
|
|
122
|
+
reason: `You belong to organizations, so no workspace is assumed.\n ${CHOOSE}`
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The workspace a login works in — asked once, at `uniweb login`, and stored with the
|
|
128
|
+
* session.
|
|
129
|
+
*
|
|
130
|
+
* `--org @acme` must be an organization you belong to
|
|
131
|
+
* `--personal` your personal workspace
|
|
132
|
+
* neither, no organization your personal workspace — said, not asked
|
|
133
|
+
* neither, organizations a pick at a terminal; refused without one
|
|
134
|
+
*
|
|
135
|
+
* @param {object} p
|
|
136
|
+
* @param {string} p.apiBase
|
|
137
|
+
* @param {string} p.token
|
|
138
|
+
* @param {string[]} [p.args]
|
|
139
|
+
* @returns {Promise<{ choice: string, note?: string } | { refused: true, reason: string }>}
|
|
140
|
+
* `choice` is `@acme` or `PERSONAL`
|
|
141
|
+
*/
|
|
142
|
+
export async function chooseWorkspace({ apiBase, token, args = [] }) {
|
|
143
|
+
const flag = readOrgFlag(args)
|
|
144
|
+
if (!flag && args.includes('--personal')) return { choice: PERSONAL }
|
|
145
|
+
|
|
146
|
+
const { orgs } = await fetchOrgs({ apiBase, token })
|
|
147
|
+
const mine = orgs.map((o) => `@${o.handle}`)
|
|
148
|
+
if (flag) {
|
|
149
|
+
const h = workspaceHandle(flag)
|
|
150
|
+
if (!mine.includes(h)) {
|
|
151
|
+
return {
|
|
152
|
+
refused: true,
|
|
153
|
+
reason:
|
|
154
|
+
`You are not a member of ${h} on ${apiBase}. ` +
|
|
155
|
+
(mine.length
|
|
156
|
+
? `Your workspaces: ${[...mine, `--personal`].join(', ')}.`
|
|
157
|
+
: 'You belong to no organization — use --personal.')
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { choice: h }
|
|
161
|
+
}
|
|
162
|
+
if (!orgs.length) {
|
|
163
|
+
return {
|
|
164
|
+
choice: PERSONAL,
|
|
165
|
+
note: 'You belong to no organization, so you work in your personal workspace.'
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { isNonInteractive } = await import('../utils/interactive.js')
|
|
170
|
+
if (isNonInteractive(args)) {
|
|
171
|
+
return {
|
|
172
|
+
refused: true,
|
|
173
|
+
reason: `You belong to organizations — name the workspace you work in: ${[...mine, '--personal'].map((w) => (w.startsWith('@') ? `--org ${w}` : w)).join(' | ')}.`
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const prompts = (await import('prompts')).default
|
|
177
|
+
const { choice } = await prompts(
|
|
178
|
+
{
|
|
179
|
+
type: 'select',
|
|
180
|
+
name: 'choice',
|
|
181
|
+
message: 'Which workspace do you work in?',
|
|
182
|
+
choices: [
|
|
183
|
+
{ title: 'Personal — your own sites', value: PERSONAL },
|
|
184
|
+
...orgs.map((o) => ({
|
|
185
|
+
title: `@${o.handle}${o.is_primary ? ' (primary)' : ''}`,
|
|
186
|
+
value: `@${o.handle}`
|
|
187
|
+
}))
|
|
188
|
+
],
|
|
189
|
+
initial: 0
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
onCancel: () => {
|
|
193
|
+
console.error('\nCancelled.')
|
|
194
|
+
process.exit(0)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
return choice ? { choice } : { refused: true, reason: 'No workspace chosen.' }
|
|
199
|
+
}
|
package/src/commands/clone.js
CHANGED
|
@@ -39,10 +39,9 @@
|
|
|
39
39
|
* uniweb clone <uuid> --path sites Place under sites/ (segregated layout)
|
|
40
40
|
* uniweb clone <uuid> --project docs Co-located docs/site
|
|
41
41
|
* uniweb clone <uuid> --no-records Pull pages only; skip records
|
|
42
|
-
* uniweb clone <uuid> --org @org
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* sync.json for every command after.
|
|
42
|
+
* uniweb clone <uuid> --org @org Work in @org for this clone, instead of the
|
|
43
|
+
* workspace chosen at `uniweb login`. A site in
|
|
44
|
+
* another workspace stops it.
|
|
46
45
|
*
|
|
47
46
|
* Backend: via BackendClient (the site-content pull lane). Origin from
|
|
48
47
|
* UNIWEB_REGISTER_URL > the local default (internal dev overrides;
|
|
@@ -59,13 +58,8 @@ import { resolvePlacement, SITE_KIND } from '../utils/placement.js'
|
|
|
59
58
|
import { findWorkspaceRoot } from '../utils/workspace.js'
|
|
60
59
|
import { addWorkspaceGlob } from '../utils/config.js'
|
|
61
60
|
import { detectWorkspacePm, installCmd } from '../utils/pm.js'
|
|
62
|
-
import {
|
|
63
|
-
|
|
64
|
-
describeRequestError,
|
|
65
|
-
refusalDetail,
|
|
66
|
-
workspaceHandle
|
|
67
|
-
} from '../backend/client.js'
|
|
68
|
-
import { readOrgFlag } from '../utils/args.js'
|
|
61
|
+
import { BackendClient, describeRequestError, refusalDetail } from '../backend/client.js'
|
|
62
|
+
import { resolveWorkspace } from '../backend/workspace.js'
|
|
69
63
|
import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
|
|
70
64
|
import { extractFoundationRef } from '../utils/site-content-refs.js'
|
|
71
65
|
import { readUwxDocuments } from '../utils/uwx-read.js'
|
|
@@ -108,7 +102,7 @@ function slugify(s) {
|
|
|
108
102
|
// than imported because pull.js statically imports @uniweb/build).
|
|
109
103
|
export function extractDocument(payload) {
|
|
110
104
|
if (!payload || typeof payload !== 'object') return null
|
|
111
|
-
if (payload.$
|
|
105
|
+
if (payload.$schema || payload.$uuid || payload.$id || payload.info) return payload
|
|
112
106
|
return payload.document || payload.entity || null
|
|
113
107
|
}
|
|
114
108
|
|
|
@@ -153,7 +147,7 @@ export function extractCloneSeeds(document) {
|
|
|
153
147
|
// `site.yml`'s equivalent needed a hand-rolled dependency-free WRITER for exactly
|
|
154
148
|
// this. The store stays the one writer everywhere else; this seeds a fresh file.
|
|
155
149
|
// `test/clone.test.js` covers it end to end with `skipInstall: true`.
|
|
156
|
-
function seedSyncJson(siteDir, origin, uuid
|
|
150
|
+
function seedSyncJson(siteDir, origin, uuid) {
|
|
157
151
|
const file = join(siteDir, 'sync.json')
|
|
158
152
|
let doc = { version: 1, backends: {} }
|
|
159
153
|
if (existsSync(file)) {
|
|
@@ -165,19 +159,11 @@ function seedSyncJson(siteDir, origin, uuid, workspace = null) {
|
|
|
165
159
|
}
|
|
166
160
|
}
|
|
167
161
|
const key = new URL(origin).origin
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
// As the create records it: a handle bare in `org`, a handle-less unit's uuid in
|
|
171
|
-
// `unit` (site-sync.js::readSiteWorkspace).
|
|
172
|
-
const w = typeof workspace === 'string' && workspace ? workspace : null
|
|
173
|
-
const named = w
|
|
174
|
-
? w.startsWith('@')
|
|
175
|
-
? { org: w.slice(1) }
|
|
176
|
-
: { unit: w }
|
|
177
|
-
: {}
|
|
162
|
+
// ⛔ No workspace: a command works in the one chosen with the login, and the clone
|
|
163
|
+
// does not know which workspace OWNS the site — it may read it from a parent.
|
|
178
164
|
doc.backends[key] = {
|
|
179
165
|
...(doc.backends[key] || {}),
|
|
180
|
-
site: { ...(doc.backends[key]?.site || {}), uuid
|
|
166
|
+
site: { ...(doc.backends[key]?.site || {}), uuid }
|
|
181
167
|
}
|
|
182
168
|
mkdirSync(dirname(file), { recursive: true })
|
|
183
169
|
writeFileSync(file, JSON.stringify(doc, null, 2) + '\n')
|
|
@@ -229,11 +215,15 @@ export async function clone(args = [], deps = {}) {
|
|
|
229
215
|
args,
|
|
230
216
|
command: 'Cloning'
|
|
231
217
|
})
|
|
232
|
-
// The workspace
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
218
|
+
// The workspace this clone works in — the login's, unless this command names another
|
|
219
|
+
// (`workspace.js`). A site the backend keeps elsewhere stops it, naming how to switch.
|
|
220
|
+
const ws = await resolveWorkspace({ client, args })
|
|
221
|
+
if (ws.refused) {
|
|
222
|
+
error('This clone works in one workspace, and none is chosen.')
|
|
223
|
+
note(ws.reason)
|
|
224
|
+
return { exitCode: 2 }
|
|
225
|
+
}
|
|
226
|
+
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
237
227
|
|
|
238
228
|
// 1. GET the site-content document.
|
|
239
229
|
//
|
|
@@ -385,7 +375,7 @@ export async function clone(args = [], deps = {}) {
|
|
|
385
375
|
// the old `$backend`: the uuid came from whoever we read and nothing on disk said
|
|
386
376
|
// so, and a teammate who pulled while logged in elsewhere sent it to the wrong
|
|
387
377
|
// backend. Stored under the origin, that cannot be expressed.
|
|
388
|
-
seedSyncJson(siteDir, client.origin, siteUuid
|
|
378
|
+
seedSyncJson(siteDir, client.origin, siteUuid)
|
|
389
379
|
success(
|
|
390
380
|
`Scaffolded the site harness${foundationRef ? ` (foundation: ${foundationRef})` : ''}.`
|
|
391
381
|
)
|
package/src/commands/deploy.js
CHANGED
|
@@ -93,10 +93,6 @@ export async function deploy(args = []) {
|
|
|
93
93
|
const dryRun = args.includes('--dry-run')
|
|
94
94
|
const siteDir = await resolveSiteDir(args)
|
|
95
95
|
|
|
96
|
-
// Advisory only — warns and ships. See utils/conformance.js for why this
|
|
97
|
-
// is not a gate.
|
|
98
|
-
await warnIfContentDoesNotConform(siteDir, { args, warn: say.warn, dim: say.dim })
|
|
99
|
-
|
|
100
96
|
// Host dispatch. Resolution order:
|
|
101
97
|
// 1. --target <name> picks a target from deploy.yml
|
|
102
98
|
// 2. deploy.yml's `default:` target when no flag is given
|
|
@@ -184,6 +180,13 @@ export async function deploy(args = []) {
|
|
|
184
180
|
}
|
|
185
181
|
}
|
|
186
182
|
|
|
183
|
+
// A static host: advisory — warns and ships (utils/conformance.js). Uniweb Cloud is
|
|
184
|
+
// `publish`, below, which refuses content that does not conform: there the backend
|
|
185
|
+
// enforces the schemas, so the check is a gate, not advice (2026-09-24).
|
|
186
|
+
if (plan.kind !== 'uniweb') {
|
|
187
|
+
await warnIfContentDoesNotConform(siteDir, { args, warn: say.warn, dim: say.dim })
|
|
188
|
+
}
|
|
189
|
+
|
|
187
190
|
// Uniweb Cloud is `publish`'s flow — delegate so deploy.yml stays one
|
|
188
191
|
// actionable record and there's a single implementation of go-live.
|
|
189
192
|
if (plan.kind === 'uniweb') {
|
|
@@ -224,9 +227,8 @@ export async function deploy(args = []) {
|
|
|
224
227
|
// publish ignores deploy's --host/--target; --dry-run/--no-save pass straight
|
|
225
228
|
// through.
|
|
226
229
|
const { publish } = await import('./publish.js')
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
const result = await publish([...args, '--no-validate'])
|
|
230
|
+
// publish runs the conformance gate itself; deploy checked nothing for this host.
|
|
231
|
+
const result = await publish(args)
|
|
230
232
|
process.exit(result?.exitCode ?? 0)
|
|
231
233
|
}
|
|
232
234
|
|
package/src/commands/publish.js
CHANGED
|
@@ -29,15 +29,11 @@
|
|
|
29
29
|
* uniweb publish --dry-run Resolve everything; POST nothing
|
|
30
30
|
* uniweb publish --yes Skip confirmations (CI); never block on a prompt
|
|
31
31
|
* uniweb publish --force Overwrite upstream app-side edits (drop the push gate)
|
|
32
|
-
* uniweb publish --org @org
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* it
|
|
37
|
-
* the workspace the backend works on the site from,
|
|
38
|
-
* or publish stops.
|
|
39
|
-
* uniweb publish --personal Own the new site personally, deliberately: its
|
|
40
|
-
* requests name no workspace.
|
|
32
|
+
* uniweb publish --org @org Work in @org for this publish, instead of the
|
|
33
|
+
* uniweb publish --personal workspace chosen at `uniweb login` (or your
|
|
34
|
+
* personal workspace). A site it creates is created
|
|
35
|
+
* — owned — there; a site in another workspace stops
|
|
36
|
+
* it.
|
|
41
37
|
* uniweb publish --no-save Do not record this publish in deploy.yml
|
|
42
38
|
*/
|
|
43
39
|
|
|
@@ -72,8 +68,8 @@ import {
|
|
|
72
68
|
} from '../backend/client.js'
|
|
73
69
|
import { DEFAULT_BACKEND_ORIGIN } from '../utils/config.js'
|
|
74
70
|
import { resolveSiteDir } from './deploy.js'
|
|
75
|
-
import {
|
|
76
|
-
import { readFlagValue
|
|
71
|
+
import { refuseIfContentDoesNotConform } from '../utils/conformance.js'
|
|
72
|
+
import { readFlagValue } from '../utils/args.js'
|
|
77
73
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
78
74
|
import {
|
|
79
75
|
syncedElsewhere,
|
|
@@ -91,13 +87,12 @@ import {
|
|
|
91
87
|
ensureItemUuids,
|
|
92
88
|
readFolderItemUuids,
|
|
93
89
|
ensureSiteExists,
|
|
90
|
+
refuseUnsendableRecords,
|
|
94
91
|
clearRemoteSyncStateIfUnbound,
|
|
95
92
|
dropSiteBoundValues,
|
|
96
|
-
pushSyncPackages
|
|
97
|
-
resolveSiteOrgForCreate,
|
|
98
|
-
nameSiteWorkspace,
|
|
99
|
-
EXPLICIT_OWNER
|
|
93
|
+
pushSyncPackages
|
|
100
94
|
} from '../backend/site-sync.js'
|
|
95
|
+
import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
|
|
101
96
|
import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
|
|
102
97
|
import {
|
|
103
98
|
updateBackendMap,
|
|
@@ -261,9 +256,11 @@ export async function publish(args = []) {
|
|
|
261
256
|
|
|
262
257
|
const siteDir = await resolveSiteDir(args, 'publish')
|
|
263
258
|
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
await
|
|
259
|
+
// ⭐ A GATE, before anything is sent — the backend checks these records against the
|
|
260
|
+
// schemas this publish registers. See utils/conformance.js.
|
|
261
|
+
if (await refuseIfContentDoesNotConform(siteDir, { args, error: say.err, warn: say.warn, dim: say.dim })) {
|
|
262
|
+
return { exitCode: 1 }
|
|
263
|
+
}
|
|
267
264
|
const siteYml = readSiteYml(join(siteDir, 'site.yml'))
|
|
268
265
|
// ⭐ THE BACKEND YOU ARE LOGGED IN TO is where this goes *[Diego, 2026-09-21]*, for every
|
|
269
266
|
// backend verb (resolveBackendOrigin). Only UNIWEB_REGISTER_URL — the automation
|
|
@@ -309,21 +306,13 @@ export async function publish(args = []) {
|
|
|
309
306
|
}
|
|
310
307
|
}
|
|
311
308
|
|
|
312
|
-
//
|
|
313
|
-
// up front: `ensureSiteExists` below
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
args,
|
|
320
|
-
flag: readOrgFlag(args),
|
|
321
|
-
personal: args.includes('--personal'),
|
|
322
|
-
offline: dryRun
|
|
323
|
-
})
|
|
324
|
-
if (org.refused) {
|
|
325
|
-
say.err('Refusing to create this site without naming an owner.')
|
|
326
|
-
say.dim(org.reason)
|
|
309
|
+
// ⭐ THE WORKSPACE THIS PUBLISH WORKS IN — the one chosen with the login, unless this
|
|
310
|
+
// command names another (`workspace.js`). Resolved up front: `ensureSiteExists` below
|
|
311
|
+
// creates the site in it, and a site the backend keeps elsewhere is refused.
|
|
312
|
+
const ws = await resolveWorkspace({ client, args, offline: dryRun })
|
|
313
|
+
if (ws.refused) {
|
|
314
|
+
say.err('This publish works in one workspace, and none is chosen.')
|
|
315
|
+
say.dim(ws.reason)
|
|
327
316
|
return { exitCode: 2 }
|
|
328
317
|
}
|
|
329
318
|
|
|
@@ -513,16 +502,13 @@ export async function publish(args = []) {
|
|
|
513
502
|
// be the silent-wrong-success this whole branch exists to prevent.
|
|
514
503
|
if (!fnd.proceed) return { exitCode: fnd.refused ? 1 : 0 }
|
|
515
504
|
|
|
516
|
-
// ⭐
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
explicit: EXPLICIT_OWNER.has(org.source),
|
|
524
|
-
note: (m) => say.dim(m)
|
|
525
|
-
})
|
|
505
|
+
// ⭐ Every site request names the workspace — the create, the push, the publish and
|
|
506
|
+
// the data lane — and a site outside it stops the publish (`WorkspaceMismatchError`).
|
|
507
|
+
// A site this publish creates is created in it — said first.
|
|
508
|
+
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
509
|
+
if (ws.source !== 'offline' && !readBackendState(siteDir, client.origin).site?.uuid) {
|
|
510
|
+
say.dim(`This publish creates the site in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source]}).`)
|
|
511
|
+
}
|
|
526
512
|
|
|
527
513
|
// 2. Build the site data (link mode): dist/site-content.json (+ per-locale),
|
|
528
514
|
// dist/data/*, dist/assets/*. Spawn the SAME CLI binary so the inner
|
|
@@ -585,6 +571,11 @@ export async function publish(args = []) {
|
|
|
585
571
|
say.err(`Could not build the sync package: ${err.message}`)
|
|
586
572
|
return { exitCode: 1 }
|
|
587
573
|
}
|
|
574
|
+
// A record the backend would refuse stops the publish HERE — before the site is
|
|
575
|
+
// created or a byte uploaded, not merely before the send (`refuseUnsendableRecords`).
|
|
576
|
+
if (refuseUnsendableRecords(probe.refusals, { error: say.err, note: say.dim })) {
|
|
577
|
+
return { exitCode: 1 }
|
|
578
|
+
}
|
|
588
579
|
const schemalessNames = (probe.schemaless || []).map((col) => col.name)
|
|
589
580
|
// A product decision the author is usually making unknowingly — say it at warn
|
|
590
581
|
// level, not dim among everything else. See the helper for what the old
|
|
@@ -981,6 +972,9 @@ export async function publish(args = []) {
|
|
|
981
972
|
return { exitCode: 1 }
|
|
982
973
|
}
|
|
983
974
|
for (const w of pkg.warnings) say.dim(`! ${w}`)
|
|
975
|
+
if (refuseUnsendableRecords(pkg.refusals, { error: say.err, note: say.dim })) {
|
|
976
|
+
return { exitCode: 1 }
|
|
977
|
+
}
|
|
984
978
|
const report = {
|
|
985
979
|
info: (m) => say.info(m),
|
|
986
980
|
note: (m) => say.dim(m),
|