diff --git a/packages/app/src/project-config-import/availability.ts b/packages/app/src/project-config-import/availability.ts index 13f59a520..858e460bb 100644 --- a/packages/app/src/project-config-import/availability.ts +++ b/packages/app/src/project-config-import/availability.ts @@ -12,3 +12,11 @@ export function projectConfigImportAvailabilityStatus(input: { } return input.availableCount === 1 ? "one" : "many"; } + +export function projectConfigImportPreviewIsOpenable( + preview: { ok: true; status: string } | { ok: false; error: { code: string } } | null | undefined, +): boolean { + return preview?.ok === true + ? preview.status === "available" + : preview?.error.code === "invalid_source_config"; +} diff --git a/packages/app/src/project-config-import/project-config-import-model.test.ts b/packages/app/src/project-config-import/project-config-import-model.test.ts index df65c91a3..327da1893 100644 --- a/packages/app/src/project-config-import/project-config-import-model.test.ts +++ b/packages/app/src/project-config-import/project-config-import-model.test.ts @@ -18,7 +18,10 @@ import { stripProjectConfigImportSearchParams, } from "./route"; import { projectConfigImportApplyFailureRetryAction } from "./retry"; -import { projectConfigImportAvailabilityStatus } from "./availability"; +import { + projectConfigImportAvailabilityStatus, + projectConfigImportPreviewIsOpenable, +} from "./availability"; import { createProjectConfigImportSourceRegistry, type ProjectConfigImportSourceDescriptor, @@ -301,4 +304,19 @@ describe("project config import availability", () => { "none", ); }); + + it("keeps invalid advertised sources openable", () => { + expect( + projectConfigImportPreviewIsOpenable({ + ok: false, + error: { code: "invalid_source_config" }, + }), + ).toBe(true); + expect( + projectConfigImportPreviewIsOpenable({ + ok: false, + error: { code: "source_config_not_found" }, + }), + ).toBe(false); + }); }); diff --git a/packages/app/src/project-config-import/use-project-config-import-model.ts b/packages/app/src/project-config-import/use-project-config-import-model.ts index 53d718c23..b89a542b4 100644 --- a/packages/app/src/project-config-import/use-project-config-import-model.ts +++ b/packages/app/src/project-config-import/use-project-config-import-model.ts @@ -33,7 +33,10 @@ import { projectConfigImportApplyFailureRetryAction, type ProjectConfigImportRetryAction, } from "./retry"; -import { projectConfigImportAvailabilityStatus } from "./availability"; +import { + projectConfigImportAvailabilityStatus, + projectConfigImportPreviewIsOpenable, +} from "./availability"; const EMPTY_IMPORT_SOURCES: readonly ProjectConfigImportAdvertisedSource[] = []; type ProjectConfigImportPreviewSuccess = Extract; @@ -302,23 +305,22 @@ export function useProjectConfigImportAvailability(input: { sources, enabled: input.enabled, }); - const availableSources = sources.filter((_, index) => { - const data = previews[index]?.data; - return data?.ok === true && data.status === "available"; - }); - const availableKinds = new Set(availableSources.map((source) => source.kind)); + const openableSources = sources.filter((_, index) => + projectConfigImportPreviewIsOpenable(previews[index]?.data), + ); + const availableKinds = new Set(openableSources.map((source) => source.kind)); const availableSourceKeys = new Set( - availableSources.map((source) => stableProjectConfigImportSourceKey(source.source)), + openableSources.map((source) => stableProjectConfigImportSourceKey(source.source)), ); const isLoading = previews.some((preview) => preview.isLoading || preview.isPending); return { status: projectConfigImportAvailabilityStatus({ - availableCount: availableSources.length, + availableCount: openableSources.length, isLoading, }), - source: availableSources.length === 1 ? availableSources[0] : null, - sources: availableSources, + source: openableSources.length === 1 ? openableSources[0] : null, + sources: openableSources, availableKinds, availableSourceKeys, }; diff --git a/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts b/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts index dee519394..cad949294 100644 --- a/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts +++ b/packages/server/src/server/session/project-config/import/sources/conductor/importer.test.ts @@ -248,6 +248,31 @@ cwd = "apps/local-web" }); }); + test("merges root conductor.json before scoped JSON until TOML migration", () => { + const repo = makeRepo(); + writeFileSync( + join(repo, "conductor.json"), + JSON.stringify({ scripts: { setup: "legacy setup" }, runScriptMode: "nonconcurrent" }), + ); + writeSharedJson(repo, { scripts: { run: { dev: { command: "npm run dev" } } } }); + + const preview = inspect(repo); + + expect(preview.inputs).toEqual([ + { role: "legacy", relativePath: "conductor.json" }, + { role: "shared", relativePath: ".conductor/settings.json" }, + ]); + expect(preview.preview).toMatchObject({ + worktree: { setup: "legacy setup" }, + scripts: { dev: { command: "npm run dev" } }, + }); + expect(preview.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "runScriptMode", outcome: "unsupported" }), + ]), + ); + }); + test("reports missing and empty repository-local Conductor configs", () => { const missingRepo = makeRepo(); const emptyRepo = makeRepo(); @@ -420,6 +445,47 @@ command = "npm run dev -- --port \${CONDUCTOR_PORT:-3000}" }); }); + test("rewrites bare Conductor ports inside shell arithmetic", () => { + const repo = makeRepo(); + writeSharedToml( + repo, + ` +[scripts.run.dev] +command = "npm run dev -- --hmr-port $((CONDUCTOR_PORT + 1))" +`, + ); + + expect(inspect(repo).preview).toMatchObject({ + scripts: { + dev: { + type: "service", + command: "npm run dev -- --hmr-port $((PASEO_PORT + 1))", + }, + }, + }); + }); + + test("preserves shell arithmetic expansion in script arguments", () => { + const repo = makeRepo(); + writeSharedToml( + repo, + ` +[scripts.run.dev] +command = "npm run dev" +args = ["--hmr-port=$((CONDUCTOR_PORT + 1))"] +`, + ); + + expect(inspect(repo).preview).toMatchObject({ + scripts: { + dev: { + type: "service", + command: `npm run dev '--hmr-port='"$((PASEO_PORT + 1))"`, + }, + }, + }); + }); + test("does not import scripts available only in Conductor cloud", () => { const repo = makeRepo(); writeSharedToml( diff --git a/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts b/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts index 635dee100..dbb7193e5 100644 --- a/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts +++ b/packages/server/src/server/session/project-config/import/sources/conductor/importer.ts @@ -113,15 +113,15 @@ function discoverConductorSources( const worktreeIncludePath = join(repoRoot, ".worktreeinclude"); const files: SourceFile[] = []; + if (!existsSync(sharedTomlPath) && existsSync(rootLegacyPath)) { + files.push(readSourceFile(repoRoot, rootLegacyPath, "legacy", true, source)); + } if (existsSync(sharedJsonPath)) { files.push(readSourceFile(repoRoot, sharedJsonPath, "shared", true, source)); } if (existsSync(sharedTomlPath)) { files.push(readSourceFile(repoRoot, sharedTomlPath, "shared", true, source)); } - if (!existsSync(sharedJsonPath) && !existsSync(sharedTomlPath) && existsSync(rootLegacyPath)) { - files.push(readSourceFile(repoRoot, rootLegacyPath, "legacy", true, source)); - } if (existsSync(localJsonPath)) { files.push(readSourceFile(repoRoot, localJsonPath, "local", true, source)); } @@ -516,12 +516,34 @@ function rewriteVariables( function replaceShellVariable(command: string, from: string, to: string): string { const pattern = new RegExp(`\\$\\{${from}(?=[}:#%+\\-=?])|\\$${from}(?![A-Za-z0-9_])`, "g"); - return command.replace(pattern, (match) => (match.startsWith("${") ? `\${${to}` : `$${to}`)); + return replaceArithmeticVariable( + command.replace(pattern, (match) => (match.startsWith("${") ? `\${${to}` : `$${to}`)), + from, + to, + ); } function containsShellVariable(command: string, name: string): boolean { const pattern = new RegExp(`\\$\\{${name}(?=[}:#%+\\-=?])|\\$${name}(?![A-Za-z0-9_])`); - return pattern.test(command); + return pattern.test(command) || containsArithmeticVariable(command, name); +} + +function replaceArithmeticVariable(command: string, from: string, to: string): string { + return command.replace(/\$\(\(([\s\S]*?)\)\)/g, (expression, body: string) => { + const identifier = new RegExp(`(^|[^A-Za-z0-9_])${from}(?![A-Za-z0-9_])`, "g"); + const rewrittenBody = body.replace(identifier, (_match, prefix: string) => `${prefix}${to}`); + return rewrittenBody === body ? expression : `$((` + rewrittenBody + `))`; + }); +} + +function containsArithmeticVariable(command: string, name: string): boolean { + const identifier = new RegExp(`(^|[^A-Za-z0-9_])${name}(?![A-Za-z0-9_])`); + for (const match of command.matchAll(/\$\(\(([\s\S]*?)\)\)/g)) { + if (identifier.test(match[1])) { + return true; + } + } + return false; } function appendArgs(command: string, args: string[]): string { @@ -565,7 +587,7 @@ function normalizeAvailableIn(value: unknown): string | string[] | undefined { function shellQuoteArgument(value: string): string { const variablePattern = - /\$(?:\{[A-Za-z_][A-Za-z0-9_]*(?:(?:[^{}])|\{[^{}]*\})*\}|[A-Za-z_][A-Za-z0-9_]*)/g; + /\$\(\([\s\S]*?\)\)|\$(?:\{[A-Za-z_][A-Za-z0-9_]*(?:(?:[^{}])|\{[^{}]*\})*\}|[A-Za-z_][A-Za-z0-9_]*)/g; const parts: string[] = []; let offset = 0; for (const match of value.matchAll(variablePattern)) {