uniweb 1.1.0 → 1.2.1
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 +4 -4
- package/partials/agents.md +1 -0
- package/src/backend/client.js +3 -1
- package/src/backend/foundation-bring-along.js +17 -7
- package/src/commands/add.js +118 -68
- package/src/commands/build.js +64 -138
- package/src/commands/clone.js +15 -3
- package/src/commands/dev.js +57 -0
- package/src/commands/doctor.js +48 -44
- package/src/commands/rename.js +11 -26
- package/src/commands/status.js +93 -4
- package/src/framework-index.json +3 -3
- package/src/utils/config.js +98 -90
- package/src/utils/discover.js +17 -20
- package/src/utils/placement.js +55 -0
- package/src/utils/scaffold.js +1 -1
- package/src/utils/workspace-packages.js +431 -0
- package/src/utils/workspace.js +10 -71
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// The workspace's packages — read as pnpm reads them, and written without touching
|
|
2
|
+
// anything else in the file.
|
|
3
|
+
//
|
|
4
|
+
// A workspace lists its packages as folder patterns: `packages:` in
|
|
5
|
+
// pnpm-workspace.yaml, or `workspaces` in package.json for npm and yarn. Every
|
|
6
|
+
// command that asks which packages a workspace holds — discovery, the workspace
|
|
7
|
+
// build, `update`'s survey, `dev`, `doctor` — asks here, and every command that adds
|
|
8
|
+
// or moves one edits the list through here.
|
|
9
|
+
//
|
|
10
|
+
// Patterns are read as pnpm reads them: `*`, `?` and `[...]` within one folder name,
|
|
11
|
+
// `**` for any depth, `{a,b}` for alternatives, and a leading `!` to exclude what the
|
|
12
|
+
// other patterns match. A folder a wildcard finds counts when it holds a package.json,
|
|
13
|
+
// as pnpm counts it — so `**` never sweeps in a `pages/` or `dist/` folder — and a
|
|
14
|
+
// wildcard never enters `node_modules` or a dot-folder. A folder listed by its own path
|
|
15
|
+
// counts if it exists: listing it says it is one, and a command that reads content only
|
|
16
|
+
// (`validate`) works on a folder with no manifest yet. Any other syntax is refused by
|
|
17
|
+
// name, never read as a path.
|
|
18
|
+
//
|
|
19
|
+
// ⛔ Until 2026-10-13 the CLI had two readers, each a partial copy of glob syntax:
|
|
20
|
+
// one read `x/*`, `*/x` and plain paths (discovery, the workspace build), the other
|
|
21
|
+
// only `x/*` and plain paths (`update`). `*/sites/*` matched nothing in either while
|
|
22
|
+
// pnpm read it, and `update` could not see the `*/src` that `add project` writes — it
|
|
23
|
+
// reported a stale foundation as aligned. And three writers (`add`, `rename`,
|
|
24
|
+
// `doctor --fix`) rewrote the file from its `packages` alone, deleting its comments
|
|
25
|
+
// and every other key, `onlyBuiltDependencies` and `allowBuilds` among them.
|
|
26
|
+
//
|
|
27
|
+
// Node built-ins, js-yaml and Handlebars only: the CLI's startup path reaches this
|
|
28
|
+
// module (`utils/workspace.js`), before any project is installed.
|
|
29
|
+
|
|
30
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
31
|
+
import { dirname, join } from 'node:path'
|
|
32
|
+
import { fileURLToPath } from 'node:url'
|
|
33
|
+
import yaml from 'js-yaml'
|
|
34
|
+
import Handlebars from 'handlebars'
|
|
35
|
+
|
|
36
|
+
export const WORKSPACE_FILE = 'pnpm-workspace.yaml'
|
|
37
|
+
|
|
38
|
+
const TEMPLATE = join(
|
|
39
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
40
|
+
'..',
|
|
41
|
+
'..',
|
|
42
|
+
'templates',
|
|
43
|
+
'workspace',
|
|
44
|
+
`${WORKSPACE_FILE}.hbs`
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
// ─── Reading ────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The package patterns a workspace declares, and the file they are read from:
|
|
51
|
+
* pnpm-workspace.yaml when the workspace has one, else package.json's `workspaces`.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} rootDir
|
|
54
|
+
* @returns {{ source: string|null, patterns: string[] }}
|
|
55
|
+
*/
|
|
56
|
+
export function readWorkspacePatterns(rootDir) {
|
|
57
|
+
const ymlPath = join(rootDir, WORKSPACE_FILE)
|
|
58
|
+
if (existsSync(ymlPath)) {
|
|
59
|
+
const doc = yaml.load(readFileSync(ymlPath, 'utf8'))
|
|
60
|
+
return { source: WORKSPACE_FILE, patterns: strings(doc?.packages) }
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const pkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8'))
|
|
64
|
+
if (Array.isArray(pkg.workspaces)) return { source: 'package.json', patterns: strings(pkg.workspaces) }
|
|
65
|
+
} catch {
|
|
66
|
+
// No package.json, or not JSON — a workspace with no packages declared.
|
|
67
|
+
}
|
|
68
|
+
return { source: null, patterns: [] }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The workspace's packages: the folders its patterns name.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} rootDir
|
|
75
|
+
* @returns {string[]} see {@link resolveWorkspacePackages}
|
|
76
|
+
*/
|
|
77
|
+
export function workspacePackages(rootDir) {
|
|
78
|
+
return resolveWorkspacePackages(rootDir, readWorkspacePatterns(rootDir).patterns)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The folders `patterns` name under `rootDir`, as workspace-relative paths in `/`
|
|
83
|
+
* form, each once, in the order of the first pattern that names it — alphabetical
|
|
84
|
+
* within a pattern. A folder a wildcard finds counts only when it holds a
|
|
85
|
+
* package.json; one a pattern names by its own path counts if it exists.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} rootDir
|
|
88
|
+
* @param {string[]} patterns
|
|
89
|
+
* @returns {string[]}
|
|
90
|
+
*/
|
|
91
|
+
export function resolveWorkspacePackages(rootDir, patterns) {
|
|
92
|
+
const { include, exclude } = parsePatterns(patterns)
|
|
93
|
+
const seen = new Set()
|
|
94
|
+
const out = []
|
|
95
|
+
for (const pattern of include) {
|
|
96
|
+
const byWildcard = pattern.some((segment) => segment === '**' || hasMagic(segment))
|
|
97
|
+
for (const rel of expand(rootDir, pattern, [], 0, [])) {
|
|
98
|
+
// The root is the workspace itself, never one of its packages.
|
|
99
|
+
if (!rel || seen.has(rel)) continue
|
|
100
|
+
if (byWildcard && !existsSync(join(rootDir, rel, 'package.json'))) continue
|
|
101
|
+
if (exclude.some((ex) => matches(ex, rel.split('/')))) continue
|
|
102
|
+
seen.add(rel)
|
|
103
|
+
out.push(rel)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Do `patterns` cover the folder at `relPath` — would it be one of the workspace's
|
|
111
|
+
* packages? Answered from the patterns alone, so it holds for a folder about to be
|
|
112
|
+
* created.
|
|
113
|
+
*
|
|
114
|
+
* @param {string[]} patterns
|
|
115
|
+
* @param {string} relPath - workspace-relative
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
export function patternsCover(patterns, relPath) {
|
|
119
|
+
const path = normalizePath(relPath)
|
|
120
|
+
if (!path) return false
|
|
121
|
+
const segments = path.split('/')
|
|
122
|
+
const { include, exclude } = parsePatterns(patterns)
|
|
123
|
+
return (
|
|
124
|
+
include.some((p) => matches(p, segments)) &&
|
|
125
|
+
!exclude.some((p) => matches(p, segments))
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Writing ────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A new pnpm-workspace.yaml listing `entries`, made from the workspace template —
|
|
133
|
+
* as `uniweb create` makes it, build settings included.
|
|
134
|
+
*
|
|
135
|
+
* @param {string[]} entries
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
export function renderWorkspaceFile(entries) {
|
|
139
|
+
return Handlebars.compile(readFileSync(TEMPLATE, 'utf8'))({ workspaceGlobs: entries })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* pnpm-workspace.yaml's text with its `packages:` list changed by `edit`, and nothing
|
|
144
|
+
* else: comments, every other key and the layout stay as they were. `edit` may append
|
|
145
|
+
* entries or replace entries in place.
|
|
146
|
+
*
|
|
147
|
+
* The result is proven before it is returned — parsed again, it must hold the new
|
|
148
|
+
* list and every other key unchanged. Null when it does not, or when the list is not
|
|
149
|
+
* one this can edit in place: the caller says so rather than rewrite the file.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} text
|
|
152
|
+
* @param {(list: string[]) => string[]} edit
|
|
153
|
+
* @returns {string|null}
|
|
154
|
+
*/
|
|
155
|
+
export function editWorkspaceFilePackages(text, edit) {
|
|
156
|
+
let before
|
|
157
|
+
try {
|
|
158
|
+
before = yaml.load(text) ?? {}
|
|
159
|
+
} catch {
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
if (typeof before !== 'object' || Array.isArray(before)) return null
|
|
163
|
+
const current = strings(before.packages)
|
|
164
|
+
const next = edit([...current])
|
|
165
|
+
const out = rewriteList(text, current, next)
|
|
166
|
+
if (out == null) return null
|
|
167
|
+
|
|
168
|
+
let after
|
|
169
|
+
try {
|
|
170
|
+
after = yaml.load(out) ?? {}
|
|
171
|
+
} catch {
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
if (!sameValue(strings(after.packages), next)) return null
|
|
175
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
176
|
+
if (key !== 'packages' && !sameValue(before[key], after[key])) return null
|
|
177
|
+
}
|
|
178
|
+
return out
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Patterns ───────────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
// `@(…)`, `+(…)`, `!(…)`, `*(…)`, `?(…)` — extended globs, which this does not read.
|
|
184
|
+
const EXTGLOB = /[@+!*?]\(/
|
|
185
|
+
const warned = new Set()
|
|
186
|
+
|
|
187
|
+
/** Positive and negative patterns as folder-name lists, `{a,b}` expanded. */
|
|
188
|
+
function parsePatterns(patterns) {
|
|
189
|
+
const include = []
|
|
190
|
+
const exclude = []
|
|
191
|
+
for (const raw of strings(patterns)) {
|
|
192
|
+
let pattern = raw.trim()
|
|
193
|
+
const negated = pattern.startsWith('!')
|
|
194
|
+
if (negated) pattern = pattern.slice(1)
|
|
195
|
+
if (EXTGLOB.test(pattern)) {
|
|
196
|
+
if (!warned.has(raw)) {
|
|
197
|
+
warned.add(raw)
|
|
198
|
+
process.stderr.write(
|
|
199
|
+
`⚠ uniweb does not read the workspace pattern "${raw}" (extended glob syntax) — ` +
|
|
200
|
+
`list the folders it stands for instead.\n`
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
for (const one of expandBraces(pattern)) {
|
|
206
|
+
const path = normalizePath(one)
|
|
207
|
+
if (path) (negated ? exclude : include).push(path.split('/'))
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { include, exclude }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `a/{b,c}/d` → `a/b/d`, `a/c/d`; nested lists expand innermost first. */
|
|
214
|
+
function expandBraces(pattern) {
|
|
215
|
+
const m = /\{([^{}]*)\}/.exec(pattern)
|
|
216
|
+
if (!m) return [pattern]
|
|
217
|
+
const options = m[1].split(',')
|
|
218
|
+
// `{x}` lists nothing to choose between: a literal brace, as in pnpm.
|
|
219
|
+
if (options.length < 2) return [pattern]
|
|
220
|
+
const head = pattern.slice(0, m.index)
|
|
221
|
+
const tail = pattern.slice(m.index + m[0].length)
|
|
222
|
+
return options.flatMap((o) => expandBraces(head + o + tail))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** `./a//b/` → `a/b`; `\` becomes `/`; `.` and `` are the root. */
|
|
226
|
+
function normalizePath(path) {
|
|
227
|
+
let s = String(path).replace(/\\/g, '/').replace(/\/{2,}/g, '/')
|
|
228
|
+
while (s.startsWith('./')) s = s.slice(2)
|
|
229
|
+
s = s.replace(/^\/+/, '').replace(/\/+$/, '')
|
|
230
|
+
return s === '.' ? '' : s
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const hasMagic = (segment) => /[*?[]/.test(segment)
|
|
234
|
+
|
|
235
|
+
/** Can a wildcard step into this folder? Never `node_modules`, never a dot-folder. */
|
|
236
|
+
const wildcardMayEnter = (name) => !name.startsWith('.') && name !== 'node_modules'
|
|
237
|
+
|
|
238
|
+
const regexCache = new Map()
|
|
239
|
+
|
|
240
|
+
/** One folder-name pattern as a regex: `*`, `?` and `[...]`, everything else literal. */
|
|
241
|
+
function segmentRegex(segment) {
|
|
242
|
+
let re = regexCache.get(segment)
|
|
243
|
+
if (re) return re
|
|
244
|
+
let src = ''
|
|
245
|
+
for (let i = 0; i < segment.length; i++) {
|
|
246
|
+
const c = segment[i]
|
|
247
|
+
if (c === '*') src += '[^/]*'
|
|
248
|
+
else if (c === '?') src += '[^/]'
|
|
249
|
+
else if (c === '[' && segment.indexOf(']', i + 2) > i) {
|
|
250
|
+
const end = segment.indexOf(']', i + 2)
|
|
251
|
+
let body = segment.slice(i + 1, end).replace(/\\/g, '\\\\')
|
|
252
|
+
if (body.startsWith('!')) body = `^${body.slice(1)}`
|
|
253
|
+
src += `[${body}]`
|
|
254
|
+
i = end
|
|
255
|
+
} else src += c.replace(/[.+^$(){}|\\[\]]/g, '\\$&')
|
|
256
|
+
}
|
|
257
|
+
re = new RegExp(`^${src}$`)
|
|
258
|
+
regexCache.set(segment, re)
|
|
259
|
+
return re
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function segmentMatches(segment, name) {
|
|
263
|
+
if (!hasMagic(segment)) return segment === name
|
|
264
|
+
// A wildcard does not match a dot-folder unless the pattern spells the dot.
|
|
265
|
+
if (name.startsWith('.') && !segment.startsWith('.')) return false
|
|
266
|
+
if (name === 'node_modules') return false
|
|
267
|
+
return segmentRegex(segment).test(name)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Does the folder-name list `path` match the pattern `pattern`? `**` is any depth. */
|
|
271
|
+
function matches(pattern, path, i = 0, j = 0) {
|
|
272
|
+
if (i === pattern.length) return j === path.length
|
|
273
|
+
if (pattern[i] === '**') {
|
|
274
|
+
for (let k = j; k <= path.length; k++) {
|
|
275
|
+
if (k > j && !wildcardMayEnter(path[k - 1])) break
|
|
276
|
+
if (matches(pattern, path, i + 1, k)) return true
|
|
277
|
+
}
|
|
278
|
+
return false
|
|
279
|
+
}
|
|
280
|
+
if (j === path.length || !segmentMatches(pattern[i], path[j])) return false
|
|
281
|
+
return matches(pattern, path, i + 1, j + 1)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The folders under `rootDir` that `pattern` names, found on disk. */
|
|
285
|
+
function expand(rootDir, pattern, base, i, out) {
|
|
286
|
+
if (i === pattern.length) {
|
|
287
|
+
out.push(base.join('/'))
|
|
288
|
+
return out
|
|
289
|
+
}
|
|
290
|
+
const segment = pattern[i]
|
|
291
|
+
const here = join(rootDir, ...base)
|
|
292
|
+
if (segment === '**') {
|
|
293
|
+
expand(rootDir, pattern, base, i + 1, out)
|
|
294
|
+
// Real folders only: a symlink back up the tree would never end.
|
|
295
|
+
for (const name of subfolders(here, { followLinks: false })) {
|
|
296
|
+
if (wildcardMayEnter(name)) expand(rootDir, pattern, [...base, name], i, out)
|
|
297
|
+
}
|
|
298
|
+
return out
|
|
299
|
+
}
|
|
300
|
+
if (!hasMagic(segment)) {
|
|
301
|
+
if (segment === '.') return expand(rootDir, pattern, base, i + 1, out)
|
|
302
|
+
if (isFolder(join(here, segment))) expand(rootDir, pattern, [...base, segment], i + 1, out)
|
|
303
|
+
return out
|
|
304
|
+
}
|
|
305
|
+
for (const name of subfolders(here)) {
|
|
306
|
+
if (segmentMatches(segment, name)) expand(rootDir, pattern, [...base, name], i + 1, out)
|
|
307
|
+
}
|
|
308
|
+
return out
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function subfolders(dir, { followLinks = true } = {}) {
|
|
312
|
+
let entries
|
|
313
|
+
try {
|
|
314
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
315
|
+
} catch {
|
|
316
|
+
return []
|
|
317
|
+
}
|
|
318
|
+
return entries
|
|
319
|
+
.filter(
|
|
320
|
+
(e) => e.isDirectory() || (followLinks && e.isSymbolicLink() && isFolder(join(dir, e.name)))
|
|
321
|
+
)
|
|
322
|
+
.map((e) => e.name)
|
|
323
|
+
.sort()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function isFolder(path) {
|
|
327
|
+
try {
|
|
328
|
+
return statSync(path).isDirectory()
|
|
329
|
+
} catch {
|
|
330
|
+
return false
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ─── The `packages:` list, as text ──────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Apply `current` → `next` to the text of the `packages:` list: entries appended at
|
|
338
|
+
* its end, entries replaced on their own lines. Null for any other change, or a list
|
|
339
|
+
* written in a shape this does not edit (a flow list over several lines, an entry
|
|
340
|
+
* over several lines).
|
|
341
|
+
*/
|
|
342
|
+
function rewriteList(text, current, next) {
|
|
343
|
+
const appended = next.length >= current.length && current.every((v, i) => v === next[i])
|
|
344
|
+
const replaced = next.length === current.length
|
|
345
|
+
if (!appended && !replaced) return null
|
|
346
|
+
|
|
347
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n'
|
|
348
|
+
const lines = text.split(/\r?\n/)
|
|
349
|
+
const key = lines.findIndex((l) => /^packages\s*:/.test(l))
|
|
350
|
+
|
|
351
|
+
// No `packages:` key yet — the list goes first, the rest of the file after it.
|
|
352
|
+
if (key < 0) {
|
|
353
|
+
if (!appended) return null
|
|
354
|
+
const items = next.map((v) => ` - ${formatEntry(v, '"')}`)
|
|
355
|
+
return ['packages:', ...items, '', text].join(eol)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const inline = lines[key].slice(lines[key].indexOf(':') + 1)
|
|
359
|
+
if (inline.trim() && !inline.trim().startsWith('#')) {
|
|
360
|
+
// A flow list on the key's own line: `packages: ["a", "b"]`.
|
|
361
|
+
const m = /^(\s*)\[(.*)\](\s*(#.*)?)$/.exec(inline)
|
|
362
|
+
if (!m) return null
|
|
363
|
+
lines[key] = `packages:${m[1]}[${next.map((v) => formatEntry(v, '"')).join(', ')}]${m[3]}`
|
|
364
|
+
return lines.join(eol)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// A block list: the `- ` lines that follow, until the next key.
|
|
368
|
+
const items = []
|
|
369
|
+
for (let i = key + 1; i < lines.length; i++) {
|
|
370
|
+
const l = lines[i]
|
|
371
|
+
if (/^\s*(#.*)?$/.test(l)) continue
|
|
372
|
+
if (/^\s*-(\s|$)/.test(l)) items.push(i)
|
|
373
|
+
else if (/^\s/.test(l)) return null // an entry over several lines
|
|
374
|
+
else break
|
|
375
|
+
}
|
|
376
|
+
if (items.length !== current.length) return null
|
|
377
|
+
|
|
378
|
+
for (let p = 0; p < current.length; p++) {
|
|
379
|
+
if (next[p] === current[p]) continue
|
|
380
|
+
const parts = splitItem(lines[items[p]])
|
|
381
|
+
if (!parts) return null
|
|
382
|
+
lines[items[p]] = `${parts.indent}- ${formatEntry(next[p], parts.quote)}${parts.tail}`
|
|
383
|
+
}
|
|
384
|
+
if (next.length > current.length) {
|
|
385
|
+
const last = items.length ? splitItem(lines[items[items.length - 1]]) : null
|
|
386
|
+
const indent = last ? last.indent : ' '
|
|
387
|
+
const quote = last ? last.quote : '"'
|
|
388
|
+
const added = next.slice(current.length).map((v) => `${indent}- ${formatEntry(v, quote)}`)
|
|
389
|
+
lines.splice((items.length ? items[items.length - 1] : key) + 1, 0, ...added)
|
|
390
|
+
}
|
|
391
|
+
return lines.join(eol)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** A list line's indent, its value's quote style, and what follows the value. */
|
|
395
|
+
function splitItem(line) {
|
|
396
|
+
const m = /^(\s*)-\s+(.*)$/.exec(line)
|
|
397
|
+
if (!m) return null
|
|
398
|
+
const [, indent, rest] = m
|
|
399
|
+
const q = rest[0]
|
|
400
|
+
if (q === '"' || q === "'") {
|
|
401
|
+
let i = 1
|
|
402
|
+
for (; i < rest.length; i++) {
|
|
403
|
+
if (q === '"' && rest[i] === '\\') i++
|
|
404
|
+
else if (rest[i] === q) {
|
|
405
|
+
if (q === "'" && rest[i + 1] === "'") i++
|
|
406
|
+
else break
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (i >= rest.length) return null
|
|
410
|
+
return { indent, quote: q, tail: rest.slice(i + 1) }
|
|
411
|
+
}
|
|
412
|
+
const hash = rest.search(/\s#/)
|
|
413
|
+
return { indent, quote: '', tail: hash < 0 ? '' : rest.slice(hash) }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** An entry as YAML, in the list's quote style — quoted whenever a bare one could misread. */
|
|
417
|
+
function formatEntry(value, quote) {
|
|
418
|
+
if (quote === "'") return `'${value.replace(/'/g, "''")}'`
|
|
419
|
+
if (quote === '' && /^[A-Za-z0-9_][A-Za-z0-9_./-]*$/.test(value)) return value
|
|
420
|
+
return JSON.stringify(value)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
function strings(list) {
|
|
426
|
+
return Array.isArray(list) ? list.filter((v) => typeof v === 'string') : []
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function sameValue(a, b) {
|
|
430
|
+
return JSON.stringify(a) === JSON.stringify(b)
|
|
431
|
+
}
|
package/src/utils/workspace.js
CHANGED
|
@@ -6,10 +6,9 @@
|
|
|
6
6
|
* Used by commands to auto-detect targets when run from workspace root.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { existsSync,
|
|
10
|
-
import { readFile } from 'node:fs/promises'
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
11
10
|
import { resolve, dirname, join } from 'node:path'
|
|
12
|
-
import
|
|
11
|
+
import { workspacePackages } from './workspace-packages.js'
|
|
13
12
|
|
|
14
13
|
// `classifyPackage` from @uniweb/build is loaded lazily — this module
|
|
15
14
|
// is statically imported by index.js (for `findWorkspaceRoot`), and a
|
|
@@ -62,79 +61,19 @@ export function findWorkspaceRoot(startDir = process.cwd()) {
|
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
64
|
+
* Get workspace packages from pnpm-workspace.yaml or package.json workspaces —
|
|
65
|
+
* read as pnpm reads them (`workspacePackages`, ./workspace-packages.js).
|
|
66
|
+
*
|
|
67
|
+
* ⛔ Until 2026-10-13 this was its own partial reader of the patterns, which saw
|
|
68
|
+
* `x/*` and plain paths only: `update`, which surveys through it, could not see a
|
|
69
|
+
* project's `<project>/src` package as `add project` lists it — by a pattern whose
|
|
70
|
+
* first folder is a star — and reported a stale one as aligned.
|
|
67
71
|
*
|
|
68
|
-
* @param {string[]} patterns - Array of patterns from pnpm-workspace.yaml
|
|
69
|
-
* @param {string} workspaceRoot - Workspace root directory
|
|
70
|
-
* @returns {string[]} - Array of existing package directories (relative paths)
|
|
71
|
-
*/
|
|
72
|
-
function resolvePatterns(patterns, workspaceRoot) {
|
|
73
|
-
const packages = []
|
|
74
|
-
|
|
75
|
-
for (const pattern of patterns) {
|
|
76
|
-
// Remove quotes if present
|
|
77
|
-
const cleanPattern = pattern.replace(/^["']|["']$/g, '')
|
|
78
|
-
|
|
79
|
-
if (cleanPattern.endsWith('/*')) {
|
|
80
|
-
// Glob pattern like "foundations/*" - list subdirectories
|
|
81
|
-
const baseDir = cleanPattern.slice(0, -2)
|
|
82
|
-
const fullPath = join(workspaceRoot, baseDir)
|
|
83
|
-
|
|
84
|
-
if (existsSync(fullPath)) {
|
|
85
|
-
try {
|
|
86
|
-
const entries = readdirSync(fullPath, { withFileTypes: true })
|
|
87
|
-
for (const entry of entries) {
|
|
88
|
-
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
89
|
-
packages.push(join(baseDir, entry.name))
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
} catch {
|
|
93
|
-
// Ignore read errors
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
} else {
|
|
97
|
-
// Direct path like "foundation" or "site"
|
|
98
|
-
const fullPath = join(workspaceRoot, cleanPattern)
|
|
99
|
-
if (existsSync(fullPath)) {
|
|
100
|
-
packages.push(cleanPattern)
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return packages
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Get workspace packages from pnpm-workspace.yaml or package.json workspaces
|
|
110
72
|
* @param {string} workspaceRoot
|
|
111
73
|
* @returns {Promise<string[]>} - Array of package directories (relative paths)
|
|
112
74
|
*/
|
|
113
75
|
export async function getWorkspacePackages(workspaceRoot) {
|
|
114
|
-
|
|
115
|
-
const configPath = join(workspaceRoot, 'pnpm-workspace.yaml')
|
|
116
|
-
if (existsSync(configPath)) {
|
|
117
|
-
const content = await readFile(configPath, 'utf-8')
|
|
118
|
-
const config = yaml.load(content)
|
|
119
|
-
if (config?.packages && Array.isArray(config.packages)) {
|
|
120
|
-
return resolvePatterns(config.packages, workspaceRoot)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Fall back to package.json workspaces
|
|
125
|
-
const pkgPath = join(workspaceRoot, 'package.json')
|
|
126
|
-
if (existsSync(pkgPath)) {
|
|
127
|
-
try {
|
|
128
|
-
const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
|
|
129
|
-
if (Array.isArray(pkg.workspaces)) {
|
|
130
|
-
return resolvePatterns(pkg.workspaces, workspaceRoot)
|
|
131
|
-
}
|
|
132
|
-
} catch {
|
|
133
|
-
// ignore
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return []
|
|
76
|
+
return workspacePackages(workspaceRoot)
|
|
138
77
|
}
|
|
139
78
|
|
|
140
79
|
/**
|