diff --git a/packages/app/src/app/settings/projects/[projectKey].tsx b/packages/app/src/app/settings/projects/[projectKey].tsx index 9e5fee4716..d4fb095eed 100644 --- a/packages/app/src/app/settings/projects/[projectKey].tsx +++ b/packages/app/src/app/settings/projects/[projectKey].tsx @@ -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( () => ({ 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 70a86b7d4d..2f0b6c7183 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 @@ -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", () => { diff --git a/packages/app/src/project-config-import/retry.ts b/packages/app/src/project-config-import/retry.ts new file mode 100644 index 0000000000..d2736ce3fb --- /dev/null +++ b/packages/app/src/project-config-import/retry.ts @@ -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"; +} diff --git a/packages/app/src/project-config-import/route.ts b/packages/app/src/project-config-import/route.ts index 8f391f924a..f81c7e46bf 100644 --- a/packages/app/src/project-config-import/route.ts +++ b/packages/app/src/project-config-import/route.ts @@ -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 { 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 3191e89140..cb8688c6d6 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 @@ -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; @@ -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 { diff --git a/packages/app/src/utils/project-config-form.test.ts b/packages/app/src/utils/project-config-form.test.ts index 389705e335..3c1dce330e 100644 --- a/packages/app/src/utils/project-config-form.test.ts +++ b/packages/app/src/utils/project-config-form.test.ts @@ -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); + }); }); diff --git a/packages/app/src/utils/project-config-form.ts b/packages/app/src/utils/project-config-form.ts index 74201bfb2b..68f9791058 100644 --- a/packages/app/src/utils/project-config-form.ts +++ b/packages/app/src/utils/project-config-form.ts @@ -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); } 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 9a55eaf145..219b494045 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 @@ -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"); 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 72e75ad996..37c8f5ed06 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 @@ -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, 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)) {