uniweb 0.64.0 → 0.65.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 +5 -5
- package/partials/agents.md +26 -17
- package/src/backend/client.js +61 -51
- package/src/backend/site-sync.js +17 -229
- package/src/backend/workspace.js +199 -0
- package/src/commands/clone.js +19 -29
- package/src/commands/publish.js +22 -39
- package/src/commands/pull.js +13 -14
- package/src/commands/push.js +21 -40
- package/src/commands/status.js +7 -12
- package/src/framework-index.json +3 -3
- package/src/index.js +19 -14
- package/src/utils/flag-guard.js +7 -5
- package/src/utils/registry-auth.js +75 -16
- package/src/utils/registry-orgs.js +2 -2
|
@@ -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'
|
|
@@ -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/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
|
|
|
@@ -73,7 +69,7 @@ import {
|
|
|
73
69
|
import { DEFAULT_BACKEND_ORIGIN } from '../utils/config.js'
|
|
74
70
|
import { resolveSiteDir } from './deploy.js'
|
|
75
71
|
import { warnIfContentDoesNotConform } from '../utils/conformance.js'
|
|
76
|
-
import { readFlagValue
|
|
72
|
+
import { readFlagValue } from '../utils/args.js'
|
|
77
73
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
78
74
|
import {
|
|
79
75
|
syncedElsewhere,
|
|
@@ -93,11 +89,9 @@ import {
|
|
|
93
89
|
ensureSiteExists,
|
|
94
90
|
clearRemoteSyncStateIfUnbound,
|
|
95
91
|
dropSiteBoundValues,
|
|
96
|
-
pushSyncPackages
|
|
97
|
-
resolveSiteOrgForCreate,
|
|
98
|
-
nameSiteWorkspace,
|
|
99
|
-
EXPLICIT_OWNER
|
|
92
|
+
pushSyncPackages
|
|
100
93
|
} from '../backend/site-sync.js'
|
|
94
|
+
import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
|
|
101
95
|
import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
|
|
102
96
|
import {
|
|
103
97
|
updateBackendMap,
|
|
@@ -309,21 +303,13 @@ export async function publish(args = []) {
|
|
|
309
303
|
}
|
|
310
304
|
}
|
|
311
305
|
|
|
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)
|
|
306
|
+
// ⭐ THE WORKSPACE THIS PUBLISH WORKS IN — the one chosen with the login, unless this
|
|
307
|
+
// command names another (`workspace.js`). Resolved up front: `ensureSiteExists` below
|
|
308
|
+
// creates the site in it, and a site the backend keeps elsewhere is refused.
|
|
309
|
+
const ws = await resolveWorkspace({ client, args, offline: dryRun })
|
|
310
|
+
if (ws.refused) {
|
|
311
|
+
say.err('This publish works in one workspace, and none is chosen.')
|
|
312
|
+
say.dim(ws.reason)
|
|
327
313
|
return { exitCode: 2 }
|
|
328
314
|
}
|
|
329
315
|
|
|
@@ -513,16 +499,13 @@ export async function publish(args = []) {
|
|
|
513
499
|
// be the silent-wrong-success this whole branch exists to prevent.
|
|
514
500
|
if (!fnd.proceed) return { exitCode: fnd.refused ? 1 : 0 }
|
|
515
501
|
|
|
516
|
-
// ⭐
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
explicit: EXPLICIT_OWNER.has(org.source),
|
|
524
|
-
note: (m) => say.dim(m)
|
|
525
|
-
})
|
|
502
|
+
// ⭐ Every site request names the workspace — the create, the push, the publish and
|
|
503
|
+
// the data lane — and a site outside it stops the publish (`WorkspaceMismatchError`).
|
|
504
|
+
// A site this publish creates is created in it — said first.
|
|
505
|
+
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
506
|
+
if (ws.source !== 'offline' && !readBackendState(siteDir, client.origin).site?.uuid) {
|
|
507
|
+
say.dim(`This publish creates the site in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source]}).`)
|
|
508
|
+
}
|
|
526
509
|
|
|
527
510
|
// 2. Build the site data (link mode): dist/site-content.json (+ per-locale),
|
|
528
511
|
// dist/data/*, dist/assets/*. Spawn the SAME CLI binary so the inner
|
package/src/commands/pull.js
CHANGED
|
@@ -90,11 +90,9 @@ import {
|
|
|
90
90
|
mergeBaseVersions,
|
|
91
91
|
mergeItemBaseVersions,
|
|
92
92
|
writeUnitBases,
|
|
93
|
-
writeItemUuids
|
|
94
|
-
nameSiteWorkspace,
|
|
95
|
-
readSiteWorkspace
|
|
93
|
+
writeItemUuids
|
|
96
94
|
} from '../backend/site-sync.js'
|
|
97
|
-
import {
|
|
95
|
+
import { resolveWorkspace } from '../backend/workspace.js'
|
|
98
96
|
import {
|
|
99
97
|
uncommittedUnder,
|
|
100
98
|
siteContentRoots,
|
|
@@ -106,7 +104,6 @@ import {
|
|
|
106
104
|
BackendClient,
|
|
107
105
|
describeRequestError,
|
|
108
106
|
refusalDetail,
|
|
109
|
-
workspaceHandle,
|
|
110
107
|
WorkspaceMismatchError
|
|
111
108
|
} from '../backend/client.js'
|
|
112
109
|
import { resolveSiteDir as defaultResolveSiteDir } from './deploy.js'
|
|
@@ -601,15 +598,6 @@ export async function pull(args = [], deps = {}) {
|
|
|
601
598
|
args,
|
|
602
599
|
command: 'Pulling'
|
|
603
600
|
})
|
|
604
|
-
// The workspace these requests name: `--org` explicitly, else the one this project
|
|
605
|
-
// recorded for this backend — adopted afresh from the backend's answer when stale.
|
|
606
|
-
const orgFlag = readOrgFlag(args)
|
|
607
|
-
nameSiteWorkspace(client, {
|
|
608
|
-
siteDir,
|
|
609
|
-
workspace: orgFlag ? workspaceHandle(orgFlag) : readSiteWorkspace(siteDir, client.origin),
|
|
610
|
-
explicit: Boolean(orgFlag),
|
|
611
|
-
note
|
|
612
|
-
})
|
|
613
601
|
|
|
614
602
|
// ⭐ No scope check any more — and none is needed. This project's identity on
|
|
615
603
|
// `client.origin` is read from that origin's own section of sync.json, so a
|
|
@@ -664,6 +652,17 @@ export async function pull(args = [], deps = {}) {
|
|
|
664
652
|
return { exitCode: 0 }
|
|
665
653
|
}
|
|
666
654
|
|
|
655
|
+
// The workspace this pull works in — the one chosen with the login, unless this
|
|
656
|
+
// command names another (`workspace.js`). Resolved at the first request, not before:
|
|
657
|
+
// a pull with nothing to pull never needs one. A site kept elsewhere stops it.
|
|
658
|
+
const ws = await resolveWorkspace({ client, args })
|
|
659
|
+
if (ws.refused) {
|
|
660
|
+
error('This pull works in one workspace, and none is chosen.')
|
|
661
|
+
note(ws.reason)
|
|
662
|
+
return { exitCode: 2 }
|
|
663
|
+
}
|
|
664
|
+
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
665
|
+
|
|
667
666
|
// GET a pull lane via the client and return `{ docs, etag }` from its `.uwx` (ZIP)
|
|
668
667
|
// body — `readPullDocuments` reads the entity files out of it (JSON fallback). A
|
|
669
668
|
// conditional request whose ETag matches returns `{ notModified: true }` (304, empty
|
package/src/commands/push.js
CHANGED
|
@@ -27,15 +27,11 @@
|
|
|
27
27
|
*
|
|
28
28
|
* Usage:
|
|
29
29
|
* uniweb push Build, push both lanes, back-fill $uuid
|
|
30
|
-
* uniweb push --org @org
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* once. Later, it must match the workspace the
|
|
36
|
-
* backend works on the site from, or push stops.
|
|
37
|
-
* uniweb push --personal Own the new site personally, deliberately:
|
|
38
|
-
* its requests name no workspace.
|
|
30
|
+
* uniweb push --org @org Work in @org for this push, instead of the
|
|
31
|
+
* uniweb push --personal workspace chosen at `uniweb login` (or your
|
|
32
|
+
* personal workspace). A site this push creates
|
|
33
|
+
* is created — owned — there; a site the backend
|
|
34
|
+
* keeps in another workspace stops the push.
|
|
39
35
|
* uniweb push --dry-run Report what would be pushed; submit nothing
|
|
40
36
|
* uniweb push -o out.uwx Write the .uwx file(s) per lane; submit nothing
|
|
41
37
|
* uniweb push --foundation <dir> Use this local foundation for the Model schema
|
|
@@ -62,7 +58,7 @@
|
|
|
62
58
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
63
59
|
import { join, resolve } from 'node:path'
|
|
64
60
|
import yaml from 'js-yaml'
|
|
65
|
-
import { emitSyncPackages } from '@uniweb/build/uwx'
|
|
61
|
+
import { emitSyncPackages, readBackendState } from '@uniweb/build/uwx'
|
|
66
62
|
import { findSiteCopies, describeSiteCopies } from '../utils/site-copies.js'
|
|
67
63
|
import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
|
|
68
64
|
import { updateBackendMap, carryServed, SYNC_STORE_FILE } from '@uniweb/build/uwx'
|
|
@@ -70,7 +66,6 @@ import { BackendClient } from '../backend/client.js'
|
|
|
70
66
|
import { resolveSiteDir } from './deploy.js'
|
|
71
67
|
import { warnIfContentDoesNotConform } from '../utils/conformance.js'
|
|
72
68
|
import { reportSchemalessQueries } from '../utils/schemaless-report.js'
|
|
73
|
-
import { readOrgFlag } from '../utils/args.js'
|
|
74
69
|
import { checkFlags } from '../utils/flag-guard.js'
|
|
75
70
|
import {
|
|
76
71
|
syncedElsewhere,
|
|
@@ -91,11 +86,9 @@ import {
|
|
|
91
86
|
ensureSiteExists,
|
|
92
87
|
clearRemoteSyncStateIfUnbound,
|
|
93
88
|
dropSiteBoundValues,
|
|
94
|
-
pushSyncPackages
|
|
95
|
-
resolveSiteOrgForCreate,
|
|
96
|
-
nameSiteWorkspace,
|
|
97
|
-
EXPLICIT_OWNER
|
|
89
|
+
pushSyncPackages
|
|
98
90
|
} from '../backend/site-sync.js'
|
|
91
|
+
import { resolveWorkspace, describeWorkspace, SOURCE_LABEL } from '../backend/workspace.js'
|
|
99
92
|
|
|
100
93
|
// Re-exported for downstream importers (pull.js, push.test.js) that read these
|
|
101
94
|
// helpers from this module — their canonical home is now ../backend/site-sync.js.
|
|
@@ -216,21 +209,13 @@ export async function push(args = [], deps = {}) {
|
|
|
216
209
|
}
|
|
217
210
|
}
|
|
218
211
|
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
args,
|
|
227
|
-
flag: readOrgFlag(args),
|
|
228
|
-
personal: args.includes('--personal'),
|
|
229
|
-
offline: !!output || dryRun
|
|
230
|
-
})
|
|
231
|
-
if (org.refused) {
|
|
232
|
-
error('Refusing to create this site without naming an owner.')
|
|
233
|
-
note(org.reason)
|
|
212
|
+
// ⭐ THE WORKSPACE THIS PUSH WORKS IN — the one chosen with the login, unless this
|
|
213
|
+
// command names another (`workspace.js`). Resolved before any lane runs: a site this
|
|
214
|
+
// push creates is created in it, and a site the backend keeps elsewhere is refused.
|
|
215
|
+
const ws = await resolveWorkspace({ client, args, offline: !!output || dryRun })
|
|
216
|
+
if (ws.refused) {
|
|
217
|
+
error('This push works in one workspace, and none is chosen.')
|
|
218
|
+
note(ws.reason)
|
|
234
219
|
return { exitCode: 2 }
|
|
235
220
|
}
|
|
236
221
|
|
|
@@ -289,16 +274,12 @@ export async function push(args = [], deps = {}) {
|
|
|
289
274
|
}
|
|
290
275
|
if (!fnd.proceed) return { exitCode: 1 }
|
|
291
276
|
|
|
292
|
-
// ⭐
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
workspace: org.workspace,
|
|
299
|
-
explicit: EXPLICIT_OWNER.has(org.source),
|
|
300
|
-
note
|
|
301
|
-
})
|
|
277
|
+
// ⭐ Every site request names the workspace; a site outside it stops the push
|
|
278
|
+
// (`WorkspaceMismatchError`). A site this push creates is created in it — said first.
|
|
279
|
+
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
280
|
+
if (ws.source !== 'offline' && !readBackendState(siteDir, client.origin).site?.uuid) {
|
|
281
|
+
note(`This push creates the site in ${describeWorkspace(ws.workspace)} (${SOURCE_LABEL[ws.source]}).`)
|
|
282
|
+
}
|
|
302
283
|
|
|
303
284
|
// Build BOTH directional packages (the producer side). Each carries its own
|
|
304
285
|
// `index` — the per-entity source-file map for back-fill, correlated by submission
|
package/src/commands/status.js
CHANGED
|
@@ -25,12 +25,11 @@ import { join } from 'node:path'
|
|
|
25
25
|
import yaml from 'js-yaml'
|
|
26
26
|
|
|
27
27
|
import { resolveSiteDir } from './deploy.js'
|
|
28
|
-
import { probeUnpushed
|
|
29
|
-
import {
|
|
28
|
+
import { probeUnpushed } from '../backend/site-sync.js'
|
|
29
|
+
import { resolveWorkspace } from '../backend/workspace.js'
|
|
30
30
|
import {
|
|
31
31
|
BackendClient,
|
|
32
32
|
resolveBackendOrigin,
|
|
33
|
-
workspaceHandle,
|
|
34
33
|
WorkspaceMismatchError
|
|
35
34
|
} from '../backend/client.js'
|
|
36
35
|
import { readBackendState } from '@uniweb/build/uwx'
|
|
@@ -128,15 +127,11 @@ export async function status(args = []) {
|
|
|
128
127
|
args,
|
|
129
128
|
command: 'Status'
|
|
130
129
|
})
|
|
131
|
-
// The workspace
|
|
132
|
-
//
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
workspace: orgFlag ? workspaceHandle(orgFlag) : readSiteWorkspace(siteDir, client.origin),
|
|
137
|
-
explicit: Boolean(orgFlag),
|
|
138
|
-
note: jsonMode ? undefined : say.dim
|
|
139
|
-
})
|
|
130
|
+
// The workspace this status works in — the login's, unless this command names
|
|
131
|
+
// another (`workspace.js`). None chosen is reported, like a site outside it.
|
|
132
|
+
const ws = await resolveWorkspace({ client, args })
|
|
133
|
+
if (ws.refused) throw Object.assign(new Error(ws.reason), { status: 409 })
|
|
134
|
+
client.setWorkspace(ws.workspace, { source: ws.source })
|
|
140
135
|
if (uuid) site = await client.siteStatus(uuid)
|
|
141
136
|
// Foundation freshness: prefer the LOCAL foundation's scoped name (so a
|
|
142
137
|
// local-foundation site can be checked too); fall back to a scoped
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-23T16:
|
|
3
|
+
"generatedAt": "2026-09-23T16:44:03.282Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
|
-
"version": "0.6.
|
|
6
|
+
"version": "0.6.1",
|
|
7
7
|
"path": "framework/api",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/core"
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"deps": []
|
|
117
117
|
},
|
|
118
118
|
"@uniweb/templates": {
|
|
119
|
-
"version": "0.16.
|
|
119
|
+
"version": "0.16.3",
|
|
120
120
|
"path": "framework/templates",
|
|
121
121
|
"deps": []
|
|
122
122
|
},
|