@moxxy/plugin-channel-imessage 0.29.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bluebubbles-client.d.ts +79 -0
  3. package/dist/bluebubbles-client.d.ts.map +1 -0
  4. package/dist/bluebubbles-client.js +113 -0
  5. package/dist/bluebubbles-client.js.map +1 -0
  6. package/dist/channel/chunker.d.ts +66 -0
  7. package/dist/channel/chunker.d.ts.map +1 -0
  8. package/dist/channel/chunker.js +130 -0
  9. package/dist/channel/chunker.js.map +1 -0
  10. package/dist/channel/turn-runner.d.ts +32 -0
  11. package/dist/channel/turn-runner.d.ts.map +1 -0
  12. package/dist/channel/turn-runner.js +58 -0
  13. package/dist/channel/turn-runner.js.map +1 -0
  14. package/dist/channel.d.ts +87 -0
  15. package/dist/channel.d.ts.map +1 -0
  16. package/dist/channel.js +264 -0
  17. package/dist/channel.js.map +1 -0
  18. package/dist/index.d.ts +27 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +211 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/keys.d.ts +70 -0
  23. package/dist/keys.d.ts.map +1 -0
  24. package/dist/keys.js +111 -0
  25. package/dist/keys.js.map +1 -0
  26. package/dist/message-gate.d.ts +40 -0
  27. package/dist/message-gate.d.ts.map +1 -0
  28. package/dist/message-gate.js +54 -0
  29. package/dist/message-gate.js.map +1 -0
  30. package/dist/permission.d.ts +25 -0
  31. package/dist/permission.d.ts.map +1 -0
  32. package/dist/permission.js +31 -0
  33. package/dist/permission.js.map +1 -0
  34. package/dist/schema.d.ts +96 -0
  35. package/dist/schema.d.ts.map +1 -0
  36. package/dist/schema.js +41 -0
  37. package/dist/schema.js.map +1 -0
  38. package/dist/setup-wizard.d.ts +15 -0
  39. package/dist/setup-wizard.d.ts.map +1 -0
  40. package/dist/setup-wizard.js +127 -0
  41. package/dist/setup-wizard.js.map +1 -0
  42. package/package.json +96 -0
  43. package/src/bluebubbles-client.ts +182 -0
  44. package/src/channel/chunker.test.ts +132 -0
  45. package/src/channel/chunker.ts +144 -0
  46. package/src/channel/turn-runner.ts +82 -0
  47. package/src/channel.test.ts +256 -0
  48. package/src/channel.ts +352 -0
  49. package/src/index.ts +273 -0
  50. package/src/keys.test.ts +86 -0
  51. package/src/keys.ts +109 -0
  52. package/src/message-gate.test.ts +126 -0
  53. package/src/message-gate.ts +94 -0
  54. package/src/permission.ts +40 -0
  55. package/src/schema.test.ts +51 -0
  56. package/src/schema.ts +47 -0
  57. package/src/setup-wizard.ts +155 -0
  58. package/src/subcommands.test.ts +194 -0
