shepherd-onboard 0.1.10 → 0.1.12

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/README.md CHANGED
@@ -10,8 +10,8 @@ Give this to a coding agent:
10
10
  npx -y shepherd-onboard@latest agent
11
11
  ```
12
12
 
13
- The command prints the exact prompt the agent should follow, then the exact follow-up commands to open Shepherd WorkOS login/signup, open source auth, open Granola's API key page, finalize, start cloud raw polling/backfills, and install local Messages sync.
14
- The agent prompt tells coding agents to ask short selection questions first: existing/new org, sources to connect, and Messages skip/provide-handle. If Messages is selected, the agent runs `messages-chats --json`, shows the 20 most recent local Messages chats with contact/group names, and asks which chats to sync. Account creation/relinking always starts with Shepherd WorkOS auth.
13
+ The command prints the exact prompt the agent should follow, then the exact follow-up commands to open Shepherd WorkOS login/signup, guide Google Workspace Admin Console delegation, open source auth for non-Google sources, open Granola's API key page, finalize, start cloud raw polling/backfills, and install local Messages sync.
14
+ The agent prompt tells coding agents to ask short selection questions first: existing/new org, sources to connect, and Messages skip/provide-handle. If Messages is selected, the agent runs `messages-chats`, opens a minimal local webpage with the 20 most recent local Messages chats, and has the user select which contacts/groups to sync. Account creation/relinking always starts with Shepherd WorkOS auth.
15
15
  Existing-organization joins are verified from Shepherd login and company email domain; the typed org name is not trusted by itself.
16
16
 
17
17
  ## Human Terminal One-liner
@@ -27,17 +27,33 @@ The command:
27
27
  - creates or reuses the Shepherd customer account from the WorkOS-authenticated email
28
28
  - creates or reuses the organization, including case-insensitive and close-name matches
29
29
  - only reuses an existing organization when the authenticated account is allowed to join it
30
- - opens Google Workspace authorization for Gmail, Drive, Docs, and Calendar consent
30
+ - prints Google Workspace domain-wide delegation setup for a Workspace super admin
31
31
  - opens Slack authorization
32
32
  - opens the Granola desktop app to Settings -> Connectors -> API keys
33
33
  - collects the Granola API key after opening the Granola screen when Granola is enabled
34
- - shows the 20 most recent local Messages chats with contact/group names and only syncs the chats selected by the user
34
+ - opens a minimal local webpage showing the 20 most recent local Messages chats with contact/group names and only syncs the chats selected by the user
35
35
  - sets up local macOS Messages raw sync with a background LaunchAgent scoped to the selected chats
36
36
  - starts raw polling/backfill for connected sources
37
37
  - does not start wiki generation, memory compilation, or doc summaries
38
38
 
39
39
  The command does not expose Railway, database, Redis, or internal service details to the user.
40
40
 
41
+ ## Google Workspace Domain-wide Delegation
42
+
43
+ Business customers connect Google Workspace once as an admin. Employees do not create service accounts and do not each complete Google OAuth for Gmail, Calendar, Drive, Docs, Sheets, Slides, Tasks, or Contacts.
44
+
45
+ Customer-side setup in Google Admin Console:
46
+
47
+ ```text
48
+ App name: Shepherd
49
+ Service account email: gigabrain-delegation@shepherd-gigabrain.iam.gserviceaccount.com
50
+ Domain-wide delegation OAuth Client ID: 118363960386741325727
51
+ ```
52
+
53
+ The customer super admin authorizes that Client ID with the scopes printed by the CLI. The customer does not upload service account JSON; Shepherd stores its private service account JSON server-side in `GOOGLE_WORKSPACE_SERVICE_ACCOUNT_KEY_JSON` and requests delegated tokens with `sub=<allowed_employee_email>`.
54
+
55
+ Shepherd must still enforce selected users and groups internally before impersonating or polling employee emails.
56
+
41
57
  ## Options
42
58
 
43
59
  ```sh
@@ -48,7 +64,7 @@ The command does not expose Railway, database, Redis, or internal service detail
48
64
  --messages-handle <value> Messages phone number or Apple ID email
49
65
  --messages-chat-ids <ids> Comma-separated chat IDs selected from messages-chats
50
66
  --messages-backfill-days Local Messages backfill window, default 30
51
- --no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar)
67
+ --no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts)
52
68
  --no-slack Skip Slack
53
69
  --no-granola Skip Granola
54
70
  --no-open-granola Do not open the Granola API key screen
@@ -58,12 +74,14 @@ The command does not expose Railway, database, Redis, or internal service detail
58
74
 
59
75
  ## Messages Chat Selection
60
76
 
61
- List recent local Messages chats:
77
+ Open the local Messages chat selector:
62
78
 
