fix(settings): close project import edge cases

This commit is contained in:
Mohamed Boudra
2026-07-17 15:11:04 +02:00
parent f44ef807ef
commit 60462337b6
16 changed files with 139 additions and 16 deletions
+1
View File
@@ -2085,6 +2085,7 @@ export const ar: TranslationResources = {
errorTitle: "Couldn't import settings",
errors: {
capabilityMissing: "Update the host to use this.",
unsavedChanges: "Save or discard your project changes before importing.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
+1
View File
@@ -2099,6 +2099,7 @@ export const en = {
errorTitle: "Couldn't import settings",
errors: {
capabilityMissing: "Update the host to use this.",
unsavedChanges: "Save or discard your project changes before importing.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
+1
View File
@@ -2137,6 +2137,7 @@ export const es: TranslationResources = {
errorTitle: "Couldn't import settings",
errors: {
capabilityMissing: "Update the host to use this.",
unsavedChanges: "Save or discard your project changes before importing.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
+1
View File
@@ -2140,6 +2140,7 @@ export const fr: TranslationResources = {
errorTitle: "Couldn't import settings",
errors: {
capabilityMissing: "Update the host to use this.",
unsavedChanges: "Save or discard your project changes before importing.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
+1
View File
@@ -2110,6 +2110,7 @@ export const ja: TranslationResources = {
errorTitle: "Couldn't import settings",
errors: {
capabilityMissing: "Update the host to use this.",
unsavedChanges: "Save or discard your project changes before importing.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
+1
View File
@@ -2123,6 +2123,7 @@ export const ptBR: TranslationResources = {
errorTitle: "Couldn't import settings",
errors: {
capabilityMissing: "Update the host to use this.",
unsavedChanges: "Save or discard your project changes before importing.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
+1
View File
@@ -2128,6 +2128,7 @@ export const ru: TranslationResources = {
errorTitle: "Couldn't import settings",
errors: {
capabilityMissing: "Update the host to use this.",
unsavedChanges: "Save or discard your project changes before importing.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
+1
View File
@@ -2061,6 +2061,7 @@ export const zhCN: TranslationResources = {
errorTitle: "Couldn't import settings",
errors: {
capabilityMissing: "Update the host to use this.",
unsavedChanges: "Save or discard your project changes before importing.",
notFound: "No {{source}} project config was found.",
invalid: "{{path}} couldn't be parsed.",
staleSource: "The {{source}} config changed. Refresh the preview before importing.",
@@ -20,6 +20,7 @@ export function ProjectConfigImportSection(input: {
onRouteIntentConsumed?: () => void;
projectConfigLoaded: boolean;
projectConfigQueryKey: readonly [string, string, string];
hasUnsavedChanges: boolean;
}) {
const model = useProjectConfigImportModel(input);
@@ -17,6 +17,7 @@ import type { ProjectConfigImportIntent } from "./route";
export type ProjectConfigImportVisibleError =
| ProjectConfigRpcError
| { code: "capability_missing" }
| { code: "unsaved_changes" }
| { code: "transport"; message: string };
export type ProjectConfigImportState =
@@ -137,7 +138,7 @@ function ImportErrorRetryButton(input: {
onApply: () => void;
}) {
const { t } = useTranslation();
if (input.error.code === "capability_missing") {
if (input.error.code === "capability_missing" || input.error.code === "unsaved_changes") {
return null;
}
const needsRefresh =
@@ -237,6 +238,8 @@ function projectConfigImportErrorText(
return error.message;
case "capability_missing":
return t("settings.project.import.errors.capabilityMissing");
case "unsaved_changes":
return t("settings.project.import.errors.unsavedChanges");
case "source_config_not_found":
return t("settings.project.import.errors.notFound", { source: sourceName });
case "invalid_source_config":
@@ -11,6 +11,7 @@ import { useToast } from "@/contexts/toast-context";
import { useFetchQueries, useFetchQuery } from "@/data/query";
import { useSessionStore } from "@/stores/session-store";
import {
projectConfigImportPreviewQueryKey,
projectConfigImportPreviewQueryInput,
stableProjectConfigImportSourceKey,
type ProjectConfigImportPreviewResult,
@@ -30,6 +31,7 @@ import {
} from "./sources";
const EMPTY_IMPORT_SOURCES: readonly ProjectConfigImportAdvertisedSource[] = [];
type ProjectConfigImportPreviewSuccess = Extract<ProjectConfigImportPreviewResult, { ok: true }>;
export function useProjectConfigImportModel(input: {
routeIntent: ProjectConfigImportIntent | null;
@@ -38,6 +40,7 @@ export function useProjectConfigImportModel(input: {
client: DaemonClient | null;
projectConfigLoaded: boolean;
projectConfigQueryKey: readonly [string, string, string];
hasUnsavedChanges: boolean;
registry?: ProjectConfigImportSourceRegistry;
onRouteIntentConsumed?: () => void;
}) {
@@ -61,7 +64,12 @@ export function useProjectConfigImportModel(input: {
repoRoot: input.repoRoot,
source: intent?.source ?? null,
protocolSource: intent?.protocolSource ?? null,
enabled: Boolean(intent && input.projectConfigLoaded && !routeIntentCapabilityMissing),
enabled: Boolean(
intent &&
input.projectConfigLoaded &&
!routeIntentCapabilityMissing &&
!input.hasUnsavedChanges,
),
});
const preview = activePreview.data?.ok ? activePreview.data : null;
const queryClient = useQueryClient();
@@ -134,6 +142,14 @@ export function useProjectConfigImportModel(input: {
repoRoot: input.repoRoot,
});
queryClient.invalidateQueries({ queryKey: ["projects"] });
void queryClient.invalidateQueries({
queryKey: projectConfigImportPreviewQueryKey(
input.serverId,
input.repoRoot,
intent?.source ?? result.source,
),
exact: true,
});
const appliedSource = registry.get(result.source);
toast.show(
t("settings.project.import.success", {
@@ -164,14 +180,16 @@ export function useProjectConfigImportModel(input: {
if (!intent) {
return null;
}
const error = routeIntentCapabilityMissing
? ({ code: "capability_missing" } as const)
: (applyError ??
normalizeProjectConfigImportError(
activePreview.data && !activePreview.data.ok
? activePreview.data.error
: activePreview.error,
));
const error = projectConfigImportVisibleError({
routeIntentCapabilityMissing,
hasUnsavedChanges: input.hasUnsavedChanges,
applyError,
preview,
requestError:
activePreview.data && !activePreview.data.ok
? activePreview.data.error
: activePreview.error,
});
if (error) {
return {
status: "error",
@@ -194,6 +212,7 @@ export function useProjectConfigImportModel(input: {
applyError,
applyMutation.isPending,
intent,
input.hasUnsavedChanges,
preview,
retryAction,
routeIntentCapabilityMissing,
@@ -220,7 +239,7 @@ export function useProjectConfigImportModel(input: {
void activePreview.refetch();
},
apply: () => {
if (!applyMutation.isPending) {
if (!applyMutation.isPending && !input.hasUnsavedChanges) {
setApplyError(null);
applyMutation.mutate();
}
@@ -228,6 +247,38 @@ export function useProjectConfigImportModel(input: {
};
}
function projectConfigImportPreviewError(
preview: ProjectConfigImportPreviewSuccess | null,
): ProjectConfigRpcError | null {
if (preview?.status === "not_found") {
return { code: "source_config_not_found", source: preview.source };
}
if (preview?.status === "nothing_to_import") {
return { code: "nothing_to_import" };
}
return null;
}
function projectConfigImportVisibleError(input: {
routeIntentCapabilityMissing: boolean;
hasUnsavedChanges: boolean;
applyError: ProjectConfigImportVisibleError | null;
preview: ProjectConfigImportPreviewSuccess | null;
requestError: ProjectConfigRpcError | Error | null;
}): ProjectConfigImportVisibleError | null {
if (input.routeIntentCapabilityMissing) {
return { code: "capability_missing" };
}
if (input.hasUnsavedChanges) {
return { code: "unsaved_changes" };
}
return (
input.applyError ??
projectConfigImportPreviewError(input.preview) ??
normalizeProjectConfigImportError(input.requestError)
);
}
export function useProjectConfigImportAvailability(input: {
client: DaemonClient | null;
serverId: string | null | undefined;
@@ -40,6 +40,7 @@ import { confirmDialog } from "@/utils/confirm-dialog";
import {
applyDraftToConfig,
configToDraft,
hasProjectConfigDraftChanges,
METADATA_PROMPT_KEYS,
type LifecycleOriginalKind,
type MetadataPromptKey,
@@ -681,6 +682,10 @@ function ProjectConfigForm({
const isStale = writeError?.code === "stale_project_config";
const isWriteFailed = writeError?.code === "write_failed";
const saveDisabled = saveMutation.isPending || isStale || hasInvalidScripts;
const hasUnsavedChanges = useMemo(
() => hasProjectConfigDraftChanges({ draft, base: baseConfig }),
[baseConfig, draft],
);
return (
<View>
@@ -697,6 +702,7 @@ function ProjectConfigForm({
onRouteIntentConsumed={onImportIntentConsumed}
projectConfigLoaded
projectConfigQueryKey={queryKey}
hasUnsavedChanges={hasUnsavedChanges}
/>
<SettingsSection
@@ -1,7 +1,12 @@
import { describe, expect, it } from "vitest";
import { PaseoConfigRawSchema } from "@getpaseo/protocol/paseo-config-schema";
import type { PaseoConfigRaw } from "@getpaseo/protocol/messages";
import { applyDraftToConfig, configToDraft, type ProjectConfigDraft } from "./project-config-form";
import {
applyDraftToConfig,
configToDraft,
hasProjectConfigDraftChanges,
type ProjectConfigDraft,
} from "./project-config-form";
function emptyDraft(): ProjectConfigDraft {
return {
@@ -350,3 +355,14 @@ describe("applyDraftToConfig", () => {
expect(Object.keys(scripts)).toEqual(["dev"]);
});
});
describe("hasProjectConfigDraftChanges", () => {
it("distinguishes an untouched form from unsaved project edits", () => {
const base = { worktree: { setup: "npm ci" } };
const untouched = configToDraft(base);
const edited = { ...untouched, setupText: "npm install" };
expect(hasProjectConfigDraftChanges({ draft: untouched, base })).toBe(false);
expect(hasProjectConfigDraftChanges({ draft: edited, base })).toBe(true);
});
});
@@ -4,6 +4,7 @@ import type {
PaseoMetadataGenerationEntry,
PaseoScriptEntryRaw,
} from "@getpaseo/protocol/messages";
import equal from "fast-deep-equal";
export type LifecycleOriginalKind = "string" | "array" | "missing";
@@ -244,3 +245,7 @@ export function applyDraftToConfig(input: ApplyDraftInput): PaseoConfigRaw {
}
return result as PaseoConfigRaw;
}
export function hasProjectConfigDraftChanges(input: ApplyDraftInput): boolean {
return !equal(applyDraftToConfig(input), input.base ?? {});
}
@@ -274,6 +274,11 @@ cwd = "./.."
command = "npm test"
[scripts.run.nested.options]
cwd = "apps/web/../../.."
[scripts.run.unc]
command = "npm test"
[scripts.run.unc.options]
cwd = '\\\\server\\share'
`,
);
@@ -283,16 +288,39 @@ cwd = "apps/web/../../.."
scripts: {
parent: { command: "npm test" },
nested: { command: "npm test" },
unc: { command: "npm test" },
},
});
expect(preview.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: "scripts.parent.cwd", outcome: "unsupported" }),
expect.objectContaining({ key: "scripts.nested.cwd", outcome: "unsupported" }),
expect.objectContaining({ key: "scripts.unc.cwd", outcome: "unsupported" }),
]),
);
});
test("rewrites Conductor ports inside shell parameter expansions", () => {
const repo = makeRepo();
writeSharedToml(
repo,
`
[scripts.run.dev]
command = "npm run dev -- --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("does not import scripts available only in Conductor cloud", () => {
const repo = makeRepo();
writeSharedToml(
@@ -383,12 +383,12 @@ 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}`));
const pattern = new RegExp(`\\$\\{${from}(?=[}:#%+\\-=?])|\\$${from}(?![A-Za-z0-9_])`, "g");
return command.replace(pattern, (match) => (match.startsWith("${") ? `\${${to}` : `$${to}`));
}
function containsShellVariable(command: string, name: string): boolean {
const pattern = new RegExp(`\\$(?:\\{${name}\\}|${name}(?![A-Za-z0-9_]))`);
const pattern = new RegExp(`\\$\\{${name}(?=[}:#%+\\-=?])|\\$${name}(?![A-Za-z0-9_])`);
return pattern.test(command);
}
@@ -401,7 +401,12 @@ function appendArgs(command: string, args: string[]): string {
function safeCwdPrefix(cwd: string): string | null {
const normalized = posix.normalize(cwd.replaceAll("\\", "/"));
if (/^(?:\/|[A-Za-z]:[\\/])/.test(cwd) || normalized === ".." || normalized.startsWith("../")) {
if (
normalized.startsWith("/") ||
/^(?:\/|[A-Za-z]:[\\/])/.test(cwd) ||
normalized === ".." ||
normalized.startsWith("../")
) {
return null;
}
return `cd -- ${shellQuote(cwd)} && `;