mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 05:32:58 +00:00
fix(settings): handle remaining import edge cases
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
+64
-1
@@ -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");
|
||||
|
||||
+15
-2
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user