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
package/src/commands/status.js
CHANGED
|
@@ -7,15 +7,17 @@
|
|
|
7
7
|
* `uniweb push` runs) — no auth, no backend round-trip.
|
|
8
8
|
*
|
|
9
9
|
* `--remote` adds the backend signals (may prompt for login, like `git fetch`):
|
|
10
|
-
* - whether the synced draft differs from what's live (publish needed),
|
|
11
|
-
* - whether a newer foundation version is registered than the site pins
|
|
10
|
+
* - whether the synced draft differs from what's live (publish needed),
|
|
11
|
+
* - whether a newer foundation version is registered than the site pins, and
|
|
12
|
+
* - who has the site open in the app right now, and on what (`describeEditors`).
|
|
12
13
|
* Those use ASSUMED endpoints (see kb shipping-verbs-and-freshness.md §6.5); until
|
|
13
14
|
* the backend exposes them, `--remote` degrades silently to the local view.
|
|
14
15
|
*
|
|
15
16
|
* Usage:
|
|
16
17
|
* uniweb status Sync identity + unpushed content + foundation ref (local)
|
|
17
18
|
* uniweb status --remote Also: whether the backend still holds the site, draft-vs-live,
|
|
18
|
-
*
|
|
19
|
+
* a newer-registered-foundation check, and who has it open in
|
|
20
|
+
* the app
|
|
19
21
|
* uniweb status --json One JSON line (adds a `remote` object under --remote, whose
|
|
20
22
|
* `site_state` is live / gone / unknown — `gone` only on the
|
|
21
23
|
* backend's own word that it holds no such site)
|
|
@@ -28,7 +30,7 @@ import { join } from 'node:path'
|
|
|
28
30
|
import yaml from 'js-yaml'
|
|
29
31
|
|
|
30
32
|
import { resolveSiteDir } from './deploy.js'
|
|
31
|
-
import { probeUnpushed } from '../backend/site-sync.js'
|
|
33
|
+
import { probeUnpushed, readItemUuids, unitFilesOf } from '../backend/site-sync.js'
|
|
32
34
|
import { resolveWorkspace } from '../backend/workspace.js'
|
|
33
35
|
import {
|
|
34
36
|
BackendClient,
|
|
@@ -75,6 +77,79 @@ function foundationRef(siteYml) {
|
|
|
75
77
|
return typeof f === 'string' ? f : f.ref || null
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
// What an item that this copy has not synced is called — by its Section on the site.
|
|
81
|
+
const ITEM_KIND = { pages: 'a page', page_sections: 'a section', layout_sections: 'a layout section' }
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Who has the site open in the app right now, and on what — the status's `editors`
|
|
85
|
+
* (backend, since 2026-10-13), as lines a developer can follow up on. Each tab's item is
|
|
86
|
+
* named by the file it is in this copy, and by its kind and route when this copy has not
|
|
87
|
+
* synced it (`fileByItemUuid`).
|
|
88
|
+
*
|
|
89
|
+
* ⛔ Two ways not to read it. `changed_at` is the item's last change by ANYONE — this
|
|
90
|
+
* author, another, a push — so it says when the item last changed, never what this
|
|
91
|
+
* person did. And it is presence, not a lock: nothing refuses or warns a push over it.
|
|
92
|
+
*
|
|
93
|
+
* @param {object[]} editors - `[{ person: { uuid, handle, brief }, tabs: [{ page, item }] }]`
|
|
94
|
+
* @param {object} [opts]
|
|
95
|
+
* @param {(uuid: string) => string|null} [opts.fileOf] - an item's file in this copy
|
|
96
|
+
* @param {number} [opts.now] - for "3 min ago"
|
|
97
|
+
* @returns {{ person: string, tabs: string[] }[]} a tab with nothing open is left out
|
|
98
|
+
*/
|
|
99
|
+
export function describeEditors(editors, { fileOf = () => null, now = Date.now() } = {}) {
|
|
100
|
+
return editors.map((editor) => ({
|
|
101
|
+
person: personLabel(editor?.person),
|
|
102
|
+
tabs: (editor?.tabs || []).map((tab) => tabLabel(tab, fileOf, now)).filter(Boolean)
|
|
103
|
+
}))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function personLabel(person) {
|
|
107
|
+
const name = person?.brief?.display_name
|
|
108
|
+
const handle = person?.handle ? `@${person.handle}` : null
|
|
109
|
+
if (name && handle) return `${name} (${handle})`
|
|
110
|
+
return name || handle || `someone (${person?.uuid || 'unnamed'})`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function tabLabel(tab, fileOf, now) {
|
|
114
|
+
const item = tab?.item
|
|
115
|
+
if (!item?.uuid) return null
|
|
116
|
+
const route = tab.page?.route || null
|
|
117
|
+
const file = fileOf(item.uuid)
|
|
118
|
+
const what = file
|
|
119
|
+
? `${file}${route ? ` (${route})` : ''}`
|
|
120
|
+
: `${ITEM_KIND[item.section] || 'an item'} not in this copy${route ? ` (${route})` : ''}`
|
|
121
|
+
const when = sinceLabel(item.changed_at, now)
|
|
122
|
+
return when ? `${what} · changed ${when}` : what
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sinceLabel(iso, now) {
|
|
126
|
+
const t = Date.parse(iso)
|
|
127
|
+
if (Number.isNaN(t)) return null
|
|
128
|
+
const min = Math.floor(Math.max(0, now - t) / 60_000)
|
|
129
|
+
if (min < 1) return 'just now'
|
|
130
|
+
if (min < 60) return `${min} min ago`
|
|
131
|
+
const h = Math.floor(min / 60)
|
|
132
|
+
if (h < 24) return `${h} h ago`
|
|
133
|
+
const d = Math.floor(h / 24)
|
|
134
|
+
return `${d} day${d === 1 ? '' : 's'} ago`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* An item's file in this copy, from its uuid: `sync.json` keeps each unit's uuid on
|
|
139
|
+
* `backend` by its path (`items`, banked from every push and pull), and `unitFilesOf`
|
|
140
|
+
* finds the file the author keeps it in (`1-hero.md` for `hero.md`). Null for an item
|
|
141
|
+
* this copy has not synced — one added in the app since its last pull.
|
|
142
|
+
*/
|
|
143
|
+
export function fileByItemUuid(siteDir, backend) {
|
|
144
|
+
const unitOf = new Map(
|
|
145
|
+
Object.entries(readItemUuids(siteDir, backend)).map(([unit, uuid]) => [String(uuid).toLowerCase(), unit])
|
|
146
|
+
)
|
|
147
|
+
return (uuid) => {
|
|
148
|
+
const unit = unitOf.get(String(uuid).toLowerCase())
|
|
149
|
+
return unit ? (unitFilesOf(siteDir, unit)[0] ?? unit) : null
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
78
153
|
// A versioned registry ref `@org/name@1.2.3` → its scoped name `@org/name` and
|
|
79
154
|
// pinned version `1.2.3`. A bare/local/unversioned ref → nulls.
|
|
80
155
|
function splitFoundationRef(fnd) {
|
|
@@ -263,6 +338,20 @@ export async function status(args = []) {
|
|
|
263
338
|
)
|
|
264
339
|
}
|
|
265
340
|
}
|
|
341
|
+
// An array is the backend's answer, `[]` included; no key is a backend that does not
|
|
342
|
+
// say, and then neither do we.
|
|
343
|
+
if (Array.isArray(site?.editors)) {
|
|
344
|
+
if (!site.editors.length) {
|
|
345
|
+
say.dim('Nobody has this site open in the app.')
|
|
346
|
+
} else {
|
|
347
|
+
say.info('Open in the app now:')
|
|
348
|
+
const fileOf = fileByItemUuid(siteDir, probeBackend)
|
|
349
|
+
for (const { person, tabs } of describeEditors(site.editors, { fileOf })) {
|
|
350
|
+
console.log(` ${person}${tabs.length ? '' : ', with nothing open'}`)
|
|
351
|
+
for (const tab of tabs) console.log(` ${c.dim}${tab}${c.reset}`)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
266
355
|
if (
|
|
267
356
|
fdnLatest?.latest_version &&
|
|
268
357
|
fndVersion &&
|
package/src/framework-index.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-10-
|
|
3
|
+
"generatedAt": "2026-10-14T18:03:31.718Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
|
-
"version": "1.1.
|
|
6
|
+
"version": "1.1.1"
|
|
7
7
|
},
|
|
8
8
|
"@uniweb/build": {
|
|
9
|
-
"version": "1.1.
|
|
9
|
+
"version": "1.1.1"
|
|
10
10
|
},
|
|
11
11
|
"@uniweb/content-reader": {
|
|
12
12
|
"version": "1.2.8"
|
package/src/utils/config.js
CHANGED
|
@@ -18,10 +18,16 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
18
18
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
19
19
|
import { join } from 'node:path'
|
|
20
20
|
import { homedir } from 'node:os'
|
|
21
|
-
import yaml from 'js-yaml'
|
|
22
21
|
import { filterCmd } from './pm.js'
|
|
23
22
|
import { writeJsonPreservingStyleAsync } from './json-file.js'
|
|
24
23
|
import { loggedInOriginOf, readSessionFileSync } from './session-file.js'
|
|
24
|
+
import {
|
|
25
|
+
WORKSPACE_FILE,
|
|
26
|
+
readWorkspacePatterns,
|
|
27
|
+
patternsCover,
|
|
28
|
+
editWorkspaceFilePackages,
|
|
29
|
+
renderWorkspaceFile
|
|
30
|
+
} from './workspace-packages.js'
|
|
25
31
|
|
|
26
32
|
// ── Backend origin ─────────────────────────────────────────────
|
|
27
33
|
|
|
@@ -214,59 +220,88 @@ export const DEFAULT_BACKEND_ORIGIN = 'https://uniweb.app'
|
|
|
214
220
|
/**
|
|
215
221
|
* Read workspace package globs.
|
|
216
222
|
* Tries pnpm-workspace.yaml first, falls back to package.json workspaces.
|
|
223
|
+
* The folders they name: `workspacePackages` (./workspace-packages.js).
|
|
217
224
|
* @param {string} rootDir - Workspace root directory
|
|
218
225
|
* @returns {Promise<{packages: string[]}>}
|
|
219
226
|
*/
|
|
220
227
|
export async function readWorkspaceConfig(rootDir) {
|
|
221
|
-
|
|
222
|
-
const configPath = join(rootDir, 'pnpm-workspace.yaml')
|
|
223
|
-
if (existsSync(configPath)) {
|
|
224
|
-
const content = await readFile(configPath, 'utf-8')
|
|
225
|
-
const config = yaml.load(content)
|
|
226
|
-
return { packages: config?.packages || [] }
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Fall back to package.json workspaces
|
|
230
|
-
const pkg = await readRootPackageJson(rootDir)
|
|
231
|
-
if (Array.isArray(pkg.workspaces)) {
|
|
232
|
-
return { packages: pkg.workspaces }
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
return { packages: [] }
|
|
228
|
+
return { packages: readWorkspacePatterns(rootDir).patterns }
|
|
236
229
|
}
|
|
237
230
|
|
|
238
231
|
/**
|
|
239
|
-
*
|
|
232
|
+
* Make the folder at `relPath` a workspace package.
|
|
233
|
+
*
|
|
234
|
+
* Nothing is written for a folder a pattern already covers — it is a package
|
|
235
|
+
* already. ⛔ Until 2026-10-13 its own path was added beside the pattern, and
|
|
236
|
+
* discovery listed it twice. Otherwise `entry` — the folder itself, unless a
|
|
237
|
+
* pattern is given for it — is added to each manifest that does not cover it:
|
|
238
|
+
*
|
|
239
|
+
* - pnpm-workspace.yaml's `packages:` list, edited in place: comments and every
|
|
240
|
+
* other key stay as they were. ⛔ Until 2026-10-13 the file was rewritten from
|
|
241
|
+
* its `packages` alone, deleting `onlyBuiltDependencies` and `allowBuilds` with
|
|
242
|
+
* the comments that explain them. A workspace without the file (a blank one, at
|
|
243
|
+
* its first `add`) gets it from the workspace template, as `create` writes it.
|
|
244
|
+
* - package.json's `workspaces`, which npm and yarn read.
|
|
245
|
+
*
|
|
240
246
|
* @param {string} rootDir - Workspace root directory
|
|
241
|
-
* @param {
|
|
247
|
+
* @param {string} relPath - The package's folder, workspace-relative
|
|
248
|
+
* @param {{ entry?: string }} [options] - `entry`: a pattern to list for it instead
|
|
242
249
|
*/
|
|
243
|
-
export async function
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
|
|
250
|
+
export async function registerWorkspacePackage(rootDir, relPath, { entry = relPath } = {}) {
|
|
251
|
+
const { source, patterns } = readWorkspacePatterns(rootDir)
|
|
252
|
+
const ymlPath = join(rootDir, WORKSPACE_FILE)
|
|
253
|
+
if (!patternsCover(patterns, relPath)) {
|
|
254
|
+
if (source === WORKSPACE_FILE) {
|
|
255
|
+
const text = await readFile(ymlPath, 'utf-8')
|
|
256
|
+
await writeFile(ymlPath, editedWorkspaceFile(text, (list) => [...list, entry], entry))
|
|
257
|
+
} else {
|
|
258
|
+
await writeFile(ymlPath, renderWorkspaceFile([...patterns, entry]))
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const pkg = await readRootPackageJson(rootDir)
|
|
263
|
+
const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : []
|
|
264
|
+
if (!patternsCover(workspaces, relPath)) {
|
|
265
|
+
pkg.workspaces = [...workspaces, entry]
|
|
266
|
+
await writeRootPackageJson(rootDir, pkg)
|
|
267
|
+
}
|
|
247
268
|
}
|
|
248
269
|
|
|
249
270
|
/**
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
271
|
+
* A package folder moved from `oldPath` to `newPath`: rename its entry in each
|
|
272
|
+
* manifest that lists it by path — a pattern is left as written — then register
|
|
273
|
+
* the new path, in case it has moved out from under the pattern that covered it.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} rootDir
|
|
276
|
+
* @param {string} oldPath
|
|
277
|
+
* @param {string} newPath
|
|
253
278
|
*/
|
|
254
|
-
export async function
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
await
|
|
279
|
+
export async function moveWorkspacePackage(rootDir, oldPath, newPath) {
|
|
280
|
+
const { source, patterns } = readWorkspacePatterns(rootDir)
|
|
281
|
+
if (source === WORKSPACE_FILE && patterns.includes(oldPath)) {
|
|
282
|
+
const ymlPath = join(rootDir, WORKSPACE_FILE)
|
|
283
|
+
const text = await readFile(ymlPath, 'utf-8')
|
|
284
|
+
const rename = (list) => list.map((p) => (p === oldPath ? newPath : p))
|
|
285
|
+
await writeFile(ymlPath, editedWorkspaceFile(text, rename, newPath))
|
|
259
286
|
}
|
|
260
|
-
|
|
261
|
-
// Sync workspaces array in package.json (for npm compatibility)
|
|
262
287
|
const pkg = await readRootPackageJson(rootDir)
|
|
263
|
-
if (
|
|
264
|
-
pkg.workspaces =
|
|
265
|
-
}
|
|
266
|
-
if (!pkg.workspaces.includes(glob)) {
|
|
267
|
-
pkg.workspaces.push(glob)
|
|
288
|
+
if (Array.isArray(pkg.workspaces) && pkg.workspaces.includes(oldPath)) {
|
|
289
|
+
pkg.workspaces = pkg.workspaces.map((p) => (p === oldPath ? newPath : p))
|
|
268
290
|
await writeRootPackageJson(rootDir, pkg)
|
|
269
291
|
}
|
|
292
|
+
await registerWorkspacePackage(rootDir, newPath)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The edited file, or an error that says what to add by hand — the file is never rewritten. */
|
|
296
|
+
function editedWorkspaceFile(text, edit, entry) {
|
|
297
|
+
const next = editWorkspaceFilePackages(text, edit)
|
|
298
|
+
if (next == null) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`Could not edit the packages: list in ${WORKSPACE_FILE} in place, and it is not rewritten ` +
|
|
301
|
+
`whole. Add "${entry}" to it by hand.`
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
return next
|
|
270
305
|
}
|
|
271
306
|
|
|
272
307
|
/**
|
|
@@ -352,66 +387,39 @@ export function computeRootScripts(sites, pm = 'pnpm') {
|
|
|
352
387
|
*/
|
|
353
388
|
export async function updateRootScripts(rootDir, sites, pm = 'pnpm') {
|
|
354
389
|
const pkg = await readRootPackageJson(rootDir)
|
|
355
|
-
const
|
|
390
|
+
const unique = sites.filter((s, i) => sites.findIndex((o) => o.path === s.path) === i)
|
|
391
|
+
const newScripts = computeRootScripts(unique, pm)
|
|
392
|
+
const scripts = { ...(pkg.scripts || {}) }
|
|
356
393
|
|
|
357
394
|
// If we're adding a second site, rename existing dev/preview to dev:{firstName}
|
|
358
|
-
if (
|
|
359
|
-
const firstName =
|
|
395
|
+
if (unique.length === 2 && scripts.dev) {
|
|
396
|
+
const firstName = unique[0].name
|
|
360
397
|
// Only rename if the existing dev script references the first site's name
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
pkg.scripts = { ...pkg.scripts, ...newScripts }
|
|
368
|
-
await writeRootPackageJson(rootDir, pkg)
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// Resolve a workspace glob pattern to actual directories
|
|
372
|
-
export async function resolveGlob(rootDir, pattern) {
|
|
373
|
-
const clean = pattern.replace(/^["']|["']$/g, '')
|
|
374
|
-
|
|
375
|
-
if (clean.endsWith('/*')) {
|
|
376
|
-
// Pattern like "foundations/*" - list subdirectories
|
|
377
|
-
const baseDir = clean.slice(0, -2)
|
|
378
|
-
const fullPath = join(rootDir, baseDir)
|
|
379
|
-
if (!existsSync(fullPath)) return []
|
|
380
|
-
try {
|
|
381
|
-
const { readdirSync } = await import('node:fs')
|
|
382
|
-
const entries = readdirSync(fullPath, { withFileTypes: true })
|
|
383
|
-
return entries
|
|
384
|
-
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
385
|
-
.map((e) => join(baseDir, e.name))
|
|
386
|
-
} catch {
|
|
387
|
-
return []
|
|
398
|
+
if (scripts.dev.includes(firstName)) {
|
|
399
|
+
scripts[`dev:${firstName}`] = scripts.dev
|
|
400
|
+
scripts[`preview:${firstName}`] = scripts.preview
|
|
388
401
|
}
|
|
389
402
|
}
|
|
390
403
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
e.isDirectory() &&
|
|
401
|
-
!e.name.startsWith('.') &&
|
|
402
|
-
e.name !== 'node_modules'
|
|
403
|
-
)
|
|
404
|
-
.filter((e) => existsSync(join(rootDir, e.name, suffix)))
|
|
405
|
-
.map((e) => join(e.name, suffix))
|
|
406
|
-
} catch {
|
|
407
|
-
return []
|
|
408
|
-
}
|
|
404
|
+
// ⭐ The per-site scripts this CLI writes are regenerated from the sites there are
|
|
405
|
+
// now, and one it wrote for a site that is gone — renamed, moved, removed — goes.
|
|
406
|
+
// A script whose command is not one the CLI writes is the user's, and stays.
|
|
407
|
+
// ⛔ Until 2026-10-13 they were only ever added: after `rename site blog journal`,
|
|
408
|
+
// `dev:blog` still ran `uniweb dev blog`, a site that no longer existed.
|
|
409
|
+
for (const [key, value] of Object.entries(scripts)) {
|
|
410
|
+
if (key in newScripts) continue
|
|
411
|
+
const m = /^(dev|preview):(.+)$/.exec(key)
|
|
412
|
+
if (m && cliSiteScripts(m[1], m[2]).includes(value)) delete scripts[key]
|
|
409
413
|
}
|
|
410
414
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
}
|
|
415
|
+
pkg.scripts = { ...scripts, ...newScripts }
|
|
416
|
+
await writeRootPackageJson(rootDir, pkg)
|
|
417
|
+
}
|
|
415
418
|
|
|
416
|
-
|
|
419
|
+
/** Every command this CLI has written for the `dev:<site>` / `preview:<site>` script. */
|
|
420
|
+
function cliSiteScripts(kind, site) {
|
|
421
|
+
return kind === 'dev'
|
|
422
|
+
? [`uniweb dev ${site}`, filterCmd('pnpm', site, 'dev'), filterCmd('npm', site, 'dev')]
|
|
423
|
+
: [filterCmd('pnpm', site, 'preview'), filterCmd('npm', site, 'preview')]
|
|
417
424
|
}
|
|
425
|
+
|
package/src/utils/discover.js
CHANGED
|
@@ -24,7 +24,7 @@ import { existsSync } from 'node:fs'
|
|
|
24
24
|
import { readFile } from 'node:fs/promises'
|
|
25
25
|
import { join } from 'node:path'
|
|
26
26
|
import { classifyPackage } from '@uniweb/build'
|
|
27
|
-
import {
|
|
27
|
+
import { workspacePackages } from './workspace-packages.js'
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Discover foundations in the workspace.
|
|
@@ -45,30 +45,27 @@ export async function discoverSites(rootDir) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
async function discoverByKind(rootDir, kind) {
|
|
48
|
-
const { packages } = await readWorkspaceConfig(rootDir)
|
|
49
48
|
const out = []
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (classifyPackage(fullPath) !== kind) continue
|
|
50
|
+
// The workspace's packages as pnpm reads its patterns — each folder once.
|
|
51
|
+
for (const dir of workspacePackages(rootDir)) {
|
|
52
|
+
const fullPath = join(rootDir, dir)
|
|
53
|
+
if (classifyPackage(fullPath) !== kind) continue
|
|
56
54
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
55
|
+
// Read package.json for the package name. Synthesize one from
|
|
56
|
+
// the directory if it's missing or malformed — we still want
|
|
57
|
+
// the package to surface in pickers.
|
|
58
|
+
const pkgPath = join(fullPath, 'package.json')
|
|
59
|
+
let name = dir.split('/').pop()
|
|
60
|
+
if (existsSync(pkgPath)) {
|
|
61
|
+
try {
|
|
62
|
+
const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
|
|
63
|
+
if (pkg.name) name = pkg.name
|
|
64
|
+
} catch {
|
|
65
|
+
// keep directory-derived name
|
|
69
66
|
}
|
|
70
|
-
out.push({ name, path: dir })
|
|
71
67
|
}
|
|
68
|
+
out.push({ name, path: dir })
|
|
72
69
|
}
|
|
73
70
|
|
|
74
71
|
return out
|
package/src/utils/placement.js
CHANGED
|
@@ -96,6 +96,12 @@ export const EXTENSION_KIND = {
|
|
|
96
96
|
* @returns {{ relativePath: string, packageName: string }}
|
|
97
97
|
*/
|
|
98
98
|
export function resolvePlacement(rootDir, name, opts, kind) {
|
|
99
|
+
// 0. `--project` names the folder itself (rule 4), so a name or a `--path` beside it
|
|
100
|
+
// names a second one: refused, never ignored (`projectConflictLines`).
|
|
101
|
+
if (opts.project && (name || opts.path)) {
|
|
102
|
+
return { projectConflict: true, relativePath: null, packageName: null }
|
|
103
|
+
}
|
|
104
|
+
|
|
99
105
|
// 1. --path is a PARENT directory. The folder is `<path>/<name>` if a
|
|
100
106
|
// name was given, or `<path>` itself if not (the path's last segment
|
|
101
107
|
// is then taken as the package name).
|
|
@@ -149,3 +155,52 @@ export function resolvePlacement(rootDir, name, opts, kind) {
|
|
|
149
155
|
packageName: kind.defaultPkg
|
|
150
156
|
}
|
|
151
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* What to say when `--project` came with a name or a `--path` — the first line an
|
|
161
|
+
* error, the rest the commands that do what was meant.
|
|
162
|
+
*
|
|
163
|
+
* `--project <p>` is the co-located shorthand: it places a project's one foundation
|
|
164
|
+
* at `<p>/src/` and its one site at `<p>/site/`, and names nothing else. A name beside
|
|
165
|
+
* it asks for a different folder, which naming the folder says plainly.
|
|
166
|
+
* ⛔ Until 2026-10-13 the name won without a word: `add site fitness --project
|
|
167
|
+
* memberships` created `fitness/` at the workspace root.
|
|
168
|
+
*
|
|
169
|
+
* @param {'foundation'|'site'|'extension'} kind
|
|
170
|
+
* @param {string|null} name - the name given, if any
|
|
171
|
+
* @param {{ project: string, path?: string }} opts
|
|
172
|
+
* @param {string} command - how to run the verb: `uniweb add site`, `uniweb clone <uuid>`
|
|
173
|
+
* @returns {string[]}
|
|
174
|
+
*/
|
|
175
|
+
export function projectConflictLines(kind, name, opts, command) {
|
|
176
|
+
const p = opts.project
|
|
177
|
+
if (!name) {
|
|
178
|
+
return [
|
|
179
|
+
`--project and --path both name the folder — use one.`,
|
|
180
|
+
` ${command} --project ${p} ${kind === 'site' ? `${p}/site/` : `${p}/src/`}`,
|
|
181
|
+
` ${command} --path ${opts.path}`
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
if (kind === 'extension') {
|
|
185
|
+
return [
|
|
186
|
+
`--project places a project's foundation or site; an extension goes in the folder you name.`,
|
|
187
|
+
` ${command} ${p}/extensions/${name}`
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
if (kind === 'site') {
|
|
191
|
+
return [
|
|
192
|
+
`--project ${p} places the project's one site, at ${p}/site/, and takes no name.`,
|
|
193
|
+
`For a site named "${name}" in ${p}, name its folder — a project with several sites keeps them in ${p}/sites/:`,
|
|
194
|
+
` ${command} ${p}/sites/${name}`,
|
|
195
|
+
`Or the project's one site:`,
|
|
196
|
+
` ${command} --project ${p}`
|
|
197
|
+
]
|
|
198
|
+
}
|
|
199
|
+
return [
|
|
200
|
+
`--project ${p} places the project's foundation, at ${p}/src/, and takes no name.`,
|
|
201
|
+
`For a foundation named "${name}" in ${p}, name its folder:`,
|
|
202
|
+
` ${command} ${p}/${name}`,
|
|
203
|
+
`Or the project's foundation:`,
|
|
204
|
+
` ${command} --project ${p}`
|
|
205
|
+
]
|
|
206
|
+
}
|
package/src/utils/scaffold.js
CHANGED
|
@@ -37,7 +37,7 @@ export async function scaffoldWorkspace(targetDir, context, options = {}) {
|
|
|
37
37
|
registerVersions(getVersionsForTemplates())
|
|
38
38
|
|
|
39
39
|
// Skip pnpm-workspace.yaml for blank workspaces (no packages yet).
|
|
40
|
-
// The `add` command creates it on demand
|
|
40
|
+
// The `add` command creates it on demand (registerWorkspacePackage), from this template.
|
|
41
41
|
// Without this, pnpm fails with ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND
|
|
42
42
|
// because an empty packages: [] makes pnpm search parent directories.
|
|
43
43
|
const skip = context.workspaceGlobs?.length ? [] : ['pnpm-workspace.yaml']
|