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
package/src/utils/flag-guard.js
CHANGED
|
@@ -64,30 +64,33 @@ const VERBS = {
|
|
|
64
64
|
// flag-guard-coverage.test.js the moment push gained the import, which is
|
|
65
65
|
// exactly the hand-enumeration failure that test exists to catch.
|
|
66
66
|
'--no-verify',
|
|
67
|
-
// ship content against the already-released code, releasing nothing
|
|
68
|
-
|
|
67
|
+
// ship content against the already-released code, releasing nothing — or
|
|
68
|
+
// release above a newer registered version instead of stopping (both bring-along)
|
|
69
|
+
'--no-release', '--bump', ...VIA_DEPLOY
|
|
69
70
|
],
|
|
70
71
|
publish: [
|
|
71
72
|
'--org', '--dry-run', '--force', '--foundation',
|
|
72
73
|
'--personal',
|
|
73
74
|
// read in utils/conformance.js, backend/site-sync.js, and
|
|
74
75
|
// backend/foundation-bring-along.js — none appear in publish.js
|
|
75
|
-
'--no-validate', '--yes', '--no-verify', '--no-release', ...VIA_DEPLOY
|
|
76
|
+
'--no-validate', '--yes', '--no-verify', '--no-release', '--bump', ...VIA_DEPLOY
|
|
76
77
|
],
|
|
77
78
|
pull: [
|
|
78
79
|
'--content-only', '--dry-run', '--force', '--merge',
|
|
79
80
|
'--no-assets',
|
|
80
81
|
'--no-records', '--no-delete', '--no-prune',
|
|
81
82
|
// via backend/site-sync.js (the owner resolver) and utils/conformance.js
|
|
82
|
-
'--yes', '--org', '--no-validate', ...VIA_DEPLOY
|
|
83
|
+
'--yes', '--org', '--personal', '--no-validate', ...VIA_DEPLOY
|
|
83
84
|
],
|
|
84
85
|
clone: [
|
|
85
86
|
'--content-only', '--no-assets', '--no-records', '--path',
|
|
86
|
-
'--project', '--org'
|
|
87
|
+
'--project', '--org', '--personal'
|
|
87
88
|
],
|
|
88
89
|
register: [
|
|
89
90
|
'--dry-run', '--json', '--output', '-o',
|
|
90
|
-
'--schema-only', '--scope', '--org'
|
|
91
|
+
'--schema-only', '--scope', '--org',
|
|
92
|
+
// reach the login a register may trigger, which chooses the workspace it works in
|
|
93
|
+
'--personal'
|
|
91
94
|
],
|
|
92
95
|
/**
|
|
93
96
|
* `forget` = remove one backend's records (`--backend <url>`), or everything a
|
|
@@ -98,10 +101,10 @@ const VERBS = {
|
|
|
98
101
|
forget: ['--backend', '--all'],
|
|
99
102
|
status: [
|
|
100
103
|
'--json', '--remote', '--dry-run',
|
|
101
|
-
'--force', '--no-verify', '--no-validate', '--yes', '--org',
|
|
104
|
+
'--force', '--no-verify', '--no-validate', '--yes', '--org', '--personal',
|
|
102
105
|
// inert here, reachable through the bring-along module status imports for
|
|
103
106
|
// `resolveLocalFoundation` — listed per the over-approximation note above
|
|
104
|
-
'--no-release', ...VIA_DEPLOY
|
|
107
|
+
'--no-release', '--bump', ...VIA_DEPLOY
|
|
105
108
|
],
|
|
106
109
|
/**
|
|
107
110
|
* `refresh` = `git pull`, then a DELEGATED `pull --merge`.
|
|
@@ -118,7 +121,7 @@ const VERBS = {
|
|
|
118
121
|
*/
|
|
119
122
|
refresh: [
|
|
120
123
|
'--no-backend', '--no-git',
|
|
121
|
-
'--org', '--dry-run', '--no-validate', '--yes', ...VIA_DEPLOY
|
|
124
|
+
'--org', '--personal', '--dry-run', '--no-validate', '--yes', ...VIA_DEPLOY
|
|
122
125
|
]
|
|
123
126
|
}
|
|
124
127
|
|
package/src/utils/git.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { execFileSync } from 'node:child_process'
|
|
21
|
+
import { createHash } from 'node:crypto'
|
|
21
22
|
import { readFileSync, realpathSync } from 'node:fs'
|
|
22
23
|
import { join, relative } from 'node:path'
|
|
23
24
|
import yaml from 'js-yaml'
|
|
@@ -173,8 +174,25 @@ export function uncommittedUnder(dir, relPaths) {
|
|
|
173
174
|
* never committed has no ancestor, so there is nothing to merge against).
|
|
174
175
|
*/
|
|
175
176
|
export function showAtHead(dir, relPath) {
|
|
177
|
+
return showAt(dir, 'HEAD', relPath)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A file's content at a revision, by a path relative to `dir`.
|
|
182
|
+
*
|
|
183
|
+
* ⛔ THE `./` IS WHAT MAKES THE PATH RELATIVE TO `dir`. In `<rev>:<path>` a bare path
|
|
184
|
+
* is read from the REPOSITORY ROOT, whatever the cwd. With the repo at the project
|
|
185
|
+
* root and the site in `site/` — the default layout — `HEAD:pages/home/hero.md`
|
|
186
|
+
* named nothing, every lookup failed, and `pull --merge` "kept yours" for every file:
|
|
187
|
+
* the backend's changes never merged in, while the pull recorded them as taken
|
|
188
|
+
* (2026-09-23). Every merge test ran with the repo AT the site directory, the one
|
|
189
|
+
* layout where a bare path happens to work.
|
|
190
|
+
*
|
|
191
|
+
* @returns {Buffer|null} null when the path isn't in that revision
|
|
192
|
+
*/
|
|
193
|
+
export function showAt(dir, rev, relPath) {
|
|
176
194
|
try {
|
|
177
|
-
return execFileSync('git', ['show',
|
|
195
|
+
return execFileSync('git', ['show', `${rev}:./${relPath}`], {
|
|
178
196
|
cwd: dir,
|
|
179
197
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
180
198
|
maxBuffer: 64 * 1024 * 1024
|
|
@@ -184,6 +202,35 @@ export function showAtHead(dir, relPath) {
|
|
|
184
202
|
}
|
|
185
203
|
}
|
|
186
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The most recent committed version of a file whose content hashes to `sha256` —
|
|
207
|
+
* how the version a pull wrote is found again, as the ancestor of a merge.
|
|
208
|
+
*
|
|
209
|
+
* HEAD is that version only until the user commits an edit on top of it. After that
|
|
210
|
+
* HEAD is THEIR edit, and a merge against it reads their change as the starting
|
|
211
|
+
* point: the backend's version wins whole and the committed edit is reverted in the
|
|
212
|
+
* working tree. The pull recorded the hash of what it wrote (`pull-written.json`),
|
|
213
|
+
* so the version is recoverable from history by content, wherever it was committed.
|
|
214
|
+
*
|
|
215
|
+
* @param {number} [limit] - how many commits touching the file to search
|
|
216
|
+
* @returns {Buffer|null} null when no committed version matches
|
|
217
|
+
*/
|
|
218
|
+
export function findCommitted(dir, relPath, sha256, { limit = 50 } = {}) {
|
|
219
|
+
let revs
|
|
220
|
+
try {
|
|
221
|
+
revs = git(['log', `-n${limit}`, '--format=%H', '--', `./${relPath}`], dir)
|
|
222
|
+
.split('\n')
|
|
223
|
+
.filter(Boolean)
|
|
224
|
+
} catch {
|
|
225
|
+
return null
|
|
226
|
+
}
|
|
227
|
+
for (const rev of revs) {
|
|
228
|
+
const buf = showAt(dir, rev, relPath)
|
|
229
|
+
if (buf && createHash('sha256').update(buf).digest('hex') === sha256) return buf
|
|
230
|
+
}
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
187
234
|
/**
|
|
188
235
|
* Three-way merge, in place: `minePath` is rewritten with the merged result.
|
|
189
236
|
*
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The record of what a machine wrote into a site project — pull's own output.
|
|
3
3
|
*
|
|
4
|
+
* ⭐ And, since 2026-09-23, what a push delivered: the files of the units the backend
|
|
5
|
+
* now holds as they were sent (`recordPushedUnits`, site-sync.js). Both are "the last
|
|
6
|
+
* version this file synced at", which is what `pull --merge` needs as its ancestor —
|
|
7
|
+
* found again in the repo's history by this hash (`findCommitted`, utils/git.js).
|
|
8
|
+
*
|
|
4
9
|
* ## What it is for
|
|
5
10
|
*
|
|
6
11
|
* `uniweb pull` refuses to run when there are uncommitted changes under the files
|
|
@@ -49,6 +49,7 @@ import { createServer } from 'node:http'
|
|
|
49
49
|
import { randomBytes } from 'node:crypto'
|
|
50
50
|
|
|
51
51
|
import { DEFAULT_BACKEND_ORIGIN } from './config.js'
|
|
52
|
+
import { readOrgFlag } from './args.js'
|
|
52
53
|
import { normalizeSessionFile, sessionFilePath, loggedInOriginOf } from './session-file.js'
|
|
53
54
|
|
|
54
55
|
const LOGIN_PATH = '/dev/auth/login'
|
|
@@ -641,6 +642,36 @@ async function loginViaBrowser({ apiBase }) {
|
|
|
641
642
|
return record
|
|
642
643
|
}
|
|
643
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Choose the WORKSPACE this login works in, and store it with the session.
|
|
647
|
+
*
|
|
648
|
+
* ⭐ A login works in one workspace *[Diego, 2026-09-23: "at login, it can be mandatory to
|
|
649
|
+
* specify which workspace"]* — `--org @acme` / `--personal`, else your personal workspace
|
|
650
|
+
* when you belong to no organization, else a pick at a terminal. Without one (no
|
|
651
|
+
* terminal, organizations, no flag) the login stays and this says how to choose; every
|
|
652
|
+
* command refuses until one is chosen (`backend/workspace.js`).
|
|
653
|
+
*
|
|
654
|
+
* @param {object} record - the session just stored (`token`, `origin`, …)
|
|
655
|
+
* @param {string[]} args
|
|
656
|
+
* @returns {Promise<{ record: object, refused?: string }>}
|
|
657
|
+
*/
|
|
658
|
+
async function settleWorkspace(record, args) {
|
|
659
|
+
const { chooseWorkspace } = await import('../backend/workspace.js')
|
|
660
|
+
const pick = await chooseWorkspace({ apiBase: record.origin, token: record.token, args })
|
|
661
|
+
if (pick.refused) return { record, refused: pick.reason }
|
|
662
|
+
if (pick.note) console.error(`\x1b[2m${pick.note}\x1b[0m`)
|
|
663
|
+
const next = { ...record, workspace: pick.choice }
|
|
664
|
+
await writeRegistryAuth(next)
|
|
665
|
+
return { record: next }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** ` · working in @acme` — the tail of a login line. */
|
|
669
|
+
async function workspaceTail(record) {
|
|
670
|
+
if (!record?.workspace) return ''
|
|
671
|
+
const { describeWorkspace } = await import('../backend/workspace.js')
|
|
672
|
+
return `, working in \x1b[1m${describeWorkspace(record.workspace)}\x1b[0m`
|
|
673
|
+
}
|
|
674
|
+
|
|
644
675
|
/**
|
|
645
676
|
* `uniweb login` against the new backend — a multi-method picker:
|
|
646
677
|
* browser/social (default, once available) · username+password · paste a token.
|
|
@@ -675,19 +706,37 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
|
|
|
675
706
|
existing.handle ||
|
|
676
707
|
(existing.uuid ? `account ${existing.uuid}` : '')
|
|
677
708
|
if (!forced) {
|
|
678
|
-
// Already logged in to this backend: nothing to
|
|
679
|
-
// method flag. (Also where a file from before sessions were single, holding
|
|
680
|
-
// backend among others, becomes single: the rewrite keeps only this session.)
|
|
709
|
+
// Already logged in to this backend: nothing to re-authenticate — logging in again is
|
|
710
|
+
// behind a method flag. (Also where a file from before sessions were single, holding
|
|
711
|
+
// this backend among others, becomes single: the rewrite keeps only this session.)
|
|
712
|
+
let session = { ...existing, origin: key }
|
|
681
713
|
if (current !== key || Object.keys(file.sessions).length > 1) {
|
|
682
|
-
await writeRegistryAuth(
|
|
714
|
+
await writeRegistryAuth(session)
|
|
715
|
+
}
|
|
716
|
+
// ⭐ SWITCHING WORKSPACE NEEDS NO NEW LOGIN: `--org @x` / `--personal` on a valid
|
|
717
|
+
// session just changes the workspace it works in. One with none chosen yet (a
|
|
718
|
+
// session from before workspaces) is asked now.
|
|
719
|
+
const wants = readOrgFlag(args) || args.includes('--personal')
|
|
720
|
+
if (wants || !session.workspace) {
|
|
721
|
+
const settled = await settleWorkspace(session, args)
|
|
722
|
+
if (settled.refused) {
|
|
723
|
+
console.error(`\x1b[32m✓\x1b[0m Logged in to ${key}${who ? ` as \x1b[1m${who}\x1b[0m` : ''}.`)
|
|
724
|
+
console.error(`\x1b[31m✗\x1b[0m ${settled.refused}`)
|
|
725
|
+
process.exit(2)
|
|
726
|
+
}
|
|
727
|
+
session = settled.record
|
|
728
|
+
if (wants) {
|
|
729
|
+
console.error(`\x1b[32m✓\x1b[0m Now working in ${(await workspaceTail(session)).replace(/^, working in /, '')} (${key}).`)
|
|
730
|
+
return session
|
|
731
|
+
}
|
|
683
732
|
}
|
|
684
733
|
console.error(
|
|
685
|
-
`\x1b[32m✓\x1b[0m Already logged in to ${key}${who ? ` as \x1b[1m${who}\x1b[0m` : ''}.`
|
|
734
|
+
`\x1b[32m✓\x1b[0m Already logged in to ${key}${who ? ` as \x1b[1m${who}\x1b[0m` : ''}${await workspaceTail(session)}.`
|
|
686
735
|
)
|
|
687
736
|
console.error(
|
|
688
|
-
'\x1b[
|
|
737
|
+
'\x1b[2mSwitch workspace: --org @acme or --personal. To log in again, name a method: --password, --browser, --token-paste or --token <bearer>.\x1b[0m'
|
|
689
738
|
)
|
|
690
|
-
return
|
|
739
|
+
return session
|
|
691
740
|
}
|
|
692
741
|
console.error(
|
|
693
742
|
`Already logged in${who ? ` as \x1b[1m${who}\x1b[0m` : ''} (${key}) — logging in again replaces that session.\n`
|
|
@@ -720,10 +769,7 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
|
|
|
720
769
|
if (account?.username) record.username = account.username
|
|
721
770
|
if (account?.handle) record.handle = account.handle
|
|
722
771
|
await writeRegistryAuth(record)
|
|
723
|
-
|
|
724
|
-
`\x1b[32m✓\x1b[0m Logged in${account?.username ? ` as \x1b[1m${account.username}\x1b[0m` : ''}${apiBase ? ` (${apiBase})` : ''}`
|
|
725
|
-
)
|
|
726
|
-
return record
|
|
772
|
+
return finishLogin(record, apiBase, args)
|
|
727
773
|
}
|
|
728
774
|
|
|
729
775
|
let method = args.includes('--browser')
|
|
@@ -787,10 +833,23 @@ export async function runRegistryLogin({ apiBase, args = [] } = {}) {
|
|
|
787
833
|
process.exit(1)
|
|
788
834
|
}
|
|
789
835
|
|
|
790
|
-
if (record?.token)
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
836
|
+
if (!record?.token) return record
|
|
837
|
+
return finishLogin({ ...record, origin: record.origin || normOrigin(apiBase) }, apiBase, args)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* A login succeeded and its session is stored: choose its workspace, and say where the
|
|
842
|
+
* login works. Without a workspace (no terminal, organizations, no flag) the session
|
|
843
|
+
* stays, the reason is printed, and the login exits 2 — every command would refuse.
|
|
844
|
+
*/
|
|
845
|
+
async function finishLogin(record, apiBase, args) {
|
|
846
|
+
const settled = await settleWorkspace(record, args)
|
|
847
|
+
const who = settled.record.username ? ` as \x1b[1m${settled.record.username}\x1b[0m` : ''
|
|
848
|
+
if (settled.refused) {
|
|
849
|
+
console.error(`\x1b[32m✓\x1b[0m Logged in${who} (${apiBase}).`)
|
|
850
|
+
console.error(`\x1b[31m✗\x1b[0m ${settled.refused}`)
|
|
851
|
+
process.exit(2)
|
|
794
852
|
}
|
|
795
|
-
|
|
853
|
+
console.error(`\x1b[32m✓\x1b[0m Logged in${who} (${apiBase})${await workspaceTail(settled.record)}.`)
|
|
854
|
+
return settled.record
|
|
796
855
|
}
|
|
@@ -99,8 +99,8 @@ export function validateHandle(handle) {
|
|
|
99
99
|
* personal org already exists.
|
|
100
100
|
* @returns {Promise<{account_handle: string|null, orgs: Array<{handle: string, is_primary: boolean}>}>}
|
|
101
101
|
*/
|
|
102
|
-
export async function fetchOrgs({ apiBase, token }) {
|
|
103
|
-
const res = await
|
|
102
|
+
export async function fetchOrgs({ apiBase, token, fetchImpl = globalThis.fetch }) {
|
|
103
|
+
const res = await fetchImpl(`${apiBase.replace(/\/$/, '')}${ORGS_PATH}`, {
|
|
104
104
|
headers: { Authorization: `Bearer ${token}` }
|
|
105
105
|
})
|
|
106
106
|
if (!res.ok)
|
|
@@ -24,6 +24,29 @@ export function parseSemver(version) {
|
|
|
24
24
|
return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ? m[4].split('.') : [] }
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The next version above `version` on its own line — what `--bump` releases under
|
|
29
|
+
* when the registry already holds the local version with other code.
|
|
30
|
+
*
|
|
31
|
+
* A release gets its next patch (`1.4.2` → `1.4.3`); a pre-release its next
|
|
32
|
+
* pre-release (`2.0.0-beta.3` → `2.0.0-beta.4`, `1.0.0-rc` → `1.0.0-rc.0`), so a bump
|
|
33
|
+
* nobody chose never promotes a beta line to a release. Build metadata is dropped: it
|
|
34
|
+
* carries no order, so it could not make a version newer.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} version
|
|
37
|
+
* @returns {string|null} null when `version` is not SemVer
|
|
38
|
+
*/
|
|
39
|
+
export function nextVersionAbove(version) {
|
|
40
|
+
const v = parseSemver(version)
|
|
41
|
+
if (!v) return null
|
|
42
|
+
if (!v.pre.length) return `${v.major}.${v.minor}.${v.patch + 1}`
|
|
43
|
+
const pre = [...v.pre]
|
|
44
|
+
const last = pre.length - 1
|
|
45
|
+
if (/^\d+$/.test(pre[last])) pre[last] = String(BigInt(pre[last]) + 1n)
|
|
46
|
+
else pre.push('0')
|
|
47
|
+
return `${v.major}.${v.minor}.${v.patch}-${pre.join('.')}`
|
|
48
|
+
}
|
|
49
|
+
|
|
27
50
|
/**
|
|
28
51
|
* @param {string} a
|
|
29
52
|
* @param {string} b
|