pushary 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1943 -0
- package/LICENSE +21 -0
- package/README.md +330 -0
- package/data/SKILL.md +591 -0
- package/data/cowork/SKILL.md +77 -0
- package/data/cursor-plugin/.cursor-plugin/plugin.json +25 -0
- package/data/cursor-plugin/CHANGELOG.md +70 -0
- package/data/cursor-plugin/CONTRIBUTING.md +40 -0
- package/data/cursor-plugin/LICENSE +21 -0
- package/data/cursor-plugin/README.md +113 -0
- package/data/cursor-plugin/SECURITY.md +33 -0
- package/data/cursor-plugin/assets/logo.png +0 -0
- package/data/cursor-plugin/commands/notify-when-done.md +14 -0
- package/data/cursor-plugin/commands/pushary-test.md +13 -0
- package/data/cursor-plugin/hooks/hooks.json +75 -0
- package/data/cursor-plugin/mcp.json +11 -0
- package/data/cursor-plugin/rules/pushary.mdc +40 -0
- package/data/cursor-plugin/scripts/pushary-gate.mjs +724 -0
- package/data/cursor-plugin/scripts/pushary-gate.test.mjs +137 -0
- package/data/cursor-plugin/scripts/redaction.mjs +51 -0
- package/data/cursor-plugin/skills/pushary/SKILL.md +584 -0
- package/data/vscode-plugin/.claude-plugin/plugin.json +28 -0
- package/data/vscode-plugin/.mcp.json +11 -0
- package/data/vscode-plugin/CHANGELOG.md +49 -0
- package/data/vscode-plugin/CONTRIBUTING.md +40 -0
- package/data/vscode-plugin/LICENSE +21 -0
- package/data/vscode-plugin/README.md +128 -0
- package/data/vscode-plugin/SECURITY.md +56 -0
- package/data/vscode-plugin/assets/logo.png +0 -0
- package/data/vscode-plugin/commands/notify-when-done.md +14 -0
- package/data/vscode-plugin/commands/pushary-test.md +13 -0
- package/data/vscode-plugin/hooks/hooks.json +60 -0
- package/data/vscode-plugin/scripts/pushary-gate.mjs +762 -0
- package/data/vscode-plugin/scripts/redaction.mjs +51 -0
- package/data/vscode-plugin/skills/pushary/SKILL.md +584 -0
- package/dist/bin/pushary-bell-hook.d.ts +1 -0
- package/dist/bin/pushary-bell-hook.js +46 -0
- package/dist/bin/pushary-bell.d.ts +1 -0
- package/dist/bin/pushary-bell.js +146 -0
- package/dist/bin/pushary-claude.d.ts +1 -0
- package/dist/bin/pushary-claude.js +1916 -0
- package/dist/bin/pushary-clean.d.ts +1 -0
- package/dist/bin/pushary-clean.js +1300 -0
- package/dist/bin/pushary-codex-bridge.d.ts +1 -0
- package/dist/bin/pushary-codex-bridge.js +599 -0
- package/dist/bin/pushary-codex-hook.d.ts +1 -0
- package/dist/bin/pushary-codex-hook.js +886 -0
- package/dist/bin/pushary-codex.d.ts +1 -0
- package/dist/bin/pushary-codex.js +125 -0
- package/dist/bin/pushary-connect.d.ts +1 -0
- package/dist/bin/pushary-connect.js +100 -0
- package/dist/bin/pushary-cowork.d.ts +1 -0
- package/dist/bin/pushary-cowork.js +36 -0
- package/dist/bin/pushary-daemon-supervisor.d.ts +1 -0
- package/dist/bin/pushary-daemon-supervisor.js +27 -0
- package/dist/bin/pushary-daemon.d.ts +1 -0
- package/dist/bin/pushary-daemon.js +878 -0
- package/dist/bin/pushary-disconnect.d.ts +1 -0
- package/dist/bin/pushary-disconnect.js +264 -0
- package/dist/bin/pushary-doctor.d.ts +1 -0
- package/dist/bin/pushary-doctor.js +1433 -0
- package/dist/bin/pushary-elicitation-hook.d.ts +1 -0
- package/dist/bin/pushary-elicitation-hook.js +149 -0
- package/dist/bin/pushary-gemini-bridge.d.ts +1 -0
- package/dist/bin/pushary-gemini-bridge.js +339 -0
- package/dist/bin/pushary-gemini-hook.d.ts +1 -0
- package/dist/bin/pushary-gemini-hook.js +381 -0
- package/dist/bin/pushary-hook.d.ts +1 -0
- package/dist/bin/pushary-hook.js +95 -0
- package/dist/bin/pushary-login.d.ts +1 -0
- package/dist/bin/pushary-login.js +198 -0
- package/dist/bin/pushary-logout.d.ts +1 -0
- package/dist/bin/pushary-logout.js +147 -0
- package/dist/bin/pushary-mcp.d.ts +1 -0
- package/dist/bin/pushary-mcp.js +114 -0
- package/dist/bin/pushary-mode.d.ts +1 -0
- package/dist/bin/pushary-mode.js +130 -0
- package/dist/bin/pushary-notification-hook.d.ts +1 -0
- package/dist/bin/pushary-notification-hook.js +56 -0
- package/dist/bin/pushary-opencode-hook.d.ts +1 -0
- package/dist/bin/pushary-opencode-hook.js +338 -0
- package/dist/bin/pushary-permission-denied-hook.d.ts +1 -0
- package/dist/bin/pushary-permission-denied-hook.js +60 -0
- package/dist/bin/pushary-permission-hook.d.ts +1 -0
- package/dist/bin/pushary-permission-hook.js +65 -0
- package/dist/bin/pushary-post-hook.d.ts +1 -0
- package/dist/bin/pushary-post-hook.js +70 -0
- package/dist/bin/pushary-prompt-hook.d.ts +1 -0
- package/dist/bin/pushary-prompt-hook.js +61 -0
- package/dist/bin/pushary-session-end-hook.d.ts +1 -0
- package/dist/bin/pushary-session-end-hook.js +59 -0
- package/dist/bin/pushary-session-start-hook.d.ts +1 -0
- package/dist/bin/pushary-session-start-hook.js +72 -0
- package/dist/bin/pushary-setup.d.ts +1 -0
- package/dist/bin/pushary-setup.js +2660 -0
- package/dist/bin/pushary-stats.d.ts +1 -0
- package/dist/bin/pushary-stats.js +44 -0
- package/dist/bin/pushary-status.d.ts +1 -0
- package/dist/bin/pushary-status.js +262 -0
- package/dist/bin/pushary-stop-hook.d.ts +1 -0
- package/dist/bin/pushary-stop-hook.js +63 -0
- package/dist/bin/pushary-stopfailure-hook.d.ts +1 -0
- package/dist/bin/pushary-stopfailure-hook.js +56 -0
- package/dist/bin/pushary-suggestions.d.ts +1 -0
- package/dist/bin/pushary-suggestions.js +100 -0
- package/dist/bin/pushary-transcript-register.d.ts +1 -0
- package/dist/bin/pushary-transcript-register.js +40 -0
- package/dist/bin/pushary-transcripts.d.ts +1 -0
- package/dist/bin/pushary-transcripts.js +77 -0
- package/dist/bin/pushary-upgrade.d.ts +1 -0
- package/dist/bin/pushary-upgrade.js +599 -0
- package/dist/bin/pushary-wait.d.ts +1 -0
- package/dist/bin/pushary-wait.js +122 -0
- package/dist/bin/pushary.d.ts +1 -0
- package/dist/bin/pushary.js +74 -0
- package/dist/chunk-2ABTSGFT.js +173 -0
- package/dist/chunk-2OY3ZZ5N.js +124 -0
- package/dist/chunk-3EGEA4KH.js +44 -0
- package/dist/chunk-3EVPNIBE.js +185 -0
- package/dist/chunk-5OP5MQI7.js +45 -0
- package/dist/chunk-6J2JC2RT.js +149 -0
- package/dist/chunk-7QLSKOSU.js +19 -0
- package/dist/chunk-7Z7Q4GGS.js +88 -0
- package/dist/chunk-A2A2YTMG.js +835 -0
- package/dist/chunk-AHV4LNB5.js +377 -0
- package/dist/chunk-AXPCESYO.js +197 -0
- package/dist/chunk-C2WCPBF7.js +86 -0
- package/dist/chunk-CJVVKOSR.js +73 -0
- package/dist/chunk-CULUZTWQ.js +2117 -0
- package/dist/chunk-CVQYGMSR.js +142 -0
- package/dist/chunk-CVWG2N3Y.js +16 -0
- package/dist/chunk-D2FX3EPW.js +15 -0
- package/dist/chunk-DB3ONZB4.js +44 -0
- package/dist/chunk-DE4Y6TCC.js +160 -0
- package/dist/chunk-E4FJTUH4.js +249 -0
- package/dist/chunk-EPCXUP3P.js +41 -0
- package/dist/chunk-EX3GAPA4.js +44 -0
- package/dist/chunk-EYK3GSRH.js +143 -0
- package/dist/chunk-GMXKITVA.js +70 -0
- package/dist/chunk-GPEGOGRJ.js +49 -0
- package/dist/chunk-GU3EJCVV.js +43 -0
- package/dist/chunk-HQ6G3B5R.js +59 -0
- package/dist/chunk-HZVVNGEW.js +22 -0
- package/dist/chunk-IPVKH2ST.js +231 -0
- package/dist/chunk-J5TIV3J6.js +238 -0
- package/dist/chunk-JU6W3XAU.js +64 -0
- package/dist/chunk-JYG4B33G.js +227 -0
- package/dist/chunk-JZTHGAPA.js +503 -0
- package/dist/chunk-KGDWDSNL.js +102 -0
- package/dist/chunk-KVQBWI5T.js +99 -0
- package/dist/chunk-KZERVKTD.js +16 -0
- package/dist/chunk-LXQ5FBBD.js +47 -0
- package/dist/chunk-M5ICYGHT.js +138 -0
- package/dist/chunk-M5JDZ45W.js +943 -0
- package/dist/chunk-MDEPU45E.js +1226 -0
- package/dist/chunk-MPWXGONV.js +157 -0
- package/dist/chunk-N6IOSZOE.js +185 -0
- package/dist/chunk-NGDD6TXY.js +192 -0
- package/dist/chunk-NQTHFF4W.js +2830 -0
- package/dist/chunk-OKXS7WDZ.js +68 -0
- package/dist/chunk-OLQRRFBX.js +21 -0
- package/dist/chunk-P7WQEVWA.js +307 -0
- package/dist/chunk-POFBE6ZY.js +12 -0
- package/dist/chunk-PQ4K74WL.js +30 -0
- package/dist/chunk-PUZZELE6.js +37 -0
- package/dist/chunk-Q7E4I6UA.js +767 -0
- package/dist/chunk-QCGNGEGE.js +15 -0
- package/dist/chunk-R7437YI3.js +151 -0
- package/dist/chunk-RHR6NARM.js +305 -0
- package/dist/chunk-SAXBC4AZ.js +60 -0
- package/dist/chunk-SGZJ5LYH.js +76 -0
- package/dist/chunk-SIFVFP6T.js +70 -0
- package/dist/chunk-SLFABHW3.js +150 -0
- package/dist/chunk-T7E75EKA.js +67 -0
- package/dist/chunk-TY4JT7W3.js +38 -0
- package/dist/chunk-U4TMAANR.js +111 -0
- package/dist/chunk-UI36QSBI.js +177 -0
- package/dist/chunk-VQKRG3AC.js +339 -0
- package/dist/chunk-WE2J62NC.js +1131 -0
- package/dist/chunk-XHKBHWLX.js +14 -0
- package/dist/chunk-ZSZSSJCV.js +275 -0
- package/dist/chunk-ZVSKMPBZ.js +135 -0
- package/dist/reapply-CW3MA66V.js +34 -0
- package/dist/src/index.d.ts +204 -0
- package/dist/src/index.js +64 -0
- package/package.json +108 -0
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
import {
|
|
2
|
+
generateKeypair,
|
|
3
|
+
openSealedKey,
|
|
4
|
+
publicKeyFingerprint
|
|
5
|
+
} from "./chunk-3EGEA4KH.js";
|
|
6
|
+
import {
|
|
7
|
+
humanIsTty,
|
|
8
|
+
writeHuman
|
|
9
|
+
} from "./chunk-QCGNGEGE.js";
|
|
10
|
+
import {
|
|
11
|
+
clipToRow,
|
|
12
|
+
terminalColumns,
|
|
13
|
+
useColor
|
|
14
|
+
} from "./chunk-U4TMAANR.js";
|
|
15
|
+
import {
|
|
16
|
+
callMcpTool
|
|
17
|
+
} from "./chunk-M5ICYGHT.js";
|
|
18
|
+
import {
|
|
19
|
+
reportedInstallSource
|
|
20
|
+
} from "./chunk-DB3ONZB4.js";
|
|
21
|
+
import {
|
|
22
|
+
NPX_LATEST_COMMAND
|
|
23
|
+
} from "./chunk-E4FJTUH4.js";
|
|
24
|
+
import {
|
|
25
|
+
PAIR_TTL_SECONDS,
|
|
26
|
+
isAnswerSource
|
|
27
|
+
} from "./chunk-NQTHFF4W.js";
|
|
28
|
+
|
|
29
|
+
// src/reach.ts
|
|
30
|
+
var reachVerdict = (channels) => {
|
|
31
|
+
const { yours, site } = channels;
|
|
32
|
+
if (yours === null || site === null) {
|
|
33
|
+
return channels.phoneReachable || channels.webActive > 0 ? { kind: "yours" } : { kind: "nobody" };
|
|
34
|
+
}
|
|
35
|
+
if (yours.phoneReachable) return { kind: "yours" };
|
|
36
|
+
const solo = site.otherMembers === 0;
|
|
37
|
+
if (solo) {
|
|
38
|
+
return site.phoneReachable || site.webActive > 0 ? { kind: "yours" } : { kind: "nobody" };
|
|
39
|
+
}
|
|
40
|
+
if (site.appDevices === 0 && site.webActive === 0) return { kind: "nobody" };
|
|
41
|
+
if (site.webActive > 0 && !yours.webAttributable) {
|
|
42
|
+
return { kind: "unattributable", otherMembers: site.otherMembers };
|
|
43
|
+
}
|
|
44
|
+
return { kind: "others", otherMembers: site.otherMembers };
|
|
45
|
+
};
|
|
46
|
+
var reachesYou = (channels) => reachVerdict(channels).kind === "yours";
|
|
47
|
+
var plural = (count, one, many = `${one}s`) => `${count} ${count === 1 ? one : many}`;
|
|
48
|
+
var describeReach = (channels) => {
|
|
49
|
+
const verdict = reachVerdict(channels);
|
|
50
|
+
if (verdict.kind === "nobody") return "nothing can receive an approval";
|
|
51
|
+
const parts = [];
|
|
52
|
+
const own = channels.yours;
|
|
53
|
+
if (verdict.kind === "yours" && own !== null && own.appDevices > 0) {
|
|
54
|
+
const platforms = own.appPlatforms.length > 0 ? ` (${own.appPlatforms.join(", ")})` : "";
|
|
55
|
+
parts.push(`${plural(own.appDevices, "app device")}${platforms}`);
|
|
56
|
+
} else if (channels.appDevices > 0) {
|
|
57
|
+
const platforms = channels.appPlatforms.length > 0 ? ` (${channels.appPlatforms.join(", ")})` : "";
|
|
58
|
+
parts.push(`${plural(channels.appDevices, "app device")}${platforms}`);
|
|
59
|
+
}
|
|
60
|
+
if (channels.webPhone > 0) parts.push(`${plural(channels.webPhone, "browser")} on a phone`);
|
|
61
|
+
if (channels.webDesktop > 0) parts.push(`${plural(channels.webDesktop, "browser")} on a desktop`);
|
|
62
|
+
const summary = parts.length > 0 ? parts.join(", ") : "a device";
|
|
63
|
+
if (verdict.kind === "others") return `${summary}, none of them yours`;
|
|
64
|
+
if (verdict.kind === "unattributable") return `${summary}, and no way to tell whose`;
|
|
65
|
+
return summary;
|
|
66
|
+
};
|
|
67
|
+
var describeAppRepair = (verdict) => {
|
|
68
|
+
switch (verdict.kind) {
|
|
69
|
+
case "yours":
|
|
70
|
+
return null;
|
|
71
|
+
case "nobody":
|
|
72
|
+
return [
|
|
73
|
+
"Open the Pushary app, sign in, and allow notifications.",
|
|
74
|
+
"If this terminal was never paired with your phone, run: npx @pushary/agent-hooks@latest setup --connect app"
|
|
75
|
+
];
|
|
76
|
+
case "others":
|
|
77
|
+
return [
|
|
78
|
+
`Reachable devices belong to other members of this workspace (${verdict.otherMembers}).`,
|
|
79
|
+
"Sign in on the Pushary app with your own account so approvals can reach you."
|
|
80
|
+
];
|
|
81
|
+
case "unattributable":
|
|
82
|
+
return [
|
|
83
|
+
"Something is reachable here, but not identifiably yours.",
|
|
84
|
+
"Sign in on the Pushary app so your approvals are routed to your device."
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
var describeJustPairedRepair = (verdict) => {
|
|
89
|
+
if (verdict.kind === "nobody") {
|
|
90
|
+
return [
|
|
91
|
+
"The app asks for the notification permission after it grants the pairing.",
|
|
92
|
+
"Open the Pushary app and allow notifications. That is the whole remaining step."
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
return describeAppRepair(verdict) ?? [];
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/setup/verify.ts
|
|
99
|
+
var plural2 = (count, one) => `${count} ${one}${count === 1 ? "" : "s"}`;
|
|
100
|
+
var describeRecipients = (push, channels) => {
|
|
101
|
+
const parts = [];
|
|
102
|
+
if (push.app > 0) {
|
|
103
|
+
const platforms = channels.yours?.appPlatforms ?? channels.appPlatforms;
|
|
104
|
+
const suffix = platforms.length > 0 ? ` (${platforms.join(", ")})` : "";
|
|
105
|
+
parts.push(`${plural2(push.app, "app device")}${suffix}`);
|
|
106
|
+
}
|
|
107
|
+
if (push.web > 0) parts.push(plural2(push.web, "browser"));
|
|
108
|
+
return parts.length > 0 ? parts.join(" and ") : plural2(push.total, "device");
|
|
109
|
+
};
|
|
110
|
+
var classifyDelivery = (channels, push) => {
|
|
111
|
+
if (push.total <= 0) return { kind: "no-recipients" };
|
|
112
|
+
const verdict = reachVerdict(channels);
|
|
113
|
+
if (verdict.kind === "yours" && (push.app > 0 || channels.yours === null || channels.site?.otherMembers === 0)) {
|
|
114
|
+
return { kind: "delivered", recipients: push.total, deviceLabel: describeRecipients(push, channels) };
|
|
115
|
+
}
|
|
116
|
+
if (verdict.kind === "unattributable") {
|
|
117
|
+
return { kind: "unattributable", recipients: push.total, otherMembers: verdict.otherMembers };
|
|
118
|
+
}
|
|
119
|
+
if (verdict.kind === "others") {
|
|
120
|
+
return { kind: "others-only", recipients: push.total, otherMembers: verdict.otherMembers };
|
|
121
|
+
}
|
|
122
|
+
if (verdict.kind === "yours") {
|
|
123
|
+
const otherMembers = channels.site?.otherMembers ?? 0;
|
|
124
|
+
return { kind: "unattributable", recipients: push.total, otherMembers };
|
|
125
|
+
}
|
|
126
|
+
return { kind: "delivered", recipients: push.total, deviceLabel: describeRecipients(push, channels) };
|
|
127
|
+
};
|
|
128
|
+
var sendTestNotification = async (apiKey) => {
|
|
129
|
+
const result = await callMcpTool(apiKey, "send_notification", {
|
|
130
|
+
title: "Pushary is connected",
|
|
131
|
+
body: "This setup notification reached you. Agent hooks still need a real-agent check.",
|
|
132
|
+
agentName: "Pushary setup"
|
|
133
|
+
}, { timeoutMs: 2e4 });
|
|
134
|
+
const toCount = (value) => typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
135
|
+
const app = toCount(result.delivery?.mobile?.recipients);
|
|
136
|
+
const web = toCount(result.delivery?.web?.recipients);
|
|
137
|
+
return { app, web, total: typeof result.sent === "number" ? result.sent : app + web };
|
|
138
|
+
};
|
|
139
|
+
var verifyDelivery = async (apiKey, channels, deps = {}) => {
|
|
140
|
+
const send = deps.send ?? sendTestNotification;
|
|
141
|
+
try {
|
|
142
|
+
return classifyDelivery(channels, await send(apiKey));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return { kind: "error", detail: err instanceof Error ? err.message : "send failed" };
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
var describeVerifyOutcome = (outcome) => {
|
|
148
|
+
switch (outcome.kind) {
|
|
149
|
+
case "delivered":
|
|
150
|
+
return { ok: true, message: `Delivered to ${outcome.deviceLabel}. Push works.`, next: [] };
|
|
151
|
+
case "no-recipients":
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
message: "Nothing to deliver to. Agents are configured; approvals will fall back to the terminal.",
|
|
155
|
+
next: [
|
|
156
|
+
"Run pushary connect to add your phone.",
|
|
157
|
+
"Then pushary doctor --roundtrip to test a synthetic question and answer."
|
|
158
|
+
]
|
|
159
|
+
};
|
|
160
|
+
case "others-only":
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
message: `Delivered to ${plural2(outcome.recipients, "device")} on this site, none of them yours.`,
|
|
164
|
+
next: [
|
|
165
|
+
`Approvals for your agent would wake one of your ${plural2(outcome.otherMembers, "teammate")}.`,
|
|
166
|
+
"Run pushary connect to add your own phone."
|
|
167
|
+
]
|
|
168
|
+
};
|
|
169
|
+
case "unattributable":
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
message: `Delivered to ${plural2(outcome.recipients, "device")} on this site, and no way to tell whose.`,
|
|
173
|
+
next: [
|
|
174
|
+
"Browser subscriptions carry no owner, and this site has other members.",
|
|
175
|
+
"Install the Pushary app with pushary connect, or run pushary doctor --roundtrip to prove it reaches you."
|
|
176
|
+
]
|
|
177
|
+
};
|
|
178
|
+
case "error":
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
message: `Could not send the test notification (${outcome.detail}).`,
|
|
182
|
+
next: ["Agents are configured. Run pushary doctor once the connection is back."]
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
var ROUND_TRIP_QUESTION = "Pushary delivery test. Tap Yes or No to test returning an answer. This does not test your agent hooks.";
|
|
187
|
+
var ROUND_TRIP_TIMEOUT_MS = 55e3;
|
|
188
|
+
var defaultAsk = (apiKey, agentName) => (
|
|
189
|
+
// wait:false so the poll is ours: the blocking form returns on the SITE's
|
|
190
|
+
// policy timeout, which a user may have set to seconds, and a setup check
|
|
191
|
+
// reporting "unanswered" after three seconds would be a lie about the product.
|
|
192
|
+
callMcpTool(apiKey, "ask_user", {
|
|
193
|
+
question: ROUND_TRIP_QUESTION,
|
|
194
|
+
type: "confirm",
|
|
195
|
+
agentName,
|
|
196
|
+
wait: false
|
|
197
|
+
}, { timeoutMs: 15e3 })
|
|
198
|
+
);
|
|
199
|
+
var defaultWait = (apiKey, correlationId) => callMcpTool(apiKey, "wait_for_answer", {
|
|
200
|
+
correlationId,
|
|
201
|
+
timeoutMs: ROUND_TRIP_TIMEOUT_MS
|
|
202
|
+
}, { timeoutMs: ROUND_TRIP_TIMEOUT_MS + 5e3 });
|
|
203
|
+
var defaultCancel = async (apiKey, correlationId) => {
|
|
204
|
+
await callMcpTool(apiKey, "cancel_question", { correlationId }, { timeoutMs: 1e4 }).catch(() => {
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
var answeredRoundTrip = (answer, correlationId, latencyMs) => {
|
|
208
|
+
if (answer.value !== "yes" && answer.value !== "no") {
|
|
209
|
+
return { kind: "error", detail: "the server reported an answer without a valid Yes or No value" };
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
kind: "answered",
|
|
213
|
+
value: answer.value,
|
|
214
|
+
latencyMs,
|
|
215
|
+
correlationId,
|
|
216
|
+
...isAnswerSource(answer.answerSource) ? { answerSource: answer.answerSource } : {}
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
var verifyRoundTrip = async (apiKey, agentName = "Pushary setup", deps = {}) => {
|
|
220
|
+
const ask = deps.ask ?? defaultAsk;
|
|
221
|
+
const wait = deps.wait ?? defaultWait;
|
|
222
|
+
const cancel = deps.cancel ?? defaultCancel;
|
|
223
|
+
const now = deps.now ?? Date.now;
|
|
224
|
+
const started = now();
|
|
225
|
+
let asked;
|
|
226
|
+
try {
|
|
227
|
+
asked = await ask(apiKey, agentName);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return { kind: "error", detail: err instanceof Error ? err.message : "ask failed" };
|
|
230
|
+
}
|
|
231
|
+
let shutdown;
|
|
232
|
+
const stop = (code) => {
|
|
233
|
+
if (shutdown) return;
|
|
234
|
+
shutdown = (async () => {
|
|
235
|
+
let timer;
|
|
236
|
+
try {
|
|
237
|
+
await Promise.race([
|
|
238
|
+
cancel(apiKey, asked.correlationId).catch(() => {
|
|
239
|
+
}),
|
|
240
|
+
new Promise((resolve) => {
|
|
241
|
+
timer = setTimeout(resolve, 4e3);
|
|
242
|
+
})
|
|
243
|
+
]);
|
|
244
|
+
} finally {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
process.stderr.write("Delivery test interrupted. Cleanup was attempted; an unresolved question expires automatically.\n");
|
|
247
|
+
process.exit(code);
|
|
248
|
+
}
|
|
249
|
+
})();
|
|
250
|
+
};
|
|
251
|
+
const onInterrupt = () => stop(130);
|
|
252
|
+
const onTerminate = () => stop(143);
|
|
253
|
+
if (asked.correlationId) {
|
|
254
|
+
process.on("SIGINT", onInterrupt);
|
|
255
|
+
process.on("SIGTERM", onTerminate);
|
|
256
|
+
}
|
|
257
|
+
let answerObserved = false;
|
|
258
|
+
try {
|
|
259
|
+
if (asked.noDevices || asked.warning) return { kind: "no-recipients" };
|
|
260
|
+
if (asked.mode === "notify_only" || asked.status === "notified") return { kind: "notify-only" };
|
|
261
|
+
if (!asked.correlationId) return { kind: "error", detail: "no correlationId returned" };
|
|
262
|
+
const answer = asked.answered === true ? asked : await wait(apiKey, asked.correlationId);
|
|
263
|
+
if (answer.answered === true) {
|
|
264
|
+
const outcome = answeredRoundTrip(answer, asked.correlationId, now() - started);
|
|
265
|
+
answerObserved = outcome.kind === "answered";
|
|
266
|
+
return outcome;
|
|
267
|
+
}
|
|
268
|
+
return { kind: "unanswered" };
|
|
269
|
+
} catch (err) {
|
|
270
|
+
return { kind: "error", detail: err instanceof Error ? err.message : "wait failed" };
|
|
271
|
+
} finally {
|
|
272
|
+
if (shutdown) await shutdown;
|
|
273
|
+
process.off("SIGINT", onInterrupt);
|
|
274
|
+
process.off("SIGTERM", onTerminate);
|
|
275
|
+
if (!answerObserved && asked.correlationId) await cancel(apiKey, asked.correlationId).catch(() => {
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
var describeAnswerSource = (source) => {
|
|
280
|
+
switch (source) {
|
|
281
|
+
case "mobile_app":
|
|
282
|
+
case "mobile_background":
|
|
283
|
+
return "A phone answered this synthetic test.";
|
|
284
|
+
case "mac_app":
|
|
285
|
+
return "A Mac answered this synthetic test.";
|
|
286
|
+
case "sandbox":
|
|
287
|
+
return "This was a simulated sandbox answer.";
|
|
288
|
+
default:
|
|
289
|
+
return source ? `Answer source: ${source}. This was a synthetic test.` : "The answering surface was not reported. This was a synthetic test.";
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
var describeRoundTripOutcome = (outcome) => {
|
|
293
|
+
switch (outcome.kind) {
|
|
294
|
+
case "answered":
|
|
295
|
+
return {
|
|
296
|
+
ok: true,
|
|
297
|
+
message: `Answered "${outcome.value}" in ${(outcome.latencyMs / 1e3).toFixed(1)}s. ${describeAnswerSource(outcome.answerSource)}`,
|
|
298
|
+
next: []
|
|
299
|
+
};
|
|
300
|
+
case "unanswered":
|
|
301
|
+
return {
|
|
302
|
+
ok: false,
|
|
303
|
+
message: "The question was sent and nothing came back.",
|
|
304
|
+
next: [
|
|
305
|
+
"A push that arrives and cannot be answered is the most common way this breaks, so this is worth resolving now.",
|
|
306
|
+
"Check the notification actually opened something you could tap, then run pushary doctor --roundtrip."
|
|
307
|
+
]
|
|
308
|
+
};
|
|
309
|
+
case "notify-only":
|
|
310
|
+
return {
|
|
311
|
+
ok: true,
|
|
312
|
+
message: "Sent. This site is in notify-only mode, so questions never wait for an answer.",
|
|
313
|
+
next: ["Switch with pushary mode push_first if you want approvals to hold for your phone."]
|
|
314
|
+
};
|
|
315
|
+
case "no-recipients":
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
message: "Nothing to ask. No phone or browser is connected to this site.",
|
|
319
|
+
next: [
|
|
320
|
+
"Run pushary connect to add your phone.",
|
|
321
|
+
"Or pushary connect --app if you already have the Pushary app."
|
|
322
|
+
]
|
|
323
|
+
};
|
|
324
|
+
case "error":
|
|
325
|
+
return {
|
|
326
|
+
ok: false,
|
|
327
|
+
message: `Could not complete the round trip (${outcome.detail}).`,
|
|
328
|
+
next: ["Agents are configured. Run pushary doctor --roundtrip once the connection is back."]
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
var describeRoundTrip = (outcome) => {
|
|
333
|
+
const report = describeRoundTripOutcome(outcome);
|
|
334
|
+
return { ...report, next: [...report.next, "Agent hook activation remains unverified. Restart your agent and check one real approval."] };
|
|
335
|
+
};
|
|
336
|
+
var roundTripEvidence = (outcome) => ({
|
|
337
|
+
kind: outcome?.kind ?? "skipped",
|
|
338
|
+
...outcome?.kind === "answered" ? {
|
|
339
|
+
correlationId: outcome.correlationId ?? null,
|
|
340
|
+
answerSource: outcome.answerSource ?? null,
|
|
341
|
+
value: outcome.value,
|
|
342
|
+
latencyMs: outcome.latencyMs
|
|
343
|
+
} : {},
|
|
344
|
+
hookActivation: "unverified"
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// src/onboarding.ts
|
|
348
|
+
import qrcodeTerminal from "qrcode-terminal";
|
|
349
|
+
var paint = (open) => (s) => useColor() ? `${open}${s}\x1B[0m` : s;
|
|
350
|
+
var dim = paint("\x1B[2m");
|
|
351
|
+
var bold = paint("\x1B[1m");
|
|
352
|
+
var green = paint("\x1B[32m");
|
|
353
|
+
var cyan = paint("\x1B[36m");
|
|
354
|
+
var yellow = paint("\x1B[33m");
|
|
355
|
+
var check = green("\u2713");
|
|
356
|
+
var apiBase = () => process.env.PUSHARY_API_URL?.trim() || process.env.PUSHARY_BASE_URL?.trim() || "https://pushary.com";
|
|
357
|
+
var EXIT_ABORTED = 130;
|
|
358
|
+
var CONNECT_POLL_INTERVAL_MS = 2500;
|
|
359
|
+
var CONNECT_TIMEOUT_MS = 18e4;
|
|
360
|
+
var EMPTY_CHANNELS = {
|
|
361
|
+
appDevices: 0,
|
|
362
|
+
appPlatforms: [],
|
|
363
|
+
webActive: 0,
|
|
364
|
+
webPhone: 0,
|
|
365
|
+
webDesktop: 0,
|
|
366
|
+
phoneReachable: false,
|
|
367
|
+
precise: true,
|
|
368
|
+
yours: null,
|
|
369
|
+
site: null
|
|
370
|
+
};
|
|
371
|
+
var authHeaders = (apiKey) => ({
|
|
372
|
+
"Content-Type": "application/json",
|
|
373
|
+
Authorization: `Bearer ${apiKey}`
|
|
374
|
+
});
|
|
375
|
+
var getJson = async (url, apiKey) => {
|
|
376
|
+
try {
|
|
377
|
+
const res = await fetch(url, { headers: authHeaders(apiKey), signal: AbortSignal.timeout(1e4) });
|
|
378
|
+
const body = await res.json().catch(() => null);
|
|
379
|
+
if (!res.ok) return { kind: "http", status: res.status, body };
|
|
380
|
+
if (!body) return { kind: "http", status: res.status, body: null };
|
|
381
|
+
return { kind: "ok", body };
|
|
382
|
+
} catch (err) {
|
|
383
|
+
return { kind: "network", detail: err instanceof Error ? err.message : "network error" };
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
var fetchSite = async (apiKey) => {
|
|
387
|
+
const result = await getJson(`${apiBase()}/api/v1/server/site`, apiKey);
|
|
388
|
+
if (result.kind !== "ok") return null;
|
|
389
|
+
const data = result.body;
|
|
390
|
+
if (typeof data.slug !== "string" || typeof data.subscribeUrl !== "string") return null;
|
|
391
|
+
return { slug: data.slug, name: typeof data.name === "string" ? data.name : data.slug, subscribeUrl: data.subscribeUrl };
|
|
392
|
+
};
|
|
393
|
+
var num = (value) => typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
394
|
+
var strings = (value) => Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
395
|
+
var record = (value) => typeof value === "object" && value !== null ? value : {};
|
|
396
|
+
var parseOwnerCounts = (value) => {
|
|
397
|
+
if (typeof value !== "object" || value === null) return null;
|
|
398
|
+
const raw = value;
|
|
399
|
+
const app = record(raw.app);
|
|
400
|
+
return {
|
|
401
|
+
appDevices: num(app.devices),
|
|
402
|
+
appPlatforms: strings(app.platforms),
|
|
403
|
+
phoneReachable: raw.phoneReachable === true,
|
|
404
|
+
webAttributable: raw.webAttributable === true
|
|
405
|
+
};
|
|
406
|
+
};
|
|
407
|
+
var parseSiteCounts = (value) => {
|
|
408
|
+
if (typeof value !== "object" || value === null) return null;
|
|
409
|
+
const raw = value;
|
|
410
|
+
const app = record(raw.app);
|
|
411
|
+
const web = record(raw.web);
|
|
412
|
+
return {
|
|
413
|
+
appDevices: num(app.devices),
|
|
414
|
+
appPlatforms: strings(app.platforms),
|
|
415
|
+
webActive: num(web.active),
|
|
416
|
+
webPhone: num(web.phone),
|
|
417
|
+
webDesktop: num(web.desktop),
|
|
418
|
+
phoneReachable: raw.phoneReachable === true,
|
|
419
|
+
otherMembers: num(raw.otherMembers)
|
|
420
|
+
};
|
|
421
|
+
};
|
|
422
|
+
var parseChannels = (data) => {
|
|
423
|
+
const app = record(data.app);
|
|
424
|
+
const web = record(data.web);
|
|
425
|
+
return {
|
|
426
|
+
appDevices: num(app.devices),
|
|
427
|
+
appPlatforms: strings(app.platforms),
|
|
428
|
+
webActive: num(web.active),
|
|
429
|
+
webPhone: num(web.phone),
|
|
430
|
+
webDesktop: num(web.desktop),
|
|
431
|
+
phoneReachable: data.phoneReachable === true,
|
|
432
|
+
precise: true,
|
|
433
|
+
yours: parseOwnerCounts(data.yours),
|
|
434
|
+
site: parseSiteCounts(data.site)
|
|
435
|
+
};
|
|
436
|
+
};
|
|
437
|
+
var SUBSCRIPTION_REQUIRED = "subscription_required";
|
|
438
|
+
var fetchChannels = async (apiKey) => {
|
|
439
|
+
const result = await getJson(`${apiBase()}/api/v1/server/channels`, apiKey);
|
|
440
|
+
if (result.kind === "ok") return { kind: "ok", status: parseChannels(result.body) };
|
|
441
|
+
if (result.kind === "network") return { kind: "unreachable", detail: result.detail };
|
|
442
|
+
if (result.status === 401) return { kind: "unauthorized" };
|
|
443
|
+
if (result.status === 403) {
|
|
444
|
+
return result.body?.code === SUBSCRIPTION_REQUIRED ? { kind: "locked" } : { kind: "unreachable", detail: "HTTP 403" };
|
|
445
|
+
}
|
|
446
|
+
if (result.status !== 404) return { kind: "unreachable", detail: `HTTP ${result.status}` };
|
|
447
|
+
const legacy = await getJson(`${apiBase()}/api/v1/server/subscribers/count`, apiKey);
|
|
448
|
+
if (legacy.kind === "network") return { kind: "unreachable", detail: legacy.detail };
|
|
449
|
+
if (legacy.kind === "http") {
|
|
450
|
+
if (legacy.status === 401) return { kind: "unauthorized" };
|
|
451
|
+
return { kind: "unreachable", detail: `HTTP ${legacy.status}` };
|
|
452
|
+
}
|
|
453
|
+
const active = num(legacy.body.active);
|
|
454
|
+
return {
|
|
455
|
+
kind: "ok",
|
|
456
|
+
status: { ...EMPTY_CHANNELS, webActive: active, phoneReachable: active > 0, precise: false }
|
|
457
|
+
};
|
|
458
|
+
};
|
|
459
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
460
|
+
var SPINNER_HEARTBEAT_MS = 3e4;
|
|
461
|
+
var SPINNER_FRAME_MS = 200;
|
|
462
|
+
var writeStatusLine = (text) => {
|
|
463
|
+
writeHuman(humanIsTty() ? `\r ${text}\x1B[K
|
|
464
|
+
` : ` ${text}
|
|
465
|
+
`);
|
|
466
|
+
};
|
|
467
|
+
var startSpinner = (getLabel) => {
|
|
468
|
+
if (!humanIsTty()) {
|
|
469
|
+
writeHuman(` ${getLabel()}
|
|
470
|
+
`);
|
|
471
|
+
const interval2 = setInterval(() => {
|
|
472
|
+
writeHuman(` ${getLabel()}
|
|
473
|
+
`);
|
|
474
|
+
}, SPINNER_HEARTBEAT_MS);
|
|
475
|
+
interval2.unref?.();
|
|
476
|
+
return (finalGlyph, finalLabel) => {
|
|
477
|
+
clearInterval(interval2);
|
|
478
|
+
writeStatusLine(`${finalGlyph} ${finalLabel}`);
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const frames = [" ", ". ", ".. ", "..."];
|
|
482
|
+
let i = 0;
|
|
483
|
+
const interval = setInterval(() => {
|
|
484
|
+
const line = `${dim(frames[i++ % frames.length])} ${getLabel()}`;
|
|
485
|
+
writeHuman(`\r ${clipToRow(line, terminalColumns())}\x1B[K`);
|
|
486
|
+
}, SPINNER_FRAME_MS);
|
|
487
|
+
return (finalGlyph, finalLabel) => {
|
|
488
|
+
clearInterval(interval);
|
|
489
|
+
writeStatusLine(`${finalGlyph} ${finalLabel}`);
|
|
490
|
+
};
|
|
491
|
+
};
|
|
492
|
+
var printQr = (url) => new Promise((resolve, reject) => {
|
|
493
|
+
try {
|
|
494
|
+
qrcodeTerminal.generate(url, { small: true }, (qr) => {
|
|
495
|
+
writeHuman("\n" + qr.split("\n").map((line) => " " + line).join("\n") + "\n");
|
|
496
|
+
resolve();
|
|
497
|
+
});
|
|
498
|
+
} catch (err) {
|
|
499
|
+
reject(err);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
var phoneFailure = (detail, repair) => ({
|
|
503
|
+
reachesYou: false,
|
|
504
|
+
outcome: null,
|
|
505
|
+
detail,
|
|
506
|
+
...repair ? { repair } : {}
|
|
507
|
+
});
|
|
508
|
+
var waitForDevice = async (apiKey, baseline) => {
|
|
509
|
+
const deadline = Date.now() + CONNECT_TIMEOUT_MS;
|
|
510
|
+
let consecutiveFailures = 0;
|
|
511
|
+
let warned = false;
|
|
512
|
+
const stop = startSpinner(() => {
|
|
513
|
+
const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1e3));
|
|
514
|
+
return `Waiting for your phone ${dim(`(${left}s)`)}`;
|
|
515
|
+
});
|
|
516
|
+
try {
|
|
517
|
+
while (Date.now() < deadline) {
|
|
518
|
+
const probe = await fetchChannels(apiKey);
|
|
519
|
+
if (probe.kind === "unauthorized") {
|
|
520
|
+
stop(yellow("!"), "The server stopped accepting this key");
|
|
521
|
+
return { kind: "unauthorized" };
|
|
522
|
+
}
|
|
523
|
+
if (probe.kind === "locked") {
|
|
524
|
+
stop(yellow("!"), "This workspace has no active plan");
|
|
525
|
+
return { kind: "locked" };
|
|
526
|
+
}
|
|
527
|
+
if (probe.kind === "unreachable") {
|
|
528
|
+
consecutiveFailures += 1;
|
|
529
|
+
if (consecutiveFailures >= 3 && !warned) {
|
|
530
|
+
warned = true;
|
|
531
|
+
writeStatusLine(`${yellow("!")} Still trying. pushary.com is not answering ${dim(`(${probe.detail})`)}`);
|
|
532
|
+
}
|
|
533
|
+
await sleep(CONNECT_POLL_INTERVAL_MS);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
consecutiveFailures = 0;
|
|
537
|
+
const channels = probe.status;
|
|
538
|
+
if (reachesYou(channels) || channels.webActive > baseline.webActive) {
|
|
539
|
+
stop(check, "Phone connected");
|
|
540
|
+
return { kind: "connected", channels };
|
|
541
|
+
}
|
|
542
|
+
await sleep(CONNECT_POLL_INTERVAL_MS);
|
|
543
|
+
}
|
|
544
|
+
stop(yellow("!"), "Didn't detect a connection yet");
|
|
545
|
+
console.log(` ${dim("Signed in on the app already? Open it and allow notifications.")}`);
|
|
546
|
+
console.log(` ${dim("Signing in alone does not connect the phone. Then run")} ${cyan("pushary doctor")}${dim(".")}`);
|
|
547
|
+
return { kind: "timeout" };
|
|
548
|
+
} catch (err) {
|
|
549
|
+
stop(yellow("!"), `Couldn't check connection ${dim(`(${err instanceof Error ? err.message : "network error"})`)}`);
|
|
550
|
+
return { kind: "timeout" };
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
var sendAndConfirm = async (apiKey, channels) => {
|
|
554
|
+
const stopSend = startSpinner(() => "Sending a test notification");
|
|
555
|
+
const outcome = await verifyDelivery(apiKey, channels);
|
|
556
|
+
const report = describeVerifyOutcome(outcome);
|
|
557
|
+
stopSend(report.ok ? check : yellow("!"), report.message);
|
|
558
|
+
for (const line of report.next) console.log(` ${dim(line)}`);
|
|
559
|
+
return { reachesYou: report.ok, outcome, detail: null };
|
|
560
|
+
};
|
|
561
|
+
var printCapabilityDelta = () => {
|
|
562
|
+
console.log(` ${dim("Two ways to get approvals on your phone:")}`);
|
|
563
|
+
console.log();
|
|
564
|
+
console.log(` ${bold("Pushary app")} ${dim("(iOS / Android)")} ${cyan("pushary setup --connect app")}`);
|
|
565
|
+
console.log(` ${dim("\u2022 Answers from the lock screen, and while the app is closed")}`);
|
|
566
|
+
console.log(` ${dim("\u2022 Can start and steer agents remotely")}`);
|
|
567
|
+
console.log();
|
|
568
|
+
console.log(` ${bold("Browser")} ${dim("(add to home screen)")} ${dim("scan below")}`);
|
|
569
|
+
console.log(` ${dim("\u2022 Works without installing an app")}`);
|
|
570
|
+
console.log(` ${dim("\u2022 Answers require opening the page")}`);
|
|
571
|
+
console.log(` ${dim("\u2022 iOS needs the page added to the home screen first")}`);
|
|
572
|
+
};
|
|
573
|
+
var printSiteOnlyWarning = (channels) => {
|
|
574
|
+
const others = channels.site?.otherMembers ?? 0;
|
|
575
|
+
console.log(` ${yellow("!")} This site has ${channels.appDevices + channels.webActive} device(s), ${describeReach(channels)}.`);
|
|
576
|
+
console.log(` ${dim(`Approvals for your agent would wake one of your ${others === 1 ? "teammate" : `${others} teammates`}.`)}`);
|
|
577
|
+
console.log(` ${dim("Connect your own phone:")}`);
|
|
578
|
+
};
|
|
579
|
+
var connectDevice = async (apiKey, options = {}) => {
|
|
580
|
+
const probe = await fetchChannels(apiKey);
|
|
581
|
+
if (probe.kind !== "ok") return describeProbeFailure(probe);
|
|
582
|
+
const channels = probe.status;
|
|
583
|
+
if (reachesYou(channels)) {
|
|
584
|
+
console.log(` ${check} ${describeExistingConnection(channels)}`);
|
|
585
|
+
return sendAndConfirm(apiKey, channels);
|
|
586
|
+
}
|
|
587
|
+
const site = await fetchSite(apiKey);
|
|
588
|
+
if (!site) {
|
|
589
|
+
console.log(` ${yellow("!")} Couldn't load your subscribe link. Connect your phone later at ${cyan("pushary.com/download")}.`);
|
|
590
|
+
return phoneFailure("no_subscribe_link");
|
|
591
|
+
}
|
|
592
|
+
console.log();
|
|
593
|
+
if (channels.site !== null && channels.site.otherMembers > 0 && channels.phoneReachable) {
|
|
594
|
+
printSiteOnlyWarning(channels);
|
|
595
|
+
} else {
|
|
596
|
+
console.log(` ${bold("Connect your phone")}`);
|
|
597
|
+
if (channels.webDesktop > 0) {
|
|
598
|
+
console.log(` ${dim("A browser on this computer is subscribed, but no phone is connected yet.")}`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
console.log();
|
|
602
|
+
if (options.showCapabilities) printCapabilityDelta();
|
|
603
|
+
console.log();
|
|
604
|
+
console.log(` ${dim("Scan this with your phone camera:")}`);
|
|
605
|
+
await printQr(site.subscribeUrl);
|
|
606
|
+
console.log(` ${dim("or open")} ${cyan(site.subscribeUrl)}`);
|
|
607
|
+
console.log();
|
|
608
|
+
const waited = await waitForDevice(apiKey, channels);
|
|
609
|
+
if (waited.kind === "unauthorized") return phoneFailure("unauthorized");
|
|
610
|
+
if (waited.kind === "locked") return phoneFailure("locked");
|
|
611
|
+
if (waited.kind === "timeout") {
|
|
612
|
+
console.log(` ${dim("No rush, open")} ${cyan(site.subscribeUrl)} ${dim("on your phone whenever you're ready.")}`);
|
|
613
|
+
return phoneFailure("timeout");
|
|
614
|
+
}
|
|
615
|
+
return sendAndConfirm(apiKey, waited.channels);
|
|
616
|
+
};
|
|
617
|
+
var describeProbeFailure = (probe) => {
|
|
618
|
+
if (probe.kind === "unauthorized") {
|
|
619
|
+
console.log(` ${yellow("!")} The server rejected this key when checking for devices.`);
|
|
620
|
+
return phoneFailure("unauthorized");
|
|
621
|
+
}
|
|
622
|
+
if (probe.kind === "locked") {
|
|
623
|
+
console.log(` ${yellow("!")} No approval can be delivered until the plan is active.`);
|
|
624
|
+
return phoneFailure("locked");
|
|
625
|
+
}
|
|
626
|
+
if (probe.kind === "unreachable") {
|
|
627
|
+
console.log(` ${yellow("!")} Couldn't check your devices ${dim(`(${probe.detail})`)}`);
|
|
628
|
+
console.log(` ${dim("Agents are configured. Run")} ${cyan("pushary doctor")} ${dim("when the connection is back.")}`);
|
|
629
|
+
return phoneFailure(probe.detail);
|
|
630
|
+
}
|
|
631
|
+
return phoneFailure("unknown");
|
|
632
|
+
};
|
|
633
|
+
var describeExistingConnection = (channels) => {
|
|
634
|
+
if (!channels.precise) return "A device is already connected";
|
|
635
|
+
const own = channels.yours;
|
|
636
|
+
if (own !== null && own.appDevices > 0) {
|
|
637
|
+
const platforms = own.appPlatforms.length > 0 ? ` ${dim(`(${own.appPlatforms.join(", ")})`)}` : "";
|
|
638
|
+
return `Pushary app connected${platforms}`;
|
|
639
|
+
}
|
|
640
|
+
if (channels.appDevices > 0) {
|
|
641
|
+
const platforms = channels.appPlatforms.length > 0 ? ` ${dim(`(${channels.appPlatforms.join(", ")})`)}` : "";
|
|
642
|
+
return `Pushary app connected${platforms}`;
|
|
643
|
+
}
|
|
644
|
+
return "Phone already connected";
|
|
645
|
+
};
|
|
646
|
+
var APP_REGISTER_TIMEOUT_MS = 45e3;
|
|
647
|
+
var appRegisterTimeoutMs = () => {
|
|
648
|
+
const raw = Number(process.env.PUSHARY_APP_REGISTER_TIMEOUT_MS);
|
|
649
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : APP_REGISTER_TIMEOUT_MS;
|
|
650
|
+
};
|
|
651
|
+
var ownedApp = (status) => status.yours?.appDevices ?? status.appDevices;
|
|
652
|
+
var waitForAppRegistration = async (apiKey) => {
|
|
653
|
+
const deadline = Date.now() + appRegisterTimeoutMs();
|
|
654
|
+
if (Date.now() >= deadline) return { kind: "timeout" };
|
|
655
|
+
const stop = startSpinner(() => {
|
|
656
|
+
const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1e3));
|
|
657
|
+
return `Waiting for your phone to register for notifications ${dim(`(${left}s)`)}`;
|
|
658
|
+
});
|
|
659
|
+
try {
|
|
660
|
+
while (Date.now() < deadline) {
|
|
661
|
+
await sleep(CONNECT_POLL_INTERVAL_MS);
|
|
662
|
+
const probe = await fetchChannels(apiKey);
|
|
663
|
+
if (probe.kind === "unauthorized") {
|
|
664
|
+
stop(yellow("!"), "The server stopped accepting this key");
|
|
665
|
+
return { kind: "unauthorized" };
|
|
666
|
+
}
|
|
667
|
+
if (probe.kind === "locked") {
|
|
668
|
+
stop(yellow("!"), "This workspace has no active plan");
|
|
669
|
+
return { kind: "locked" };
|
|
670
|
+
}
|
|
671
|
+
if (probe.kind !== "ok") continue;
|
|
672
|
+
if (!probe.status.precise || ownedApp(probe.status) > 0) {
|
|
673
|
+
stop(check, "Phone registered for notifications");
|
|
674
|
+
return { kind: "connected", channels: probe.status };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
} catch {
|
|
678
|
+
}
|
|
679
|
+
stop(yellow("!"), "This phone has not registered for notifications yet");
|
|
680
|
+
return { kind: "timeout" };
|
|
681
|
+
};
|
|
682
|
+
var confirmAppConnection = async (apiKey) => {
|
|
683
|
+
const probe = await fetchChannels(apiKey);
|
|
684
|
+
if (probe.kind !== "ok") return describeProbeFailure(probe);
|
|
685
|
+
let channels = probe.status;
|
|
686
|
+
if (channels.precise && ownedApp(channels) === 0) {
|
|
687
|
+
const settled = await waitForAppRegistration(apiKey);
|
|
688
|
+
if (settled.kind === "unauthorized") return describeProbeFailure({ kind: "unauthorized" });
|
|
689
|
+
if (settled.kind === "locked") return describeProbeFailure({ kind: "locked" });
|
|
690
|
+
if (settled.kind === "connected") channels = settled.channels;
|
|
691
|
+
}
|
|
692
|
+
if (channels.precise && ownedApp(channels) === 0) {
|
|
693
|
+
const repair = describeJustPairedRepair(reachVerdict(channels));
|
|
694
|
+
console.log(` ${yellow("!")} Paired, but this phone has not registered for notifications yet.`);
|
|
695
|
+
for (const line of repair) console.log(` ${dim(line)}`);
|
|
696
|
+
console.log(` ${dim("Then run")} ${cyan("pushary doctor")}${dim(".")}`);
|
|
697
|
+
return phoneFailure("app_not_registered", repair);
|
|
698
|
+
}
|
|
699
|
+
return sendAndConfirm(apiKey, channels);
|
|
700
|
+
};
|
|
701
|
+
var planAppConnect = (channels) => channels.precise && ownedApp(channels) === 0 ? { kind: "unpaired", repair: describeAppRepair(reachVerdict(channels)) ?? [] } : { kind: "send" };
|
|
702
|
+
var connectExistingApp = async (apiKey) => {
|
|
703
|
+
const probe = await fetchChannels(apiKey);
|
|
704
|
+
if (probe.kind !== "ok") return describeProbeFailure(probe);
|
|
705
|
+
const plan = planAppConnect(probe.status);
|
|
706
|
+
if (plan.kind === "send") {
|
|
707
|
+
console.log(` ${check} ${describeExistingConnection(probe.status)}`);
|
|
708
|
+
return sendAndConfirm(apiKey, probe.status);
|
|
709
|
+
}
|
|
710
|
+
console.log(` ${yellow("!")} No Pushary app of yours is connected to this site, and nothing was paired just now.`);
|
|
711
|
+
for (const line of plan.repair) console.log(` ${dim(line)}`);
|
|
712
|
+
console.log(` ${dim("No app yet? Get it at")} ${cyan("https://pushary.com/download")}`);
|
|
713
|
+
return phoneFailure("app_not_paired", plan.repair);
|
|
714
|
+
};
|
|
715
|
+
var printConnectInstructions = async (apiKey) => {
|
|
716
|
+
const probe = await fetchChannels(apiKey);
|
|
717
|
+
if (probe.kind !== "ok") return describeProbeFailure(probe);
|
|
718
|
+
const channels = probe.status;
|
|
719
|
+
if (reachesYou(channels)) {
|
|
720
|
+
console.log(` ${check} ${describeExistingConnection(channels)}`);
|
|
721
|
+
return sendAndConfirm(apiKey, channels);
|
|
722
|
+
}
|
|
723
|
+
if (channels.site !== null && channels.site.otherMembers > 0 && channels.phoneReachable) {
|
|
724
|
+
printSiteOnlyWarning(channels);
|
|
725
|
+
}
|
|
726
|
+
const site = await fetchSite(apiKey);
|
|
727
|
+
const target = site?.subscribeUrl ?? "https://pushary.com/download";
|
|
728
|
+
console.log(` ${dim("Connect your phone for push notifications at")} ${cyan(target)}`);
|
|
729
|
+
console.log(` ${dim("or get the Pushary app at")} ${cyan("https://pushary.com/download")}`);
|
|
730
|
+
return phoneFailure("no_device");
|
|
731
|
+
};
|
|
732
|
+
var PAIR_POLL_INTERVAL_MS = 2e3;
|
|
733
|
+
var PAIR_STOP_BEFORE_EXPIRY_MS = 1e4;
|
|
734
|
+
var PAIR_TIMEOUT_MS = PAIR_TTL_SECONDS * 1e3 - PAIR_STOP_BEFORE_EXPIRY_MS;
|
|
735
|
+
var PAIR_HINT_AFTER_MS = 45e3;
|
|
736
|
+
var PAIR_BROWSER_FALLBACK_AFTER_MS = 6e4;
|
|
737
|
+
var BROWSER_CONNECT_COMMAND = `${NPX_LATEST_COMMAND} setup --connect browser`;
|
|
738
|
+
var PAIR_BROWSER_FALLBACK_LINE = `Still waiting? Sign in with a browser instead: Ctrl-C, then ${BROWSER_CONNECT_COMMAND}`;
|
|
739
|
+
var SYSTEM_PAIR_CLOCK = { now: () => Date.now(), sleep };
|
|
740
|
+
var startPairing = async (cliPublicKey) => {
|
|
741
|
+
try {
|
|
742
|
+
const res = await fetch(`${apiBase()}/api/mobile/pair/start`, {
|
|
743
|
+
method: "POST",
|
|
744
|
+
headers: { "Content-Type": "application/json" },
|
|
745
|
+
// The only moment this fact can be carried across the handshake. This
|
|
746
|
+
// terminal knows which front door it came through and has no session; the
|
|
747
|
+
// phone that authorizes has the session and mints the key but never saw
|
|
748
|
+
// this terminal. Sending it here is what lets the minted key name its
|
|
749
|
+
// origin. An older server ignores the extra field.
|
|
750
|
+
body: JSON.stringify({ cliPublicKey, installSource: reportedInstallSource() }),
|
|
751
|
+
signal: AbortSignal.timeout(1e4)
|
|
752
|
+
});
|
|
753
|
+
if (!res.ok) return null;
|
|
754
|
+
const data = await res.json().catch(() => null);
|
|
755
|
+
if (!data || typeof data.pairId !== "string") return null;
|
|
756
|
+
return { pairId: data.pairId, code: typeof data.code === "string" ? data.code : null };
|
|
757
|
+
} catch {
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
var describePairFailure = (reason) => {
|
|
762
|
+
switch (reason) {
|
|
763
|
+
case "no_site":
|
|
764
|
+
return "Couldn't set up your workspace. Try again, or finish setup at pushary.com/dashboard";
|
|
765
|
+
case "blocked":
|
|
766
|
+
return "Finish setting up in the Pushary app, then run setup again";
|
|
767
|
+
case "authorize_failed":
|
|
768
|
+
return "The app could not authorize this terminal. Re-run setup to try again";
|
|
769
|
+
case "payment_required":
|
|
770
|
+
return "Your Pushary workspace has no active subscription. Subscribe in the Pushary app or at pushary.com, then run setup again";
|
|
771
|
+
default:
|
|
772
|
+
return "Pairing failed. Re-run setup to try again";
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
var cancelPairing = async (pairId) => {
|
|
776
|
+
try {
|
|
777
|
+
await fetch(`${apiBase()}/api/mobile/pair/start`, {
|
|
778
|
+
method: "DELETE",
|
|
779
|
+
headers: { "Content-Type": "application/json" },
|
|
780
|
+
body: JSON.stringify({ pairId }),
|
|
781
|
+
signal: AbortSignal.timeout(5e3)
|
|
782
|
+
});
|
|
783
|
+
} catch {
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
var PAIR_MAX_CONSECUTIVE_FAILURES = 8;
|
|
787
|
+
var claimPairing = async (pairId) => {
|
|
788
|
+
try {
|
|
789
|
+
const res = await fetch(`${apiBase()}/api/mobile/pair/claim?id=${encodeURIComponent(pairId)}`, {
|
|
790
|
+
signal: AbortSignal.timeout(1e4)
|
|
791
|
+
});
|
|
792
|
+
if (!res.ok) return null;
|
|
793
|
+
return await res.json().catch(() => null);
|
|
794
|
+
} catch {
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
var ackPairing = async (pairId) => {
|
|
799
|
+
try {
|
|
800
|
+
await fetch(`${apiBase()}/api/mobile/pair/ack`, {
|
|
801
|
+
method: "POST",
|
|
802
|
+
headers: { "Content-Type": "application/json" },
|
|
803
|
+
body: JSON.stringify({ pairId }),
|
|
804
|
+
signal: AbortSignal.timeout(5e3)
|
|
805
|
+
});
|
|
806
|
+
} catch {
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
var pairFailed = (reason) => ({ kind: "failed", reason });
|
|
810
|
+
var pairingEscapeApplies = (outcome) => outcome.reason === "timeout" || outcome.reason === "expired" || outcome.reason === "cancelled";
|
|
811
|
+
var buildPairLinks = (pairId, publicKeyB64, baseUrl = apiBase(), code = null) => {
|
|
812
|
+
const id = encodeURIComponent(pairId);
|
|
813
|
+
const pk = encodeURIComponent(publicKeyB64);
|
|
814
|
+
const origin = baseUrl.replace(/\/+$/, "");
|
|
815
|
+
const longLink = `${origin}/pair?id=${id}&pk=${pk}`;
|
|
816
|
+
return {
|
|
817
|
+
// Never shortened. Both scanners have to read this one: the phone camera,
|
|
818
|
+
// which opens it, and the app's own scanner, which parses id and pk straight
|
|
819
|
+
// off it and cannot resolve a code. 22 rows of QR instead of 16 is the price
|
|
820
|
+
// of the in-app scan button working at all.
|
|
821
|
+
qrLink: longLink,
|
|
822
|
+
// 33 characters against 114, for the person reading it off the screen.
|
|
823
|
+
universalLink: code ? `${origin}/pair/${encodeURIComponent(code)}` : longLink,
|
|
824
|
+
// Never shortened: the scheme reaches an installed app with no server round
|
|
825
|
+
// trip, which is the path that has to survive the short code failing.
|
|
826
|
+
deepLink: `pushary://pair?pk=${pk}&id=${id}`
|
|
827
|
+
};
|
|
828
|
+
};
|
|
829
|
+
var connectViaAppPairing = async (options = {}) => {
|
|
830
|
+
const clock = options.clock ?? SYSTEM_PAIR_CLOCK;
|
|
831
|
+
const keypair = generateKeypair();
|
|
832
|
+
const requestedAt = clock.now();
|
|
833
|
+
const started = await startPairing(keypair.publicKeyB64);
|
|
834
|
+
if (!started) {
|
|
835
|
+
console.log(` ${yellow("!")} Couldn't start pairing. Check your connection and try again.`);
|
|
836
|
+
return pairFailed("unreachable");
|
|
837
|
+
}
|
|
838
|
+
const { pairId, code } = started;
|
|
839
|
+
const { qrLink, universalLink, deepLink } = buildPairLinks(pairId, keypair.publicKeyB64, apiBase(), code);
|
|
840
|
+
console.log();
|
|
841
|
+
console.log(` ${bold("Connect your Pushary app")}`);
|
|
842
|
+
console.log(` ${dim("Scan this with your phone camera:")}`);
|
|
843
|
+
await printQr(qrLink);
|
|
844
|
+
console.log(` ${dim("No camera handy? Open this on the phone instead:")}`);
|
|
845
|
+
console.log(` ${cyan(universalLink)}`);
|
|
846
|
+
console.log(` ${dim("Confirm the app shows fingerprint")} ${cyan(publicKeyFingerprint(keypair.publicKeyB64))}`);
|
|
847
|
+
console.log(` ${dim("Already have the app and it opened a browser? Use")} ${cyan(deepLink)}`);
|
|
848
|
+
console.log();
|
|
849
|
+
const startedAt = clock.now();
|
|
850
|
+
const deadline = requestedAt + PAIR_TIMEOUT_MS;
|
|
851
|
+
let hintShown = false;
|
|
852
|
+
let browserFallbackShown = false;
|
|
853
|
+
const stop = startSpinner(() => {
|
|
854
|
+
const left = Math.max(0, Math.ceil((deadline - clock.now()) / 6e4));
|
|
855
|
+
if (clock.now() - startedAt < PAIR_HINT_AFTER_MS) {
|
|
856
|
+
return "Waiting for the app to authorize";
|
|
857
|
+
}
|
|
858
|
+
if (!hintShown) {
|
|
859
|
+
hintShown = true;
|
|
860
|
+
writeStatusLine(
|
|
861
|
+
`${dim("No app yet? Install it, sign in with this account, then scan. Ctrl-C to stop.")}`
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
return `Waiting for the app ${dim(`(${left}m left)`)}`;
|
|
865
|
+
});
|
|
866
|
+
let cancelled = false;
|
|
867
|
+
const onInterrupt = () => {
|
|
868
|
+
cancelled = true;
|
|
869
|
+
stop(yellow("!"), "Cancelled");
|
|
870
|
+
const flushed = options.onCancelled?.();
|
|
871
|
+
void Promise.allSettled([Promise.resolve(flushed), cancelPairing(pairId)]).finally(() => process.exit(EXIT_ABORTED));
|
|
872
|
+
};
|
|
873
|
+
process.once("SIGINT", onInterrupt);
|
|
874
|
+
let consecutiveFailures = 0;
|
|
875
|
+
try {
|
|
876
|
+
while (clock.now() < deadline) {
|
|
877
|
+
if (cancelled) return pairFailed("cancelled");
|
|
878
|
+
if (!browserFallbackShown && clock.now() - startedAt >= PAIR_BROWSER_FALLBACK_AFTER_MS) {
|
|
879
|
+
browserFallbackShown = true;
|
|
880
|
+
writeStatusLine(dim(PAIR_BROWSER_FALLBACK_LINE));
|
|
881
|
+
}
|
|
882
|
+
const claim = await claimPairing(pairId);
|
|
883
|
+
if (claim === null) {
|
|
884
|
+
consecutiveFailures += 1;
|
|
885
|
+
if (consecutiveFailures >= PAIR_MAX_CONSECUTIVE_FAILURES) {
|
|
886
|
+
stop(yellow("!"), "Can't reach pushary.com to finish pairing. Check your connection, then re-run setup.");
|
|
887
|
+
await cancelPairing(pairId);
|
|
888
|
+
return pairFailed("unreachable");
|
|
889
|
+
}
|
|
890
|
+
await clock.sleep(PAIR_POLL_INTERVAL_MS);
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
consecutiveFailures = 0;
|
|
894
|
+
if (claim?.status === "authorized") {
|
|
895
|
+
let apiKey;
|
|
896
|
+
try {
|
|
897
|
+
apiKey = openSealedKey(claim.sealed, keypair);
|
|
898
|
+
} catch {
|
|
899
|
+
stop(yellow("!"), "Pairing failed to decrypt \u2014 re-run setup to try again");
|
|
900
|
+
await cancelPairing(pairId);
|
|
901
|
+
return pairFailed("decrypt");
|
|
902
|
+
}
|
|
903
|
+
await ackPairing(pairId);
|
|
904
|
+
stop(check, "App connected");
|
|
905
|
+
return { kind: "paired", apiKey, handle: claim.siteSlug };
|
|
906
|
+
}
|
|
907
|
+
if (claim?.status === "expired") {
|
|
908
|
+
stop(yellow("!"), "Pairing expired \u2014 re-run setup to try again");
|
|
909
|
+
return pairFailed("expired");
|
|
910
|
+
}
|
|
911
|
+
if (claim?.status === "failed") {
|
|
912
|
+
stop(yellow("!"), describePairFailure(claim.reason));
|
|
913
|
+
return pairFailed("rejected");
|
|
914
|
+
}
|
|
915
|
+
await clock.sleep(PAIR_POLL_INTERVAL_MS);
|
|
916
|
+
}
|
|
917
|
+
stop(yellow("!"), "Timed out waiting for the app");
|
|
918
|
+
await cancelPairing(pairId);
|
|
919
|
+
return pairFailed("timeout");
|
|
920
|
+
} catch (err) {
|
|
921
|
+
stop(yellow("!"), `Pairing error ${dim(`(${err instanceof Error ? err.message : "network"})`)}`);
|
|
922
|
+
await cancelPairing(pairId);
|
|
923
|
+
return pairFailed("unreachable");
|
|
924
|
+
} finally {
|
|
925
|
+
process.off("SIGINT", onInterrupt);
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
export {
|
|
930
|
+
reachVerdict,
|
|
931
|
+
describeReach,
|
|
932
|
+
verifyRoundTrip,
|
|
933
|
+
describeRoundTrip,
|
|
934
|
+
roundTripEvidence,
|
|
935
|
+
fetchChannels,
|
|
936
|
+
connectDevice,
|
|
937
|
+
confirmAppConnection,
|
|
938
|
+
connectExistingApp,
|
|
939
|
+
printConnectInstructions,
|
|
940
|
+
BROWSER_CONNECT_COMMAND,
|
|
941
|
+
pairingEscapeApplies,
|
|
942
|
+
connectViaAppPairing
|
|
943
|
+
};
|