@crewhaus/channel-adapter-imessage 0.1.1 → 0.1.2
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 +6 -11
- package/src/index.osascript.test.ts +116 -0
- package/src/index.test.ts +95 -1
- package/src/index.ts +36 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/channel-adapter-imessage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "macOS-only iMessage channel adapter: ~/Library/Messages/chat.db polling + osascript send + cursor-based idempotency (Section 33)",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
15
|
+
"@crewhaus/errors": "0.1.2"
|
|
16
16
|
},
|
|
17
17
|
"license": "Apache-2.0",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Max Meier",
|
|
20
|
-
"email": "max@
|
|
21
|
-
"url": "https://
|
|
20
|
+
"email": "max@crewhaus.ai",
|
|
21
|
+
"url": "https://crewhaus.ai"
|
|
22
22
|
},
|
|
23
23
|
"repository": {
|
|
24
24
|
"type": "git",
|
|
@@ -30,12 +30,7 @@
|
|
|
30
30
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
31
|
},
|
|
32
32
|
"publishConfig": {
|
|
33
|
-
"access": "
|
|
33
|
+
"access": "public"
|
|
34
34
|
},
|
|
35
|
-
"files": [
|
|
36
|
-
"src",
|
|
37
|
-
"README.md",
|
|
38
|
-
"LICENSE",
|
|
39
|
-
"NOTICE"
|
|
40
|
-
]
|
|
35
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
41
36
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage for the production `defaultOsascript` path — the branch that
|
|
3
|
+
* dynamically `import("node:child_process")` and spawns the real `osascript`.
|
|
4
|
+
*
|
|
5
|
+
* Calling `sendReply` without an injected `osascript` runner exercises that
|
|
6
|
+
* default. To keep the test deterministic (no real process, no real iMessage
|
|
7
|
+
* send) we replace `node:child_process` with a fake `spawn` via `mock.module`.
|
|
8
|
+
*
|
|
9
|
+
* This lives in its own file so only these tests see the mock — but Bun does
|
|
10
|
+
* NOT reset module mocks at the file boundary: `bun test` runs every file in
|
|
11
|
+
* one process, file order is nondeterministic, and a `mock.module` persists
|
|
12
|
+
* until overwritten. The `afterAll` below re-mocks the real module so the
|
|
13
|
+
* fake `spawn` cannot leak into files that happen to run later.
|
|
14
|
+
*/
|
|
15
|
+
import { afterAll, describe, expect, mock, test } from "bun:test";
|
|
16
|
+
import { EventEmitter } from "node:events";
|
|
17
|
+
import { rmSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { buildFixtureChatDb } from "./fixtures/build-chat-db";
|
|
21
|
+
|
|
22
|
+
// Captured BEFORE the mock below so afterAll can reinstall the real module.
|
|
23
|
+
const realChildProcess = require("node:child_process") as typeof import("node:child_process");
|
|
24
|
+
|
|
25
|
+
/** Records the scripts handed to the fake `osascript` across the file. */
|
|
26
|
+
const writtenScripts: string[] = [];
|
|
27
|
+
let nextExit = 0;
|
|
28
|
+
|
|
29
|
+
mock.module("node:child_process", () => ({
|
|
30
|
+
spawn: (command: string, args: readonly string[], options: unknown) => {
|
|
31
|
+
// Sanity-check the default runner invokes osascript reading from stdin.
|
|
32
|
+
expect(command).toBe("osascript");
|
|
33
|
+
expect(args).toEqual(["-"]);
|
|
34
|
+
expect(options).toEqual({ stdio: ["pipe", "ignore", "pipe"] });
|
|
35
|
+
const child = new EventEmitter() as EventEmitter & {
|
|
36
|
+
stderr: EventEmitter;
|
|
37
|
+
stdin: { write(s: string): void; end(): void };
|
|
38
|
+
};
|
|
39
|
+
child.stderr = new EventEmitter();
|
|
40
|
+
child.stdin = {
|
|
41
|
+
write: (s: string) => writtenScripts.push(s),
|
|
42
|
+
end: () => {},
|
|
43
|
+
};
|
|
44
|
+
queueMicrotask(() => {
|
|
45
|
+
if (nextExit === 0) {
|
|
46
|
+
child.emit("close", 0);
|
|
47
|
+
} else {
|
|
48
|
+
child.stderr.emit("data", Buffer.from("osascript boom", "utf8"));
|
|
49
|
+
child.emit("close", nextExit);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return child;
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
afterAll(() => {
|
|
57
|
+
// Bun has no mock.module restore API; re-mocking with the real exports is
|
|
58
|
+
// the documented way to undo a module mock. Without this, the fake `spawn`
|
|
59
|
+
// (which asserts it is called as `osascript`) stays registered for every
|
|
60
|
+
// test file that runs after this one.
|
|
61
|
+
mock.module("node:child_process", () => realChildProcess);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Import AFTER registering the mock so the dynamic import inside
|
|
65
|
+
// defaultOsascript resolves to the fake.
|
|
66
|
+
const { createIMessageAdapter } = await import("./index");
|
|
67
|
+
|
|
68
|
+
function makeAdapter() {
|
|
69
|
+
const dbPath = buildFixtureChatDb();
|
|
70
|
+
const cursorPath = join(dirname(dbPath), "cursor.json");
|
|
71
|
+
const adapter = createIMessageAdapter({
|
|
72
|
+
chatDbPath: dbPath,
|
|
73
|
+
cursorPath,
|
|
74
|
+
requireHostOptIn: false,
|
|
75
|
+
});
|
|
76
|
+
return { adapter, cleanup: () => rmSync(dirname(dbPath), { recursive: true, force: true }) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const baseEvent = {
|
|
80
|
+
idempotencyKey: "imsg:1",
|
|
81
|
+
workspaceId: "imessage",
|
|
82
|
+
channelId: "alice@example.com",
|
|
83
|
+
userId: "alice@example.com",
|
|
84
|
+
ts: "0",
|
|
85
|
+
text: "in",
|
|
86
|
+
subtype: "message",
|
|
87
|
+
} as const;
|
|
88
|
+
|
|
89
|
+
describe("defaultOsascript (real-runner branch, child_process mocked)", () => {
|
|
90
|
+
test("sendReply with no injected runner spawns osascript and writes the script", async () => {
|
|
91
|
+
nextExit = 0;
|
|
92
|
+
writtenScripts.length = 0;
|
|
93
|
+
const { adapter, cleanup } = makeAdapter();
|
|
94
|
+
try {
|
|
95
|
+
await adapter.sendReply({ event: baseEvent, text: "hello from default" });
|
|
96
|
+
expect(writtenScripts.length).toBe(1);
|
|
97
|
+
expect(writtenScripts[0]).toContain('tell application "Messages"');
|
|
98
|
+
expect(writtenScripts[0]).toContain('send "hello from default"');
|
|
99
|
+
} finally {
|
|
100
|
+
cleanup();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("sendReply rejects when the spawned osascript exits non-zero", async () => {
|
|
105
|
+
nextExit = 3;
|
|
106
|
+
writtenScripts.length = 0;
|
|
107
|
+
const { adapter, cleanup } = makeAdapter();
|
|
108
|
+
try {
|
|
109
|
+
await expect(adapter.sendReply({ event: baseEvent, text: "x" })).rejects.toThrow(
|
|
110
|
+
/osascript exited with 3: osascript boom/,
|
|
111
|
+
);
|
|
112
|
+
} finally {
|
|
113
|
+
cleanup();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
package/src/index.test.ts
CHANGED
|
@@ -4,10 +4,104 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
|
|
6
6
|
import { buildFixtureChatDb } from "./fixtures/build-chat-db";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
IMessageAdapterError,
|
|
9
|
+
type OsascriptSpawn,
|
|
10
|
+
_internal,
|
|
11
|
+
createIMessageAdapter,
|
|
12
|
+
runOsascript,
|
|
13
|
+
} from "./index";
|
|
8
14
|
|
|
9
15
|
const { isSafeHandle, escapeAppleScriptString, validateChatDbPath } = _internal;
|
|
10
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Build a deterministic fake of `child_process.spawn` for {@link runOsascript}
|
|
19
|
+
* tests — no real process. `behavior` decides which lifecycle events fire and
|
|
20
|
+
* is invoked on a microtask so the runner has wired its listeners first.
|
|
21
|
+
*/
|
|
22
|
+
function fakeSpawn(behavior: {
|
|
23
|
+
readonly stderrChunks?: readonly string[];
|
|
24
|
+
readonly closeCode?: number | null;
|
|
25
|
+
readonly emitError?: Error;
|
|
26
|
+
/** Force `stdin`/`stderr` to be null to exercise the optional-chaining guards. */
|
|
27
|
+
readonly nullStreams?: boolean;
|
|
28
|
+
}): { spawn: OsascriptSpawn; getWritten: () => string | undefined; ended: () => boolean } {
|
|
29
|
+
let written: string | undefined;
|
|
30
|
+
let didEnd = false;
|
|
31
|
+
const spawn: OsascriptSpawn = (command, args, options) => {
|
|
32
|
+
// The runner must always invoke osascript reading the script from stdin.
|
|
33
|
+
expect(command).toBe("osascript");
|
|
34
|
+
expect(args).toEqual(["-"]);
|
|
35
|
+
expect(options).toEqual({ stdio: ["pipe", "ignore", "pipe"] });
|
|
36
|
+
const handlers: Record<string, (arg: never) => void> = {};
|
|
37
|
+
const stderrHandlers: ((chunk: Buffer) => void)[] = [];
|
|
38
|
+
queueMicrotask(() => {
|
|
39
|
+
for (const c of behavior.stderrChunks ?? []) {
|
|
40
|
+
for (const h of stderrHandlers) h(Buffer.from(c, "utf8"));
|
|
41
|
+
}
|
|
42
|
+
if (behavior.emitError) handlers["error"]?.(behavior.emitError as never);
|
|
43
|
+
else handlers["close"]?.((behavior.closeCode ?? 0) as never);
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
stderr: behavior.nullStreams ? null : { on: (_e, cb) => stderrHandlers.push(cb) },
|
|
47
|
+
stdin: behavior.nullStreams
|
|
48
|
+
? null
|
|
49
|
+
: {
|
|
50
|
+
write: (chunk: string) => {
|
|
51
|
+
written = chunk;
|
|
52
|
+
},
|
|
53
|
+
end: () => {
|
|
54
|
+
didEnd = true;
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
on: (event: string, cb: (arg: never) => void) => {
|
|
58
|
+
handlers[event] = cb;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
return { spawn, getWritten: () => written, ended: () => didEnd };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("runOsascript (default-runner core, dependency-injected spawn)", () => {
|
|
66
|
+
test("writes the script to stdin and resolves on exit code 0", async () => {
|
|
67
|
+
const f = fakeSpawn({ closeCode: 0 });
|
|
68
|
+
await runOsascript(f.spawn, "tell app");
|
|
69
|
+
expect(f.getWritten()).toBe("tell app");
|
|
70
|
+
expect(f.ended()).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("rejects with stderr text + code on a non-zero exit", async () => {
|
|
74
|
+
const f = fakeSpawn({ closeCode: 2, stderrChunks: ["bad ", "things"] });
|
|
75
|
+
await expect(runOsascript(f.spawn, "x")).rejects.toThrow(/osascript exited with 2: bad things/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("rejects with a clear message when spawn emits 'error'", async () => {
|
|
79
|
+
const boom = new Error("ENOENT osascript");
|
|
80
|
+
const f = fakeSpawn({ emitError: boom });
|
|
81
|
+
let caught: unknown;
|
|
82
|
+
try {
|
|
83
|
+
await runOsascript(f.spawn, "x");
|
|
84
|
+
} catch (e) {
|
|
85
|
+
caught = e;
|
|
86
|
+
}
|
|
87
|
+
expect(caught).toBeInstanceOf(IMessageAdapterError);
|
|
88
|
+
expect((caught as Error).message).toBe("osascript spawn failed");
|
|
89
|
+
// The originating error is preserved on the cause chain.
|
|
90
|
+
expect((caught as IMessageAdapterError).cause).toBe(boom);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("null stdin/stderr streams are tolerated (optional-chaining guards)", async () => {
|
|
94
|
+
const f = fakeSpawn({ nullStreams: true, closeCode: 0 });
|
|
95
|
+
await runOsascript(f.spawn, "x");
|
|
96
|
+
// Nothing was written because stdin was null; the runner still resolved.
|
|
97
|
+
expect(f.getWritten()).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("exposed on _internal for the adapter's wiring", () => {
|
|
101
|
+
expect(_internal.runOsascript).toBe(runOsascript);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
11
105
|
describe("isSafeHandle / escapeAppleScriptString / validateChatDbPath", () => {
|
|
12
106
|
test("accepts well-formed iMessage handles", () => {
|
|
13
107
|
expect(isSafeHandle("alice@example.com")).toBe(true);
|
package/src/index.ts
CHANGED
|
@@ -274,13 +274,38 @@ function writeCursor(cursorPath: string, cursor: number): void {
|
|
|
274
274
|
|
|
275
275
|
// ─── default osascript runner ──────────────────────────────────────────────
|
|
276
276
|
|
|
277
|
-
|
|
278
|
-
|
|
277
|
+
/**
|
|
278
|
+
* Minimal structural type for the slice of `child_process.spawn` that the
|
|
279
|
+
* osascript runner uses. Keeping it explicit (rather than importing Node's
|
|
280
|
+
* `ChildProcess`) lets tests inject a deterministic fake without a real
|
|
281
|
+
* process spawn — mirroring the dependency-injection style used elsewhere in
|
|
282
|
+
* factory's adapters.
|
|
283
|
+
*/
|
|
284
|
+
type SpawnedChild = {
|
|
285
|
+
readonly stdout?: { on(event: "data", cb: (chunk: Buffer) => void): void } | null;
|
|
286
|
+
readonly stderr: { on(event: "data", cb: (chunk: Buffer) => void): void } | null;
|
|
287
|
+
readonly stdin: { write(chunk: string): void; end(): void } | null;
|
|
288
|
+
on(event: "error", cb: (err: Error) => void): void;
|
|
289
|
+
on(event: "close", cb: (code: number | null) => void): void;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
export type OsascriptSpawn = (
|
|
293
|
+
command: string,
|
|
294
|
+
args: readonly string[],
|
|
295
|
+
options: { stdio: readonly ["pipe", "ignore", "pipe"] },
|
|
296
|
+
) => SpawnedChild;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Run `osascript` reading the script from stdin, using an injected `spawn`.
|
|
300
|
+
* Resolves on exit code 0; rejects with an {@link IMessageAdapterError} on a
|
|
301
|
+
* spawn failure or any non-zero exit (surfacing captured stderr).
|
|
302
|
+
*/
|
|
303
|
+
export function runOsascript(spawn: OsascriptSpawn, script: string): Promise<void> {
|
|
279
304
|
return new Promise<void>((resolve, reject) => {
|
|
280
305
|
const head = "osascript";
|
|
281
306
|
const child = spawn(head, ["-"], { stdio: ["pipe", "ignore", "pipe"] });
|
|
282
307
|
const errBufs: Buffer[] = [];
|
|
283
|
-
child.stderr
|
|
308
|
+
child.stderr?.on("data", (b) => errBufs.push(b));
|
|
284
309
|
child.on("error", (e) => reject(new IMessageAdapterError("osascript spawn failed", e)));
|
|
285
310
|
child.on("close", (code) => {
|
|
286
311
|
if (code === 0) resolve();
|
|
@@ -291,9 +316,14 @@ const defaultOsascript = async (script: string): Promise<void> => {
|
|
|
291
316
|
),
|
|
292
317
|
);
|
|
293
318
|
});
|
|
294
|
-
child.stdin
|
|
295
|
-
child.stdin
|
|
319
|
+
child.stdin?.write(script);
|
|
320
|
+
child.stdin?.end();
|
|
296
321
|
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const defaultOsascript = async (script: string): Promise<void> => {
|
|
325
|
+
const { spawn } = await import("node:child_process");
|
|
326
|
+
return runOsascript(spawn as unknown as OsascriptSpawn, script);
|
|
297
327
|
};
|
|
298
328
|
|
|
299
329
|
// ─── chat.db row shape + query ──────────────────────────────────────────────
|
|
@@ -327,4 +357,5 @@ export const _internal = {
|
|
|
327
357
|
isSafeHandle,
|
|
328
358
|
escapeAppleScriptString,
|
|
329
359
|
validateChatDbPath,
|
|
360
|
+
runOsascript,
|
|
330
361
|
};
|