uniweb 0.16.4 → 0.17.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/src/backend/site-sync.js +288 -4
- package/src/commands/clone.js +48 -4
- package/src/commands/publish.js +39 -3
- package/src/commands/pull.js +17 -73
- package/src/commands/push.js +39 -3
- package/src/commands/register.js +8 -0
- package/src/commands/status.js +9 -0
- package/src/framework-index.json +8 -8
- package/src/index.js +10 -0
- package/src/utils/args.js +98 -0
- package/src/utils/flag-guard.js +89 -0
- package/src/utils/git.js +43 -4
- package/src/utils/pull-written.js +127 -0
- package/src/utils/uwx-read.js +157 -0
package/src/commands/push.js
CHANGED
|
@@ -26,7 +26,15 @@
|
|
|
26
26
|
*
|
|
27
27
|
* Usage:
|
|
28
28
|
* uniweb push Build, push both lanes, back-fill $uuid
|
|
29
|
-
* uniweb push --
|
|
29
|
+
* uniweb push --org @org Own the new site under @org (alias: --as-org).
|
|
30
|
+
* Read only on the FIRST push of a site — it
|
|
31
|
+
* decides which org owns it, and whose storage
|
|
32
|
+
* its assets are charged to. Recorded as
|
|
33
|
+
* `site.yml::$org` and replayed after that.
|
|
34
|
+
* Without it, you are asked once.
|
|
35
|
+
* uniweb push --personal Own the new site personally, deliberately.
|
|
36
|
+
* Sends NO `as_org` — the same wire as before
|
|
37
|
+
* this prompt existed. First push only.
|
|
30
38
|
* uniweb push --dry-run Report what would be pushed; submit nothing
|
|
31
39
|
* uniweb push -o out.uwx Write the .uwx file(s) per lane; submit nothing
|
|
32
40
|
* uniweb push --registry <url> Override the backend origin
|
|
@@ -59,6 +67,8 @@ import { uploadSiteMedia, describeAssetRefusal } from '../backend/site-media.js'
|
|
|
59
67
|
import { BackendClient } from '../backend/client.js'
|
|
60
68
|
import { resolveSiteDir, resolveSiteBackend } from './deploy.js'
|
|
61
69
|
import { warnIfContentDoesNotConform } from '../utils/conformance.js'
|
|
70
|
+
import { readOrgFlag } from '../utils/args.js'
|
|
71
|
+
import { checkFlags } from '../utils/flag-guard.js'
|
|
62
72
|
import {
|
|
63
73
|
makeModelResolver,
|
|
64
74
|
readSyncCache,
|
|
@@ -68,7 +78,8 @@ import {
|
|
|
68
78
|
ensureItemUuids,
|
|
69
79
|
ensureSiteExists,
|
|
70
80
|
clearRemoteSyncStateIfUnbound,
|
|
71
|
-
pushSyncPackages
|
|
81
|
+
pushSyncPackages,
|
|
82
|
+
resolveSiteOrgForCreate
|
|
72
83
|
} from '../backend/site-sync.js'
|
|
73
84
|
|
|
74
85
|
// Re-exported for downstream importers (pull.js, push.test.js) that read these
|
|
@@ -103,10 +114,16 @@ function flagValue(args, name) {
|
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
export async function push(args = []) {
|
|
117
|
+
// An unrecognized flag is invisible to a literal scan, so it silently keeps the
|
|
118
|
+
// default — including for --backend, where the default can be production.
|
|
119
|
+
const bad = checkFlags('push', args)
|
|
120
|
+
if (bad) {
|
|
121
|
+
error(bad.message)
|
|
122
|
+
return { exitCode: 2 }
|
|
123
|
+
}
|
|
106
124
|
const dryRun = args.includes('--dry-run')
|
|
107
125
|
const output = flagValue(args, '-o') || flagValue(args, '--output')
|
|
108
126
|
const tokenFlag = flagValue(args, '--token')
|
|
109
|
-
const asOrg = flagValue(args, '--as-org')
|
|
110
127
|
const foundationDir = flagValue(args, '--foundation')
|
|
111
128
|
const sendAll = args.includes('--all') // bypass the send-only-changed cache
|
|
112
129
|
// --force drops the optimistic-concurrency precondition, making the push
|
|
@@ -135,6 +152,25 @@ export async function push(args = []) {
|
|
|
135
152
|
command: 'Syncing'
|
|
136
153
|
})
|
|
137
154
|
|
|
155
|
+
// WHO will own this site, if this push is the one that creates it. Resolved
|
|
156
|
+
// before any lane runs, because both create paths below consume it and neither
|
|
157
|
+
// should be reached with the question still open. A site that already exists
|
|
158
|
+
// resolves to null without asking — ownership was settled at its create.
|
|
159
|
+
const org = await resolveSiteOrgForCreate({
|
|
160
|
+
client,
|
|
161
|
+
siteDir,
|
|
162
|
+
args,
|
|
163
|
+
flag: readOrgFlag(args),
|
|
164
|
+
personal: args.includes('--personal'),
|
|
165
|
+
offline: !!output || dryRun
|
|
166
|
+
})
|
|
167
|
+
if (org.refused) {
|
|
168
|
+
error('Refusing to create this site without naming an owner.')
|
|
169
|
+
note(org.reason)
|
|
170
|
+
return { exitCode: 2 }
|
|
171
|
+
}
|
|
172
|
+
const asOrg = org.asOrg
|
|
173
|
+
|
|
138
174
|
// Build BOTH directional packages (the producer side). Each carries its own
|
|
139
175
|
// `index` — the per-entity source-file map for back-fill, correlated by submission
|
|
140
176
|
// position. Non-local Models are fetched from the registry on demand. `priorHashes`
|
package/src/commands/register.js
CHANGED
|
@@ -81,6 +81,7 @@ import {
|
|
|
81
81
|
promptSelect
|
|
82
82
|
} from '../utils/workspace.js'
|
|
83
83
|
import { isNonInteractive, getCliPrefix } from '../utils/interactive.js'
|
|
84
|
+
import { checkFlags } from '../utils/flag-guard.js'
|
|
84
85
|
|
|
85
86
|
const colors = {
|
|
86
87
|
reset: '\x1b[0m',
|
|
@@ -265,6 +266,13 @@ export function foundationNeedsBuild(targetDir) {
|
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
export async function register(args = []) {
|
|
269
|
+
// See utils/flag-guard.js — an unrecognized flag is invisible to a
|
|
270
|
+
// literal scan, so it silently keeps the default (production, for --backend).
|
|
271
|
+
const badFlag = checkFlags('register', args)
|
|
272
|
+
if (badFlag) {
|
|
273
|
+
error(badFlag.message)
|
|
274
|
+
return { exitCode: 2 }
|
|
275
|
+
}
|
|
268
276
|
jsonMode = args.includes('--json')
|
|
269
277
|
jsonEmitted = false
|
|
270
278
|
lastError = null
|
package/src/commands/status.js
CHANGED
|
@@ -30,6 +30,7 @@ import { BackendClient } from '../backend/client.js'
|
|
|
30
30
|
import { readFlagValue } from '../utils/args.js'
|
|
31
31
|
import { resolveLocalFoundation } from '../backend/foundation-bring-along.js'
|
|
32
32
|
import { computeFoundationDigest } from '../utils/code-upload.js'
|
|
33
|
+
import { checkFlags } from '../utils/flag-guard.js'
|
|
33
34
|
|
|
34
35
|
const c = {
|
|
35
36
|
reset: '\x1b[0m',
|
|
@@ -73,6 +74,14 @@ function splitFoundationRef(fnd) {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
export async function status(args = []) {
|
|
77
|
+
// See utils/flag-guard.js — an unrecognized flag is invisible to a literal scan.
|
|
78
|
+
// Straight to stderr: this is a usage error, so it must not be mistaken for the
|
|
79
|
+
// status document even under --json.
|
|
80
|
+
const badFlag = checkFlags('status', args)
|
|
81
|
+
if (badFlag) {
|
|
82
|
+
console.error(`\x1b[31m✗\x1b[0m ${badFlag.message}`)
|
|
83
|
+
return { exitCode: 2 }
|
|
84
|
+
}
|
|
76
85
|
const jsonMode = args.includes('--json')
|
|
77
86
|
const remote = args.includes('--remote')
|
|
78
87
|
const siteDir = await resolveSiteDir(args, 'status')
|
package/src/framework-index.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-12T22:56:55.282Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
|
-
"version": "0.18.
|
|
6
|
+
"version": "0.18.5",
|
|
7
7
|
"path": "framework/build",
|
|
8
8
|
"deps": [
|
|
9
9
|
"@uniweb/content-reader",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"deps": []
|
|
29
29
|
},
|
|
30
30
|
"@uniweb/core": {
|
|
31
|
-
"version": "0.8.
|
|
31
|
+
"version": "0.8.5",
|
|
32
32
|
"path": "framework/core",
|
|
33
33
|
"deps": [
|
|
34
34
|
"@uniweb/semantic-parser",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"deps": []
|
|
47
47
|
},
|
|
48
48
|
"@uniweb/kit": {
|
|
49
|
-
"version": "0.11.
|
|
49
|
+
"version": "0.11.3",
|
|
50
50
|
"path": "framework/kit",
|
|
51
51
|
"deps": [
|
|
52
52
|
"@uniweb/core",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"deps": []
|
|
66
66
|
},
|
|
67
67
|
"@uniweb/projections": {
|
|
68
|
-
"version": "0.2.
|
|
68
|
+
"version": "0.2.8",
|
|
69
69
|
"path": "framework/projections",
|
|
70
70
|
"deps": [
|
|
71
71
|
"@uniweb/content-writer",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
]
|
|
74
74
|
},
|
|
75
75
|
"@uniweb/runtime": {
|
|
76
|
-
"version": "0.11.
|
|
76
|
+
"version": "0.11.6",
|
|
77
77
|
"path": "framework/runtime",
|
|
78
78
|
"deps": [
|
|
79
79
|
"@uniweb/core",
|
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
"deps": []
|
|
97
97
|
},
|
|
98
98
|
"@uniweb/semantic-parser": {
|
|
99
|
-
"version": "1.2.
|
|
99
|
+
"version": "1.2.2",
|
|
100
100
|
"path": "framework/semantic-parser",
|
|
101
101
|
"deps": []
|
|
102
102
|
},
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
"deps": []
|
|
112
112
|
},
|
|
113
113
|
"@uniweb/unipress": {
|
|
114
|
-
"version": "0.8.
|
|
114
|
+
"version": "0.8.4",
|
|
115
115
|
"path": "framework/unipress",
|
|
116
116
|
"deps": [
|
|
117
117
|
"@uniweb/build",
|
package/src/index.js
CHANGED
|
@@ -1318,6 +1318,14 @@ ${colors.bright}Options:${colors.reset}
|
|
|
1318
1318
|
--yes Skip confirmations (CI); never block on a prompt
|
|
1319
1319
|
--no-save Skip the deploy.yml lastDeploy auto-save
|
|
1320
1320
|
--no-validate Skip the content-conformance check (it only warns)
|
|
1321
|
+
--org @org Publish under @org (membership-gated; alias: --as-org). Read
|
|
1322
|
+
only on a site's FIRST publish — that create decides which org
|
|
1323
|
+
owns the site, and whose storage its assets are charged to.
|
|
1324
|
+
Recorded as site.yml \$org and replayed, so it is never
|
|
1325
|
+
re-typed. Without it, you are asked once.
|
|
1326
|
+
--personal Create the site under your personal account, deliberately.
|
|
1327
|
+
Only needed on a first publish, and only to answer the owner
|
|
1328
|
+
question without a prompt (CI, agents, scripts).
|
|
1321
1329
|
--backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
|
|
1322
1330
|
--token <bearer> Auth bearer (skips \`uniweb login\`)
|
|
1323
1331
|
`,
|
|
@@ -1693,6 +1701,8 @@ ${colors.bright}Global Options:${colors.reset}
|
|
|
1693
1701
|
${colors.bright}Publish Options:${colors.reset}
|
|
1694
1702
|
--dry-run Resolve everything; release/sync/POST nothing
|
|
1695
1703
|
--yes Skip confirmations (CI); never block on a prompt
|
|
1704
|
+
--org @org Publish under @org (first publish only; then remembered)
|
|
1705
|
+
--personal Own the new site personally, deliberately (first publish only)
|
|
1696
1706
|
--no-save Skip the deploy.yml lastDeploy auto-save
|
|
1697
1707
|
--no-validate Skip the content-conformance check (it only warns)
|
|
1698
1708
|
--backend <url> Backend origin (default: \$UNIWEB_REGISTER_URL or built-in)
|
package/src/utils/args.js
CHANGED
|
@@ -20,6 +20,104 @@
|
|
|
20
20
|
* @param {string} name — Including the leading dashes, e.g. '--host'.
|
|
21
21
|
* @returns {string | null | undefined}
|
|
22
22
|
*/
|
|
23
|
+
/**
|
|
24
|
+
* The org a site is created under. `--org` is the documented spelling; `--as-org`
|
|
25
|
+
* is a working alias.
|
|
26
|
+
*
|
|
27
|
+
* `--as-org` mirrors the wire (`?as_org=`) and names an *acting capacity* — the
|
|
28
|
+
* request is made as a member of that org, membership-gated. That is accurate, and
|
|
29
|
+
* it is also not the name anyone reaches for: asked in one day, the backend's docs
|
|
30
|
+
* said `--as-unit` and a second reader said `--org`; nobody produced `--as-org`.
|
|
31
|
+
* Since the flag's main job is answering *who owns this site*, `--org` is the name
|
|
32
|
+
* that matches the question, and the alias costs one `||` (the same shape
|
|
33
|
+
* `--backend` / `--registry` already uses).
|
|
34
|
+
*
|
|
35
|
+
* `||`, not `??`, on purpose: a valueless `--org` falls through to `--as-org`
|
|
36
|
+
* rather than shadowing it.
|
|
37
|
+
*
|
|
38
|
+
* @param {string[]} args
|
|
39
|
+
* @returns {string|null|undefined}
|
|
40
|
+
*/
|
|
41
|
+
export function readOrgFlag(args) {
|
|
42
|
+
return readFlagValue(args, '--org') || readFlagValue(args, '--as-org')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Every `--flag` / `-f` token in `args` that `known` does not list.
|
|
47
|
+
*
|
|
48
|
+
* The CLI reads flags by scanning argv for a literal, so an unrecognized flag is
|
|
49
|
+
* not an error — it is *invisible*, and whatever it was meant to change silently
|
|
50
|
+
* keeps its default. The sharp case is `--backend`: mistype it and the origin
|
|
51
|
+
* ladder falls through to the session, the saved config, or `https://uniweb.app`,
|
|
52
|
+
* so a command aimed at localhost can reach production. `--token` degrades the
|
|
53
|
+
* same way, to a stored session belonging to someone else.
|
|
54
|
+
*
|
|
55
|
+
* Scanning rules, chosen to avoid false positives (a wrong rejection is worse than
|
|
56
|
+
* a missed one — it breaks a working command):
|
|
57
|
+
* - only tokens beginning with `-` are candidates; a VALUE is never one unless it
|
|
58
|
+
* itself looks like a flag, which `readFlagValue` already refuses to consume;
|
|
59
|
+
* - `--flag=value` is checked on the name half;
|
|
60
|
+
* - a bare `--` ends flag scanning, the POSIX convention;
|
|
61
|
+
* - a lone `-` is a value (stdin), not a flag.
|
|
62
|
+
*
|
|
63
|
+
* @param {string[]} args
|
|
64
|
+
* @param {string[]} known - every flag this command accepts, with dashes
|
|
65
|
+
* @returns {string[]} the unrecognized tokens, in order, deduped
|
|
66
|
+
*/
|
|
67
|
+
export function findUnknownFlags(args, known) {
|
|
68
|
+
const set = new Set(known)
|
|
69
|
+
const out = []
|
|
70
|
+
for (const raw of args) {
|
|
71
|
+
if (raw === '--') break
|
|
72
|
+
if (raw === '-' || !raw.startsWith('-')) continue
|
|
73
|
+
const name = raw.split('=')[0]
|
|
74
|
+
if (set.has(name) || out.includes(name)) continue
|
|
75
|
+
out.push(name)
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Levenshtein distance, small and iterative — used only for a suggestion. */
|
|
81
|
+
function editDistance(a, b) {
|
|
82
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
83
|
+
for (let i = 1; i <= a.length; i++) {
|
|
84
|
+
let diag = prev[0]
|
|
85
|
+
prev[0] = i
|
|
86
|
+
for (let j = 1; j <= b.length; j++) {
|
|
87
|
+
const tmp = prev[j]
|
|
88
|
+
prev[j] = Math.min(
|
|
89
|
+
prev[j] + 1,
|
|
90
|
+
prev[j - 1] + 1,
|
|
91
|
+
diag + (a[i - 1] === b[j - 1] ? 0 : 1)
|
|
92
|
+
)
|
|
93
|
+
diag = tmp
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return prev[b.length]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The closest known flag to `flag`, or null when nothing is close enough.
|
|
101
|
+
* The threshold scales with length so `--org` → `--as-org` is offered while two
|
|
102
|
+
* unrelated short flags are not.
|
|
103
|
+
* @param {string} flag
|
|
104
|
+
* @param {string[]} known
|
|
105
|
+
* @returns {string|null}
|
|
106
|
+
*/
|
|
107
|
+
export function didYouMean(flag, known) {
|
|
108
|
+
let best = null
|
|
109
|
+
let bestD = Infinity
|
|
110
|
+
for (const k of known) {
|
|
111
|
+
const d = editDistance(flag, k)
|
|
112
|
+
if (d < bestD) {
|
|
113
|
+
bestD = d
|
|
114
|
+
best = k
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const limit = Math.max(2, Math.floor(flag.length / 3))
|
|
118
|
+
return best && bestD <= limit ? best : null
|
|
119
|
+
}
|
|
120
|
+
|
|
23
121
|
export function readFlagValue(args, name) {
|
|
24
122
|
const eqPrefix = name + '='
|
|
25
123
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reject unrecognized flags on the backend verbs.
|
|
3
|
+
*
|
|
4
|
+
* Every one of these commands can send data to, or authenticate against, a remote
|
|
5
|
+
* host — and the CLI resolves flags by scanning argv for a literal, so a flag it
|
|
6
|
+
* does not recognize does not fail: it *disappears*, and the thing it was meant to
|
|
7
|
+
* change silently keeps its default.
|
|
8
|
+
*
|
|
9
|
+
* That is tolerable for a cosmetic flag and dangerous for these two:
|
|
10
|
+
*
|
|
11
|
+
* --backend mistyped ⇒ the origin ladder falls through to the session origin,
|
|
12
|
+
* ~/.uniweb/config.json, and finally https://uniweb.app. A command
|
|
13
|
+
* aimed at a local backend can reach production.
|
|
14
|
+
* --token mistyped ⇒ falls back to the stored session, so the request is
|
|
15
|
+
* made as whoever is logged in rather than whoever was intended.
|
|
16
|
+
*
|
|
17
|
+
* Neither produces an error today; both produce a plausible success against the
|
|
18
|
+
* wrong host. This turns that class into one sentence.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ A wrong rejection is worse than a missed one — it breaks an invocation that
|
|
21
|
+
* works — so the per-command lists must be complete, INCLUDING flags read by
|
|
22
|
+
* helpers rather than by the command file itself. Two live examples: `--no-validate`
|
|
23
|
+
* is consumed inside `utils/conformance.js`, and `--yes` inside
|
|
24
|
+
* `backend/foundation-bring-along.js`. Grepping only the command's own source
|
|
25
|
+
* misses both. When you add a flag anywhere on one of these paths, add it here.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { findUnknownFlags, didYouMean } from './args.js'
|
|
29
|
+
|
|
30
|
+
/** Accepted by every command, wherever they are actually consumed. */
|
|
31
|
+
const GLOBAL = ['--non-interactive', '--help', '-h']
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Per-verb flag sets. Derived by scanning each command for dash-literals AND the
|
|
35
|
+
* helpers it calls — not from the help text, which has drifted from the parser in
|
|
36
|
+
* both directions (`--as-org` was implemented and undocumented; `--yes` is
|
|
37
|
+
* documented on `publish` and consumed two files away).
|
|
38
|
+
*/
|
|
39
|
+
export const VERB_FLAGS = {
|
|
40
|
+
push: [
|
|
41
|
+
'--all', '--as-org', '--org', '--backend', '--dry-run', '--force',
|
|
42
|
+
'--foundation', '--output', '-o', '--personal', '--registry', '--token',
|
|
43
|
+
'--no-validate'
|
|
44
|
+
],
|
|
45
|
+
publish: [
|
|
46
|
+
'--as-org', '--org', '--backend', '--dry-run', '--force', '--foundation',
|
|
47
|
+
'--no-save', '--personal', '--registry', '--token', '--no-validate', '--yes'
|
|
48
|
+
],
|
|
49
|
+
pull: [
|
|
50
|
+
'--backend', '--content-only', '--dry-run', '--force', '--merge',
|
|
51
|
+
'--no-collections', '--no-delete', '--no-prune', '--registry', '--token'
|
|
52
|
+
],
|
|
53
|
+
clone: [
|
|
54
|
+
'--backend', '--content-only', '--no-collections', '--path', '--project',
|
|
55
|
+
'--registry', '--token'
|
|
56
|
+
],
|
|
57
|
+
register: [
|
|
58
|
+
'--backend', '--dry-run', '--json', '--output', '-o', '--registry',
|
|
59
|
+
'--schema-only', '--scope', '--token'
|
|
60
|
+
],
|
|
61
|
+
status: ['--backend', '--json', '--registry', '--remote', '--token']
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check `args` against the verb's accepted set. Returns null when everything is
|
|
66
|
+
* recognized, or a ready-to-print message naming the first offender (plus a
|
|
67
|
+
* suggestion when one is close).
|
|
68
|
+
*
|
|
69
|
+
* Reports ONE flag rather than all of them: the first is usually the cause, and a
|
|
70
|
+
* list invites skimming past the suggestion, which is the actionable half.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} verb - a key of VERB_FLAGS
|
|
73
|
+
* @param {string[]} args - the argv slice for this command
|
|
74
|
+
* @returns {{ flag: string, message: string, suggestion: string|null }|null}
|
|
75
|
+
*/
|
|
76
|
+
export function checkFlags(verb, args = []) {
|
|
77
|
+
const known = VERB_FLAGS[verb]
|
|
78
|
+
if (!known) return null
|
|
79
|
+
const all = [...known, ...GLOBAL]
|
|
80
|
+
const unknown = findUnknownFlags(args, all)
|
|
81
|
+
if (!unknown.length) return null
|
|
82
|
+
|
|
83
|
+
const flag = unknown[0]
|
|
84
|
+
const suggestion = didYouMean(flag, all)
|
|
85
|
+
const lines = [`Unknown flag \`${flag}\` for \`uniweb ${verb}\`.`]
|
|
86
|
+
if (suggestion) lines.push(` Did you mean \`${suggestion}\`?`)
|
|
87
|
+
lines.push(` Run \`uniweb ${verb} --help\` for the accepted flags.`)
|
|
88
|
+
return { flag, suggestion, message: lines.join('\n') }
|
|
89
|
+
}
|
package/src/utils/git.js
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { execFileSync } from 'node:child_process'
|
|
21
|
-
import { readFileSync } from 'node:fs'
|
|
22
|
-
import { join } from 'node:path'
|
|
21
|
+
import { readFileSync, realpathSync } from 'node:fs'
|
|
22
|
+
import { join, relative } from 'node:path'
|
|
23
23
|
import yaml from 'js-yaml'
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -85,11 +85,44 @@ export function isGitRepo(dir) {
|
|
|
85
85
|
* as modified here: a section file that exists only locally is not on the backend,
|
|
86
86
|
* so a pruning pull deletes it — losing work that was never committed anywhere.
|
|
87
87
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
88
|
+
* ⛔ **Returned relative to `dir`, which is NOT what git prints.** `--porcelain`
|
|
89
|
+
* paths are always relative to the REPOSITORY ROOT, whatever directory it runs
|
|
90
|
+
* in, so they only coincide with `dir`-relative when the site *is* the repo root.
|
|
91
|
+
* Both callers do `join(siteDir, rel)` with the result, so the un-normalized form
|
|
92
|
+
* was wrong for every nested layout — a site at `myproject/site/` inside a repo.
|
|
93
|
+
*
|
|
94
|
+
* What that cost, measured 2026-08-12: pull exempts its own previous output from
|
|
95
|
+
* the uncommitted-work guard by looking each dirty path up in a record keyed
|
|
96
|
+
* `dir`-relative (`utils/pull-written.js`). Given `myproject/site/site.yml` the
|
|
97
|
+
* lookup missed, so **pull refused on files pull itself had written** — the exact
|
|
98
|
+
* false alarm that record exists to prevent, and the one its own comment warns
|
|
99
|
+
* teaches people to reach for `--force`. `captureLocalWork` had the same defect
|
|
100
|
+
* one door along: it reads each path back with `join(siteDir, rel)`.
|
|
101
|
+
*
|
|
102
|
+
* The earlier `@returns` said "repo-relative-ish paths as git reports them" while
|
|
103
|
+
* the summary above said "relative to `dir`" — the ambiguity was noticed and left,
|
|
104
|
+
* and both callers had picked the other reading.
|
|
105
|
+
*
|
|
106
|
+
* @returns {string[]|null} paths relative to `dir`, or `null` when this isn't a
|
|
107
|
+
* git work tree (distinct from `[]`, which means "clean").
|
|
90
108
|
*/
|
|
91
109
|
export function uncommittedUnder(dir, relPaths) {
|
|
92
110
|
if (!isGitRepo(dir)) return null
|
|
111
|
+
// ⚠️ Both sides must be REAL paths before `relative` can compare them.
|
|
112
|
+
// `rev-parse --show-toplevel` resolves symlinks; the caller's `dir` usually has
|
|
113
|
+
// not. On macOS that alone breaks it — `/var` is a symlink to `/private/var`,
|
|
114
|
+
// so a repo under the temp dir yields a root of `/private/var/…` against a dir
|
|
115
|
+
// of `/var/…`, and `relative` walks all the way up and back down. The result is
|
|
116
|
+
// a valid-looking path that matches nothing, i.e. the same silent miss this
|
|
117
|
+
// normalization exists to fix. Any symlinked checkout does it, not just tmp.
|
|
118
|
+
let root
|
|
119
|
+
let base
|
|
120
|
+
try {
|
|
121
|
+
root = git(['rev-parse', '--show-toplevel'], dir).trim()
|
|
122
|
+
base = realpathSync(dir)
|
|
123
|
+
} catch {
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
93
126
|
try {
|
|
94
127
|
const out = git(
|
|
95
128
|
['status', '--porcelain', '--untracked-files=all', '--', ...relPaths],
|
|
@@ -102,6 +135,12 @@ export function uncommittedUnder(dir, relPaths) {
|
|
|
102
135
|
// porcelain v1: XY<space>path, and a rename is "orig -> new".
|
|
103
136
|
.map((line) => line.slice(3).trim())
|
|
104
137
|
.map((p) => (p.includes(' -> ') ? p.split(' -> ')[1] : p))
|
|
138
|
+
// Porcelain paths are repo-root-relative; callers want them relative to
|
|
139
|
+
// `dir`. Quoted paths (git quotes names with spaces or non-ASCII when
|
|
140
|
+
// `core.quotePath` is on) are left alone rather than half-decoded — a
|
|
141
|
+
// wrong path is worse than an unmatched one here, since an unmatched
|
|
142
|
+
// path merely stays "dirty" and the guard errs toward refusing.
|
|
143
|
+
.map((p) => (p.startsWith('"') ? p : relative(base, join(root, p))))
|
|
105
144
|
.filter(Boolean)
|
|
106
145
|
)
|
|
107
146
|
} catch {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record of what a machine wrote into a site project — pull's own output.
|
|
3
|
+
*
|
|
4
|
+
* ## What it is for
|
|
5
|
+
*
|
|
6
|
+
* `uniweb pull` refuses to run when there are uncommitted changes under the files
|
|
7
|
+
* it rewrites, because it reconciles the working tree to the backend and would
|
|
8
|
+
* overwrite them. That guard needs one distinction to be useful: **a file the
|
|
9
|
+
* user edited** versus **a file a previous pull wrote and nobody has touched
|
|
10
|
+
* since**. Without it the guard cries wolf — pull rewrites the tree, so the next
|
|
11
|
+
* pull sees its own output as uncommitted work and refuses, listing files the
|
|
12
|
+
* user never touched. A guard that fires on nothing teaches people to reach for
|
|
13
|
+
* `--force`, which is the destructive option.
|
|
14
|
+
*
|
|
15
|
+
* So each write is recorded with a content hash, and a dirty path whose hash
|
|
16
|
+
* still matches is not user work.
|
|
17
|
+
*
|
|
18
|
+
* ## Why it lives here rather than in `pull.js`
|
|
19
|
+
*
|
|
20
|
+
* `uniweb clone` has the same claim to make and cannot make it from there.
|
|
21
|
+
* `pull.js` statically imports `@uniweb/build`, which resolves from the
|
|
22
|
+
* *project's* `node_modules` — and `clone` runs before a project exists (see
|
|
23
|
+
* `utils/uwx-read.js` for the same constraint on the `.uwx` reader).
|
|
24
|
+
*
|
|
25
|
+
* Clone scaffolds `site.yml` and `theme.yml` and then delegates to `pull`, so
|
|
26
|
+
* without this the delegated pull sees clone's own scaffolding as uncommitted
|
|
27
|
+
* user work and refuses — on a project created seconds earlier, where there is no
|
|
28
|
+
* user work to protect. Clone records what it wrote; the guard then exempts those
|
|
29
|
+
* files **for the right reason** rather than being overridden.
|
|
30
|
+
*
|
|
31
|
+
* Nothing here imports `@uniweb/build`, and nothing here may start to.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
35
|
+
import { createHash } from 'node:crypto'
|
|
36
|
+
import { join, dirname, relative } from 'node:path'
|
|
37
|
+
|
|
38
|
+
/** Where the record lives — gitignored, beside the other per-site caches. */
|
|
39
|
+
export function writtenCachePath(siteDir) {
|
|
40
|
+
return join(siteDir, '.uniweb', 'pull-written.json')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} siteDir
|
|
45
|
+
* @returns {{files: Record<string,string>, deleted: string[]}}
|
|
46
|
+
*/
|
|
47
|
+
export function readWritten(siteDir) {
|
|
48
|
+
try {
|
|
49
|
+
const o = JSON.parse(readFileSync(writtenCachePath(siteDir), 'utf8'))
|
|
50
|
+
return {
|
|
51
|
+
files: o && typeof o.files === 'object' ? o.files : {},
|
|
52
|
+
deleted: Array.isArray(o?.deleted) ? o.deleted : []
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
return { files: {}, deleted: [] }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Record files as machine-written, by absolute path.
|
|
61
|
+
*
|
|
62
|
+
* MERGE, don't replace. A conditional pull that 304s writes nothing, and a
|
|
63
|
+
* partial pull writes only some lanes — in both cases the previous record is
|
|
64
|
+
* still "the last thing written there". Replacing would forget those paths and
|
|
65
|
+
* the next pull would see them as the user's work again, which is the false alarm
|
|
66
|
+
* this cache exists to prevent. A stale entry for a file that no longer exists is
|
|
67
|
+
* harmless: the hash read fails and it counts as a local change.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} siteDir
|
|
70
|
+
* @param {string[]} absPaths - files just written
|
|
71
|
+
* @param {string[]} [deletedAbs] - files just pruned
|
|
72
|
+
*/
|
|
73
|
+
export function recordWritten(siteDir, absPaths, deletedAbs = []) {
|
|
74
|
+
const prior = readWritten(siteDir)
|
|
75
|
+
const files = prior.files
|
|
76
|
+
for (const abs of absPaths) {
|
|
77
|
+
try {
|
|
78
|
+
files[relative(siteDir, abs)] = createHash('sha256')
|
|
79
|
+
.update(readFileSync(abs))
|
|
80
|
+
.digest('hex')
|
|
81
|
+
} catch {
|
|
82
|
+
/* deleted or unreadable — nothing to remember */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Pull PRUNES too, and a deletion is a dirty path git reports just like an edit.
|
|
86
|
+
// Without recording them, pull's own pruning reads as the user having deleted
|
|
87
|
+
// files — the same false alarm as its writes, arriving by the other door.
|
|
88
|
+
const deleted = [
|
|
89
|
+
...new Set([...prior.deleted, ...deletedAbs.map((a) => relative(siteDir, a))])
|
|
90
|
+
]
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(dirname(writtenCachePath(siteDir)), { recursive: true })
|
|
93
|
+
writeFileSync(
|
|
94
|
+
writtenCachePath(siteDir),
|
|
95
|
+
JSON.stringify({ version: 1, files, deleted }, null, 2) + '\n'
|
|
96
|
+
)
|
|
97
|
+
} catch {
|
|
98
|
+
/* best-effort: losing it only costs a spurious refusal */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Is this dirty path just machine output nobody has touched?
|
|
104
|
+
*
|
|
105
|
+
* The hash comparison is what keeps the guard honest: a scaffolded or pulled file
|
|
106
|
+
* the user then EDITED no longer matches, so it counts as their work and the
|
|
107
|
+
* refusal stands.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} siteDir
|
|
110
|
+
* @param {string} relPath
|
|
111
|
+
* @param {{files: Record<string,string>, deleted: string[]}} written
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
export function isPullOutput(siteDir, relPath, written) {
|
|
115
|
+
let exists = true
|
|
116
|
+
let hash = null
|
|
117
|
+
try {
|
|
118
|
+
hash = createHash('sha256')
|
|
119
|
+
.update(readFileSync(join(siteDir, relPath)))
|
|
120
|
+
.digest('hex')
|
|
121
|
+
} catch {
|
|
122
|
+
exists = false
|
|
123
|
+
}
|
|
124
|
+
// Absent because pull pruned it — not because the user deleted it.
|
|
125
|
+
if (!exists) return written.deleted.includes(relPath)
|
|
126
|
+
return written.files[relPath] === hash
|
|
127
|
+
}
|