63
79
  ```sh
64
- npx -y shepherd-onboard@latest messages-chats --json
80
+ npx -y shepherd-onboard@latest messages-chats
65
81
  ```
66
82
 
83
+ Use `--json` for machine-readable chat metadata, or `--text` for a terminal list.
84
+
67
85
  Pass the selected chat IDs when finishing onboarding:
68
86
 
69
87
  ```sh
@@ -2,6 +2,7 @@
2
2
  import { execFile, execFileSync, spawn } from "node:child_process";
3
3
  import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { createServer } from "node:http";
5
6
  import { homedir, platform } from "node:os";
6
7
  import { dirname, join } from "node:path";
7
8
  import readline from "node:readline";
@@ -13,6 +14,7 @@ const DEFAULT_AGENT_STATE_PATH = join(homedir(), ".shepherd", "raw-onboarding-ag
13
14
  const MAX_BATCH_SIZE = 50;
14
15
  const MAX_QUEUE_MESSAGES = 10_000;
15
16
  const DEFAULT_RECENT_MESSAGE_CHATS = 20;
17
+ const GRANOLA_API_KEYS_PATH = "/settings/integrations/api-keys";
16
18
  const GOOGLE_WORKSPACE_DELEGATION_APP_NAME = "Shepherd";
17
19
  const GOOGLE_WORKSPACE_DELEGATION_SERVICE_ACCOUNT_EMAIL =
18
20
  "gigabrain-delegation@shepherd-gigabrain.iam.gserviceaccount.com";
@@ -283,7 +285,9 @@ async function runAgentOnboarding() {
283
285
  account: session.account,
284
286
  sources,
285
287
  authUrls: session.authUrls ?? {},
286
- googleWorkspaceDelegation: googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation),
288
+ googleWorkspaceDelegation: sources.google
289
+ ? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation)
290
+ : undefined,
287
291
  workosAuth,
288
292
  createdAt: new Date().toISOString(),
289
293
  });
@@ -309,7 +313,7 @@ async function runAgentOnboarding() {
309
313
  googleWorkspaceDelegation: sources.google ? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation) : undefined,
310
314
  granolaApiKeyPage,
311
315
  statePath,
312
- messagesChatsCommand: sources.messages ? `${agentCommand()} messages-chats --json` : undefined,
316
+ messagesChatsCommand: sources.messages ? `${agentCommand()} messages-chats` : undefined,
313
317
  nextCommand: `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`,
314
318
  needsUserAction: agentNeedsUserAction(sources, opened),
315
319
  }, null, 2));
@@ -337,15 +341,16 @@ async function runAgentOnboarding() {
337
341
  }
338
342
  console.log(`State saved: ${statePath}`);
339
343
  console.log("\nCoding agent next steps:");
344
+ let step = 1;
340
345
  if (sources.google) {
341
- console.log("1. Ask the user's Google Workspace super admin to authorize Shepherd in Google Admin Console with the Client ID and scopes above.");
346
+ console.log(`${step++}. Ask the user's Google Workspace super admin to authorize Shepherd in Google Admin Console with the Client ID and scopes above.`);
342
347
  }
343
- if (sources.slack) console.log("2. Ask the user to finish the opened Slack browser authorization.");
344
- if (sources.granola) console.log("3. Ask the user for their Granola API key from the Granola Mac app.");
348
+ if (sources.slack) console.log(`${step++}. Ask the user to finish the opened Slack browser authorization.`);
349
+ if (sources.granola) console.log(`${step++}. Ask the user for their Granola API key from the Granola Mac app.`);
345
350
  if (sources.messages) {
346
- console.log(`4. Run ${agentCommand()} messages-chats --json, ask the user which recent chats to sync, and keep those chat IDs.`);
351
+ console.log(`${step++}. Run ${agentCommand()} messages-chats, have the user select chats in the browser page, and keep the printed chat IDs.`);
347
352
  }
