pi-openappa 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/gate.ts CHANGED
@@ -1,20 +1,17 @@
1
1
  /**
2
- * Session gate: protection is ON by default and fixed at session start.
2
+ * Session gate: protection is opt-in, fixed at session start.
3
3
  *
4
- * Opt-outs, most specific first:
5
- * - APPA_GATE=1 / APPA_GATE=0 force on/off for one launch,
6
- * - a project opt-out marker `<cwd>/.pi/no-openappa`,
7
- * - a project marker `<cwd>/.pi/openappa` (which also names that project's
8
- * policy, absolute or cwd-relative),
9
- * - the global `/appa off` marker below; `/appa on` clears it.
10
- * Otherwise the session is protected (the default).
4
+ * Three ways a session becomes protected, most specific first for reporting:
5
+ * - a project marker `<cwd>/.pi/openappa` (project-scoped; optional content
6
+ * names that project's policy, absolute or cwd-relative),
7
+ * - launched with APPA_GATE=1 (the launcher route, mirroring `clappa`), or
8
+ * - always-on mode, persisted by `/appa on` (marker file below).
11
9
  *
12
10
  * An explicit APPA_CONFIG always wins as the policy source; otherwise a
13
- * project marker's content is used; otherwise APPA's own default. The gate is
11
+ * project marker's content is used; otherwise the settings file's `config`;
12
+ * otherwise APPA's own default. The gate is
14
13
  * captured once per session so a session cannot disable its own protection
15
14
  * mid-run; `/appa on|off` are deliberate user commands and do re-resolve.
16
- * The legacy `always-on` marker from opt-in days is ignored; `/appa on`
17
- * removes it.
18
15
  */
19
16
 
20
17
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -29,71 +26,92 @@ function baseConfigDir(env: NodeJS.ProcessEnv): string {
29
26
  return join(env.HOME ?? "", ".config");
30
27
  }
31
28
 
