mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
fix(server): keep project config work off the daemon hot path (#7944)
Project settings and script health updates synchronously read unbounded user files. Share admitted async snapshots across consumers, budget script validation, and serialize revision-checked edits while keeping lexical project identity.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -7,6 +7,7 @@ import { ScriptRouteStore } from "./script-proxy.js";
|
||||
import {
|
||||
buildWorkspaceScriptPayloads,
|
||||
createScriptStatusEmitter,
|
||||
readPaseoConfigForProjection,
|
||||
} from "./script-status-projection.js";
|
||||
import { WorkspaceScriptPayloadSchema } from "@getpaseo/protocol/messages";
|
||||
import type { ScriptHealthState } from "./script-health-monitor.js";
|
||||
@@ -50,7 +51,7 @@ function createWorkspaceRepo(options?: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildPayloads(input: {
|
||||
async function buildPayloads(input: {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
paseoConfig?: PaseoConfig | null;
|
||||
@@ -63,7 +64,9 @@ function buildPayloads(input: {
|
||||
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
||||
}) {
|
||||
const paseoConfig =
|
||||
input.paseoConfig !== undefined ? input.paseoConfig : loadConfig(input.workspaceDirectory);
|
||||
input.paseoConfig !== undefined
|
||||
? input.paseoConfig
|
||||
: await loadConfig(input.workspaceDirectory);
|
||||
const { routeStore, serviceProxy, ...rest } = input;
|
||||
return buildWorkspaceScriptPayloads({
|
||||
...rest,
|
||||
@@ -72,8 +75,8 @@ function buildPayloads(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function loadConfig(repoRoot: string): PaseoConfig | null {
|
||||
const result = readPaseoConfig(repoRoot);
|
||||
async function loadConfig(repoRoot: string): Promise<PaseoConfig | null> {
|
||||
const result = await readPaseoConfig(repoRoot);
|
||||
return result.ok ? result.config : null;
|
||||
}
|
||||
|
||||
@@ -93,7 +96,7 @@ describe("script-status-projection", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("projects plain scripts and services differently", () => {
|
||||
it("projects plain scripts and services differently", async () => {
|
||||
const workspaceId = "workspace-plain-and-service";
|
||||
const workspace = createWorkspaceRepo({
|
||||
paseoConfig: {
|
||||
@@ -116,7 +119,7 @@ describe("script-status-projection", () => {
|
||||
|
||||
try {
|
||||
expect(
|
||||
buildPayloads({
|
||||
await buildPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory: workspace.repoDir,
|
||||
routeStore,
|
||||
@@ -154,7 +157,7 @@ describe("script-status-projection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("builds service hostnames from service-provided git metadata", () => {
|
||||
it("builds service hostnames from service-provided git metadata", async () => {
|
||||
const workspaceId = "workspace-service-metadata";
|
||||
const workspace = createWorkspaceRepo({
|
||||
branchName: "local-branch-that-should-not-be-read",
|
||||
@@ -168,7 +171,7 @@ describe("script-status-projection", () => {
|
||||
const runtimeStore = new WorkspaceScriptRuntimeStore();
|
||||
|
||||
try {
|
||||
const payloads = buildPayloads({
|
||||
const payloads = await buildPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory: workspace.repoDir,
|
||||
serviceProxy: routeStore,
|
||||
@@ -200,7 +203,7 @@ describe("script-status-projection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("projects local and public service URLs while keeping proxyUrl public-first", () => {
|
||||
it("projects local and public service URLs while keeping proxyUrl public-first", async () => {
|
||||
const workspaceId = "workspace-public-service";
|
||||
const workspace = createWorkspaceRepo({
|
||||
paseoConfig: {
|
||||
@@ -214,7 +217,7 @@ describe("script-status-projection", () => {
|
||||
|
||||
try {
|
||||
expect(
|
||||
buildPayloads({
|
||||
await buildPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory: workspace.repoDir,
|
||||
routeStore,
|
||||
@@ -243,7 +246,7 @@ describe("script-status-projection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("overlays runtime, route, and health state for running services", () => {
|
||||
it("overlays runtime, route, and health state for running services", async () => {
|
||||
const workspaceId = "workspace-running-service";
|
||||
const workspace = createWorkspaceRepo({
|
||||
branchName: "feature/card",
|
||||
@@ -273,7 +276,7 @@ describe("script-status-projection", () => {
|
||||
|
||||
try {
|
||||
expect(
|
||||
buildPayloads({
|
||||
await buildPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory: workspace.repoDir,
|
||||
routeStore,
|
||||
@@ -301,7 +304,7 @@ describe("script-status-projection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("maps internal pending health to null on the wire", () => {
|
||||
it("maps internal pending health to null on the wire", async () => {
|
||||
const workspaceId = "workspace-pending-health";
|
||||
const workspace = createWorkspaceRepo({
|
||||
paseoConfig: {
|
||||
@@ -330,7 +333,7 @@ describe("script-status-projection", () => {
|
||||
|
||||
try {
|
||||
expect(
|
||||
buildPayloads({
|
||||
await buildPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory: workspace.repoDir,
|
||||
routeStore,
|
||||
@@ -358,7 +361,7 @@ describe("script-status-projection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("includes orphaned running runtime entries even after config removal", () => {
|
||||
it("includes orphaned running runtime entries even after config removal", async () => {
|
||||
const workspaceId = "workspace-orphaned-service";
|
||||
const workspace = createWorkspaceRepo();
|
||||
const routeStore = new ScriptRouteStore();
|
||||
@@ -381,7 +384,7 @@ describe("script-status-projection", () => {
|
||||
|
||||
try {
|
||||
expect(
|
||||
buildPayloads({
|
||||
await buildPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory: workspace.repoDir,
|
||||
routeStore,
|
||||
@@ -408,7 +411,7 @@ describe("script-status-projection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("projects orphaned plain scripts as scripts instead of services", () => {
|
||||
it("projects orphaned plain scripts as scripts instead of services", async () => {
|
||||
const workspaceId = "workspace-orphaned-script";
|
||||
const workspace = createWorkspaceRepo();
|
||||
const routeStore = new ScriptRouteStore();
|
||||
@@ -424,7 +427,7 @@ describe("script-status-projection", () => {
|
||||
|
||||
try {
|
||||
expect(
|
||||
buildPayloads({
|
||||
await buildPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory: workspace.repoDir,
|
||||
routeStore,
|
||||
@@ -449,7 +452,7 @@ describe("script-status-projection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("readPaseoConfig fails with configPath and error when paseo.json is malformed", () => {
|
||||
it("readPaseoConfig fails with configPath and error when paseo.json is malformed", async () => {
|
||||
const workspace = createWorkspaceRepo();
|
||||
const configPath = path.join(workspace.repoDir, "paseo.json");
|
||||
writeFileSync(
|
||||
@@ -458,7 +461,7 @@ describe("script-status-projection", () => {
|
||||
);
|
||||
|
||||
try {
|
||||
const result = readPaseoConfig(workspace.repoDir);
|
||||
const result = await readPaseoConfig(workspace.repoDir);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("unreachable");
|
||||
expect(result.configPath).toBe(configPath);
|
||||
@@ -468,7 +471,7 @@ describe("script-status-projection", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", () => {
|
||||
it("buildWorkspaceScriptPayloads given paseoConfig=null still surfaces orphaned runtime scripts", async () => {
|
||||
const workspaceId = "workspace-null-config";
|
||||
const workspace = createWorkspaceRepo();
|
||||
const routeStore = new ScriptRouteStore();
|
||||
@@ -484,7 +487,7 @@ describe("script-status-projection", () => {
|
||||
|
||||
try {
|
||||
expect(
|
||||
buildPayloads({
|
||||
await buildPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory: workspace.repoDir,
|
||||
paseoConfig: null,
|
||||
@@ -538,7 +541,12 @@ describe("script-status-projection", () => {
|
||||
exitCode: null,
|
||||
});
|
||||
|
||||
const session = { emit: vi.fn() };
|
||||
const messages: unknown[] = [];
|
||||
const session = {
|
||||
emit: (message: unknown) => {
|
||||
messages.push(message);
|
||||
},
|
||||
};
|
||||
const emitUpdate = createScriptStatusEmitter({
|
||||
sessions: () => [session],
|
||||
serviceProxy: routeStore,
|
||||
@@ -550,7 +558,11 @@ describe("script-status-projection", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
emitUpdate(workspaceId, [
|
||||
const originalConfig = await readPaseoConfigForProjection(
|
||||
workspace.repoDir,
|
||||
createTestLogger(),
|
||||
);
|
||||
await emitUpdate(workspaceId, [
|
||||
{
|
||||
scriptName: "api",
|
||||
hostname: "api--repo.localhost",
|
||||
@@ -558,9 +570,25 @@ describe("script-status-projection", () => {
|
||||
health: "healthy",
|
||||
},
|
||||
]);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(session.emit).toHaveBeenCalledWith({
|
||||
expect(await readPaseoConfigForProjection(workspace.repoDir, createTestLogger())).toBe(
|
||||
originalConfig,
|
||||
);
|
||||
await emitUpdate(workspaceId, [
|
||||
{ scriptName: "api", hostname: "api--repo.localhost", port: 3001, health: "unhealthy" },
|
||||
]);
|
||||
expect(messages.at(-1)).toMatchObject({
|
||||
payload: {
|
||||
scripts: [
|
||||
{ scriptName: "api", health: "unhealthy" },
|
||||
{ scriptName: "typecheck", health: null },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(await readPaseoConfigForProjection(workspace.repoDir, createTestLogger())).toBe(
|
||||
originalConfig,
|
||||
);
|
||||
expect(messages).toContainEqual({
|
||||
type: "script_status_update",
|
||||
payload: {
|
||||
workspaceId,
|
||||
|
||||
@@ -33,11 +33,11 @@ interface BuildWorkspaceScriptPayloadsOptions {
|
||||
resolveHealth?: (hostname: string) => ScriptHealthState | null;
|
||||
}
|
||||
|
||||
export function readPaseoConfigForProjection(
|
||||
export async function readPaseoConfigForProjection(
|
||||
workspaceDirectory: string,
|
||||
logger: Logger,
|
||||
): PaseoConfig | null {
|
||||
const result = readPaseoConfig(workspaceDirectory);
|
||||
): Promise<PaseoConfig | null> {
|
||||
const result = await readPaseoConfig(workspaceDirectory);
|
||||
if (result.ok) {
|
||||
return result.config;
|
||||
}
|
||||
@@ -288,9 +288,9 @@ export function createScriptStatusEmitter({
|
||||
serviceProxyPublicBaseUrl?: string | null;
|
||||
resolveWorkspaceDirectory: (workspaceId: string) => string | null | Promise<string | null>;
|
||||
logger: Logger;
|
||||
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => void {
|
||||
return (workspaceId, scripts) => {
|
||||
void (async () => {
|
||||
}): (workspaceId: string, scripts: ScriptHealthEntry[]) => Promise<void> {
|
||||
return async (workspaceId, scripts) => {
|
||||
try {
|
||||
const workspaceDirectory = await resolveWorkspaceDirectory(workspaceId);
|
||||
if (!workspaceDirectory) {
|
||||
return;
|
||||
@@ -304,7 +304,7 @@ export function createScriptStatusEmitter({
|
||||
const projected = buildWorkspaceScriptPayloads({
|
||||
workspaceId,
|
||||
workspaceDirectory,
|
||||
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, logger),
|
||||
paseoConfig: await readPaseoConfigForProjection(workspaceDirectory, logger),
|
||||
serviceProxy,
|
||||
runtimeStore,
|
||||
daemonPort: resolvedDaemonPort,
|
||||
@@ -320,6 +320,8 @@ export function createScriptStatusEmitter({
|
||||
for (const session of sessions()) {
|
||||
session.emit(message);
|
||||
}
|
||||
})();
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, workspaceId }, "Failed to project workspace script status");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5669,7 +5669,7 @@ export class Session {
|
||||
activityAt: null,
|
||||
createdAt: workspace.createdAt,
|
||||
diffStat,
|
||||
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord),
|
||||
scripts: await this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord),
|
||||
...(resolvedProjectRecord
|
||||
? {
|
||||
project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord),
|
||||
@@ -7262,7 +7262,7 @@ export class Session {
|
||||
private buildWorkspaceScriptPayloadSnapshot(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
project: PersistedProjectRecord | null,
|
||||
): WorkspaceDescriptorPayload["scripts"] {
|
||||
): Promise<WorkspaceDescriptorPayload["scripts"]> {
|
||||
return this.workspaceScripts.buildSnapshot(workspace, project);
|
||||
}
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ describe("ProjectConfigSession", () => {
|
||||
requestId: "write-uncommitted-setup",
|
||||
repoRoot,
|
||||
config: { worktree: { setup: "npm install" } },
|
||||
expectedRevision: statPaseoConfigPath(repoRoot),
|
||||
expectedRevision: await statPaseoConfigPath(repoRoot),
|
||||
});
|
||||
|
||||
expect(emitted).toEqual([
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { resolve, sep } from "path";
|
||||
import { resolve } from "path";
|
||||
import type pino from "pino";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
|
||||
import type { ProjectRegistry } from "../../workspace-registry.js";
|
||||
import {
|
||||
readPaseoConfigForEdit,
|
||||
resolveProjectConfigRoot,
|
||||
writePaseoConfigForEdit,
|
||||
type ProjectConfigRpcError,
|
||||
} from "../../../utils/paseo-config-file.js";
|
||||
@@ -47,7 +47,7 @@ export class ProjectConfigSession {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = readPaseoConfigForEdit(repoRoot);
|
||||
const result = await readPaseoConfigForEdit(repoRoot);
|
||||
if (!result.ok) {
|
||||
this.logger.warn(
|
||||
{ repoRoot, requestId: msg.requestId, outcome: result.error.code },
|
||||
@@ -94,7 +94,7 @@ export class ProjectConfigSession {
|
||||
{ repoRoot, requestId: msg.requestId, outcome: "write_attempt" },
|
||||
"Writing project config",
|
||||
);
|
||||
const result = writePaseoConfigForEdit({
|
||||
const result = await writePaseoConfigForEdit({
|
||||
repoRoot,
|
||||
config: msg.config,
|
||||
expectedRevision: msg.expectedRevision,
|
||||
@@ -173,34 +173,21 @@ export class ProjectConfigSession {
|
||||
}
|
||||
|
||||
private async resolveKnownProjectRoot(repoRoot: string): Promise<string | null> {
|
||||
const requestedRoot = canonicalizeConfigRoot(repoRoot);
|
||||
const requestedRoot = resolve(repoRoot);
|
||||
const projects = await this.projectRegistry.list();
|
||||
for (const project of projects) {
|
||||
if (project.archivedAt !== null) {
|
||||
continue;
|
||||
}
|
||||
const projectRoot = canonicalizeConfigRoot(project.rootPath);
|
||||
if (requestedRoot === projectRoot) {
|
||||
return projectRoot;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
const activeRoots = new Set(
|
||||
projects
|
||||
.filter((project) => project.archivedAt === null)
|
||||
.map((project) => resolve(project.rootPath)),
|
||||
);
|
||||
const root = await resolveProjectConfigRoot(requestedRoot, activeRoots);
|
||||
if (!root) return null;
|
||||
// Authorization follows live registry state after alias IO, including archive/removal.
|
||||
const current = await this.projectRegistry.list();
|
||||
return current.some(
|
||||
(project) => project.archivedAt === null && resolve(project.rootPath) === root,
|
||||
)
|
||||
? root
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalizeConfigRoot(repoRoot: string): string {
|
||||
const resolved = resolve(repoRoot);
|
||||
try {
|
||||
return stripTrailingPathSeparators(realpathSync(resolved));
|
||||
} catch {
|
||||
return stripTrailingPathSeparators(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
function stripTrailingPathSeparators(path: string): string {
|
||||
let normalized = path;
|
||||
while (normalized.length > 1 && normalized.endsWith(sep)) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
+17
-9
@@ -178,14 +178,20 @@ describe("buildSnapshot", () => {
|
||||
test("returns no scripts when the service proxy is unavailable", async () => {
|
||||
const { service } = buildService({ serviceProxy: null });
|
||||
expect(
|
||||
service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord),
|
||||
await service.buildSnapshot({
|
||||
workspaceId: "ws-1",
|
||||
cwd: "/tmp/repo",
|
||||
} as PersistedWorkspaceRecord),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns no scripts when the runtime store is unavailable", async () => {
|
||||
const { service } = buildService({ scriptRuntimeStore: null });
|
||||
expect(
|
||||
service.buildSnapshot({ workspaceId: "ws-1", cwd: "/tmp/repo" } as PersistedWorkspaceRecord),
|
||||
await service.buildSnapshot({
|
||||
workspaceId: "ws-1",
|
||||
cwd: "/tmp/repo",
|
||||
} as PersistedWorkspaceRecord),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -194,7 +200,7 @@ describe("buildSnapshot", () => {
|
||||
tempDirs.push(dir);
|
||||
const { service } = buildService();
|
||||
expect(
|
||||
service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord),
|
||||
await service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -229,7 +235,7 @@ describe("buildSnapshot", () => {
|
||||
runtimeStore: { get: () => null },
|
||||
});
|
||||
|
||||
expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe(
|
||||
expect((await service.buildSnapshot(workspace, project))[0]?.hostname).toBe(
|
||||
serviceProxy.projectWorkspaceService({
|
||||
projectSlug: deriveProjectServiceSlug(project),
|
||||
branchName: workspace.branch,
|
||||
@@ -374,10 +380,12 @@ describe("start", () => {
|
||||
daemonPort: 6767,
|
||||
daemonListenHost: "127.0.0.1",
|
||||
});
|
||||
expect(published).toContainEqual({
|
||||
type: "script_status_update",
|
||||
payload: { workspaceId: "ws-1", scripts: [] },
|
||||
});
|
||||
await expect
|
||||
.poll(() => published)
|
||||
.toContainEqual({
|
||||
type: "script_status_update",
|
||||
payload: { workspaceId: "ws-1", scripts: [] },
|
||||
});
|
||||
expect(emitted).toContainEqual({
|
||||
type: "start_workspace_script_response",
|
||||
payload: {
|
||||
@@ -471,7 +479,7 @@ describe("start", () => {
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
const { service, spawnCalls } = buildService({ workspace, project, serviceProxy });
|
||||
|
||||
const snapshot = service.buildSnapshot(workspace, project);
|
||||
const snapshot = await service.buildSnapshot(workspace, project);
|
||||
await service.start({ ...request, workspaceId: workspace.workspaceId });
|
||||
|
||||
const started = spawnCalls[0]!;
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface WorkspaceScriptsService {
|
||||
buildSnapshot(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
project?: PersistedProjectRecord | null,
|
||||
): WorkspaceScriptsPayload;
|
||||
): Promise<WorkspaceScriptsPayload>;
|
||||
emitStatusUpdate(workspaceId: string, workspaceDirectory: string): Promise<void>;
|
||||
list(workspaceId: string): Promise<WorkspaceScriptPayload[]>;
|
||||
launch(input: { workspaceId: string; scriptName: string }): Promise<WorkspaceScriptPayload>;
|
||||
@@ -110,17 +110,17 @@ export function createWorkspaceScriptsService(deps: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildSnapshot(
|
||||
async function buildSnapshot(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
project: PersistedProjectRecord | null = null,
|
||||
): WorkspaceScriptsPayload {
|
||||
): Promise<WorkspaceScriptsPayload> {
|
||||
if (!serviceProxy || !scriptRuntimeStore) {
|
||||
return [];
|
||||
}
|
||||
return buildWorkspaceScriptPayloads({
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.cwd,
|
||||
paseoConfig: readPaseoConfigForProjection(workspace.cwd, logger),
|
||||
paseoConfig: await readPaseoConfigForProjection(workspace.cwd, logger),
|
||||
serviceProxy,
|
||||
runtimeStore: scriptRuntimeStore,
|
||||
daemonPort: getDaemonTcpPort?.() ?? null,
|
||||
@@ -137,7 +137,7 @@ export function createWorkspaceScriptsService(deps: {
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
publishStatusUpdate({
|
||||
type: "script_status_update",
|
||||
payload: { workspaceId, scripts: buildSnapshot(workspace, project) },
|
||||
payload: { workspaceId, scripts: await buildSnapshot(workspace, project) },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, workspaceId }, "Failed to project workspace script status");
|
||||
@@ -203,7 +203,7 @@ export function createWorkspaceScriptsService(deps: {
|
||||
scriptName: string;
|
||||
}): Promise<WorkspaceScriptPayload> {
|
||||
const { workspace, project } = await launchProcess(input);
|
||||
const script = buildSnapshot(workspace, project).find(
|
||||
const script = (await buildSnapshot(workspace, project)).find(
|
||||
(entry) => entry.scriptName === input.scriptName,
|
||||
);
|
||||
if (!script) {
|
||||
@@ -231,7 +231,7 @@ export function createWorkspaceScriptsService(deps: {
|
||||
// The launcher's terminal exit listener owns route removal and runtime state updates.
|
||||
await available.terminalManager.killTerminalAndWait(runtime.terminalId);
|
||||
|
||||
const script = buildSnapshot(workspace, project).find(
|
||||
const script = (await buildSnapshot(workspace, project)).find(
|
||||
(entry) => entry.scriptName === input.scriptName,
|
||||
);
|
||||
if (!script) {
|
||||
|
||||
@@ -335,7 +335,7 @@ export async function runWorktreeAutoTerminals(options: {
|
||||
if (!options.terminalManager) return;
|
||||
const terminalManager = options.terminalManager;
|
||||
await Promise.all(
|
||||
getWorktreeTerminalSpecs(options.workspaceCwd).map(async (spec) => {
|
||||
(await getWorktreeTerminalSpecs(options.workspaceCwd)).map(async (spec) => {
|
||||
try {
|
||||
const terminal = await terminalManager.createTerminal({
|
||||
cwd: options.workspaceCwd,
|
||||
@@ -545,7 +545,7 @@ export async function spawnWorkspaceScript(
|
||||
logger,
|
||||
onLifecycleChanged,
|
||||
} = options;
|
||||
const configResult = readPaseoConfig(repoRoot);
|
||||
const configResult = await readPaseoConfig(repoRoot);
|
||||
if (!configResult.ok) {
|
||||
throw paseoConfigParseError(configResult);
|
||||
}
|
||||
|
||||
@@ -554,7 +554,7 @@ export async function runWorktreeSetupInBackground(
|
||||
emitSetupProgress("completed", null);
|
||||
} else {
|
||||
const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath;
|
||||
const setupCommands = getWorktreeSetupCommands(workspaceCwd);
|
||||
const setupCommands = await getWorktreeSetupCommands(workspaceCwd);
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: worktree.worktreePath,
|
||||
branchName: worktree.branchName,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { readPaseoConfigJson } from "./paseo-config-file.js";
|
||||
import {
|
||||
PaseoConfigSchema,
|
||||
type PaseoMetadataGeneration,
|
||||
} from "@getpaseo/protocol/paseo-config-schema";
|
||||
import { readPaseoConfig } from "./paseo-config-file.js";
|
||||
import { type PaseoMetadataGeneration } from "@getpaseo/protocol/paseo-config-schema";
|
||||
|
||||
export type MetadataConfigKey = "title" | "branchName" | "commitMessage" | "pullRequest";
|
||||
|
||||
@@ -52,8 +49,8 @@ async function readProjectMetadataOverrides(
|
||||
}
|
||||
try {
|
||||
const repoRoot = await options.workspaceGitService.resolveRepoRoot(options.cwd);
|
||||
const json = readPaseoConfigJson(repoRoot);
|
||||
return PaseoConfigSchema.parse(json).metadataGeneration;
|
||||
const result = await readPaseoConfig(repoRoot);
|
||||
return result.ok ? result.config?.metadataGeneration : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { writeFile, truncate, rm } from "node:fs/promises";
|
||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { getWorktreeSetupCommands, getWorktreeTeardownCommands } from "./worktree.js";
|
||||
import {
|
||||
readPaseoConfig,
|
||||
PaseoConfigSchema,
|
||||
PaseoConfigRawSchema,
|
||||
getWorktreeSetupCommands,
|
||||
getWorktreeTeardownCommands,
|
||||
} from "./worktree.js";
|
||||
import {
|
||||
readPaseoConfigForEdit,
|
||||
statPaseoConfigPath,
|
||||
@@ -21,16 +32,179 @@ describe("paseo config file substrate", () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns null config and revision when paseo.json is missing", () => {
|
||||
const result = readPaseoConfigForEdit(tempDir);
|
||||
it("rejects an oversized config before parsing its otherwise valid JSON", async () => {
|
||||
writeFileSync(
|
||||
join(tempDir, "paseo.json"),
|
||||
JSON.stringify({ extra: "x".repeat(2 * 1024 * 1024) }),
|
||||
);
|
||||
expect(await readPaseoConfigForEdit(tempDir)).toEqual({
|
||||
ok: false,
|
||||
error: { code: "invalid_project_config" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a sparse oversized file without allocating its contents", async () => {
|
||||
const configPath = join(tempDir, "paseo.json");
|
||||
await writeFile(configPath, "");
|
||||
await truncate(configPath, 1024 * 1024 * 1024);
|
||||
expect(await readPaseoConfigForEdit(tempDir)).toEqual({
|
||||
ok: false,
|
||||
error: { code: "invalid_project_config" },
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(isPlatform("win32"))("rejects a FIFO without waiting for a writer", async () => {
|
||||
await promisify(execFile)("mkfifo", [join(tempDir, "paseo.json")]);
|
||||
const modulePath = fileURLToPath(new URL("./paseo-config-file.ts", import.meta.url));
|
||||
const script = `import { readPaseoConfigForEdit } from ${JSON.stringify(modulePath)};
|
||||
const result = await readPaseoConfigForEdit(${JSON.stringify(tempDir)});
|
||||
if (JSON.stringify(result) !== JSON.stringify({ok:false,error:{code:"invalid_project_config"}})) process.exit(1);`;
|
||||
// Isolate the regression: the old sync reader blocks on the pipe and must be killed.
|
||||
await promisify(execFile)(
|
||||
process.execPath,
|
||||
["--import", "tsx", "--input-type=module", "-e", script],
|
||||
{ timeout: 2_000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the longest loop stall below 20ms while admitting large configs", async () => {
|
||||
const config = {
|
||||
scripts: Object.fromEntries(
|
||||
Array.from({ length: 7_000 }, (_, i) => [`script${i}`, { command: "echo hi" }]),
|
||||
),
|
||||
worktree: { setup: "npm ci" },
|
||||
};
|
||||
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
|
||||
let previous = performance.now();
|
||||
let longestGap = 0;
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longestGap = Math.max(longestGap, now - previous);
|
||||
previous = now;
|
||||
}, 1);
|
||||
let result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
|
||||
try {
|
||||
result = await readPaseoConfigForEdit(tempDir);
|
||||
await nextTurn();
|
||||
longestGap = Math.max(longestGap, performance.now() - previous);
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
}
|
||||
expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) });
|
||||
process.stdout.write(`project config 7,000 scripts max loop gap: ${longestGap.toFixed(2)}ms\n`);
|
||||
expect(longestGap).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it("rejects a 32MiB config with a loop stall below 20ms", async () => {
|
||||
await writeFile(
|
||||
join(tempDir, "paseo.json"),
|
||||
JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }),
|
||||
);
|
||||
let previous = performance.now();
|
||||
let longestGap = 0;
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longestGap = Math.max(longestGap, now - previous);
|
||||
previous = now;
|
||||
}, 1);
|
||||
let result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
|
||||
try {
|
||||
result = await readPaseoConfigForEdit(tempDir);
|
||||
await nextTurn();
|
||||
longestGap = Math.max(longestGap, performance.now() - previous);
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
}
|
||||
process.stdout.write(`project config 32MiB max loop gap: ${longestGap.toFixed(2)}ms\n`);
|
||||
expect(longestGap).toBeLessThan(20);
|
||||
expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
scripts: Object.fromEntries(
|
||||
Array.from({ length: 130 }, (_, i) => [
|
||||
`script${i}`,
|
||||
{ command: "echo hi", extra: { keep: true } },
|
||||
]),
|
||||
),
|
||||
},
|
||||
{
|
||||
scripts: { valid: { command: "echo hi" }, invalid: 42 },
|
||||
worktree: { setup: ["one", "", 42] },
|
||||
},
|
||||
{ scripts: ["invalid record"], worktree: { setup: "echo hi" } },
|
||||
JSON.parse(
|
||||
'{"scripts":{"__proto__":{"command":"echo hi"},"constructor":{"command":"echo hi"}}}',
|
||||
),
|
||||
])("preserves edit and runtime schema behavior with grouped validation (%#)", async (config) => {
|
||||
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
|
||||
const expected = PaseoConfigRawSchema.safeParse(config);
|
||||
expect(await readPaseoConfigForEdit(tempDir)).toEqual(
|
||||
expected.success
|
||||
? { ok: true, config: expected.data, revision: await statPaseoConfigPath(tempDir) }
|
||||
: { ok: false, error: { code: "invalid_project_config" } },
|
||||
);
|
||||
expect(await readPaseoConfig(tempDir)).toEqual({
|
||||
ok: true,
|
||||
config: PaseoConfigSchema.parse(config),
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes snapshots after an external edit, repair, and removal", async () => {
|
||||
const configPath = join(tempDir, "paseo.json");
|
||||
await writeFile(configPath, JSON.stringify({ worktree: { setup: "old" } }));
|
||||
const original = await readPaseoConfig(tempDir);
|
||||
expect(original).toEqual({ ok: true, config: { worktree: { setup: ["old"], teardown: [] } } });
|
||||
await writeFile(configPath, "{ malformed");
|
||||
expect(await readPaseoConfigForEdit(tempDir)).toEqual({
|
||||
ok: false,
|
||||
error: { code: "invalid_project_config" },
|
||||
});
|
||||
await writeFile(configPath, JSON.stringify({ worktree: { setup: "repaired" } }));
|
||||
expect(await readPaseoConfig(tempDir)).toEqual({
|
||||
ok: true,
|
||||
config: { worktree: { setup: ["repaired"], teardown: [] } },
|
||||
});
|
||||
await rm(configPath);
|
||||
expect(await readPaseoConfigForEdit(tempDir)).toEqual({
|
||||
ok: true,
|
||||
config: null,
|
||||
revision: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes competing edits so exactly one matching revision commits", async () => {
|
||||
const results = await Promise.all(
|
||||
["first", "second"].map((setup) =>
|
||||
writePaseoConfigForEdit({
|
||||
repoRoot: tempDir,
|
||||
config: { worktree: { setup } },
|
||||
expectedRevision: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const saved = await readPaseoConfigForEdit(tempDir);
|
||||
expect(results[0]).toEqual(saved);
|
||||
expect(results[1]).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "stale_project_config",
|
||||
currentRevision: await statPaseoConfigPath(tempDir),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null config and revision when paseo.json is missing", async () => {
|
||||
const result = await readPaseoConfigForEdit(tempDir);
|
||||
|
||||
expect(result).toEqual({ ok: true, config: null, revision: null });
|
||||
});
|
||||
|
||||
it("returns invalid_project_config for invalid JSON", () => {
|
||||
it("returns invalid_project_config for invalid JSON", async () => {
|
||||
writeFileSync(join(tempDir, "paseo.json"), "{ invalid json\n");
|
||||
|
||||
const result = readPaseoConfigForEdit(tempDir);
|
||||
const result = await readPaseoConfigForEdit(tempDir);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
@@ -38,7 +212,7 @@ describe("paseo config file substrate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves raw lifecycle string and array forms with a revision token", () => {
|
||||
it("preserves raw lifecycle string and array forms with a revision token", async () => {
|
||||
writeFileSync(
|
||||
join(tempDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
@@ -49,7 +223,7 @@ describe("paseo config file substrate", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const result = readPaseoConfigForEdit(tempDir);
|
||||
const result = await readPaseoConfigForEdit(tempDir);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
@@ -59,11 +233,11 @@ describe("paseo config file substrate", () => {
|
||||
teardown: ["npm run clean", "npm run reset"],
|
||||
},
|
||||
},
|
||||
revision: statPaseoConfigPath(tempDir),
|
||||
revision: await statPaseoConfigPath(tempDir),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps runtime lifecycle commands normalized for execution", () => {
|
||||
it("keeps runtime lifecycle commands normalized for execution", async () => {
|
||||
writeFileSync(
|
||||
join(tempDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
@@ -74,15 +248,15 @@ describe("paseo config file substrate", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getWorktreeSetupCommands(tempDir)).toEqual(["npm install"]);
|
||||
expect(getWorktreeTeardownCommands(tempDir)).toEqual(["npm run clean", "npm run reset"]);
|
||||
expect(await getWorktreeSetupCommands(tempDir)).toEqual(["npm install"]);
|
||||
expect(await getWorktreeTeardownCommands(tempDir)).toEqual(["npm run clean", "npm run reset"]);
|
||||
});
|
||||
|
||||
it("writes pretty JSON with a trailing newline when revision matches", () => {
|
||||
it("writes pretty JSON with a trailing newline when revision matches", async () => {
|
||||
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
|
||||
const expectedRevision = statPaseoConfigPath(tempDir);
|
||||
const expectedRevision = await statPaseoConfigPath(tempDir);
|
||||
|
||||
const result = writePaseoConfigForEdit({
|
||||
const result = await writePaseoConfigForEdit({
|
||||
repoRoot: tempDir,
|
||||
config: { worktree: { setup: "npm install" } },
|
||||
expectedRevision,
|
||||
@@ -91,7 +265,7 @@ describe("paseo config file substrate", () => {
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm install" } },
|
||||
revision: statPaseoConfigPath(tempDir),
|
||||
revision: await statPaseoConfigPath(tempDir),
|
||||
});
|
||||
expect(readFileSync(join(tempDir, "paseo.json"), "utf8")).toBe(
|
||||
'{\n "worktree": {\n "setup": "npm install"\n }\n}\n',
|
||||
@@ -101,13 +275,13 @@ describe("paseo config file substrate", () => {
|
||||
// POSIX-only: Windows mtime granularity can collapse the two revisions in this fixture.
|
||||
it.skipIf(isPlatform("win32"))(
|
||||
"rejects stale writes when the current revision changed before rename",
|
||||
() => {
|
||||
async () => {
|
||||
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } }));
|
||||
const expectedRevision = statPaseoConfigPath(tempDir);
|
||||
const expectedRevision = await statPaseoConfigPath(tempDir);
|
||||
writeFileSync(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "new" } }));
|
||||
const currentRevision = statPaseoConfigPath(tempDir);
|
||||
const currentRevision = await statPaseoConfigPath(tempDir);
|
||||
|
||||
const result = writePaseoConfigForEdit({
|
||||
const result = await writePaseoConfigForEdit({
|
||||
repoRoot: tempDir,
|
||||
config: { worktree: { setup: "from editor" } },
|
||||
expectedRevision,
|
||||
@@ -123,7 +297,7 @@ describe("paseo config file substrate", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("round-trips unknown top-level, worktree, and script-entry fields", () => {
|
||||
it("round-trips unknown top-level, worktree, and script-entry fields", async () => {
|
||||
const config = {
|
||||
extraTop: { keep: true },
|
||||
worktree: {
|
||||
@@ -139,7 +313,7 @@ describe("paseo config file substrate", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = writePaseoConfigForEdit({
|
||||
const result = await writePaseoConfigForEdit({
|
||||
repoRoot: tempDir,
|
||||
config,
|
||||
expectedRevision: null,
|
||||
@@ -148,20 +322,20 @@ describe("paseo config file substrate", () => {
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
config,
|
||||
revision: statPaseoConfigPath(tempDir),
|
||||
revision: await statPaseoConfigPath(tempDir),
|
||||
});
|
||||
expect(readPaseoConfigForEdit(tempDir)).toEqual({
|
||||
expect(await readPaseoConfigForEdit(tempDir)).toEqual({
|
||||
ok: true,
|
||||
config,
|
||||
revision: statPaseoConfigPath(tempDir),
|
||||
revision: await statPaseoConfigPath(tempDir),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns write_failed for filesystem write exceptions", () => {
|
||||
it("returns write_failed for filesystem write exceptions", async () => {
|
||||
const fileRoot = join(tempDir, "not-a-directory");
|
||||
writeFileSync(fileRoot, "file");
|
||||
|
||||
const result = writePaseoConfigForEdit({
|
||||
const result = await writePaseoConfigForEdit({
|
||||
repoRoot: fileRoot,
|
||||
config: { worktree: { setup: "npm install" } },
|
||||
expectedRevision: null,
|
||||
@@ -173,10 +347,10 @@ describe("paseo config file substrate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("creates paseo.json when the file is still missing and expected revision is null", () => {
|
||||
it("creates paseo.json when the file is still missing and expected revision is null", async () => {
|
||||
mkdirSync(join(tempDir, "nested"));
|
||||
|
||||
const result = writePaseoConfigForEdit({
|
||||
const result = await writePaseoConfigForEdit({
|
||||
repoRoot: join(tempDir, "nested"),
|
||||
config: { scripts: { dev: { command: "npm run dev" } } },
|
||||
expectedRevision: null,
|
||||
@@ -185,7 +359,7 @@ describe("paseo config file substrate", () => {
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
config: { scripts: { dev: { command: "npm run dev" } } },
|
||||
revision: statPaseoConfigPath(join(tempDir, "nested")),
|
||||
revision: await statPaseoConfigPath(join(tempDir, "nested")),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import { open, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import pLimit from "p-limit";
|
||||
import { withTimeout } from "./promise-timeout.js";
|
||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
PaseoConfigRawSchema,
|
||||
PaseoConfigSchema,
|
||||
type PaseoConfig,
|
||||
type PaseoConfigRaw,
|
||||
type PaseoConfigRevision,
|
||||
type ProjectConfigRpcError,
|
||||
@@ -15,15 +21,18 @@ export {
|
||||
} from "@getpaseo/protocol/paseo-config-schema";
|
||||
|
||||
export const PASEO_CONFIG_FILE_NAME = "paseo.json";
|
||||
// Configuration is deliberately small. Bound both IO and main-thread JSON/schema work.
|
||||
export const MAX_PASEO_CONFIG_BYTES = 256 * 1024;
|
||||
|
||||
export type ReadPaseoConfigForEditResult =
|
||||
| { ok: true; config: PaseoConfigRaw | null; revision: PaseoConfigRevision | null }
|
||||
| { ok: false; error: ProjectConfigRpcError };
|
||||
|
||||
export type ReadPaseoConfigResult =
|
||||
| { ok: true; config: PaseoConfig | null }
|
||||
| { ok: false; configPath: string; error: unknown };
|
||||
export type WritePaseoConfigForEditResult =
|
||||
| { ok: true; config: PaseoConfigRaw; revision: PaseoConfigRevision }
|
||||
| { ok: false; error: ProjectConfigRpcError };
|
||||
|
||||
export interface WritePaseoConfigForEditInput {
|
||||
repoRoot: string;
|
||||
config: PaseoConfigRaw;
|
||||
@@ -34,96 +43,272 @@ export function resolvePaseoConfigPath(repoRoot: string): string {
|
||||
return join(repoRoot, PASEO_CONFIG_FILE_NAME);
|
||||
}
|
||||
|
||||
export function statPaseoConfigPath(repoRoot: string): PaseoConfigRevision | null {
|
||||
const configPath = resolvePaseoConfigPath(repoRoot);
|
||||
if (!existsSync(configPath)) {
|
||||
return null;
|
||||
}
|
||||
const stats = statSync(configPath);
|
||||
return {
|
||||
mtimeMs: stats.mtimeMs,
|
||||
size: stats.size,
|
||||
};
|
||||
function revision(stats: Stats): PaseoConfigRevision {
|
||||
return { mtimeMs: stats.mtimeMs, size: stats.size };
|
||||
}
|
||||
|
||||
export function readPaseoConfigJson(repoRoot: string): unknown {
|
||||
const configPath = resolvePaseoConfigPath(repoRoot);
|
||||
if (!existsSync(configPath)) {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(readFileSync(configPath, "utf8"));
|
||||
}
|
||||
|
||||
export function readPaseoConfigForEdit(repoRoot: string): ReadPaseoConfigForEditResult {
|
||||
async function inspect(repoRoot: string): Promise<Stats | null> {
|
||||
try {
|
||||
const json = readPaseoConfigJson(repoRoot);
|
||||
if (json === null) {
|
||||
return { ok: true, config: null, revision: null };
|
||||
return await stat(resolvePaseoConfigPath(repoRoot));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function statPaseoConfigPath(repoRoot: string): Promise<PaseoConfigRevision | null> {
|
||||
const stats = await inspect(repoRoot);
|
||||
return stats ? revision(stats) : null;
|
||||
}
|
||||
|
||||
function admit(stats: Stats): void {
|
||||
if (!stats.isFile() || stats.size > MAX_PASEO_CONFIG_BYTES) {
|
||||
throw new Error(`paseo.json must be a regular file of at most ${MAX_PASEO_CONFIG_BYTES} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
function fingerprint(stats: Stats | null): string {
|
||||
return stats
|
||||
? `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeMs}:${stats.ctimeMs}`
|
||||
: "missing";
|
||||
}
|
||||
|
||||
// The record schema validates every script synchronously. Validate bounded groups
|
||||
// through the same schemas so thousands of script entries do not monopolize a turn.
|
||||
async function validateConfig(json: unknown): Promise<{
|
||||
edit: ReturnType<typeof PaseoConfigRawSchema.safeParse>;
|
||||
runtime: PaseoConfig;
|
||||
}> {
|
||||
if (json === null || typeof json !== "object" || Array.isArray(json)) {
|
||||
return { edit: PaseoConfigRawSchema.safeParse(json), runtime: PaseoConfigSchema.parse(json) };
|
||||
}
|
||||
const scripts = (json as Record<string, unknown>).scripts;
|
||||
if (scripts === null || typeof scripts !== "object" || Array.isArray(scripts)) {
|
||||
return { edit: PaseoConfigRawSchema.safeParse(json), runtime: PaseoConfigSchema.parse(json) };
|
||||
}
|
||||
const withoutScripts = { ...json, scripts: {} };
|
||||
let edit = PaseoConfigRawSchema.safeParse(withoutScripts);
|
||||
const runtime = PaseoConfigSchema.parse(withoutScripts);
|
||||
const entries = Object.entries(scripts);
|
||||
if (entries.length > 32) await nextTurn();
|
||||
let lastYield = performance.now();
|
||||
for (let offset = 0; offset < entries.length; offset += 32) {
|
||||
const group = { scripts: Object.fromEntries(entries.slice(offset, offset + 32)) };
|
||||
if (edit.success) {
|
||||
const parsed = PaseoConfigRawSchema.safeParse(group);
|
||||
if (parsed.success) Object.assign(edit.data.scripts!, parsed.data.scripts);
|
||||
else edit = parsed;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
config: PaseoConfigRawSchema.parse(json),
|
||||
revision: statPaseoConfigPath(repoRoot),
|
||||
};
|
||||
Object.assign(runtime.scripts!, PaseoConfigSchema.parse(group).scripts);
|
||||
if (performance.now() - lastYield >= 2) {
|
||||
await nextTurn();
|
||||
lastYield = performance.now();
|
||||
}
|
||||
}
|
||||
return { edit, runtime };
|
||||
}
|
||||
|
||||
interface Snapshot {
|
||||
fingerprint: string;
|
||||
edit: ReadPaseoConfigForEditResult;
|
||||
runtime: ReadPaseoConfigResult;
|
||||
}
|
||||
|
||||
/** Owns config admission, snapshots and daemon read/modify/write ordering for every consumer. */
|
||||
class ProjectConfigOwner {
|
||||
// A stalled mount retains its slot until the OS returns; never consume the whole fs pool.
|
||||
private readonly filesystemOperations = pLimit(2);
|
||||
private readonly pendingAliases = new Map<string, Promise<string>>();
|
||||
private readonly snapshots = new Map<string, Snapshot>();
|
||||
private readonly pending = new Map<
|
||||
string,
|
||||
{ kind: "read" | "write"; promise: Promise<unknown> }
|
||||
>();
|
||||
|
||||
async resolveRoot(repoRoot: string, activeRoots: Set<string>): Promise<string | null> {
|
||||
const root = resolve(repoRoot);
|
||||
if (activeRoots.has(root)) return root;
|
||||
let pending = this.pendingAliases.get(root);
|
||||
if (!pending) {
|
||||
pending = this.filesystemOperations(() => realpath(root)).finally(() =>
|
||||
this.pendingAliases.delete(root),
|
||||
);
|
||||
this.pendingAliases.set(root, pending);
|
||||
}
|
||||
try {
|
||||
const alias = await withTimeout(pending, 5_000, "Project root unavailable");
|
||||
return activeRoots.has(alias) ? alias : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async serialized<T>(
|
||||
root: string,
|
||||
kind: "read" | "write",
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previous = this.pending.get(root);
|
||||
if (kind === "read" && previous?.kind === "read") return previous.promise as Promise<T>;
|
||||
const next = (async () => {
|
||||
await previous?.promise.catch(() => undefined);
|
||||
return this.filesystemOperations(operation);
|
||||
})();
|
||||
this.pending.set(root, { kind, promise: next });
|
||||
try {
|
||||
return await next;
|
||||
} finally {
|
||||
if (this.pending.get(root)?.promise === next) this.pending.delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
private remember(root: string, snapshot: Snapshot): Snapshot {
|
||||
this.snapshots.delete(root);
|
||||
this.snapshots.set(root, snapshot);
|
||||
// Worktrees come and go; retain only a bounded set of reusable snapshots.
|
||||
if (this.snapshots.size > 256) this.snapshots.delete(this.snapshots.keys().next().value!);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async read(repoRoot: string): Promise<Snapshot> {
|
||||
const root = resolve(repoRoot);
|
||||
return withTimeout(
|
||||
this.serialized(root, "read", () => this.load(root)),
|
||||
5_000,
|
||||
"Project config unavailable",
|
||||
);
|
||||
}
|
||||
|
||||
private async load(root: string): Promise<Snapshot> {
|
||||
const stats = await inspect(root);
|
||||
if (stats) admit(stats);
|
||||
const key = fingerprint(stats);
|
||||
const cached = this.snapshots.get(root);
|
||||
if (cached?.fingerprint === key) return this.remember(root, cached);
|
||||
let json: unknown = null;
|
||||
if (stats) {
|
||||
// O_NONBLOCK prevents a regular-file -> FIFO replacement between stat and open
|
||||
// from waiting for a writer. Check the opened descriptor again before any read.
|
||||
const handle = await open(
|
||||
resolvePaseoConfigPath(root),
|
||||
constants.O_RDONLY | constants.O_NONBLOCK,
|
||||
);
|
||||
try {
|
||||
const openedStats = await handle.stat();
|
||||
admit(openedStats);
|
||||
const bytes = Buffer.alloc(MAX_PASEO_CONFIG_BYTES + 1);
|
||||
let length = 0;
|
||||
while (length < bytes.length) {
|
||||
const { bytesRead } = await handle.read(bytes, length, bytes.length - length, null);
|
||||
if (bytesRead === 0) break;
|
||||
length += bytesRead;
|
||||
}
|
||||
if (length > MAX_PASEO_CONFIG_BYTES) throw new Error("paseo.json exceeds byte limit");
|
||||
// Never publish bytes under a revision that did not describe the opened file.
|
||||
if (fingerprint(await handle.stat()) !== fingerprint(openedStats)) {
|
||||
throw new Error("paseo.json changed while reading; retry the operation");
|
||||
}
|
||||
try {
|
||||
json = JSON.parse(bytes.toString("utf8", 0, length));
|
||||
} catch (error) {
|
||||
return this.remember(root, {
|
||||
fingerprint: key,
|
||||
edit: { ok: false, error: { code: "invalid_project_config" } },
|
||||
runtime: { ok: false, configPath: resolvePaseoConfigPath(root), error },
|
||||
});
|
||||
}
|
||||
if (fingerprint(openedStats) !== key) {
|
||||
throw new Error("paseo.json was replaced while reading; retry the operation");
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
const validated = json === null ? null : await validateConfig(json);
|
||||
const parsed = validated?.edit ?? null;
|
||||
let edit: ReadPaseoConfigForEditResult = { ok: true, config: null, revision: null };
|
||||
if (parsed !== null) {
|
||||
edit = parsed.success
|
||||
? { ok: true, config: parsed.data, revision: stats ? revision(stats) : null }
|
||||
: { ok: false, error: { code: "invalid_project_config" } };
|
||||
}
|
||||
return this.remember(root, {
|
||||
fingerprint: key,
|
||||
edit,
|
||||
runtime: { ok: true, config: validated?.runtime ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
async write(input: WritePaseoConfigForEditInput): Promise<WritePaseoConfigForEditResult> {
|
||||
const root = resolve(input.repoRoot);
|
||||
return this.serialized(root, "write", async () => {
|
||||
const { edit: parsed } = await validateConfig(input.config);
|
||||
if (!parsed.success) return { ok: false, error: { code: "invalid_project_config" } };
|
||||
const content = `${JSON.stringify(parsed.data, null, 2)}\n`;
|
||||
if (Buffer.byteLength(content) > MAX_PASEO_CONFIG_BYTES) {
|
||||
return { ok: false, error: { code: "invalid_project_config" } };
|
||||
}
|
||||
const tempPath = join(root, `.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`);
|
||||
try {
|
||||
await writeFile(tempPath, content, { flag: "wx" });
|
||||
const savedRevision = revision(await stat(tempPath));
|
||||
const currentStats = await inspect(root);
|
||||
if (currentStats) admit(currentStats);
|
||||
const currentRevision = currentStats ? revision(currentStats) : null;
|
||||
if (!paseoConfigRevisionsEqual(currentRevision, input.expectedRevision)) {
|
||||
return { ok: false, error: { code: "stale_project_config", currentRevision } };
|
||||
}
|
||||
// Serialized against daemon edits. External writers still have the existing
|
||||
// optimistic stat/rename race; revision tokens remain mtime + size.
|
||||
await rename(tempPath, resolvePaseoConfigPath(root));
|
||||
this.snapshots.delete(root);
|
||||
return { ok: true, config: parsed.data, revision: savedRevision };
|
||||
} catch {
|
||||
return { ok: false, error: { code: "write_failed" } };
|
||||
} finally {
|
||||
await rm(tempPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const projectConfig = new ProjectConfigOwner();
|
||||
|
||||
export function resolveProjectConfigRoot(
|
||||
repoRoot: string,
|
||||
activeRoots: Set<string>,
|
||||
): Promise<string | null> {
|
||||
return projectConfig.resolveRoot(repoRoot, activeRoots);
|
||||
}
|
||||
|
||||
export async function readPaseoConfig(repoRoot: string): Promise<ReadPaseoConfigResult> {
|
||||
try {
|
||||
return (await projectConfig.read(repoRoot)).runtime;
|
||||
} catch (error) {
|
||||
return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error };
|
||||
}
|
||||
}
|
||||
|
||||
export async function readPaseoConfigForEdit(
|
||||
repoRoot: string,
|
||||
): Promise<ReadPaseoConfigForEditResult> {
|
||||
try {
|
||||
return (await projectConfig.read(repoRoot)).edit;
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: "invalid_project_config" },
|
||||
};
|
||||
return { ok: false, error: { code: "invalid_project_config" } };
|
||||
}
|
||||
}
|
||||
|
||||
export function writePaseoConfigForEdit(
|
||||
input: WritePaseoConfigForEditInput,
|
||||
): WritePaseoConfigForEditResult {
|
||||
const parsed = PaseoConfigRawSchema.safeParse(input.config);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: { code: "invalid_project_config" } };
|
||||
}
|
||||
|
||||
const configPath = resolvePaseoConfigPath(input.repoRoot);
|
||||
const tempPath = join(
|
||||
input.repoRoot,
|
||||
`.${PASEO_CONFIG_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`,
|
||||
);
|
||||
|
||||
try {
|
||||
writeFileSync(tempPath, `${JSON.stringify(parsed.data, null, 2)}\n`);
|
||||
const currentRevision = statPaseoConfigPath(input.repoRoot);
|
||||
if (!paseoConfigRevisionsEqual(currentRevision, input.expectedRevision)) {
|
||||
removeTempPaseoConfig(tempPath);
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: "stale_project_config", currentRevision },
|
||||
};
|
||||
}
|
||||
|
||||
renameSync(tempPath, configPath);
|
||||
const revision = statPaseoConfigPath(input.repoRoot);
|
||||
if (!revision) {
|
||||
return { ok: false, error: { code: "write_failed" } };
|
||||
}
|
||||
return { ok: true, config: parsed.data, revision };
|
||||
} catch {
|
||||
removeTempPaseoConfig(tempPath);
|
||||
return { ok: false, error: { code: "write_failed" } };
|
||||
}
|
||||
): Promise<WritePaseoConfigForEditResult> {
|
||||
return projectConfig.write(input);
|
||||
}
|
||||
|
||||
function paseoConfigRevisionsEqual(
|
||||
left: PaseoConfigRevision | null,
|
||||
right: PaseoConfigRevision | null,
|
||||
): boolean {
|
||||
if (left === null || right === null) {
|
||||
return left === right;
|
||||
}
|
||||
if (left === null || right === null) return left === right;
|
||||
return left.mtimeMs === right.mtimeMs && left.size === right.size;
|
||||
}
|
||||
|
||||
function removeTempPaseoConfig(tempPath: string): void {
|
||||
try {
|
||||
rmSync(tempPath, { force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup only; callers need the original write outcome.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ import { delimiter, dirname, join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import net from "node:net";
|
||||
|
||||
function loadConfigForTest(repoRoot: string): PaseoConfig | null {
|
||||
const result = readPaseoConfig(repoRoot);
|
||||
async function loadConfigForTest(repoRoot: string): Promise<PaseoConfig | null> {
|
||||
const result = await readPaseoConfig(repoRoot);
|
||||
return result.ok ? result.config : null;
|
||||
}
|
||||
|
||||
@@ -894,7 +894,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
worktreePath: result.worktreePath,
|
||||
branchName: result.branchName,
|
||||
});
|
||||
expect(getWorktreeSetupCommands(result.worktreePath)).toEqual([
|
||||
expect(await getWorktreeSetupCommands(result.worktreePath)).toEqual([
|
||||
'greeting="hello from string setup"\necho "$greeting" > setup.log',
|
||||
]);
|
||||
expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8").trim()).toBe(
|
||||
@@ -963,7 +963,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
expect(readFileSync(join(repoDir, "setup-path.log"), "utf8").trim()).toBe("shim:ok");
|
||||
});
|
||||
|
||||
it("treats blank lifecycle strings as empty", () => {
|
||||
it("treats blank lifecycle strings as empty", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
@@ -974,11 +974,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getWorktreeSetupCommands(repoDir)).toEqual([]);
|
||||
expect(getWorktreeTeardownCommands(repoDir)).toEqual([]);
|
||||
expect(await getWorktreeSetupCommands(repoDir)).toEqual([]);
|
||||
expect(await getWorktreeTeardownCommands(repoDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters non-string and blank entries from lifecycle arrays", () => {
|
||||
it("filters non-string and blank entries from lifecycle arrays", async () => {
|
||||
writeFileSync(
|
||||
join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
@@ -999,11 +999,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getWorktreeSetupCommands(repoDir)).toEqual([
|
||||
expect(await getWorktreeSetupCommands(repoDir)).toEqual([
|
||||
'echo "first" > setup-array.log',
|
||||
'echo "second" >> setup-array.log',
|
||||
]);
|
||||
expect(getWorktreeTeardownCommands(repoDir)).toEqual([
|
||||
expect(await getWorktreeTeardownCommands(repoDir)).toEqual([
|
||||
'echo "first" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"',
|
||||
'echo "second" >> "$PASEO_SOURCE_CHECKOUT_PATH/teardown-array.log"',
|
||||
]);
|
||||
@@ -1134,7 +1134,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
|
||||
expect(getWorktreeTerminalSpecs(repoDir)).toEqual([
|
||||
expect(await getWorktreeTerminalSpecs(repoDir)).toEqual([
|
||||
{ name: "Dev Server", command: "npm run dev" },
|
||||
{ command: "cd packages/app && npm run dev" },
|
||||
]);
|
||||
@@ -1154,7 +1154,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
};
|
||||
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
|
||||
|
||||
expect(getWorktreeTerminalSpecs(repoDir)).toEqual([
|
||||
expect(await getWorktreeTerminalSpecs(repoDir)).toEqual([
|
||||
{ name: "Watch", command: "npm run watch" },
|
||||
{ command: "npm run test" },
|
||||
]);
|
||||
@@ -1172,7 +1172,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir));
|
||||
const scriptConfigs = getScriptConfigs(await loadConfigForTest(repoDir));
|
||||
const typecheck = scriptConfigs.get("typecheck");
|
||||
|
||||
expect(typecheck).toEqual({
|
||||
@@ -1196,7 +1196,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const scriptConfigs = getScriptConfigs(loadConfigForTest(repoDir));
|
||||
const scriptConfigs = getScriptConfigs(await loadConfigForTest(repoDir));
|
||||
const server = scriptConfigs.get("server");
|
||||
|
||||
expect(server).toEqual({
|
||||
@@ -1236,7 +1236,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getScriptConfigs(loadConfigForTest(repoDir))).toEqual(
|
||||
expect(getScriptConfigs(await loadConfigForTest(repoDir))).toEqual(
|
||||
new Map([
|
||||
["valid", { command: "npm run valid" }],
|
||||
["invalidType", { command: "npm run worker" }],
|
||||
@@ -1547,7 +1547,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
|
||||
await deletePaseoWorktree({ cwd: repoDir, worktreePath: created.worktreePath, paseoHome });
|
||||
|
||||
expect(getWorktreeTeardownCommands(repoDir)).toEqual([
|
||||
expect(await getWorktreeTeardownCommands(repoDir)).toEqual([
|
||||
'cleanup_message="teardown string"\necho "$cleanup_message" > "$PASEO_SOURCE_CHECKOUT_PATH/teardown.log"',
|
||||
]);
|
||||
expect(readFileSync(join(repoDir, "teardown.log"), "utf8").trim()).toBe("teardown string");
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
buildStringCommandShellInvocation,
|
||||
createStringCommandShellEnv,
|
||||
} from "./string-command-shell.js";
|
||||
import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js";
|
||||
import { readPaseoConfig } from "./paseo-config-file.js";
|
||||
export {
|
||||
PaseoConfigRawSchema,
|
||||
PaseoLifecycleCommandRawSchema,
|
||||
@@ -20,7 +20,7 @@ export {
|
||||
type PaseoConfig,
|
||||
type PaseoConfigRaw,
|
||||
} from "@getpaseo/protocol/paseo-config-schema";
|
||||
import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import { type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||
import {
|
||||
createPaseoWorktreeChangeRequestHint,
|
||||
normalizeBaseRefName,
|
||||
@@ -246,21 +246,7 @@ export class InvalidGitBranchNameError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type ReadPaseoConfigResult =
|
||||
| { ok: true; config: PaseoConfig | null }
|
||||
| { ok: false; configPath: string; error: unknown };
|
||||
|
||||
export function readPaseoConfig(repoRoot: string): ReadPaseoConfigResult {
|
||||
try {
|
||||
const json = readPaseoConfigJson(repoRoot);
|
||||
if (json === null) {
|
||||
return { ok: true, config: null };
|
||||
}
|
||||
return { ok: true, config: PaseoConfigSchema.parse(json) };
|
||||
} catch (error) {
|
||||
return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error };
|
||||
}
|
||||
}
|
||||
export { readPaseoConfig, type ReadPaseoConfigResult } from "./paseo-config-file.js";
|
||||
|
||||
export function paseoConfigParseError(failure: { configPath: string; error: unknown }): Error {
|
||||
const detail = failure.error instanceof Error ? failure.error.message : String(failure.error);
|
||||
@@ -269,24 +255,26 @@ export function paseoConfigParseError(failure: { configPath: string; error: unkn
|
||||
});
|
||||
}
|
||||
|
||||
function readPaseoConfigOrThrow(repoRoot: string): PaseoConfig | null {
|
||||
const result = readPaseoConfig(repoRoot);
|
||||
async function readPaseoConfigOrThrow(repoRoot: string): Promise<PaseoConfig | null> {
|
||||
const result = await readPaseoConfig(repoRoot);
|
||||
if (!result.ok) {
|
||||
throw paseoConfigParseError(result);
|
||||
}
|
||||
return result.config;
|
||||
}
|
||||
|
||||
export function getWorktreeSetupCommands(repoRoot: string): string[] {
|
||||
return readPaseoConfigOrThrow(repoRoot)?.worktree?.setup ?? [];
|
||||
export async function getWorktreeSetupCommands(repoRoot: string): Promise<string[]> {
|
||||
return (await readPaseoConfigOrThrow(repoRoot))?.worktree?.setup ?? [];
|
||||
}
|
||||
|
||||
export function getWorktreeTeardownCommands(repoRoot: string): string[] {
|
||||
return readPaseoConfigOrThrow(repoRoot)?.worktree?.teardown ?? [];
|
||||
export async function getWorktreeTeardownCommands(repoRoot: string): Promise<string[]> {
|
||||
return (await readPaseoConfigOrThrow(repoRoot))?.worktree?.teardown ?? [];
|
||||
}
|
||||
|
||||
export function getWorktreeTerminalSpecs(repoRoot: string): WorktreeTerminalConfig[] {
|
||||
const terminals = readPaseoConfigOrThrow(repoRoot)?.worktree?.terminals;
|
||||
export async function getWorktreeTerminalSpecs(
|
||||
repoRoot: string,
|
||||
): Promise<WorktreeTerminalConfig[]> {
|
||||
const terminals = (await readPaseoConfigOrThrow(repoRoot))?.worktree?.terminals;
|
||||
if (!Array.isArray(terminals) || terminals.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -641,7 +629,7 @@ export async function runWorktreeSetupCommands(options: {
|
||||
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
|
||||
}): Promise<WorktreeSetupCommandResult[]> {
|
||||
// Read paseo.json from the worktree (it will have the same content as the source repo)
|
||||
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
|
||||
const setupCommands = await getWorktreeSetupCommands(options.worktreePath);
|
||||
if (setupCommands.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -745,7 +733,7 @@ export async function runWorktreeTeardownCommands(options: {
|
||||
if (getRealpathAwareRelativePath(options.worktreePath, teardownCwd) === null) {
|
||||
throw new Error(`Worktree teardown cwd is outside the worktree: ${teardownCwd}`);
|
||||
}
|
||||
const teardownCommands = getWorktreeTeardownCommands(teardownCwd);
|
||||
const teardownCommands = await getWorktreeTeardownCommands(teardownCwd);
|
||||
if (teardownCommands.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user