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
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");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5670,7 +5670,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),
|
||||
@@ -7263,7 +7263,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
@@ -156,14 +156,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([]);
|
||||
});
|
||||
|
||||
@@ -172,7 +178,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([]);
|
||||
});
|
||||
|
||||
@@ -207,7 +213,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,
|
||||
@@ -352,10 +358,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: {
|
||||
@@ -449,7 +457,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");
|
||||
@@ -202,7 +202,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) {
|
||||
@@ -230,7 +230,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