pi-creel 0.1.1 → 0.2.1
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/package.json +1 -1
- package/src/index.ts +134 -4
- package/src/notify.test.ts +41 -0
- package/src/notify.ts +44 -0
- package/src/watch.test.ts +164 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-creel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "request_secret for the pi coding agent: capture an API key into a local .env via a tmux popup (the creel tool) so it never enters the chat/context.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
// pi-wakeup and guardrails use.
|
|
14
14
|
|
|
15
15
|
import { execFileSync } from "node:child_process";
|
|
16
|
-
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
17
|
-
import { tmpdir } from "node:os";
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, watch } from "node:fs";
|
|
17
|
+
import { homedir, tmpdir } from "node:os";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { randomUUID } from "node:crypto";
|
|
20
20
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
@@ -26,18 +26,27 @@ import {
|
|
|
26
26
|
tokenToText,
|
|
27
27
|
validName,
|
|
28
28
|
} from "./capture.ts";
|
|
29
|
+
import { parseEvent, savedMessage } from "./notify.ts";
|
|
29
30
|
|
|
30
31
|
const POPUP_WIDTH = "64";
|
|
31
32
|
const POPUP_HEIGHT = "14";
|
|
32
33
|
const TOKEN_TIMEOUT_MS = 120_000;
|
|
33
34
|
const POLL_INTERVAL_MS = 150;
|
|
34
35
|
|
|
36
|
+
export type WatchHandle = { close: () => void };
|
|
37
|
+
|
|
35
38
|
export type Deps = {
|
|
36
39
|
resolveTmux?: (env: NodeJS.ProcessEnv) => TmuxContext | undefined;
|
|
37
40
|
creelOnPath?: () => boolean;
|
|
38
41
|
spawnPopup?: (tmux: TmuxContext, command: string) => void;
|
|
39
42
|
waitForToken?: (statusPath: string, timeoutMs: number) => Promise<string | undefined>;
|
|
40
43
|
tmpStatusPath?: () => string;
|
|
44
|
+
resolveSessionId?: (tmux: TmuxContext) => string | undefined;
|
|
45
|
+
homedir?: () => string;
|
|
46
|
+
watch?: (dir: string, cb: (event: string, filename: string | null) => void) => WatchHandle;
|
|
47
|
+
readEventFile?: (path: string) => string;
|
|
48
|
+
eventMtimeMs?: (path: string) => number;
|
|
49
|
+
mkdir?: (dir: string) => void;
|
|
41
50
|
};
|
|
42
51
|
|
|
43
52
|
function defaultCreelOnPath(): boolean {
|
|
@@ -110,9 +119,9 @@ export function createCreel(pi: any, deps: Deps = {}): void {
|
|
|
110
119
|
name: "request_secret",
|
|
111
120
|
label: "Request Secret",
|
|
112
121
|
description:
|
|
113
|
-
"Capture an API key or secret from the user
|
|
122
|
+
"Capture an API key, token, password, or any secret from the user WITHOUT it entering the chat or your context. Opens a masked tmux popup (the creel tool); the user pastes once and the value is written straight into a local .env (chmod 600). ALWAYS use this instead of asking the user to paste a secret into chat: any time you need a credential, or are about to say 'paste your ... key/token', call request_secret first. You receive only a confirmation (added/updated/cancelled), never the value. Requires a tmux session and the 'creel' binary on PATH.",
|
|
114
123
|
promptSnippet:
|
|
115
|
-
"request_secret(name, dest?)
|
|
124
|
+
"request_secret(name, dest?) - need ANY secret, API key, token, or password? ALWAYS capture it via this popup instead of asking the user to paste it into chat.",
|
|
116
125
|
parameters: REQUEST_SECRET_PARAMS,
|
|
117
126
|
async execute(_id: string, params: { name?: string; dest?: string }) {
|
|
118
127
|
const name = typeof params?.name === "string" ? params.name : "";
|
|
@@ -152,6 +161,127 @@ export function createCreel(pi: any, deps: Deps = {}): void {
|
|
|
152
161
|
});
|
|
153
162
|
}
|
|
154
163
|
|
|
164
|
+
const EVENTS_SUBDIR = ".pi/agent/creel-events";
|
|
165
|
+
|
|
166
|
+
// defaultResolveSessionId asks tmux for the id of the session this pi runs in,
|
|
167
|
+
// so the watcher reacts only to saves made in THIS session (routing by tmux
|
|
168
|
+
// session). Returns undefined outside tmux or on any tmux error.
|
|
169
|
+
function defaultResolveSessionId(tmux: TmuxContext): string | undefined {
|
|
170
|
+
try {
|
|
171
|
+
const out = execFileSync(
|
|
172
|
+
"tmux",
|
|
173
|
+
["-L", tmux.socket, "display-message", "-p", "-t", tmux.pane, "#{session_id}"],
|
|
174
|
+
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
175
|
+
).toString("utf-8");
|
|
176
|
+
const id = out.trim();
|
|
177
|
+
return id || undefined;
|
|
178
|
+
} catch {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// startCreelWatch notifies THIS pi session when the user saves a secret via
|
|
184
|
+
// creel outside a request_secret call (the tmux keybind runs creel with
|
|
185
|
+
// --event-file <root>/<socket>/<session>.json). Routing must be GLOBALLY
|
|
186
|
+
// unique: a tmux session id (`$0`, `$1`...) is only unique within one tmux
|
|
187
|
+
// server, and we run one socket per project against a shared events root, so
|
|
188
|
+
// the key is scoped by socket name first, session id second. Without this a
|
|
189
|
+
// save on one socket wakes a same-numbered session on every other socket. It
|
|
190
|
+
// watches this socket's subdir, and on a NEW event delivers a value-free note -
|
|
191
|
+
// steering a fresh turn when idle, queuing after the current turn when busy
|
|
192
|
+
// (mirroring pi-wakeup). It is a no-op outside tmux, where there is no keybind
|
|
193
|
+
// to hear.
|
|
194
|
+
export function startCreelWatch(pi: any, deps: Deps = {}): void {
|
|
195
|
+
const resolveT = deps.resolveTmux ?? resolveTmux;
|
|
196
|
+
const resolveSid = deps.resolveSessionId ?? defaultResolveSessionId;
|
|
197
|
+
const home = (deps.homedir ?? homedir)();
|
|
198
|
+
const startWatch = deps.watch ?? watch;
|
|
199
|
+
const readEvent = deps.readEventFile ?? ((p: string) => readFileSync(p, "utf-8"));
|
|
200
|
+
const mtimeMs = deps.eventMtimeMs ?? ((p: string) => statSync(p).mtimeMs);
|
|
201
|
+
const ensureDir = deps.mkdir ?? ((d: string) => mkdirSync(d, { recursive: true }));
|
|
202
|
+
|
|
203
|
+
const tmux = resolveT(process.env);
|
|
204
|
+
if (!tmux) return;
|
|
205
|
+
const sid = resolveSid(tmux);
|
|
206
|
+
if (!sid) return;
|
|
207
|
+
|
|
208
|
+
// Scope by socket so `$0` on proj-foo and `$0` on proj-bar never collide in
|
|
209
|
+
// the shared events root.
|
|
210
|
+
const dir = join(home, EVENTS_SUBDIR, tmux.socket);
|
|
211
|
+
try {
|
|
212
|
+
ensureDir(dir);
|
|
213
|
+
} catch {
|
|
214
|
+
return; // nothing to watch if the dir cannot be made
|
|
215
|
+
}
|
|
216
|
+
const fileName = `${sid}.json`;
|
|
217
|
+
const target = join(dir, fileName);
|
|
218
|
+
|
|
219
|
+
let ctx: any;
|
|
220
|
+
pi.on?.("session_start", (_e: unknown, sessionCtx: any) => {
|
|
221
|
+
ctx = sessionCtx;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Dedupe by mtime: one write can fire the watcher twice (rename+change), and
|
|
225
|
+
// two saves with identical name/dest/action have identical CONTENT, so mtime
|
|
226
|
+
// - which advances on every write - is the reliable "is this a new save" key.
|
|
227
|
+
// Seed from any file left by a PRIOR session at this same sid so a stray dir
|
|
228
|
+
// event (macOS can deliver a null filename, bypassing the name filter) can
|
|
229
|
+
// never re-deliver that stale note - only a fresh write, with a new mtime,
|
|
230
|
+
// fires.
|
|
231
|
+
let lastMtimeMs = -1;
|
|
232
|
+
try {
|
|
233
|
+
lastMtimeMs = mtimeMs(target);
|
|
234
|
+
} catch {
|
|
235
|
+
// no pre-existing file - the first real save is a genuine new event
|
|
236
|
+
}
|
|
237
|
+
const handle = startWatch(dir, (_event: string, changed: string | null) => {
|
|
238
|
+
if (changed !== null && changed !== fileName) return;
|
|
239
|
+
let m: number;
|
|
240
|
+
try {
|
|
241
|
+
m = mtimeMs(target);
|
|
242
|
+
} catch {
|
|
243
|
+
return; // file gone or unreadable
|
|
244
|
+
}
|
|
245
|
+
if (m === lastMtimeMs) return;
|
|
246
|
+
lastMtimeMs = m;
|
|
247
|
+
let raw: string;
|
|
248
|
+
try {
|
|
249
|
+
raw = readEvent(target);
|
|
250
|
+
} catch {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const evt = parseEvent(raw);
|
|
254
|
+
if (!evt) return;
|
|
255
|
+
let idle = false;
|
|
256
|
+
try {
|
|
257
|
+
idle = ctx && typeof ctx.isIdle === "function" ? Boolean(ctx.isIdle()) : false;
|
|
258
|
+
} catch {
|
|
259
|
+
idle = false;
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
pi.sendMessage(
|
|
263
|
+
{ content: savedMessage(evt), customType: "creel_saved", display: true },
|
|
264
|
+
{ triggerTurn: true, deliverAs: idle ? "steer" : "followUp" },
|
|
265
|
+
);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
try {
|
|
268
|
+
pi.appendEntry?.("creel_notify_failed", { error: String(error) });
|
|
269
|
+
} catch {
|
|
270
|
+
// best-effort
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
pi.on?.("session_shutdown", () => {
|
|
276
|
+
try {
|
|
277
|
+
handle?.close?.();
|
|
278
|
+
} catch {
|
|
279
|
+
// best-effort
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
155
284
|
export default function (pi: any) {
|
|
156
285
|
createCreel(pi);
|
|
286
|
+
startCreelWatch(pi);
|
|
157
287
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { parseEvent, savedMessage } from "./notify.ts";
|
|
5
|
+
|
|
6
|
+
test("parseEvent accepts a well-formed added/updated event", () => {
|
|
7
|
+
assert.deepEqual(parseEvent('{"name":"STRIPE_KEY","dest":".env","action":"added"}'), {
|
|
8
|
+
name: "STRIPE_KEY",
|
|
9
|
+
dest: ".env",
|
|
10
|
+
action: "added",
|
|
11
|
+
});
|
|
12
|
+
assert.deepEqual(parseEvent('{"name":"K","dest":"config/.env","action":"updated"}'), {
|
|
13
|
+
name: "K",
|
|
14
|
+
dest: "config/.env",
|
|
15
|
+
action: "updated",
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("parseEvent rejects bad json, missing fields, bad name, bad action", () => {
|
|
20
|
+
assert.equal(parseEvent("not json"), undefined);
|
|
21
|
+
assert.equal(parseEvent("{}"), undefined);
|
|
22
|
+
assert.equal(parseEvent('{"name":"K","dest":".env"}'), undefined); // no action
|
|
23
|
+
assert.equal(parseEvent('{"name":"BAD-NAME","dest":".env","action":"added"}'), undefined);
|
|
24
|
+
assert.equal(parseEvent('{"name":"K","dest":".env","action":"deleted"}'), undefined);
|
|
25
|
+
assert.equal(parseEvent('{"name":"K","dest":"","action":"added"}'), undefined);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("parseEvent refuses any payload carrying a value field", () => {
|
|
29
|
+
assert.equal(
|
|
30
|
+
parseEvent('{"name":"K","dest":".env","action":"added","value":"sk-secret"}'),
|
|
31
|
+
undefined,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("savedMessage names the key and dest, points at process.env, and omits the value", () => {
|
|
36
|
+
const m = savedMessage({ name: "STRIPE_KEY", dest: ".env", action: "added" });
|
|
37
|
+
assert.match(m, /STRIPE_KEY/);
|
|
38
|
+
assert.match(m, /\.env/);
|
|
39
|
+
assert.match(m, /process\.env\.STRIPE_KEY/);
|
|
40
|
+
assert.ok(!/sk-/.test(m));
|
|
41
|
+
});
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Pure pieces of the creel-save notifier: the value-free event shape, its
|
|
2
|
+
// parser, and the note the agent receives. None of these ever see the secret
|
|
3
|
+
// value; creel writes only {name, dest, action} to its --event-file, and this
|
|
4
|
+
// code refuses anything carrying a value field.
|
|
5
|
+
|
|
6
|
+
export type CreelEvent = { name: string; dest: string; action: "added" | "updated" };
|
|
7
|
+
|
|
8
|
+
// A legal environment-variable name, mirroring creel's own ValidName.
|
|
9
|
+
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
10
|
+
|
|
11
|
+
// parseEvent reads a creel --event-file payload and returns a CreelEvent only
|
|
12
|
+
// when it is well-formed: a valid env-var name, a non-empty dest, an
|
|
13
|
+
// added/updated action, and crucially NO value field. Anything else (bad JSON,
|
|
14
|
+
// missing fields, or a stray value) returns undefined and is ignored, so a
|
|
15
|
+
// malformed or tampered file can never push a secret into the agent.
|
|
16
|
+
export function parseEvent(raw: string): CreelEvent | undefined {
|
|
17
|
+
let obj: unknown;
|
|
18
|
+
try {
|
|
19
|
+
obj = JSON.parse(raw);
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
if (typeof obj !== "object" || obj === null) return undefined;
|
|
24
|
+
const rec = obj as Record<string, unknown>;
|
|
25
|
+
if ("value" in rec) return undefined; // creel never writes one; refuse if present
|
|
26
|
+
const name = typeof rec.name === "string" ? rec.name : "";
|
|
27
|
+
const dest = typeof rec.dest === "string" ? rec.dest : "";
|
|
28
|
+
const action = rec.action === "added" || rec.action === "updated" ? rec.action : "";
|
|
29
|
+
if (!NAME_RE.test(name) || dest === "" || action === "") return undefined;
|
|
30
|
+
return { name, dest, action };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// savedMessage is the note the agent receives when the user saves a secret via
|
|
34
|
+
// creel OUTSIDE a request_secret call (the tmux keybind). It names what was
|
|
35
|
+
// captured and where, tells the agent how to reference it, and never carries
|
|
36
|
+
// the value.
|
|
37
|
+
export function savedMessage(e: CreelEvent): string {
|
|
38
|
+
return (
|
|
39
|
+
`The user just ${e.action} the secret \`${e.name}\` in \`${e.dest}\` using creel. ` +
|
|
40
|
+
`The value went straight into that file and is not shown here: reference it as ` +
|
|
41
|
+
`\`process.env.${e.name}\` (or have your program read \`${e.dest}\` at runtime), and do not ` +
|
|
42
|
+
`try to read the .env yourself. No action is needed unless you were waiting on this key.`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { startCreelWatch, type Deps, type WatchHandle } from "./index.ts";
|
|
5
|
+
|
|
6
|
+
type Sent = { content: any; opts: any };
|
|
7
|
+
|
|
8
|
+
function fakePi() {
|
|
9
|
+
const sent: Sent[] = [];
|
|
10
|
+
const handlers: Record<string, (...a: any[]) => void> = {};
|
|
11
|
+
const pi = {
|
|
12
|
+
on(evt: string, cb: (...a: any[]) => void) {
|
|
13
|
+
handlers[evt] = cb;
|
|
14
|
+
},
|
|
15
|
+
sendMessage(content: any, opts: any) {
|
|
16
|
+
sent.push({ content, opts });
|
|
17
|
+
},
|
|
18
|
+
appendEntry() {},
|
|
19
|
+
};
|
|
20
|
+
return { pi, sent, handlers };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// watchDeps wires a fully-injected Deps: no real fs, no real tmux. `fire`
|
|
24
|
+
// invokes the captured watch callback for this session's file; `closeFake`
|
|
25
|
+
// reports whether the handle was closed.
|
|
26
|
+
// watchDeps models a file that is ABSENT until a save creates it: eventMtimeMs
|
|
27
|
+
// throws while `present` is false (statSync's real behavior on a missing file),
|
|
28
|
+
// so the startup mtime-seed only kicks in when the file already exists. `fire`
|
|
29
|
+
// marks the file present (a save wrote it) before invoking the watch callback,
|
|
30
|
+
// mirroring reality. Pass presentAtStartup:true to simulate a stale file left
|
|
31
|
+
// by a prior session.
|
|
32
|
+
function watchDeps(
|
|
33
|
+
overrides: Partial<Deps> = {},
|
|
34
|
+
opts: { presentAtStartup?: boolean; mtime?: number } = {},
|
|
35
|
+
) {
|
|
36
|
+
let cb: ((e: string, f: string | null) => void) | undefined;
|
|
37
|
+
let closed = false;
|
|
38
|
+
let watchedDir: string | undefined;
|
|
39
|
+
const mkdirCalls: string[] = [];
|
|
40
|
+
let present = opts.presentAtStartup ?? false;
|
|
41
|
+
let mt = opts.mtime ?? 1000;
|
|
42
|
+
const deps: Deps = {
|
|
43
|
+
resolveTmux: () => ({ socket: "s", pane: "%1" }),
|
|
44
|
+
resolveSessionId: () => "$3",
|
|
45
|
+
homedir: () => "/home/u",
|
|
46
|
+
mkdir: (d) => {
|
|
47
|
+
mkdirCalls.push(d);
|
|
48
|
+
},
|
|
49
|
+
watch: (dir, c) => {
|
|
50
|
+
watchedDir = dir;
|
|
51
|
+
cb = c;
|
|
52
|
+
return { close() { closed = true; } } as WatchHandle;
|
|
53
|
+
},
|
|
54
|
+
readEventFile: () => '{"name":"STRIPE_KEY","dest":".env","action":"added"}',
|
|
55
|
+
eventMtimeMs: () => {
|
|
56
|
+
if (!present) throw new Error("ENOENT");
|
|
57
|
+
return mt;
|
|
58
|
+
},
|
|
59
|
+
...overrides,
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
deps,
|
|
63
|
+
fire: (file: string | null = "$3.json") => {
|
|
64
|
+
present = true;
|
|
65
|
+
cb?.("change", file);
|
|
66
|
+
},
|
|
67
|
+
setMtime: (v: number) => {
|
|
68
|
+
mt = v;
|
|
69
|
+
},
|
|
70
|
+
isClosed: () => closed,
|
|
71
|
+
watchedDir: () => watchedDir,
|
|
72
|
+
mkdirCalls: () => mkdirCalls,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
test("watches (and creates) a socket-scoped events dir, not the shared root", () => {
|
|
77
|
+
// session ids (`$0`, `$1`...) are unique only within one tmux server, so the
|
|
78
|
+
// routing key must include the socket name or a save on one socket wakes a
|
|
79
|
+
// same-id session on another. The watcher scopes to <root>/<socket>.
|
|
80
|
+
const { pi } = fakePi();
|
|
81
|
+
const { deps, watchedDir, mkdirCalls } = watchDeps();
|
|
82
|
+
startCreelWatch(pi, deps);
|
|
83
|
+
assert.equal(watchedDir(), "/home/u/.pi/agent/creel-events/s");
|
|
84
|
+
assert.ok(mkdirCalls().includes("/home/u/.pi/agent/creel-events/s"));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a pre-existing stale event file does not fire on the first spurious event", () => {
|
|
88
|
+
// If this session's file already exists at startup (a prior save at the same
|
|
89
|
+
// sid), a stray dir event (macOS can deliver a null filename) must not
|
|
90
|
+
// re-deliver the old note. Seeding lastMtimeMs from the file at startup guards
|
|
91
|
+
// this: an unchanged mtime is skipped.
|
|
92
|
+
const { pi, sent } = fakePi();
|
|
93
|
+
const { deps, fire } = watchDeps({}, { presentAtStartup: true, mtime: 1000 });
|
|
94
|
+
startCreelWatch(pi, deps);
|
|
95
|
+
fire(null); // null filename falls through the name filter; mtime is unchanged
|
|
96
|
+
assert.equal(sent.length, 0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("delivers a value-free note on a new event, queued after the turn", () => {
|
|
100
|
+
const { pi, sent } = fakePi();
|
|
101
|
+
const { deps, fire } = watchDeps();
|
|
102
|
+
startCreelWatch(pi, deps);
|
|
103
|
+
fire();
|
|
104
|
+
assert.equal(sent.length, 1);
|
|
105
|
+
assert.match(sent[0].content.content, /STRIPE_KEY/);
|
|
106
|
+
assert.equal(sent[0].content.customType, "creel_saved");
|
|
107
|
+
assert.equal(sent[0].opts.triggerTurn, true);
|
|
108
|
+
assert.equal(sent[0].opts.deliverAs, "followUp"); // no ctx captured -> not idle
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("an idle session steers a fresh turn", () => {
|
|
112
|
+
const { pi, sent, handlers } = fakePi();
|
|
113
|
+
const { deps, fire } = watchDeps();
|
|
114
|
+
startCreelWatch(pi, deps);
|
|
115
|
+
handlers.session_start?.(null, { isIdle: () => true });
|
|
116
|
+
fire();
|
|
117
|
+
assert.equal(sent[0].opts.deliverAs, "steer");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("the same mtime does not re-notify; a new mtime does", () => {
|
|
121
|
+
const { pi, sent } = fakePi();
|
|
122
|
+
const { deps, fire, setMtime } = watchDeps();
|
|
123
|
+
startCreelWatch(pi, deps);
|
|
124
|
+
fire();
|
|
125
|
+
fire(); // double-fire of one write -> same mtime -> skipped
|
|
126
|
+
assert.equal(sent.length, 1);
|
|
127
|
+
setMtime(2000); // a genuinely new save
|
|
128
|
+
fire();
|
|
129
|
+
assert.equal(sent.length, 2);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("ignores a change to another session's file", () => {
|
|
133
|
+
const { pi, sent } = fakePi();
|
|
134
|
+
const { deps, fire } = watchDeps();
|
|
135
|
+
startCreelWatch(pi, deps);
|
|
136
|
+
fire("$9.json");
|
|
137
|
+
assert.equal(sent.length, 0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("is a no-op outside tmux and when the session id cannot resolve", () => {
|
|
141
|
+
const a = fakePi();
|
|
142
|
+
startCreelWatch(a.pi, watchDeps({ resolveTmux: () => undefined }).deps);
|
|
143
|
+
assert.equal(a.sent.length, 0);
|
|
144
|
+
const b = fakePi();
|
|
145
|
+
startCreelWatch(b.pi, watchDeps({ resolveSessionId: () => undefined }).deps);
|
|
146
|
+
assert.equal(b.sent.length, 0);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("ignores a malformed event file", () => {
|
|
150
|
+
const { pi, sent } = fakePi();
|
|
151
|
+
const { deps, fire } = watchDeps({ readEventFile: () => "garbage" });
|
|
152
|
+
startCreelWatch(pi, deps);
|
|
153
|
+
fire();
|
|
154
|
+
assert.equal(sent.length, 0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("closes the watcher on session shutdown", () => {
|
|
158
|
+
const { pi, handlers } = fakePi();
|
|
159
|
+
const { deps, isClosed } = watchDeps();
|
|
160
|
+
startCreelWatch(pi, deps);
|
|
161
|
+
assert.equal(isClosed(), false);
|
|
162
|
+
handlers.session_shutdown?.();
|
|
163
|
+
assert.equal(isClosed(), true);
|
|
164
|
+
});
|