@@ -0,0 +1,40 @@
1
+ import { createAuditedAllowListResolver } from '@moxxy/channel-kit';
2
+ import type { PermissionResolver } from '@moxxy/sdk';
3
+
4
+ export interface ImessagePermissionLogger {
5
+ info?(msg: string, meta?: Record<string, unknown>): void;
6
+ }
7
+
8
+ /**
9
+ * Build the iMessage channel's autonomous permission resolver.
10
+ *
11
+ * Like Signal and Slack (and unlike Telegram's inline-keyboard prompts), the
12
+ * iMessage channel runs hands-off: the operator declares trust upfront via
13
+ * `channels.imessage.allowedTools`, and any tool NOT in that list is denied.
14
+ * The trust check + `'*'` expansion + audit-on-auto-approve wiring is the shared
15
+ * {@link createAuditedAllowListResolver} from `@moxxy/channel-kit`; this wrapper
16
+ * binds it to the channel logger so every auto-approved call leaves a trail. An
17
+ * empty list denies everything (effectively read-only).
18
+ *
19
+ * Because this resolver decides synchronously (no deferred operator prompt), it
20
+ * has no pending-prompt state, so `stop()` has nothing to `abortAll` — aborting
21
+ * the in-flight turn is enough.
22
+ */
23
+ export function buildImessagePermissionResolver(opts: {
24
+ allowedTools: ReadonlyArray<string>;
25
+ allToolNames: ReadonlyArray<string>;
26
+ logger?: ImessagePermissionLogger;
27
+ }): PermissionResolver {
28
+ return createAuditedAllowListResolver({
29
+ name: 'imessage-allow-list',
30
+ allowedTools: opts.allowedTools,
31
+ allToolNames: opts.allToolNames,
32
+ onAutoApprove: (call, { wildcard }) => {
33
+ opts.logger?.info?.('imessage: auto-approved tool call', {
34
+ tool: call.name,
35
+ callId: call.callId,
36
+ wildcard,
37
+ });
38
+ },
39
+ });
40
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { MAX_INBOUND_TEXT_CHARS, messageSchema } from './schema.js';
3
+
4
+ describe('messageSchema', () => {
5
+ it('accepts a well-formed new-message payload', () => {
6
+ const parsed = messageSchema.safeParse({
7
+ guid: 'msg-1',
8
+ tempGuid: 'temp-abc',
9
+ text: 'hello',
10
+ isFromMe: false,
11
+ handle: { address: '+15551234567' },
12
+ chats: [{ guid: 'iMessage;-;+15551234567' }],
13
+ dateCreated: 1_700_000_000_000,
14
+ // an un-modeled extra passes through untouched
15
+ subject: null,
16
+ });
17
+ expect(parsed.success).toBe(true);
18
+ if (parsed.success) {
19
+ expect(parsed.data.guid).toBe('msg-1');
20
+ expect(parsed.data.text).toBe('hello');
21
+ }
22
+ });
23
+
24
+ it('rejects a payload with no guid', () => {
25
+ expect(messageSchema.safeParse({ text: 'hi', chats: [] }).success).toBe(false);
26
+ });
27
+
28
+ it('rejects non-object payloads', () => {
29
+ expect(messageSchema.safeParse(null).success).toBe(false);
30
+ expect(messageSchema.safeParse('garbage').success).toBe(false);
31
+ expect(messageSchema.safeParse(42).success).toBe(false);
32
+ });
33
+
34
+ it('rejects a numeric text (wrong type)', () => {
35
+ expect(messageSchema.safeParse({ guid: 'm', text: 42 }).success).toBe(false);
36
+ });
37
+
38
+ it('rejects oversized text', () => {
39
+ const parsed = messageSchema.safeParse({
40
+ guid: 'm',
41
+ text: 'a'.repeat(MAX_INBOUND_TEXT_CHARS + 1),
42
+ });
43
+ expect(parsed.success).toBe(false);
44
+ });
45
+
46
+ it('rejects a chat entry without a guid', () => {
47
+ expect(messageSchema.safeParse({ guid: 'm', chats: [{ chatIdentifier: 'x' }] }).success).toBe(
48
+ false,
49
+ );
50
+ });
51
+ });
package/src/schema.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Zod validation for the BlueBubbles socket.io `new-message` payload — a
5
+ * serialized Message object. Every inbound message is validated + size-capped
6
+ * BEFORE any field reaches the session (channel invariant A8). Only the fields
7
+ * the channel consumes are typed; unknown extras pass through untouched so a
8
+ * newer BlueBubbles server doesn't fail validation, but nothing un-modeled is
9
+ * ever read.
10
+ */
11
+
12
+ /** Cap on inbound prompt text we will forward to the model. */
13
+ export const MAX_INBOUND_TEXT_CHARS = 16_000;
14
+
15
+ /** A single participant handle on a chat / message (`{ address, ... }`). */
16
+ export const handleSchema = z
17
+ .object({
18
+ address: z.string().min(1).max(256).nullable().optional(),
19
+ })
20
+ .passthrough();
21
+
22
+ /** A chat the message belongs to. `guid` is `SERVICE;TYPE;IDENTIFIER`. */
23
+ export const chatSchema = z
24
+ .object({
25
+ guid: z.string().min(1).max(256),
26
+ })
27
+ .passthrough();
28
+
29
+ /**
30
+ * The serialized Message. `guid` is the stable per-message id (used for the
31
+ * anti-echo drop); `tempGuid` echoes back the value the sender chose for an
32
+ * apple-script send, so our own replies can be recognised even before their
33
+ * permanent guid is known.
34
+ */
35
+ export const messageSchema = z
36
+ .object({
37
+ guid: z.string().min(1).max(256),
38
+ tempGuid: z.string().min(1).max(256).nullable().optional(),
39
+ text: z.string().max(MAX_INBOUND_TEXT_CHARS).nullable().optional(),
40
+ isFromMe: z.boolean().nullable().optional(),
41
+ handle: handleSchema.nullable().optional(),
42
+ chats: z.array(chatSchema).max(16).optional(),
43
+ dateCreated: z.number().nullable().optional(),
44
+ })
45
+ .passthrough();
46
+
47
+ export type ImessageMessage = z.infer<typeof messageSchema>;
@@ -0,0 +1,155 @@
1
+ import { cancel, intro, isCancel, log, note, outro, password, text } from '@clack/prompts';
2
+ import type { ChannelSubcommandContext } from '@moxxy/sdk';
3
+ import type { VaultStore } from '@moxxy/plugin-vault';
4
+ import {
5
+ IMESSAGE_ALLOWED_HANDLES_KEY,
6
+ IMESSAGE_OWNER_HANDLES_KEY,
7
+ IMESSAGE_SERVER_PASSWORD_KEY,
8
+ IMESSAGE_SERVER_URL_KEY,
9
+ isHandle,
10
+ normalizeHandle,
11
+ parseHandleList,
12
+ } from './keys.js';
13
+ import { BlueBubblesClient } from './bluebubbles-client.js';
14
+
15
+ const ANSI = process.stdout.isTTY && !process.env.NO_COLOR;
16
+ const bold = (s: string): string => (ANSI ? `\x1b[1m${s}\x1b[22m` : s);
17
+ const dim = (s: string): string => (ANSI ? `\x1b[2m${s}\x1b[22m` : s);
18
+
19
+ /**
20
+ * Interactive iMessage (BlueBubbles) setup wizard (the channel's
21
+ * `interactiveCommand`).
22
+ *
23
+ * Walks the operator through:
24
+ * 1. the BlueBubbles server URL + password (stored in the vault),
25
+ * 2. a best-effort reachability check against that server,
26
+ * 3. the handle allow-list — who besides yourself may talk to the agent,
27
+ * 4. your own handle(s), so texting your own "self-chat" reaches moxxy,
28
+ * 5. the autonomous tool allow-list,
29
+ * then starts the channel.
30
+ */
31
+ export async function runImessageWizard(ctx: ChannelSubcommandContext): Promise<number> {
32
+ const vault = ctx.deps.vault as VaultStore;
33
+ intro(bold('moxxy imessage setup'));
34
+
35
+ note(
36
+ 'moxxy talks to iMessage through a BlueBubbles server running on THIS Mac.\n' +
37
+ 'Install it from https://bluebubbles.app, sign in to Messages, and set a\n' +
38
+ 'server password. This channel uses the stock apple-script send method — no\n' +
39
+ 'SIP changes or Private API needed. v1 handles 1:1 text chats only.',
40
+ 'how the iMessage channel works',
41
+ );
42
+
43
+ const existingUrl = await vault.get(IMESSAGE_SERVER_URL_KEY);
44
+ const serverUrl = await text({
45
+ message: 'BlueBubbles server URL',
46
+ placeholder: 'http://localhost:1234',
47
+ initialValue: existingUrl ?? 'http://localhost:1234',
48
+ validate: (v) => {
49
+ if (!v || !v.trim()) return 'required';
50
+ try {
51
+ // eslint-disable-next-line no-new
52
+ new URL(v.trim());
53
+ return undefined;
54
+ } catch {
55
+ return 'expected a URL, e.g. http://localhost:1234';
56
+ }
57
+ },
58
+ });
59
+ if (isCancel(serverUrl)) {
60
+ cancel('cancelled.');
61
+ return 0;
62
+ }
63
+ const url = String(serverUrl).trim();
64
+
65
+ const serverPassword = await password({
66
+ message: 'BlueBubbles server password',
67
+ validate: (v) => (v && v.length > 0 ? undefined : 'required'),
68
+ });
69
+ if (isCancel(serverPassword)) {
70
+ cancel('cancelled.');
71
+ return 0;
72
+ }
73
+ const pass = String(serverPassword);
74
+
75
+ await vault.set(IMESSAGE_SERVER_URL_KEY, url, ['imessage']);
76
+ await vault.set(IMESSAGE_SERVER_PASSWORD_KEY, pass, ['imessage']);
77
+ log.success('Stored the BlueBubbles server URL + password in the vault.');
78
+
79
+ // Best-effort reachability probe — the ping only uses fetch, no socket.
80
+ try {
81
+ await new BlueBubblesClient({ serverUrl: url, password: pass }).ping();
82
+ log.success('Reached the BlueBubbles server.');
83
+ } catch (err) {
84
+ log.warn(
85
+ `Could not reach the server yet: ${err instanceof Error ? err.message : String(err)}\n` +
86
+ 'You can still finish setup — the channel will retry when it starts.',
87
+ );
88
+ }
89
+
90
+ const allowAnswer = await text({
91
+ message: 'Handles allowed to talk to moxxy (comma-separated E.164 numbers / Apple-ID emails)',
92
+ placeholder: '+15551234567, friend@icloud.com',
93
+ initialValue: parseHandleList(await vault.get(IMESSAGE_ALLOWED_HANDLES_KEY)).join(', '),
94
+ });
95
+ if (isCancel(allowAnswer)) {
96
+ cancel('cancelled.');
97
+ return 0;
98
+ }
99
+ const allowed = parseHandleInput(String(allowAnswer));
100
+ await vault.set(IMESSAGE_ALLOWED_HANDLES_KEY, JSON.stringify(allowed), ['imessage']);
101
+ log.success(
102
+ allowed.length > 0
103
+ ? `Allow-list saved (${allowed.length} handle(s)).`
104
+ : 'Allow-list cleared (no external senders allowed).',
105
+ );
106
+
107
+ note(
108
+ 'To talk to moxxy from your OWN Apple devices (texting your "self-chat"),\n' +
109
+ 'add your own iMessage handle(s) below. Leave blank to keep self-chat off —\n' +
110
+ "your outbound messages to other people are never treated as prompts.",
111
+ 'your own handles (self-chat)',
112
+ );
113
+ const ownerAnswer = await text({
114
+ message: 'Your own handle(s) (comma-separated; blank to skip)',
115
+ placeholder: '+15559876543, you@icloud.com',
116
+ initialValue: parseHandleList(await vault.get(IMESSAGE_OWNER_HANDLES_KEY)).join(', '),
117
+ });
118
+ if (isCancel(ownerAnswer)) {
119
+ cancel('cancelled.');
120
+ return 0;
121
+ }
122
+ const owner = parseHandleInput(String(ownerAnswer));
123
+ await vault.set(IMESSAGE_OWNER_HANDLES_KEY, JSON.stringify(owner), ['imessage']);
124
+ log.success(
125
+ owner.length > 0 ? `Self-chat enabled for ${owner.length} handle(s).` : 'Self-chat left off.',
126
+ );
127
+
128
+ const allowToolsAnswer = await text({
129
+ message: 'Autonomous tool allow-list (comma-separated; "*" = all, blank = read-only)',
130
+ placeholder: 'Read, Grep, Glob',
131
+ });
132
+ if (isCancel(allowToolsAnswer)) {
133
+ cancel('cancelled.');
134
+ return 0;
135
+ }
136
+ const allowedTools = String(allowToolsAnswer)
137
+ .split(',')
138
+ .map((s) => s.trim())
139
+ .filter(Boolean);
140
+
141
+ log.info('Starting the channel. Press Ctrl+C to stop.');
142
+ outro(dim('handing off to the channel…'));
143
+ return ctx.startChannel({ allowedTools });
144
+ }
145
+
146
+ /** Split comma/whitespace-separated handle input, normalize, drop non-handles. */
147
+ function parseHandleInput(raw: string): string[] {
148
+ const out = new Set<string>();
149
+ for (const token of raw.split(/[,\s]+/)) {
150
+ if (!token) continue;
151
+ const handle = normalizeHandle(token);
152
+ if (isHandle(handle)) out.add(handle);
153
+ }
154
+ return [...out];
155
+ }
@@ -0,0 +1,194 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { VaultStore, createStaticKeySource, deriveKey, generateSalt } from '@moxxy/plugin-vault';
6
+ import type { ChannelDef } from '@moxxy/sdk';
7
+ import { buildImessagePlugin } from './index.js';
8
+ import {
9
+ IMESSAGE_ALLOWED_HANDLES_KEY,
10
+ IMESSAGE_OWNER_HANDLES_KEY,
11
+ IMESSAGE_SERVER_PASSWORD_KEY,
12
+ IMESSAGE_SERVER_URL_KEY,
13
+ } from './keys.js';
14
+
15
+ let tmp: string;
16
+ let vault: VaultStore;
17
+ let def: ChannelDef;
18
+ let writeOut: string[];
19
+ let writeErr: string[];
20
+ let origStdoutWrite: typeof process.stdout.write;
21
+ let origStderrWrite: typeof process.stderr.write;
22
+ const origPlatform = process.platform;
23
+
24
+ function setPlatform(value: NodeJS.Platform): void {
25
+ Object.defineProperty(process, 'platform', { value, configurable: true });
26
+ }
27
+
28
+ beforeEach(async () => {
29
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-imsg-sub-'));
30
+ vault = new VaultStore({
31
+ filePath: path.join(tmp, 'vault.json'),
32
+ keySource: createStaticKeySource(deriveKey('test', generateSalt())),
33
+ });
34
+ const plugin = buildImessagePlugin({ vault });
35
+ const channels = plugin.channels ?? [];
36
+ def = channels[0] as ChannelDef;
37
+ writeOut = [];
38
+ writeErr = [];
39
+ origStdoutWrite = process.stdout.write.bind(process.stdout);
40
+ origStderrWrite = process.stderr.write.bind(process.stderr);
41
+ process.stdout.write = ((chunk: string | Uint8Array) => {
42
+ writeOut.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
43
+ return true;
44
+ }) as typeof process.stdout.write;
45
+ process.stderr.write = ((chunk: string | Uint8Array) => {
46
+ writeErr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString());
47
+ return true;
48
+ }) as typeof process.stderr.write;
49
+ vi.stubEnv('MOXXY_IMESSAGE_SERVER_URL', '');
50
+ vi.stubEnv('MOXXY_IMESSAGE_SERVER_PASSWORD', '');
51
+ });
52
+
53
+ afterEach(async () => {
54
+ process.stdout.write = origStdoutWrite;
55
+ process.stderr.write = origStderrWrite;
56
+ setPlatform(origPlatform);
57
+ vi.unstubAllEnvs();
58
+ await fs.rm(tmp, { recursive: true, force: true });
59
+ });
60
+
61
+ function ctx(overrides: { startChannel?: () => Promise<number> } = {}) {
62
+ return {
63
+ deps: { cwd: tmp, vault, logger: undefined, options: {} },
64
+ args: { positional: [], flags: {} },
65
+ startChannel: overrides.startChannel ?? (async () => 0),
66
+ session: { setPermissionResolver: () => {} },
67
+ } as never;
68
+ }
69
+
70
+ describe('imessage ChannelDef shape', () => {
71
+ it('declares a dedicated runner with the imessage session source', () => {
72
+ expect(def.name).toBe('imessage');
73
+ expect(def.dedicatedRunner).toBe(true);
74
+ expect(def.sessionSource).toBe('imessage');
75
+ expect(def.interactiveCommand).toBe('setup');
76
+ expect(Object.keys(def.subcommands ?? {})).toEqual(
77
+ expect.arrayContaining(['setup', 'status', 'unpair']),
78
+ );
79
+ expect(Object.keys(def.subcommands ?? {})).not.toContain('pair');
80
+ expect(def.config?.fields.map((f) => f.vaultKey)).toEqual([
81
+ IMESSAGE_SERVER_URL_KEY,
82
+ IMESSAGE_SERVER_PASSWORD_KEY,
83
+ ]);
84
+ expect(def.config?.connect?.kind).toBe('instructions');
85
+ expect(def.config?.hasRequestUrl).toBeFalsy();
86
+ // The password field is the only secret; the URL is plain text.
87
+ const password = def.config?.fields.find((f) => f.vaultKey === IMESSAGE_SERVER_PASSWORD_KEY);
88
+ expect(password?.secret).toBe(true);
89
+ });
90
+ });
91
+
92
+ describe('isAvailable', () => {
93
+ it('is unavailable off macOS (never throws)', async () => {
94
+ setPlatform('linux');
95
+ const availability = await def.isAvailable?.({ cwd: tmp, vault });
96
+ expect(availability?.ok).toBe(false);
97
+ expect(availability?.reason).toMatch(/macOS/);
98
+ });
99
+
100
+ it('asks for configuration on macOS when nothing is stored', async () => {
101
+ setPlatform('darwin');
102
+ const availability = await def.isAvailable?.({ cwd: tmp, vault });
103
+ expect(availability?.ok).toBe(false);
104
+ expect(availability?.reason).toMatch(/imessage setup/);
105
+ });
106
+
107
+ it('is available on macOS with the env pair set', async () => {
108
+ setPlatform('darwin');
109
+ vi.stubEnv('MOXXY_IMESSAGE_SERVER_URL', 'http://localhost:1234');
110
+ vi.stubEnv('MOXXY_IMESSAGE_SERVER_PASSWORD', 'secret');
111
+ const availability = await def.isAvailable?.({ cwd: tmp, vault });
112
+ expect(availability).toEqual({ ok: true });
113
+ });
114
+
115
+ it('is available on macOS with the vault pair set', async () => {
116
+ setPlatform('darwin');
117
+ await vault.set(IMESSAGE_SERVER_URL_KEY, 'http://localhost:1234');
118
+ await vault.set(IMESSAGE_SERVER_PASSWORD_KEY, 'secret');
119
+ const availability = await def.isAvailable?.({ cwd: tmp, vault });
120
+ expect(availability).toEqual({ ok: true });
121
+ });
122
+ });
123
+
124
+ describe('imessage channel subcommands', () => {
125
+ it('`status` reports unconfigured state as JSON', async () => {
126
+ const code = await def.subcommands?.status?.run(ctx());
127
+ expect(code).toBe(0);
128
+ const parsed = JSON.parse(writeOut.join(''));
129
+ expect(parsed.serverUrl).toBeNull();
130
+ expect(parsed.passwordSet).toBe(false);
131
+ expect(parsed.allowedHandles).toEqual([]);
132
+ expect(parsed.ownerHandles).toEqual([]);
133
+ expect(parsed.supported).toBe(process.platform === 'darwin');
134
+ });
135
+
136
+ it('`status` surfaces the stored server + allow-list (never the password)', async () => {
137
+ await vault.set(IMESSAGE_SERVER_URL_KEY, 'http://localhost:1234');
138
+ await vault.set(IMESSAGE_SERVER_PASSWORD_KEY, 'super-secret');
139
+ await vault.set(IMESSAGE_ALLOWED_HANDLES_KEY, JSON.stringify(['+14440002222']));
140
+ await vault.set(IMESSAGE_OWNER_HANDLES_KEY, JSON.stringify(['+19998887777']));
141
+ const code = await def.subcommands?.status?.run(ctx());
142
+ expect(code).toBe(0);
143
+ const out = writeOut.join('');
144
+ expect(out).not.toContain('super-secret');
145
+ const parsed = JSON.parse(out);
146
+ expect(parsed.serverUrl).toBe('http://localhost:1234');
147
+ expect(parsed.passwordSet).toBe(true);
148
+ expect(parsed.allowedHandles).toEqual(['+14440002222']);
149
+ expect(parsed.ownerHandles).toEqual(['+19998887777']);
150
+ });
151
+
152
+ it('`unpair` clears server config + handle lists', async () => {
153
+ await vault.set(IMESSAGE_SERVER_URL_KEY, 'http://localhost:1234');
154
+ await vault.set(IMESSAGE_SERVER_PASSWORD_KEY, 'secret');
155
+ await vault.set(IMESSAGE_ALLOWED_HANDLES_KEY, JSON.stringify(['+14440002222']));
156
+ const code = await def.subcommands?.unpair?.run(ctx());
157
+ expect(code).toBe(0);
158
+ expect(writeOut.join('')).toContain('unpaired');
159
+ expect(await vault.get(IMESSAGE_SERVER_URL_KEY)).toBeNull();
160
+ expect(await vault.get(IMESSAGE_SERVER_PASSWORD_KEY)).toBeNull();
161
+ expect(await vault.get(IMESSAGE_ALLOWED_HANDLES_KEY)).toBeNull();
162
+ });
163
+
164
+ it('`unpair` is a no-op when nothing is configured', async () => {
165
+ const code = await def.subcommands?.unpair?.run(ctx());
166
+ expect(code).toBe(0);
167
+ expect(writeOut.join('')).toContain('no BlueBubbles server was configured');
168
+ });
169
+
170
+ it('`setup` starts the channel directly when headless', async () => {
171
+ const originalIsTTY = process.stdin.isTTY;
172
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
173
+ try {
174
+ const startChannel = vi.fn(async () => 0);
175
+ const code = await def.subcommands?.setup?.run(ctx({ startChannel }));
176
+ expect(code).toBe(0);
177
+ expect(startChannel).toHaveBeenCalledTimes(1);
178
+ } finally {
179
+ Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
180
+ }
181
+ });
182
+
183
+ it('subcommands return 1 when the vault is unavailable', async () => {
184
+ const badCtx = {
185
+ deps: { cwd: tmp, vault: undefined, logger: undefined, options: {} },
186
+ args: { positional: [], flags: {} },
187
+ startChannel: async () => 0,
188
+ session: { setPermissionResolver: () => {} },
189
+ } as never;
190
+ const code = await def.subcommands?.status?.run(badCtx);
191
+ expect(code).toBe(1);
192
+ expect(writeErr.join('')).toContain('vault unavailable');
193
+ });
194
+ });