348
- console.log("5. Run:");
353
+ console.log(`${step++}. Run:`);
349
354
  console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`);
350
355
  console.log(" Omit either optional flag if that source is not being connected.");
351
356
  }
@@ -436,7 +441,7 @@ async function continueAgentOnboarding() {
436
441
  if (granolaApiKey) body.granolaApiKey = granolaApiKey;
437
442
  if (messagesHandle) body.imessage = { handle: messagesHandle };
438
443
  if (state.sources.messages && messagesHandle && selectedMessageChatIds.length === 0) {
439
- throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats --json, ask the user which chats to sync, then rerun --continue with --messages-chat-ids "<id1>,<id2>".`);
444
+ throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats, have the user select chats in the browser page, then rerun --continue with --messages-chat-ids "<id1>,<id2>".`);
440
445
  }
441
446
 
442
447
  const finalized = await postJson(
@@ -518,6 +523,16 @@ async function runMessagesChatsCommand() {
518
523
  return;
519
524
  }
520
525
 
526
+ if (!args.text && !args.list) {
527
+ const selected = await selectChatsInBrowser(chats, { noOpen: Boolean(args["no-open"]) });
528
+ const selectedIds = selected.map((chat) => chat.chatId).join(",");
529
+ console.log(`\nSelected ${selected.length} Messages chat(s).`);
530
+ console.log(`messages-chat-ids=${selectedIds}`);
531
+ console.log("\nContinue with:");
532
+ console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "${selectedIds}"`);
533
+ return;
534
+ }
535
+
521
536
  console.log(`\nRecent local Messages chats (${chats.length})\n`);
