fix(settings): handle remaining import edge cases

This commit is contained in:
Mohamed Boudra
2026-07-17 15:32:27 +02:00
parent 60462337b6
commit 59d9a25a0d
9 changed files with 159 additions and 17 deletions
@@ -1,8 +1,12 @@
import { useLocalSearchParams, useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
import { parseProjectConfigImportIntent } from "@/project-config-import/route";
import {
parseProjectConfigImportIntent,
stripProjectConfigImportSearchParams,
} from "@/project-config-import/route";
import { projectConfigImportSourceRegistry } from "@/project-config-import/sources";
import { isWeb } from "@/constants/platform";
export default function SettingsProjectDetailRoute() {
const router = useRouter();
@@ -24,6 +28,10 @@ export default function SettingsProjectDetailRoute() {
importServerId: undefined,
importIntentId: undefined,
});
if (isWeb && typeof window !== "undefined") {
const route = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.history.replaceState(null, "", stripProjectConfigImportSearchParams(route));
}
}, [router]);
const view = useMemo(
() => ({
@@ -14,7 +14,9 @@ import {
import {
createProjectConfigImportIntentFromRegistration,
parseProjectConfigImportIntent,
stripProjectConfigImportSearchParams,
} from "./route";
import { projectConfigImportApplyFailureRetryAction } from "./retry";
import {
createProjectConfigImportSourceRegistry,
type ProjectConfigImportSourceDescriptor,
@@ -106,6 +108,25 @@ describe("project config import intent", () => {
intentId: "intent-1",
});
});
it("strips consumed import params from browser routes", () => {
expect(
stripProjectConfigImportSearchParams(
"/settings/projects/repo?keep=yes&importSource=conductor&importServerId=host&importIntentId=1#section",
),
).toBe("/settings/projects/repo?keep=yes#section");
});
});
describe("project config import retries", () => {
it("refreshes the preview when the source disappears during apply", () => {
expect(
projectConfigImportApplyFailureRetryAction({
code: "source_config_not_found",
source: protocolSource,
}),
).toBe("refresh");
});
});
describe("project config import preview cache keys", () => {
@@ -0,0 +1,14 @@
import type { ProjectConfigImportVisibleError } from "./project-config-import-sheet";
export type ProjectConfigImportRetryAction = "refresh" | "apply";
export function projectConfigImportApplyFailureRetryAction(
error: ProjectConfigImportVisibleError,
): ProjectConfigImportRetryAction {
return error.code === "stale_source_config" ||
error.code === "stale_project_config" ||
error.code === "nothing_to_import" ||
error.code === "source_config_not_found"
? "refresh"
: "apply";
}
@@ -76,6 +76,23 @@ export function createProjectConfigImportIntentFromRegistration(input: {
: null;
}
export function stripProjectConfigImportSearchParams(route: string): string {
const hashIndex = route.indexOf("#");
const hash = hashIndex >= 0 ? route.slice(hashIndex) : "";
const routeWithoutHash = hashIndex >= 0 ? route.slice(0, hashIndex) : route;
const searchIndex = routeWithoutHash.indexOf("?");
if (searchIndex < 0) {
return route;
}
const pathname = routeWithoutHash.slice(0, searchIndex);
const params = new URLSearchParams(routeWithoutHash.slice(searchIndex + 1));
params.delete("importSource");
params.delete("importServerId");
params.delete("importIntentId");
const search = params.toString();
return `${pathname}${search ? `?${search}` : ""}${hash}`;
}
function parseProtocolSource(
source: ProjectConfigImportSourceDescriptor,
): ProjectConfigImportSource | null {
@@ -29,6 +29,10 @@ import {
projectConfigImportSourceRegistry,
type ProjectConfigImportSourceRegistry,
} from "./sources";
import {
projectConfigImportApplyFailureRetryAction,
type ProjectConfigImportRetryAction,
} from "./retry";
const EMPTY_IMPORT_SOURCES: readonly ProjectConfigImportAdvertisedSource[] = [];
type ProjectConfigImportPreviewSuccess = Extract<ProjectConfigImportPreviewResult, { ok: true }>;
@@ -387,18 +391,6 @@ function useProjectConfigImportPreviewQuery(input: {
return useFetchQuery(projectConfigImportPreviewQueryInput(input));
}
type ProjectConfigImportRetryAction = "refresh" | "apply";
function projectConfigImportApplyFailureRetryAction(
error: ProjectConfigImportVisibleError,
): ProjectConfigImportRetryAction {
return error.code === "stale_source_config" ||
error.code === "stale_project_config" ||
error.code === "nothing_to_import"
? "refresh"
: "apply";
}
function normalizeProjectConfigImportError(
error: ProjectConfigRpcError | Error | null,
): ProjectConfigImportVisibleError | null {
@@ -365,4 +365,14 @@ describe("hasProjectConfigDraftChanges", () => {
expect(hasProjectConfigDraftChanges({ draft: untouched, base })).toBe(false);
expect(hasProjectConfigDraftChanges({ draft: edited, base })).toBe(true);
});
it("treats empty config containers as an untouched canonical form", () => {
const base: PaseoConfigRaw = {
worktree: {},
scripts: {},
metadataGeneration: {},
};
expect(hasProjectConfigDraftChanges({ draft: configToDraft(base), base })).toBe(false);
});
});
@@ -247,5 +247,9 @@ export function applyDraftToConfig(input: ApplyDraftInput): PaseoConfigRaw {
}
export function hasProjectConfigDraftChanges(input: ApplyDraftInput): boolean {
return !equal(applyDraftToConfig(input), input.base ?? {});
const canonicalBase = applyDraftToConfig({
draft: configToDraft(input.base),
base: input.base,
});
return !equal(applyDraftToConfig(input), canonicalBase);
}
@@ -80,7 +80,6 @@ auto_run_after_setup = true
[scripts.run.dev]
command = "npm run dev -- --port $CONDUCTOR_PORT"
args = ["--host", "0.0.0.0"]
hide = true
[scripts.run.dev.options]
cwd = "apps/web"
@@ -260,6 +259,28 @@ args = ["--port", "$CONDUCTOR_PORT", "--label=$WORKSPACE_NAME"]
});
});
test("preserves shell parameter expansion in script arguments", () => {
const repo = makeRepo();
writeSharedToml(
repo,
`
[scripts.run.dev]
command = "npm run dev"
args = ["--port=\${CONDUCTOR_PORT:-3000}"]
`,
);
expect(inspect(repo).preview).toMatchObject({
scripts: {
dev: {
type: "service",
port: "$PASEO_PORT",
command: `npm run dev '--port='"\${PASEO_PORT:-3000}"`,
},
},
});
});
test("rejects normalized working directories that escape the project root", () => {
const repo = makeRepo();
writeSharedToml(
@@ -300,6 +321,23 @@ cwd = '\\\\server\\share'
);
});
test("emits normalized relative working directories", () => {
const repo = makeRepo();
writeSharedToml(
repo,
`
[scripts.run.dev]
command = "npm test"
[scripts.run.dev.options]
cwd = 'apps\\web'
`,
);
expect(inspect(repo).preview).toMatchObject({
scripts: { dev: { command: "cd -- 'apps/web' && npm test" } },
});
});
test("rewrites Conductor ports inside shell parameter expansions", () => {
const repo = makeRepo();
writeSharedToml(
@@ -353,6 +391,31 @@ available_in = ["local", "cloud"]
);
});
test("does not import hidden Conductor run scripts", () => {
const repo = makeRepo();
writeSharedToml(
repo,
`
[scripts.run.helper]
command = "npm run helper"
hide = true
`,
);
const preview = inspect(repo);
expect(preview.preview?.scripts ?? {}).not.toHaveProperty("helper");
expect(preview.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "scripts.helper",
outcome: "unsupported",
detail: "Hidden scripts are not imported.",
}),
]),
);
});
test("malformed TOML identifies the safe relative source path", () => {
const repo = makeRepo();
writeSharedToml(repo, "[scripts\nsetup = nope");
@@ -37,6 +37,7 @@ interface ConductorSettings {
interface ConductorRunScript {
command: string;
args?: string[];
hide?: boolean;
options?: {
cwd?: string;
};
@@ -218,6 +219,7 @@ function normalizeRunScript(entry: Record<string, unknown>, command: string): Co
return {
command,
...(args ? { args } : {}),
...(typeof entry.hide === "boolean" ? { hide: entry.hide } : {}),
...(options && typeof options.cwd === "string" ? { options: { cwd: options.cwd } } : {}),
...(availableIn ? { available_in: availableIn } : {}),
};
@@ -229,6 +231,16 @@ function mapRunScript(
patch: PaseoConfigRaw,
items: ProjectConfigImportItem[],
): void {
if (script.hide) {
items.push({
key: `scripts.${scriptId}`,
label: `Script ${scriptId}`,
outcome: "unsupported",
detail: "Hidden scripts are not imported.",
});
return;
}
if (isCloudOnly(script.available_in)) {
items.push({
key: `scripts.${scriptId}`,
@@ -409,7 +421,7 @@ function safeCwdPrefix(cwd: string): string | null {
) {
return null;
}
return `cd -- ${shellQuote(cwd)} && `;
return `cd -- ${shellQuote(normalized)} && `;
}
function isCloudOnly(availableIn: string | string[] | undefined): boolean {
@@ -432,7 +444,8 @@ 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;
const variablePattern =
/\$(?:\{[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)) {