Skip to content

Commit 165ad55

Browse files
committed
refactor: implement recursive workspace dependency resolution and automated cloning for local, app, and theme packages
1 parent 5a79762 commit 165ad55

8 files changed

Lines changed: 390 additions & 90 deletions

File tree

bin/lib/installWizardFlow.js

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import {
44
isInstallableDesktopModule,
55
isDesktopKitPackage,
66
shortName,
7-
getPackagesRequiredByTheme
7+
getPackagesRequiredByTheme,
8+
findMissingWorkspaceDependencies,
89
} from './workspace.js'
910
import {
1011
readDesktopConfig,
@@ -275,6 +276,42 @@ export async function executeInstallPlan(ctx) {
275276
}
276277
}
277278

279+
// Detect and clone any missing workspace:* dependencies recursively
280+
let missingWorkspaceDeps = findMissingWorkspaceDependencies(workspaceRoot)
281+
const autoClonedDeps = new Set()
282+
283+
while (missingWorkspaceDeps.length > 0) {
284+
const dep = missingWorkspaceDeps.shift()
285+
if (autoClonedDeps.has(dep)) continue
286+
autoClonedDeps.add(dep)
287+
288+
bump(`Cloning dependency ${shortName(dep)}…`)
289+
ctx.clearLogs()
290+
291+
try {
292+
const plan = await resolveInstallPlan(dep, settings, workspaceRoot)
293+
if (plan.error) {
294+
throw new Error(plan.error)
295+
}
296+
if (plan.targetDir && plan.source?.gitUrl) {
297+
await cloneRepo(plan.targetDir, plan.source.gitUrl, undefined, workspaceRoot, { quiet: true })
298+
didClone = true
299+
clonedPackages.push(dep)
300+
const newMissing = findMissingWorkspaceDependencies(workspaceRoot)
301+
for (const m of newMissing) {
302+
if (!autoClonedDeps.has(m) && !missingWorkspaceDeps.includes(m)) {
303+
missingWorkspaceDeps.push(m)
304+
}
305+
}
306+
} else {
307+
throw new Error('Unknown git clone URL')
308+
}
309+
} catch (err) {
310+
const msg = err.message?.split('\n').find(l => l.trim()) ?? String(err)
311+
failures.push({ pkg: dep, error: `Failed to resolve workspace dependency ${shortName(dep)}: ${msg}` })
312+
}
313+
}
314+
278315
if (didClone) {
279316
bump('Installing dependencies…')
280317
ctx.clearLogs()
@@ -597,6 +634,43 @@ export async function runStartupInstallFlow(ctx, { isStartup = false } = {}) {
597634
}
598635
}
599636