522
537
  for (let i = 0; i < chats.length; i++) {
523
538
  console.log(`${String(i + 1).padStart(2, " ")}. ${formatMessageChatOption(chats[i])}`);
@@ -598,7 +613,7 @@ Usage:
598
613
  npx -y ${PACKAGE_NAME}@latest agent
599
614
  npx -y ${PACKAGE_NAME}@latest agent --login
600
615
  npx -y ${PACKAGE_NAME}@latest agent --name <name> --org <organization>
601
- npx -y ${PACKAGE_NAME}@latest messages-chats --json
616
+ npx -y ${PACKAGE_NAME}@latest messages-chats
602
617
  npx -y ${PACKAGE_NAME}@latest agent --continue --granola-api-key <key> --messages-handle <value> --messages-chat-ids <ids>
603
618
  npx -y ${PACKAGE_NAME}@latest agent --status
604
619
  npx -y ${PACKAGE_NAME}@latest granola-api-keys
@@ -633,6 +648,8 @@ Usage:
633
648
 
634
649
  Options:
635
650
  --limit <n> Number of recent chats to show. Defaults to ${DEFAULT_RECENT_MESSAGE_CHATS}.
651
+ --text Print a terminal list instead of opening the selector page.
652
+ --no-open Print the local selector URL instead of opening it.
636
653
  --json Print machine-readable chat IDs and labels.
637
654
  --help Show this help.
638
655
  `);
@@ -735,7 +752,7 @@ function printAgentContract() {
735
752
  "--granola-api-key \"<granola_key>\" if Granola is being connected",
736
753
  ],
737
754
  statusCommand: `${command} agent --status`,
738
- messagesChatsCommand: `${command} messages-chats --json`,
755
+ messagesChatsCommand: `${command} messages-chats`,
739
756
  googleWorkspaceDelegation: googleWorkspaceDelegationSetup(),
740
757
  orgSecurity: "Existing organizations are only reused when Shepherd can verify the authenticated user belongs there, for example by an existing Shepherd account/membership or matching non-personal company email domain. Similar spelling helps match verified orgs, but cannot attach an unverified user to someone else's org.",
741
758
  expectedResult: "Cloud sources start raw polling/backfill in the customer-facing Shepherd environment. Local Messages starts via a macOS LaunchAgent when run on macOS. Downstream wiki, memory, and summary compilers remain outside this onboarding flow.",
@@ -758,7 +775,7 @@ Ask with short interactive prompts, not as one pasted checklist.
758
775
 
759
776
  Start with selection questions to determine intent:
760
777
  1. Organization: Join existing org, or Create new org.
761
- 2. Sources: Google Workspace (Gmail/Drive/Docs/Calendar), Slack, Granola, Messages. Allow multi-select if your interface supports it.
778
+ 2. Sources: Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts), Slack, Granola, Messages. Allow multi-select if your interface supports it.
762
779
  3. Messages, if selected: Skip Messages, or Provide handle.
763
780
 
764
781
  When discussing existing orgs, keep it short: Shepherd verifies the join from their Shepherd login and company email domain. The org name they type is not trusted by itself.
@@ -767,6 +784,7 @@ Before source setup, always run:
767
784
  ${payload.loginCommand}
768
785
 
769
786
  That opens one WorkOS Shepherd login/signup flow and saves a local onboarding auth session. It creates or relinks the Shepherd customer account; the next setup command attaches sources to the same production cloud account rows.
787
+ Do not use WorkOS Auth, WorkOS Pipes, or per-user Google OAuth for Google Workspace. WorkOS login is only Shepherd account identity.
770
788
 
771
789
  Ask the user for:
772
790
  1. Full name
@@ -780,7 +798,7 @@ If they are joining an existing org, ask for the org name they believe they belo
780
798
  If Messages is selected, run:
781
799
  ${payload.messagesChatsCommand}
782
800
 
783
- Show the 20 recent local Messages chats to the user as a selection question. Include both DMs and groups, and use the displayed contact/group names. Ask which chats Shepherd should sync. Do not select all chats by default.
801
+ This opens a minimal local webpage with the 20 recent local Messages chats. Have the user select which contacts/groups Shepherd should sync. Do not select all chats by default. When the command returns, keep the printed comma-separated chat IDs.
784
802
 
785
803
  Then run:
786
804
  ${payload.startCommand}
@@ -791,8 +809,21 @@ Add skip flags for sources the user did not select:
791
809
  - --no-granola
792
810
  - --no-messages
793
811
 
794
- That command creates/reuses the customer user and org, opens Google Workspace/Slack browser auth, and saves local state.
795
- If your browser automation can complete those auth screens, do it. If it cannot click through OAuth screens, leave the opened browser tabs for the user and ask them to complete Google Workspace and Slack auth.
812
+ That command creates/reuses the customer user and org, prints Google Workspace domain-wide delegation setup values, opens Slack browser auth if selected, and saves local state.
813
+
814
+ If Google Workspace is selected, show this Admin Console setup to the user and have their Google Workspace super admin authorize it:
815
+
816
+ App name: ${payload.googleWorkspaceDelegation.appName}
817
+ Service account email: ${payload.googleWorkspaceDelegation.serviceAccountEmail}
818
+ Domain-wide delegation OAuth Client ID: ${payload.googleWorkspaceDelegation.clientId}
819
+
820
+ Scopes:
821
+ ${payload.googleWorkspaceDelegation.scopes.join("\n")}
822
+
823
+ The customer does not create a service account and does not upload service account JSON in the default Shepherd-managed flow. Their super admin only authorizes the Client ID and scopes in Google Admin Console. Shepherd's backend stores and uses its private service account JSON server-side.
824
+ Shepherd must still enforce selected users and groups internally before polling or impersonating any employee email.
825
+
826
+ If Slack is selected and your browser automation can complete that auth screen, do it. If it cannot click through OAuth screens, leave the opened browser tab for the user and ask them to complete Slack auth.
796
827
 
797
828
  If Granola is selected, it also opens the Granola desktop app. If your local app automation can navigate it, go to:
798
829
  Settings -> Connectors -> API keys
@@ -802,7 +833,7 @@ If Granola did not come forward, run:
802
833
  ${payload.granolaApiKeyCommand}
803
834
  That command opens Granola and tries to navigate to Settings -> Connectors -> API keys. If your tool cannot click inside Granola, leave Granola open and ask the user to go to that screen.
804
835
 
805
- After Google Workspace and Slack browser auth is complete, and after the user has copied a Granola API key from the opened Granola screen if they want Granola, run:
836
+ After Google Workspace Admin Console delegation and Slack browser auth are complete, and after the user has copied a Granola API key from the opened Granola screen if they want Granola, run:
806
837
  ${payload.continueCommand} --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"
807
838
 
808
839
  Omit either optional flag if that source is not being connected.
@@ -905,7 +936,7 @@ function agentNeedsUserAction(sources, opened) {
905
936
  if (sources.google) actions.push("Have the customer's Google Workspace super admin authorize Shepherd's domain-wide delegation Client ID and scopes in Google Admin Console.");
906
937
  if (sources.slack && opened.includes("slack")) actions.push("Complete Slack browser authorization.");
907
938
  if (sources.granola) actions.push("Create/copy a Granola API key from the Granola Mac app.");
908
- if (sources.messages) actions.push("Run messages-chats, ask the user which recent local Messages chats to sync, then pass the selected chat IDs with the Messages handle.");
939
+ if (sources.messages) actions.push("Run messages-chats, have the user select local Messages contacts/groups in the browser, then pass the printed chat IDs with the Messages handle.");
909
940
  return actions;
910
941
  }
911
942
 
@@ -1017,7 +1048,7 @@ async function openOrPrint(url, opts) {
1017
1048
  }
1018
1049
 
1019
1050
  async function openGranolaApiKeys(opts = {}) {
1020
- const deepLink = "granola://settings/integrations";
1051
+ const deepLink = granolaApiKeysDeepLink();
1021
1052
  if (opts.noOpen) {
1022
1053
  console.log("Granola API keys: open the Granola desktop app -> Settings -> Connectors -> API keys");
1023
1054
  return { opened: false, target: "Granola Settings -> Connectors -> API keys" };
@@ -1030,19 +1061,20 @@ async function openGranolaApiKeys(opts = {}) {
1030
1061
 
1031
1062
  console.log("\nOpening Granola API keys");
1032
1063
  const bundleResult = await execFileQuiet("open", ["-b", "com.granola.app"], { ignoreError: true, captureError: true });
1033
- await sleep(500);
1034
- await execFileQuiet("open", [deepLink], { ignoreError: true, captureError: true });
1035
- await sleep(500);
1064
+ await sleep(900);
1065
+ const deepLinkResult = await execFileQuiet("open", ["-u", deepLink], { ignoreError: true, captureError: true });
1066
+ await sleep(700);
1067
+ const deepLinkRetryResult = await execFileQuiet("open", ["-u", deepLink], { ignoreError: true, captureError: true });
1068
+ await sleep(300);
1036
1069
  const activateByBundleResult = await execFileQuiet("osascript", [
1037
1070
  "-e",
1038
1071
  'tell application id "com.granola.app" to activate',
1039
1072
  ], { ignoreError: true, captureError: true });
1040
1073
  const activateByNameResult = await execFileQuiet("open", ["-a", "Granola"], { ignoreError: true, captureError: true });
1041
1074
 
1042
- if (bundleResult.error && activateByBundleResult.error && activateByNameResult.error) {
1075
+ if (bundleResult.error && deepLinkResult.error && deepLinkRetryResult.error && activateByBundleResult.error && activateByNameResult.error) {
1043
1076
  await execFileQuiet("open", ["-a", "Granola"], { ignoreError: true });
1044
1077
  }
1045
- await navigateGranolaApiKeysWithAppleScript();
1046
1078
 
1047
1079
  return {
1048
1080
  opened: true,
@@ -1052,32 +1084,8 @@ async function openGranolaApiKeys(opts = {}) {
1052
1084
  };
1053
1085
  }
1054
1086
 
1055
- async function navigateGranolaApiKeysWithAppleScript() {
1056
- const script = `
1057
- tell application id "com.granola.app" to activate
1058
- delay 0.7
1059
- tell application "System Events"
1060
- if not (exists process "Granola") then return
1061
- tell process "Granola"
1062
- set frontmost to true
1063
- try
1064
- set {x, y} to position of window 1
1065
- set {w, h} to size of window 1
1066
- click at {x + w - 55, y + 810}
1067
- return
1068
- end try
1069
- try
1070
- keystroke "," using command down
1071
- delay 0.5
1072
- set {x, y} to position of window 1
1073
- set {w, h} to size of window 1
1074
- click at {x + w - 55, y + 810}
1075
- return
1076
- end try
1077
- end tell
1078
- end tell
1079
- `;
1080
- await execFileQuiet("osascript", ["-e", script], { ignoreError: true });
1087
+ function granolaApiKeysDeepLink() {
1088
+ return `granola://open?path=${encodeURIComponent(GRANOLA_API_KEYS_PATH)}`;
1081
1089
  }
