210 lines · 7.0 KB · TypeScript
RawDownload
import { Effect, Schema } from "effect";import * as fs from "node:fs";import * as path from "node:path"; import {  definePlugin,  StorageError,  type PluginCtx,  type SecretProvider,} from "@executor/sdk"; // ---------------------------------------------------------------------------// XDG data dir resolution// --------------------------------------------------------------------------- const APP_NAME = "executor"; export const xdgDataHome = (): string => {  if (process.env.XDG_DATA_HOME?.trim()) return process.env.XDG_DATA_HOME.trim();  if (process.platform === "win32") {    return (      process.env.LOCALAPPDATA ||      process.env.APPDATA ||      path.join(process.env.USERPROFILE || "~", "AppData", "Local")    );  }  return path.join(process.env.HOME || "~", ".local", "share");}; const authDir = (overrideDir?: string): string =>  overrideDir ?? path.join(xdgDataHome(), APP_NAME); const authFilePath = (overrideDir?: string): string =>  path.join(authDir(overrideDir), "auth.json"); // ---------------------------------------------------------------------------// Schema for the auth file//// Top-level keys are scope IDs, values are { secretId: secretValue } maps.//   { "web-a1b2c3d4": { "github-token": "ghp_xxx" } }// --------------------------------------------------------------------------- const ScopedAuthFile = Schema.Record({  key: Schema.String,  value: Schema.Record({ key: Schema.String, value: Schema.String }),});const decodeScopedAuthFile = Schema.decodeUnknownSync(ScopedAuthFile); // ---------------------------------------------------------------------------// File I/O with restricted permissions//// These helpers throw on real I/O or decode failures — the provider wraps// every call in `Effect.try` so those throws surface as typed// `StorageError` on the Effect error channel. Previously `readFullFile`// used a blanket `try { ... } catch { return {}; }` which masked JSON// parse errors, schema decode failures, and permission errors as// "empty file", making misconfigured installs silently return null from// every `get`.// --------------------------------------------------------------------------- const readFullFile = (filePath: string): Record<string, Record<string, string>> => {  if (!fs.existsSync(filePath)) return {};  let raw: string;  try {    raw = fs.readFileSync(filePath, "utf-8");  } catch (cause) {    // Treat "file disappeared between existsSync and readFileSync" as    // absence — anything else (EACCES, EISDIR, …) propagates.    if ((cause as NodeJS.ErrnoException).code === "ENOENT") return {};    throw cause;  }  return decodeScopedAuthFile(JSON.parse(raw));}; const readScopeSecrets = (filePath: string, scopeId: string): Record<string, string> =>  readFullFile(filePath)[scopeId] ?? {}; const writeScopeSecrets = (  filePath: string,  scopeId: string,  secrets: Record<string, string>,): void => {  const dir = path.dirname(filePath);  if (!fs.existsSync(dir)) {    fs.mkdirSync(dir, { recursive: true, mode: 0o700 });  }  const full = readFullFile(filePath);  if (Object.keys(secrets).length === 0) {    delete full[scopeId];  } else {    full[scopeId] = secrets;  }  const tmp = `${filePath}.tmp`;  fs.writeFileSync(tmp, JSON.stringify(full, null, 2), { mode: 0o600 });  fs.renameSync(tmp, filePath);}; // ---------------------------------------------------------------------------// Plugin config// --------------------------------------------------------------------------- export interface FileSecretsPluginConfig {  /** Override the directory for auth.json (default: XDG data dir) */  readonly directory?: string;} // ---------------------------------------------------------------------------// Plugin extension — public API on executor.fileSecrets// --------------------------------------------------------------------------- export interface FileSecretsExtension {  /** Path to the auth file */  readonly filePath: string;} // ---------------------------------------------------------------------------// Provider factory (internal)// --------------------------------------------------------------------------- const toStorageError = (cause: unknown) =>  new StorageError({    message: cause instanceof Error ? cause.message : String(cause),    cause,  }); // Scope arg is honored at every call: the auth.json is partitioned by// scope id, so read/write/delete route to `file[scope][secretId]`. The// provider is a singleton per executor; scope routing happens via the// arg passed from the executor's secrets facade.//// `list` enumerates the innermost scope the provider was configured// for — the executor's fallback/list path passes scope separately but// the SecretProvider.list signature is scope-agnostic. That's fine for// the current use: `list` feeds `secrets.list()` which already walks// the stack at the caller layer. Innermost-first is the display default.const makeScopedProvider = (  filePath: string,  listScope: string,): SecretProvider => ({  key: "file",  writable: true,   get: (secretId, scope) =>    Effect.try({      try: () => {        const data = readScopeSecrets(filePath, scope);        return data[secretId] ?? null;      },      catch: toStorageError,    }),   set: (secretId, value, scope) =>    Effect.try({      try: () => {        const data = readScopeSecrets(filePath, scope);        data[secretId] = value;        writeScopeSecrets(filePath, scope, data);      },      catch: toStorageError,    }),   delete: (secretId, scope) =>    Effect.try({      try: () => {        const data = readScopeSecrets(filePath, scope);        const had = secretId in data;        delete data[secretId];        if (had) writeScopeSecrets(filePath, scope, data);        return had;      },      catch: toStorageError,    }),   list: () =>    Effect.try({      try: () => {        const data = readScopeSecrets(filePath, listScope);        return Object.keys(data).map((k) => ({ id: k, name: k }));      },      catch: toStorageError,    }),}); // ---------------------------------------------------------------------------// Plugin definition//// Compute the scoped file path identically in `extension` (for `filePath`)// and `secretProviders` (for the provider's read/write). Both receive ctx// and both are called once per createExecutor.// --------------------------------------------------------------------------- const resolveFilePath = (config: FileSecretsPluginConfig | undefined): string =>  authFilePath(config?.directory); export const fileSecretsPlugin = definePlugin(  (options?: FileSecretsPluginConfig) => ({    id: "fileSecrets" as const,    storage: () => ({}),     extension: (_ctx): FileSecretsExtension => ({      filePath: resolveFilePath(options),    }),     secretProviders: (ctx: PluginCtx<unknown>) => [      // list() falls back to the innermost scope for display; per-call      // get/set/delete honor the scope arg threaded from the secrets facade.      makeScopedProvider(resolveFilePath(options), ctx.scopes[0]!.id as string),    ],  }),);