mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
227 lines
7.1 KiB
TypeScript
227 lines
7.1 KiB
TypeScript
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
renameSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { dirname, normalize } from "node:path";
|
|
import type {
|
|
ExtensionCommandContext,
|
|
ExtensionContext,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
|
|
import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
|
|
import {
|
|
getGlobalConfigPath,
|
|
getLegacyExtensionConfigPath,
|
|
getLegacyGlobalPolicyPath,
|
|
getLegacyProjectPolicyPath,
|
|
} from "./config-paths";
|
|
import { buildResolvedConfigLogEntry } from "./config-reporter";
|
|
import {
|
|
DEFAULT_EXTENSION_CONFIG,
|
|
EXTENSION_ROOT,
|
|
normalizePermissionSystemConfig,
|
|
type PermissionSystemExtensionConfig,
|
|
} from "./extension-config";
|
|
import type { ResolvedPolicyPaths } from "./policy-loader";
|
|
import type { DebugReviewLogger } from "./session-logger";
|
|
import { syncPermissionSystemStatus } from "./status";
|
|
|
|
/** Read-only view of the current config — for consumers that only read. */
|
|
export interface ConfigReader {
|
|
current(): PermissionSystemExtensionConfig;
|
|
}
|
|
|
|
/**
|
|
* Narrow subset of `ConfigStore` that `PermissionSession` depends on.
|
|
*
|
|
* Using an interface rather than the concrete class avoids private-member
|
|
* coupling between the class and test doubles.
|
|
*/
|
|
export interface SessionConfigStore extends ConfigReader {
|
|
refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void;
|
|
logResolvedPaths(cwd?: string): void;
|
|
}
|
|
|
|
/**
|
|
* Narrow subset of `ConfigStore` for the `/permission-system` command.
|
|
*
|
|
* Using an interface rather than the concrete class avoids private-member
|
|
* coupling between the class and test doubles.
|
|
*/
|
|
export interface CommandConfigStore extends ConfigReader {
|
|
save(
|
|
next: PermissionSystemExtensionConfig,
|
|
ctx: ExtensionCommandContext,
|
|
): void;
|
|
}
|
|
|
|
/** Narrow view of the manager's resolved policy paths (for `logResolvedPaths`). */
|
|
export interface ResolvedPolicyPathProvider {
|
|
getResolvedPolicyPaths(): ResolvedPolicyPaths;
|
|
}
|
|
|
|
export interface ConfigStoreDeps {
|
|
agentDir: string;
|
|
policyPaths: ResolvedPolicyPathProvider;
|
|
logger: DebugReviewLogger;
|
|
}
|
|
|
|
/**
|
|
* Owns the mutable extension config and the operations that read/write it.
|
|
*
|
|
* Replaces the three `(runtime, …)` config free functions
|
|
* (`refreshExtensionConfig`, `saveExtensionConfig`, `logResolvedConfigPaths`)
|
|
* with methods that privately own `config` and `lastConfigWarning`.
|
|
*
|
|
* Implements {@link ConfigReader} so consumers that only read the current config
|
|
* can depend on the narrow interface rather than the full class.
|
|
*/
|
|
export class ConfigStore implements SessionConfigStore, CommandConfigStore {
|
|
private config: PermissionSystemExtensionConfig;
|
|
private lastConfigWarning: string | null = null;
|
|
|
|
constructor(private readonly deps: ConfigStoreDeps) {
|
|
this.config = { ...DEFAULT_EXTENSION_CONFIG };
|
|
}
|
|
|
|
/** Return the current extension config. */
|
|
current(): PermissionSystemExtensionConfig {
|
|
return this.config;
|
|
}
|
|
|
|
/**
|
|
* Reload merged config from disk.
|
|
*
|
|
* If `ctx` is provided, uses it to derive the cwd and sync UI status.
|
|
* When `projectTrusted` is `false`, the project scope is withheld so an
|
|
* untrusted repository's runtime config (`yoloMode`, `permissionReviewLog`,
|
|
* …) cannot loosen the operator's global config (#644).
|
|
*/
|
|
refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void {
|
|
const cwd = ctx?.cwd ?? null;
|
|
const mergeResult = loadAndMergeConfigs(
|
|
this.deps.agentDir,
|
|
cwd ?? "",
|
|
EXTENSION_ROOT,
|
|
{ includeProjectScope: projectTrusted },
|
|
);
|
|
const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
|
|
this.config = runtimeConfig;
|
|
|
|
if (ctx?.hasUI) {
|
|
syncPermissionSystemStatus(ctx, runtimeConfig);
|
|
}
|
|
|
|
const warning =
|
|
mergeResult.issues.length > 0 ? mergeResult.issues.join("\n") : undefined;
|
|
|
|
if (warning && warning !== this.lastConfigWarning) {
|
|
this.lastConfigWarning = warning;
|
|
ctx?.ui.notify(warning, "warning");
|
|
} else if (!warning) {
|
|
this.lastConfigWarning = null;
|
|
}
|
|
|
|
this.deps.logger.debug("config.loaded", {
|
|
warning: warning ?? null,
|
|
debugLog: runtimeConfig.debugLog,
|
|
permissionReviewLog: runtimeConfig.permissionReviewLog,
|
|
yoloMode: runtimeConfig.yoloMode,
|
|
projectTrusted,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save updated runtime knobs to the global config file, then update
|
|
* the current config and sync UI status.
|
|
*
|
|
* Equivalent to `saveExtensionConfig(runtime, next, ctx)`.
|
|
*/
|
|
// Called via the CommandConfigStore interface from config-modal.ts — fallow cannot trace through interfaces.
|
|
// fallow-ignore-next-line unused-class-member
|
|
save(
|
|
next: PermissionSystemExtensionConfig,
|
|
ctx: ExtensionCommandContext,
|
|
): void {
|
|
const normalized = normalizePermissionSystemConfig(next);
|
|
const globalPath = getGlobalConfigPath(this.deps.agentDir);
|
|
|
|
const existing = loadUnifiedConfig(globalPath);
|
|
const merged = {
|
|
...existing.config,
|
|
debugLog: normalized.debugLog,
|
|
permissionReviewLog: normalized.permissionReviewLog,
|
|
yoloMode: normalized.yoloMode,
|
|
};
|
|
|
|
const tmpPath = `${globalPath}.tmp`;
|
|
try {
|
|
mkdirSync(dirname(globalPath), { recursive: true });
|
|
writeFileSync(tmpPath, `${JSON.stringify(merged, null, 2)}\n`, "utf-8");
|
|
renameSync(tmpPath, globalPath);
|
|
} catch (error) {
|
|
try {
|
|
if (existsSync(tmpPath)) {
|
|
unlinkSync(tmpPath);
|
|
}
|
|
} catch {
|
|
// Ignore cleanup failures.
|
|
}
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
ctx.ui.notify(
|
|
`Failed to save permission-system config at '${globalPath}': ${message}`,
|
|
"error",
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.config = normalized;
|
|
syncPermissionSystemStatus(ctx, normalized);
|
|
this.lastConfigWarning = null;
|
|
|
|
this.deps.logger.debug("config.saved", {
|
|
debugLog: normalized.debugLog,
|
|
permissionReviewLog: normalized.permissionReviewLog,
|
|
yoloMode: normalized.yoloMode,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Write the resolved config path set to the review and debug logs.
|
|
*
|
|
* Equivalent to `logResolvedConfigPaths(runtime)`.
|
|
*/
|
|
logResolvedPaths(cwd?: string): void {
|
|
const policyPaths = this.deps.policyPaths.getResolvedPolicyPaths();
|
|
const { agentDir } = this.deps;
|
|
const legacyGlobalPolicyDetected = existsSync(
|
|
getLegacyGlobalPolicyPath(agentDir),
|
|
);
|
|
const legacyProjectPolicyDetected = cwd
|
|
? existsSync(getLegacyProjectPolicyPath(cwd))
|
|
: false;
|
|
const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
|
|
const newGlobalPath = getGlobalConfigPath(agentDir);
|
|
const legacyExtensionConfigDetected =
|
|
normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
|
|
existsSync(legacyExtConfigPath);
|
|
const entry = buildResolvedConfigLogEntry({
|
|
policyPaths,
|
|
legacyGlobalPolicyDetected,
|
|
legacyProjectPolicyDetected,
|
|
legacyExtensionConfigDetected,
|
|
});
|
|
this.deps.logger.review(
|
|
"config.resolved",
|
|
entry as unknown as Record<string, unknown>,
|
|
);
|
|
this.deps.logger.debug(
|
|
"config.resolved",
|
|
entry as unknown as Record<string, unknown>,
|
|
);
|
|
}
|
|
}
|