1082
1090
 
1083
1091
  async function postJson(url, body, opts = {}) {
@@ -1196,7 +1204,7 @@ async function selectRecentMessageChats() {
1196
1204
  }
1197
1205
 
1198
1206
  if (!process.stdin.isTTY) {
1199
- throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats --json and pass --messages-chat-ids "<id1>,<id2>".`);
1207
+ throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats and pass --messages-chat-ids "<id1>,<id2>".`);
1200
1208
  }
1201
1209
 
1202
1210
  const chats = await listRecentMessageChats({ limit: DEFAULT_RECENT_MESSAGE_CHATS });
@@ -1204,6 +1212,10 @@ async function selectRecentMessageChats() {
1204
1212
  throw new Error("No recent local Messages chats were found on this Mac.");
1205
1213
  }
1206
1214
 
1215
+ if (!args.text && !args.list) {
1216
+ return selectChatsInBrowser(chats, { noOpen: Boolean(args["no-open"]) });
1217
+ }
1218
+
1207
1219
  console.log(`\nSelect local Messages chats to sync\n`);
1208
1220
  console.log("Shepherd will only pull from the chats you select.");
1209
1221
  for (let i = 0; i < chats.length; i++) {
@@ -1216,6 +1228,356 @@ async function selectRecentMessageChats() {
1216
1228
  return indexes.map((idx) => chats[idx]);
1217
1229
  }
1218
1230
 
1231
+ async function selectChatsInBrowser(chats, opts = {}) {
1232
+ if (!chats.length) throw new Error("No recent local Messages chats were found on this Mac.");
1233
+
1234
+ const token = Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
1235
+ let settled = false;
1236
+ let server;
1237
+
1238
+ return new Promise((resolve, reject) => {
1239
+ const timeout = setTimeout(() => {
1240
+ if (settled) return;
1241
+ settled = true;
1242
+ server?.close();
1243
+ reject(new Error("Messages chat selection timed out."));
1244
+ }, 20 * 60 * 1000);
1245
+
1246
+ server = createServer(async (req, res) => {
1247
+ try {
1248
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
1249
+
1250
+ if (req.method === "GET" && url.pathname === "/") {
1251
+ sendHtml(res, renderMessagesSelectorPage(chats, token));
1252
+ return;
1253
+ }
1254
+
1255
+ if (req.method === "POST" && url.pathname === "/select") {
1256
+ const body = await readRequestBody(req);
1257
+ const form = new URLSearchParams(body);
1258
+ if (form.get("token") !== token) {
1259
+ sendHtml(res, renderMessagesDonePage("Invalid selection session.", true), 403);
1260
+ return;
1261
+ }
1262
+ const selectedIds = form.getAll("chatId").filter(Boolean);
1263
+ const selectedSet = new Set(selectedIds);
1264
+ const selected = chats.filter((chat) => selectedSet.has(chat.chatId));
1265
+ if (selected.length === 0) {
1266
+ sendHtml(res, renderMessagesSelectorPage(chats, token, "Select at least one chat."));
1267
+ return;
1268
+ }
1269
+
1270
+ sendHtml(res, renderMessagesDonePage(`${selected.length} chat${selected.length === 1 ? "" : "s"} selected.`));
1271
+ if (!settled) {
1272
+ settled = true;
1273
+ clearTimeout(timeout);
1274
+ setTimeout(() => server.close(), 100);
1275
+ resolve(selected);
1276
+ }
1277
+ return;
1278
+ }
1279
+
1280
+ sendHtml(res, renderMessagesDonePage("Not found.", true), 404);
1281
+ } catch (err) {
1282
+ if (!settled) {
1283
+ settled = true;
1284
+ clearTimeout(timeout);
1285
+ server?.close();
1286
+ reject(err);
1287
+ }
1288
+ }
1289
+ });
1290
+
1291
+ server.on("error", (err) => {
1292
+ if (settled) return;
1293
+ settled = true;
1294
+ clearTimeout(timeout);
1295
+ reject(err);
1296
+ });
1297
+
1298
+ server.listen(0, "127.0.0.1", async () => {
1299
+ const address = server.address();
1300
+ const port = typeof address === "object" && address ? address.port : null;
1301
+ if (!port) {
1302
+ settled = true;
1303
+ clearTimeout(timeout);
1304
+ server.close();
1305
+ reject(new Error("Could not start local Messages selector."));
1306
+ return;
1307
+ }
1308
+ const url = `http://127.0.0.1:${port}/`;
1309
+ console.log(`\nOpening Messages chat selector: ${url}`);
1310
+ await openOrPrint(url, { noOpen: Boolean(opts.noOpen) });
1311
+ console.log("Select the Messages chats to sync in the browser.");
1312
+ });
1313
+ });
1314
+ }
1315
+
1316
+ function renderMessagesSelectorPage(chats, token, error = "") {
1317
+ const rows = chats.map((chat) => `
1318
+ <label class="chat-row">
1319
+ <input type="checkbox" name="chatId" value="${htmlAttr(chat.chatId)}">
1320
+ <span class="box" aria-hidden="true"></span>
1321
+ <span class="chat-main">
1322
+ <span class="chat-top">
1323
+ <span class="chat-name">${html(chat.label)}</span>
1324
+ <span class="chat-kind">${html(chat.kind === "group" ? "Group" : chat.kind === "dm" ? "Contact" : "Chat")}</span>
1325
+ </span>
1326
+ ${renderChatPeople(chat)}
1327
+ <span class="chat-meta">${html(formatMessageChatMeta(chat))}</span>
1328
+ </span>
1329
+ </label>`).join("");
1330
+
1331
+ return `<!doctype html>
1332
+ <html lang="en">
1333
+ <head>
1334
+ <meta charset="utf-8">
1335
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1336
+ <title>Select Messages Chats</title>
1337
+ <style>
1338
+ :root {
1339
+ color-scheme: light dark;
1340
+ --bg: #FCFCFC;
1341
+ --fg: #111111;
1342
+ --muted: #6D726D;
1343
+ --line: #E8ECE8;
1344
+ --panel: #FFFFFF;
1345
+ --button: #136033;
1346
+ --button-text: #FFFFFF;
1347
+ --link: #136033;
1348
+ --radius: 10px;
1349
+ }
1350
+ @media (prefers-color-scheme: dark) {
1351
+ :root {
1352
+ --bg: #000000;
1353
+ --fg: #F8F8F8;
1354
+ --muted: #A2A8A2;
1355
+ --line: #202520;
1356
+ --panel: #070907;
1357
+ --button: #FFFFFF;
1358
+ --button-text: #000000;
1359
+ --link: #136033;
1360
+ }
1361
+ }
1362
+ * { box-sizing: border-box; }
1363
+ body {
1364
+ margin: 0;
1365
+ background: var(--bg);
1366
+ color: var(--fg);
1367
+ font-family: Geist, "Geist Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
1368
+ letter-spacing: 0;
1369
+ }
1370
+ main {
1371
+ width: min(760px, calc(100vw - 32px));
1372
+ margin: 40px auto;
1373
+ }
1374
+ header {
1375
+ display: flex;
1376
+ justify-content: space-between;
1377
+ gap: 16px;
1378
+ align-items: end;
1379
+ padding-bottom: 18px;
1380
+ border-bottom: 1px solid var(--line);
1381
+ }
1382
+ h1 {
1383
+ margin: 0;
1384
+ font-size: 24px;
1385
+ line-height: 1.1;
1386
+ font-weight: 650;
1387
+ }
1388
+ .count {
1389
+ color: var(--muted);
1390
+ font-size: 13px;
1391
+ white-space: nowrap;
1392
+ }
1393
+ form {
1394
+ margin-top: 18px;
1395
+ }
1396
+ .error {
1397
+ margin: 0 0 12px;
1398
+ color: #9B1C1C;
1399
+ font-size: 14px;
1400
+ }
1401
+ .chat-list {
1402
+ display: grid;
1403
+ gap: 8px;
1404
+ margin: 0 0 18px;
1405
+ }
1406
+ .chat-row {
1407
+ display: grid;
1408
+ grid-template-columns: 24px 1fr;
1409
+ gap: 12px;
1410
+ align-items: start;
1411
+ padding: 13px 14px;
1412
+ background: var(--panel);
1413
+ border: 1px solid var(--line);
1414
+ border-radius: var(--radius);
1415
+ cursor: pointer;
1416
+ }
1417
+ .chat-row:hover {
1418
+ border-color: color-mix(in srgb, var(--link) 45%, var(--line));
1419
+ }
1420
+ input[type="checkbox"] {
1421
+ position: absolute;
1422
+ opacity: 0;
1423
+ pointer-events: none;
1424
+ }
1425
+ .box {
1426
+ width: 18px;
1427
+ height: 18px;
1428
+ margin-top: 2px;
1429
+ border: 1.5px solid var(--muted);
1430
+ border-radius: 5px;
1431
+ display: inline-grid;
1432
+ place-items: center;
1433
+ }
1434
+ input[type="checkbox"]:checked + .box {
1435
+ background: var(--button);
1436
+ border-color: var(--button);
1437
+ }
1438
+ input[type="checkbox"]:checked + .box::after {
1439
+ content: "";
1440
+ width: 7px;
1441
+ height: 4px;
1442
+ border-left: 2px solid var(--button-text);
1443
+ border-bottom: 2px solid var(--button-text);
1444
+ transform: rotate(-45deg) translateY(-1px);
1445
+ }
1446
+ .chat-main {
1447
+ min-width: 0;
1448
+ display: grid;
1449
+ gap: 4px;
1450
+ }
1451
+ .chat-top {
1452
+ display: flex;
1453
+ gap: 10px;
1454
+ align-items: center;
1455
+ min-width: 0;
1456
+ }
1457
+ .chat-name {
1458
+ overflow: hidden;
1459
+ text-overflow: ellipsis;
1460
+ white-space: nowrap;
1461
+ font-size: 15px;
1462
+ font-weight: 600;
1463
+ }
1464
+ .chat-kind {
1465
+ color: var(--link);
1466
+ font-size: 12px;
1467
+ flex: none;
1468
+ }
1469
+ .chat-people,
1470
+ .chat-meta {
1471
+ color: var(--muted);
1472
+ font-size: 13px;
1473
+ line-height: 1.35;
1474
+ overflow-wrap: anywhere;
1475
+ }
1476
+ .actions {
1477
+ display: flex;
1478
+ justify-content: flex-end;
1479
+ position: sticky;
1480
+ bottom: 0;
1481
+ padding: 14px 0 0;
1482
+ background: linear-gradient(to top, var(--bg) 70%, transparent);
1483
+ }
1484
+ button {
1485
+ appearance: none;
1486
+ border: 0;
1487
+ border-radius: var(--radius);
1488
+ background: var(--button);
1489
+ color: var(--button-text);
1490
+ padding: 10px 14px;
1491
+ font: inherit;
1492
+ font-weight: 620;
1493
+ cursor: pointer;
1494
+ }
1495
+ a { color: var(--link); }
1496
+ </style>
1497
+ </head>
1498
+ <body>
1499
+ <main>
1500
+ <header>
1501
+ <h1>Select Messages chats</h1>
1502
+ <div class="count">${chats.length} recent chats</div>
1503
+ </header>
1504
+ <form method="post" action="/select">
1505
+ <input type="hidden" name="token" value="${htmlAttr(token)}">
1506
+ ${error ? `<p class="error">${html(error)}</p>` : ""}
1507
+ <div class="chat-list">${rows}</div>
1508
+ <div class="actions">
1509
+ <button type="submit">Use selected chats</button>
1510
+ </div>
1511
+ </form>
1512
+ </main>
1513
+ </body>
1514
+ </html>`;
1515
+ }
1516
+
1517
+ function renderMessagesDonePage(message, isError = false) {
1518
+ return `<!doctype html>
1519
+ <html lang="en">
1520
+ <head>
1521
+ <meta charset="utf-8">
1522
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1523
+ <title>Messages Selection</title>
1524
+ <style>
1525
+ :root { color-scheme: light dark; --bg: #FCFCFC; --fg: #111; --muted: #6D726D; --button: #136033; --button-text: #FFFFFF; --radius: 10px; }
1526
+ @media (prefers-color-scheme: dark) { :root { --bg: #000; --fg: #F8F8F8; --muted: #A2A8A2; --button: #FFFFFF; --button-text: #000; } }
1527
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--fg); font-family: Geist, "Geist Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0; }
1528
+ main { width: min(420px, calc(100vw - 32px)); }
1529
+ h1 { margin: 0 0 8px; font-size: 24px; line-height: 1.1; font-weight: 650; }
1530
+ p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.45; }
1531
+ .mark { width: 34px; height: 34px; border-radius: var(--radius); display: grid; place-items: center; margin-bottom: 14px; background: var(--button); color: var(--button-text); font-weight: 700; }
1532
+ </style>
1533
+ </head>
1534
+ <body>
1535
+ <main>
1536
+ <div class="mark">${isError ? "!" : "OK"}</div>
1537
+ <h1>${html(message)}</h1>
1538
+ <p>${isError ? "Return to the terminal and retry." : "You can close this tab and return to the terminal."}</p>
1539
+ </main>
1540
+ </body>
1541
+ </html>`;
1542
+ }
1543
+
1544
+ function renderChatPeople(chat) {
1545
+ const names = chat.participants?.map((participant) => participant.name ?? participant.handle).filter(Boolean) ?? [];
1546
+ if (!names.length) return "";
1547
+ return `<span class="chat-people">${html(names.slice(0, 6).join(", "))}</span>`;
1548
+ }
1549
+
1550
+ function formatMessageChatMeta(chat) {
1551
+ const parts = [];
1552
+ if (chat.service) parts.push(chat.service);
1553
+ if (chat.lastMessageAt) parts.push(new Date(chat.lastMessageAt).toLocaleString());
1554
+ return parts.join(" · ");
1555
+ }
1556
+
1557
+ function sendHtml(res, body, status = 200) {
1558
+ res.writeHead(status, {
1559
+ "Content-Type": "text/html; charset=utf-8",
1560
+ "Cache-Control": "no-store",
1561
+ });
1562
+ res.end(body);
1563
+ }
1564
+
1565
+ function readRequestBody(req) {
1566
+ return new Promise((resolve, reject) => {
1567
+ let body = "";
1568
+ req.setEncoding("utf8");
1569
+ req.on("data", (chunk) => {
1570
+ body += chunk;
1571
+ if (body.length > 64_000) {
1572
+ reject(new Error("Request body too large."));
1573
+ req.destroy();
1574
+ }
1575
+ });
1576
+ req.on("end", () => resolve(body));
1577
+ req.on("error", reject);
1578
+ });
1579
+ }
1580
+
1219
1581
  async function listRecentMessageChats({ limit }) {
1220
1582
  if (platform() !== "darwin") {
1221
1583
  throw new Error("local Messages chat discovery is only supported on macOS");
@@ -1680,6 +2042,17 @@ function parseAllowedChatIds(value) {
1680
2042
  return [...new Set(raw.map((chatId) => String(chatId).trim()).filter(Boolean))];
1681
2043
  }
1682
2044
 
2045
+ function html(value) {
2046
+ return String(value ?? "")
2047
+ .replace(/&/g, "&amp;")
2048
+ .replace(/</g, "&lt;")
2049
+ .replace(/>/g, "&gt;");
2050
+ }
2051
+
2052
+ function htmlAttr(value) {
2053
+ return html(value).replace(/"/g, "&quot;");
2054
+ }
2055
+
1683
2056
  class MessagesBatchSender {
1684
2057
  constructor(apiUrl, agentToken, userId) {
1685
2058
  this.apiUrl = trimTrailingSlash(apiUrl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shepherd-onboard",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Customer-facing Shepherd raw sync onboarding CLI",
5
5
  "type": "module",
6
6
  "bin": {