fix(settings): expose remaining import errors

This commit is contained in:
Mohamed Boudra
2026-07-17 17:47:16 +02:00
parent 35d4ae201a
commit 5e75e463ec
5 changed files with 133 additions and 17 deletions

View File

@@ -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";
}

View File

@@ -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);
});
});

View File

@@ -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<ProjectConfigImportPreviewResult, { ok: true }>;
@@ -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,
};

View File

@@ -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(

View File

@@ -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)) {