diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 1ae214caca..9f3ca031b0 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index e4b1a4922d..a3d68f45a0 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index e2e7241c16..aa8c7bdddf 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 40bc64d0f0..d7b9b1296d 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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.", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 9c61c8ca46..f670cd6272 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -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.", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 238ba05f57..2a9b67c0b7 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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.", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 4d9598187d..537f8db2f4 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 5f22bdc975..95a3fbe830 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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.", diff --git a/packages/app/src/project-config-import/project-config-import-section.tsx b/packages/app/src/project-config-import/project-config-import-section.tsx index 6998ce36c0..360e3c30bb 100644 --- a/packages/app/src/project-config-import/project-config-import-section.tsx +++ b/packages/app/src/project-config-import/project-config-import-section.tsx @@ -20,6 +20,7 @@ export function ProjectConfigImportSection(input: { onRouteIntentConsumed?: () => void; projectConfigLoaded: boolean; projectConfigQueryKey: readonly [string, string, string]; + hasUnsavedChanges: boolean; }) { const model = useProjectConfigImportModel(input); diff --git a/packages/app/src/project-config-import/project-config-import-sheet.tsx b/packages/app/src/project-config-import/project-config-import-sheet.tsx index 25c59e95ef..04733e7d29 100644 --- a/packages/app/src/project-config-import/project-config-import-sheet.tsx +++ b/packages/app/src/project-config-import/project-config-import-sheet.tsx @@ -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": 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 1892547d11..3191e89140 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 @@ -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; 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; diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 4217382a55..f5c214e4d4 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -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 ( @@ -697,6 +702,7 @@ function ProjectConfigForm({ onRouteIntentConsumed={onImportIntentConsumed} projectConfigLoaded projectConfigQueryKey={queryKey} + hasUnsavedChanges={hasUnsavedChanges} /> { 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); + }); +}); diff --git a/packages/app/src/utils/project-config-form.ts b/packages/app/src/utils/project-config-form.ts index 8e75ba0a44..74201bfb2b 100644 --- a/packages/app/src/utils/project-config-form.ts +++ b/packages/app/src/utils/project-config-form.ts @@ -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 ?? {}); +} 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 53c1f0cf4d..9a55eaf145 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 @@ -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( 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 1f11bdfd13..72e75ad996 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 @@ -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)} && `;