32
- /** Global opt-out written by `/appa off`; `/appa on` removes it. */
33
- export function globalOffMarkerPath(env: NodeJS.ProcessEnv): string {
34
- return join(baseConfigDir(env), "pi-openappa", "off");
29
+ export function alwaysOnMarkerPath(env: NodeJS.ProcessEnv): string {
30
+ return join(baseConfigDir(env), "pi-openappa", "always-on");
35
31
  }
36
32
 
37
- /** Legacy opt-in marker from before default-on; ignored, cleared by `/appa on`. */
38
- export function legacyAlwaysOnMarkerPath(env: NodeJS.ProcessEnv): string {
39
- return join(baseConfigDir(env), "pi-openappa", "always-on");
33
+ /** The policy a gated session auto-starts the runtime with, by APPA default. */
34
+ export function appaDefaultConfigPath(env: NodeJS.ProcessEnv): string {
35
+ return join(baseConfigDir(env), "appa", "appa.toml");
40
36
  }
41
37
 
42
- export function projectMarkerPath(cwd: string): string {
43
- return join(cwd, ".pi", "openappa");
38
+ /** File-backed settings for this extension (see `ExtensionSettings`). */
39
+ export function settingsPath(env: NodeJS.ProcessEnv): string {
40
+ return join(baseConfigDir(env), "pi-openappa", "settings.json");
41
+ }
42
+
43
+ /**
44
+ * Keys read from `settings.json`. Every key is optional; environment
45
+ * variables of the same meaning always win over the file, and the file
46
+ * always wins over the built-in defaults. Written by `/appa init`.
47
+ */
48
+ export interface ExtensionSettings {
49
+ /** Policy path passed as `--config` (the APPA_CONFIG fallback). */
50
+ config?: string;
51
+ /** Runtime endpoint (the APPA_RUNTIME_URL fallback). */
52
+ runtimeUrl?: string;
53
+ /** Hook binary (the APPA_HOOK_BIN fallback). */
54
+ hookBin?: string;
55
+ /** Hook timeout in ms (the APPA_HOOK_TIMEOUT_MS fallback). */
56
+ hookTimeoutMs?: number;
57
+ }
58
+
59
+ /** Read settings.json; a missing or malformed file resolves to `{}`. */
60
+ export function readSettings(env: NodeJS.ProcessEnv): ExtensionSettings {
61
+ try {
62
+ const parsed: unknown = JSON.parse(readFileSync(settingsPath(env), "utf8"));
63
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
64
+ return {};
65
+ }
66
+ const out: ExtensionSettings = {};
67
+ const record = parsed as Record<string, unknown>;
68
+ for (const key of ["config", "runtimeUrl", "hookBin"] as const) {
69
+ const value = record[key];
70
+ if (typeof value === "string" && value !== "") out[key] = value;
71
+ }
72
+ const timeout = record["hookTimeoutMs"];
73
+ if (typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0) {
74
+ out.hookTimeoutMs = timeout;
75
+ }
76
+ return out;
77
+ } catch {
78
+ return {};
79
+ }
44
80
  }
45
81
 
46
- export function projectNoMarkerPath(cwd: string): string {
47
- return join(cwd, ".pi", "no-openappa");
82
+ export function projectMarkerPath(cwd: string): string {
83
+ return join(cwd, ".pi", "openappa");
48
84
  }
49
85
 
50
- export function isGloballyOff(env: NodeJS.ProcessEnv): boolean {
51
- return existsSync(globalOffMarkerPath(env));
86
+ export function isAlwaysOn(env: NodeJS.ProcessEnv): boolean {
87
+ return existsSync(alwaysOnMarkerPath(env));
52
88
  }
53
89
 
54
- export function setGloballyOff(env: NodeJS.ProcessEnv, off: boolean): void {
55
- const marker = globalOffMarkerPath(env);
56
- if (off) {
90
+ export function setAlwaysOn(env: NodeJS.ProcessEnv, on: boolean): void {
91
+ const marker = alwaysOnMarkerPath(env);
92
+ if (on) {
57
93
  mkdirSync(dirname(marker), { recursive: true });
58
94
  writeFileSync(marker, "");
59
95
  } else {
60
96
  rmSync(marker, { force: true });
61
- rmSync(legacyAlwaysOnMarkerPath(env), { force: true });
62
97
  }
63
98
  }
64
99
 
65
- /**
66
- * Does APPA's own default policy exist? Heuristic mirror of the runtime's
67
- * lookup: `$XDG_CONFIG_HOME/appa/appa.toml` or `~/.config/appa/appa.toml`.
68
- */
69
- export function appaDefaultPolicyExists(env: NodeJS.ProcessEnv): boolean {
70
- const xdg = env.XDG_CONFIG_HOME;
71
- const candidates = [
72
- ...(xdg !== undefined && xdg !== "" ? [join(xdg, "appa", "appa.toml")] : []),
73
- join(env.HOME ?? "", ".config", "appa", "appa.toml"),
74
- ];
75
- return candidates.some((candidate) => existsSync(candidate));
76
- }
77
-
78
- export type GateSource =
79
- | "env-on"
80
- | "env-off"
81
- | "project"
82
- | "project-off"
83
- | "global-off"
84
- | "default";
100
+ export type GateSource = "project" | "env" | "always-on" | "off";
85
101
 
86
102
  export interface GateState {
87
103
  /** Protection active for this session. */
88
104
  gated: boolean;
89
105
  /** Most specific reason this session is (or is not) protected. */
90
106
  source: GateSource;
91
- /** Policy for auto-start: explicit env wins, then project marker content. */
107
+ /** Policy for auto-start: env, then project marker, then settings file. */
92
108
  config?: string;
93
109
  /** Runtime URL for health reporting (the hook binary reads it from env). */
94
110
  runtimeUrl: string;
95
111
  /** Hook binary used for reporting. */
96
112
  hookBin: string;
113
+ /** Hook timeout in ms when settings pin one; otherwise the default applies. */
114
+ hookTimeoutMs?: number;
97
115
  }
98
116
 
99
117
  /** Read a project marker's optional policy path; empty content resolves to none. */
@@ -108,55 +126,40 @@ function projectConfig(cwd: string): string | undefined {
108
126
  }
109
127
 
110
128
  export function captureGate(env: NodeJS.ProcessEnv, cwd?: string): GateState {
129
+ const settings = readSettings(env);
111
130
  const base = {
112
- runtimeUrl: env.APPA_RUNTIME_URL ?? DEFAULT_RUNTIME_URL,
113
- hookBin: env.APPA_HOOK_BIN ?? "appa",
131
+ runtimeUrl: env.APPA_RUNTIME_URL ?? settings.runtimeUrl ?? DEFAULT_RUNTIME_URL,
132
+ hookBin: env.APPA_HOOK_BIN ?? settings.hookBin ?? "appa",
133
+ ...(settings.hookTimeoutMs !== undefined ? { hookTimeoutMs: settings.hookTimeoutMs } : {}),
114
134
  };
115
135
  const explicitConfig =
116
136
  env.APPA_CONFIG !== undefined && env.APPA_CONFIG !== ""
117
137
  ? env.APPA_CONFIG
118
138
  : undefined;
119
139
  const projectGated = cwd !== undefined && existsSync(projectMarkerPath(cwd));
120
- const projectOff = cwd !== undefined && existsSync(projectNoMarkerPath(cwd));
121
140
  const projectCfg =
122
141
  projectGated && cwd !== undefined ? projectConfig(cwd) : undefined;
142
+ // Precedence: launch env, then the project marker (per-project intent),
143
+ // then the global settings file (see readSettings).
144
+ const config = explicitConfig ?? projectCfg ?? settings.config;
123
145
 
124
- // An explicit launch choice beats every marker.
125
- if (env.APPA_GATE === "1") {
126
- const config = explicitConfig ?? projectCfg;
146
+ if (env.APPA_GATE === "1" || projectGated) {
127
147
  return {
128
148
  gated: true,
129
- source: "env-on",
149
+ source: projectGated ? "project" : "env",
130
150
  ...base,
131
151
  ...(config !== undefined ? { config } : {}),
132
152
  };
133
153
  }
134
- if (env.APPA_GATE === "0") {
135
- return { gated: false, source: "env-off", ...base };
136
- }
137
- // Project level: an opt-out beats the project's own opt-in.
138
- if (projectOff) {
139
- return { gated: false, source: "project-off", ...base };
140
- }
141
- if (projectGated) {
142
- const config = explicitConfig ?? projectCfg;
154
+ if (isAlwaysOn(env)) {
143
155
  return {
144
156
  gated: true,
145
- source: "project",
157
+ source: "always-on",
146
158
  ...base,
147
159
  ...(config !== undefined ? { config } : {}),
148
160
  };
149
161
  }
150
- // Global opt-out, then the default: protection on.
151
- if (isGloballyOff(env)) {
152
- return { gated: false, source: "global-off", ...base };
153
- }
154
- return {
155
- gated: true,
156
- source: "default",
157
- ...base,
158
- ...(explicitConfig !== undefined ? { config: explicitConfig } : {}),
159
- };
162
+ return { gated: false, source: "off", ...base };
160
163
  }
161
164
 
162
165
  export interface HealthResult {
@@ -18,8 +18,6 @@ export interface HookOutcome {
18
18
  stdout: string;
19
19
  stderr: string;
20
20
  timedOut: boolean;
21
- /** The hook binary itself could not be spawned (ENOENT). */
22
- binaryMissing: boolean;
23
21
  }
24
22
 
25
23
  export interface InvokeOptions {
@@ -96,11 +94,10 @@ export async function invokeAppaHook(
96
94
  stdout,
97
95
  stderr: `${stderr}appa hook timed out after ${timeoutMs}ms`.trim(),
98
96
  timedOut: true,
99
- binaryMissing: false,
100
97
  });
101
98
  return;
102
99
  }
103
- resolve({ exitCode, stdout, stderr, timedOut: false, binaryMissing: false });
100
+ resolve({ exitCode, stdout, stderr, timedOut: false });
104
101
  };
105
102
 
106
103
  child.on("error", (error) => {
@@ -123,13 +120,11 @@ export async function invokeAppaHook(
123
120
 
124
121
  function spawnFailure(error: unknown): HookOutcome {
125
122
  const detail = error instanceof Error ? error.message : String(error);
126
- const code = (error as { code?: unknown } | null)?.code;
127
123
  return {
128
124
  exitCode: -1,
129
125
  stdout: "",
130
126
  stderr: `appa hook failed to start: ${detail}`,
131
127
  timedOut: false,
132
- binaryMissing: code === "ENOENT",
133
128
  };
134
129
  }
135
130