637+
// Detect and clone any missing workspace:* dependencies recursively
638+
let missingWorkspaceDeps = findMissingWorkspaceDependencies(workspaceRoot)
639+
const autoClonedDeps = new Set()
640+
641+
while (missingWorkspaceDeps.length > 0) {
642+
const dep = missingWorkspaceDeps.shift()
643+
if (autoClonedDeps.has(dep)) continue
644+
autoClonedDeps.add(dep)
645+
646+
bump(`Cloning dependency ${shortName(dep)}…`)
647+
ctx.clearLogs()
648+
649+
try {
650+
const plan = await resolveInstallPlan(dep, settings, workspaceRoot)
651+
if (plan.error) {
652+
throw new Error(plan.error)
653+
}
654+
if (plan.targetDir && plan.source?.gitUrl) {
655+
await cloneRepo(plan.targetDir, plan.source.gitUrl, undefined, workspaceRoot, { quiet: true })
656+
didClone = true
657+
allCloned.push(dep)
658+
const newMissing = findMissingWorkspaceDependencies(workspaceRoot)
659+
for (const m of newMissing) {
660+
if (!autoClonedDeps.has(m) && !missingWorkspaceDeps.includes(m)) {
661+
missingWorkspaceDeps.push(m)
662+
}
663+
}
664+
} else {
665+
throw new Error('Unknown git clone URL')
666+
}
667+
} catch (err) {
668+
const msg = err.message?.split('\n').find(l => l.trim()) ?? String(err)
669+
ctx.setStatus(`Failed to resolve workspace dependency ${shortName(dep)}: ${msg}`, 'error')
670+
ctx.screen.render()
671+
}
672+
}
673+
600674
if (didClone) {
601675
if (!ctx.isInstalling()) {
602676
dynTotal += 1 + allCloned.length + 1

bin/lib/workspace.js

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
1+
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs'
22
import { dirname, join } from 'node:path'
33
import { execSync } from 'node:child_process'
44
import {
@@ -220,3 +220,70 @@ export function getPackagesRequiredByTheme(workspaceRoot, themeName) {
220220
}
221221
}
222222

223+
/**
224+
* Scans all package.json files in the workspace (including desktop/)
225+
* and returns any @owdproject/ dependencies that use the 'workspace:' protocol
226+
* but are not present locally on disk.
227+
* @param {string} workspaceRoot
228+
* @returns {string[]}
229+
*/
230+
export function findMissingWorkspaceDependencies(workspaceRoot) {
231+
const missing = new Set()
232+
const localPackages = new Set()
233+
const pkgPaths = []
234+
235+
// 1. Scan KINDS subdirectories for local packages
236+
for (const kind of Object.values(KINDS)) {
237+
const dir = join(workspaceRoot, kind.workspaceDir)
238+
if (!existsSync(dir)) continue
239+
try {
240+
const entries = readdirSync(dir, { withFileTypes: true })
241+
for (const entry of entries) {
242+
if (entry.isDirectory()) {
243+
const pkgJsonPath = join(dir, entry.name, 'package.json')
244+
if (existsSync(pkgJsonPath)) {
245+
const pkgPath = join(dir, entry.name)
246+
pkgPaths.push(pkgPath)
247+
try {
248+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8'))
249+
if (pkgJson.name) {
250+
localPackages.add(pkgJson.name)
251+
}
252+
} catch {}
253+
}
254+
}
255+
}
256+
} catch {}
257+
}
258+
259+
// 2. Include the desktop app itself
260+
const desktopDir = join(workspaceRoot, 'desktop')
261+
if (existsSync(join(desktopDir, 'package.json'))) {
262+
pkgPaths.push(desktopDir)
263+
try {
264+
const pkgJson = JSON.parse(readFileSync(join(desktopDir, 'package.json'), 'utf8'))
265+
if (pkgJson.name) {
266+
localPackages.add(pkgJson.name)
267+
}
268+
} catch {}
269+
}
270+
271+
// 3. Find missing workspace:* dependencies
272+
for (const pkgPath of pkgPaths) {
273+
try {
274+
const pkgJson = JSON.parse(readFileSync(join(pkgPath, 'package.json'), 'utf8'))
275+
const deps = { ...pkgJson.dependencies, ...pkgJson.devDependencies }
276+
for (const [depName, version] of Object.entries(deps)) {
277+
if (depName.startsWith(SCOPE) && typeof version === 'string' && version.startsWith('workspace:')) {
278+
if (!localPackages.has(depName)) {
279+
missing.add(depName)
280+
}
281+
}
282+
}
283+
} catch {}
284+
}
285+
286+
return Array.from(missing)
287+
}
288+
289+

go/tui/engine.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,15 @@ func (m *TuiModel) RunWizardEngine(
271271
emitQueue(q)
272272
}
273273
}
274+
var catalogEntries []bridge.CatalogEntry
275+
if catalog != nil {
276+
catalogEntries = catalog.Entries
277+
}
278+
if err := ResolveAndCloneMissingDependencies(workspaceRoot, catalogEntries, runtime, log, nil, nil, nil); err != nil {
279+
emitPhase(PhaseFailed)
280+
msgChan <- taskFinishedMsg{Success: false, Err: err}
281+
return
282+
}
274283

275284
emitPhase(PhaseInstall)
276285
log("ℹ Running pnpm install")
@@ -330,6 +339,16 @@ func applyResolution(
330339
short := item.ShortName
331340

332341
switch method {
342+
case "local":
343+
if err := bridge.WritePackageJsonDeps(workspaceRoot, map[string]string{name: "workspace:*"}, nil); err != nil {
344+
log("✖ Failed to link local package " + short)
345+
return err
346+
}
347+
q.SetSource(name, "local", KindLocal)
348+
q.MarkSkipped(name)
349+
log("✔ " + short + " linked as local package")
350+
return nil
351+
333352
case "git-ssh", "git-https":
334353
owner := resolveOwner(name, catalogEntriesSlice(catalog))
335354
var gitURL string

go/tui/handlers.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -258,14 +258,26 @@ func (m *TuiModel) handleUninstallConfirmKeys(msg tea.KeyMsg) (tea.Model, tea.Cm
258258
}
259259

260260
func (m *TuiModel) handleResolveDependencyKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
261-
methods := []struct {
261+
shortName := "package"
262+
if m.promptItem != nil {
263+
shortName = m.promptItem.ShortName
264+
}
265+
if shortName == "" && m.promptPkg != nil {
266+
shortName = m.promptPkg.ShortName
267+
}
268+
269+
var methods []struct {
262270
Name string
263271
Label string
264-
}{
265-
{"git-ssh", "Git SSH"},
266-
{"git-https", "Git HTTPS"},
267-
{"npm", "NPM Package"},
268272
}
273+
if isLocallyAvailable(m.workspaceRoot, shortName) {
274+
methods = append(methods, struct{ Name, Label string }{"local", "Use Existing Local Folder"})
275+
}
276+
methods = append(methods,
277+
struct{ Name, Label string }{"git-ssh", "Git SSH"},
278+
struct{ Name, Label string }{"git-https", "Git HTTPS"},
279+
struct{ Name, Label string }{"npm", "NPM Package"},
280+
)
269281

270282
switch msg.String() {
271283
case "esc", "q":

go/tui/helpers.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ func getOwdDepsLocal(workspaceRoot, shortName, kind string) ([]string, bool) {
177177
continue
178178
}
179179
short := name[strings.LastIndex(name, "/")+1:]
180-
if strings.HasPrefix(short, "module-") || strings.HasPrefix(short, "kit-") {
180+
if strings.HasPrefix(short, "module-") || strings.HasPrefix(short, "kit-") || strings.HasPrefix(short, "app-") || strings.HasPrefix(short, "theme-") {
181181
deps = append(deps, name)
182182
seen[name] = true
183183
}
@@ -220,7 +220,7 @@ func getOwdDepsFromNpm(pkgName string) ([]string, error) {
220220
continue
221221
}
222222
short := name[strings.LastIndex(name, "/")+1:]
223-
if strings.HasPrefix(short, "module-") || strings.HasPrefix(short, "kit-") {
223+
if strings.HasPrefix(short, "module-") || strings.HasPrefix(short, "kit-") || strings.HasPrefix(short, "app-") || strings.HasPrefix(short, "theme-") {
224224
deps = append(deps, name)
225225
seen[name] = true
226226
}
@@ -823,8 +823,12 @@ func activeThemeName(ctx *bridge.WorkspaceContext, pendingTheme *string) string
823823
func (m *TuiModel) getInstallMethods(pkg *bridge.CatalogEntry) []InstallMethod {
824824
var methods []InstallMethod
825825

826-
// 0. Local folder option if already present in workspace (pkg.LocalSource is true)
827-
if pkg.LocalSource {
826+
// 0. Local folder option if already present in workspace
827+
short := pkg.ShortName
828+
if short == "" {
829+
short = shortNameFromPkg(pkg.Name)
830+
}
831+
if pkg.LocalSource || isLocallyAvailable(m.workspaceRoot, short) {
828832
methods = append(methods, InstallMethod{
829833
Name: "local",
830834
Label: "Use Existing Local Folder",

go/tui/modals.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,18 @@ func (m *TuiModel) renderResolveDependencyModal() string {
136136
}
137137
content.WriteString("How do you want to resolve it?\n\n")
138138

139-
options := []struct {
139+
var options []struct {
140140
Name string
141141
Label string
142-
}{
143-
{"git-ssh", "Git SSH"},
144-
{"git-https", "Git HTTPS"},
145-
{"npm", "NPM Package"},
146142
}
143+
if isLocallyAvailable(m.workspaceRoot, shortName) {
144+
options = append(options, struct{ Name, Label string }{"local", "Use Existing Local Folder"})
145+
}
146+
options = append(options,
147+
struct{ Name, Label string }{"git-ssh", "Git SSH"},
148+
struct{ Name, Label string }{"git-https", "Git HTTPS"},
149+
struct{ Name, Label string }{"npm", "NPM Package"},
150+
)
147151
for i, opt := range options {
148152
if i == m.promptSel {
149153
content.WriteString(" " + modalOptionActive.Render(opt.Label) + "\n")

0 commit comments

Comments
 (0)