frontpage-host 0.1.15 → 0.1.17

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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Site compile clock (not the editor agent run).
3
+ *
4
+ * GET /api/v1/sites/{slug}/deploy → ready when the live compile SHA equals the tip.
5
+ */
6
+ import { EXIT_OK, EXIT_USAGE, v1Get } from "./http/client.js";
7
+ import { progress } from "./runtime.js";
8
+ const SHA_RE = /^[0-9a-f]{40}$/i;
9
+ export function normalizeSha(raw) {
10
+ const sha = (raw ?? "").trim().toLowerCase();
11
+ return SHA_RE.test(sha) ? sha : null;
12
+ }
13
+ export function shortSha(raw) {
14
+ const sha = normalizeSha(raw);
15
+ return sha ? sha.slice(0, 7) : "(none)";
16
+ }
17
+ /** SHA encoded in a Blob artifact prefix URL. */
18
+ export function commitFromArtifactUrl(url) {
19
+ if (!url)
20
+ return null;
21
+ const m = String(url).match(/\/artifacts\/[^/]+\/([0-9a-f]{40})(?:\/|$)/i);
22
+ return m ? m[1].toLowerCase() : null;
23
+ }
24
+ export function compileIsReady(status, expectCommit) {
25
+ if (!status.ready)
26
+ return false;
27
+ const live = normalizeSha(status.liveCommit);
28
+ if (!live)
29
+ return false;
30
+ const expect = normalizeSha(expectCommit ?? null);
31
+ if (!expect)
32
+ return true;
33
+ return live === expect;
34
+ }
35
+ export async function fetchDeployStatus(slug) {
36
+ return v1Get(`/api/v1/sites/${encodeURIComponent(slug)}/deploy`);
37
+ }
38
+ export async function waitForCompileReady(args) {
39
+ const maxMs = args.maxMs ?? 30 * 60 * 1000;
40
+ const pollMs = args.pollMs ?? 2000;
41
+ const fetchStatus = args.fetchStatus ?? fetchDeployStatus;
42
+ const started = Date.now();
43
+ let last = null;
44
+ while (Date.now() - started < maxMs) {
45
+ last = await fetchStatus(args.slug);
46
+ if (last.compileState === "failed" || last.compileState === "canceled") {
47
+ progress("\n");
48
+ return { exitCode: EXIT_USAGE, timedOut: false, failed: true, status: last };
49
+ }
50
+ if (compileIsReady(last, args.expectCommit)) {
51
+ progress("\n");
52
+ return { exitCode: EXIT_OK, timedOut: false, failed: false, status: last };
53
+ }
54
+ const tip = shortSha(args.expectCommit ?? last.stagingCommit);
55
+ const live = shortSha(last.liveCommit ?? last.servingCommit);
56
+ progress(`\rwaiting for site compile tip ${tip} live ${live} `);
57
+ await new Promise((r) => setTimeout(r, pollMs));
58
+ }
59
+ progress("\n");
60
+ return { exitCode: EXIT_USAGE, timedOut: true, failed: false, status: last };
61
+ }
62
+ export function describePublishResult(args) {
63
+ const tipCommit = normalizeSha(args.productionCommit);
64
+ const servingCommit = commitFromArtifactUrl(args.productionUrl) ?? tipCommit;
65
+ const matchesTip = Boolean(tipCommit && servingCommit && tipCommit === servingCommit);
66
+ const lines = [
67
+ `tip ${tipCommit ?? "(none)"}`,
68
+ `serving ${servingCommit ?? "(none)"}${matchesTip ? "" : " ← not the tip; this is the previous build"}`,
69
+ ];
70
+ return { tipCommit, servingCommit, matchesTip, lines };
71
+ }
@@ -19,6 +19,7 @@ _frontpage_completions() {
19
19
  runs) COMPREPLY=( $(compgen -W "list current wait cancel retry get" -- "\${cur}") ) ;;
20
20
  deploy) COMPREPLY=( $(compgen -W "wait" -- "\${cur}") ) ;;
21
21
  media) COMPREPLY=( $(compgen -W "list upload" -- "\${cur}") ) ;;
22
+ images) COMPREPLY=( $(compgen -W "generate" -- "\${cur}") ) ;;
22
23
  fonts) COMPREPLY=( $(compgen -W "list search" -- "\${cur}") ) ;;
23
24
  tokens) COMPREPLY=( $(compgen -W "set" -- "\${cur}") ) ;;
24
25
  seo|settings) COMPREPLY=( $(compgen -W "get set" -- "\${cur}") ) ;;
@@ -27,7 +28,9 @@ _frontpage_completions() {
27
28
  modules)
28
29
  if [[ \${COMP_CWORD} -ge 3 && \${COMP_WORDS[2]} == google_reviews ]]; then
29
30
  COMPREPLY=( $(compgen -W "docs request-setup enable disable archive config connect resolve status list-reviews hide show set-stars set-sort set-weight reset-weights write-review-url refresh disconnect" -- "\${cur}") )
30
- elif [[ \${COMP_CWORD} -ge 3 && (\${COMP_WORDS[2]} == callbar || \${COMP_WORDS[2]} == forms || \${COMP_WORDS[2]} == booking || \${COMP_WORDS[2]} == signups) ]]; then
31
+ elif [[ \${COMP_CWORD} -ge 3 && \${COMP_WORDS[2]} == forms ]]; then
32
+ COMPREPLY=( $(compgen -W "docs request-setup enable disable archive config get set destinations connect-destination remove-destination disconnect-destination attach-destination detach-destination request-destination-setup" -- "\${cur}") )
33
+ elif [[ \${COMP_CWORD} -ge 3 && (\${COMP_WORDS[2]} == callbar || \${COMP_WORDS[2]} == booking || \${COMP_WORDS[2]} == signups) ]]; then
31
34
  COMPREPLY=( $(compgen -W "docs request-setup enable disable archive config get set" -- "\${cur}") )
32
35
  elif [[ \${COMP_CWORD} -ge 3 && (\${COMP_WORDS[2]} == payments || \${COMP_WORDS[2]} == shop) ]]; then
33
36
  COMPREPLY=( $(compgen -W "docs request-setup enable disable archive config status connect disconnect" -- "\${cur}") )
@@ -61,6 +64,7 @@ _frontpage() {
61
64
  runs) _values 'subcommand' current wait cancel retry get ;;
62
65
  deploy) _values 'subcommand' wait ;;
63
66
  media) _values 'subcommand' list upload ;;
67
+ images) _values 'subcommand' generate ;;
64
68
  fonts) _values 'subcommand' list search ;;
65
69
  tokens) _values 'subcommand' set ;;
66
70
  seo|settings) _values 'subcommand' get set ;;
@@ -68,7 +72,9 @@ _frontpage() {
68
72
  modules)
69
73
  if [[ $words[2] == google_reviews ]]; then
70
74
  _values 'subcommand' docs request-setup enable disable archive config connect resolve status list-reviews hide show set-stars set-sort set-weight reset-weights write-review-url refresh disconnect
71
- elif [[ $words[2] == callbar || $words[2] == forms || $words[2] == booking || $words[2] == signups ]]; then
75
+ elif [[ $words[2] == forms ]]; then
76
+ _values 'subcommand' docs request-setup enable disable archive config get set destinations connect-destination remove-destination disconnect-destination attach-destination detach-destination request-destination-setup
77
+ elif [[ $words[2] == callbar || $words[2] == booking || $words[2] == signups ]]; then
72
78
  _values 'subcommand' docs request-setup enable disable archive config get set
73
79
  elif [[ $words[2] == payments || $words[2] == shop ]]; then
74
80
  _values 'subcommand' docs request-setup enable disable archive config status connect disconnect
@@ -95,13 +101,15 @@ export function generateFishCompletion() {
95
101
  `complete -c frontpage -n "__fish_seen_subcommand_from runs" -a "current wait cancel retry get"`,
96
102
  `complete -c frontpage -n "__fish_seen_subcommand_from deploy" -a "wait"`,
97
103
  `complete -c frontpage -n "__fish_seen_subcommand_from media" -a "list upload"`,
104
+ `complete -c frontpage -n "__fish_seen_subcommand_from images" -a "generate"`,
98
105
  `complete -c frontpage -n "__fish_seen_subcommand_from fonts" -a "list search"`,
99
106
  `complete -c frontpage -n "__fish_seen_subcommand_from tokens" -a "set"`,
100
107
  `complete -c frontpage -n "__fish_seen_subcommand_from seo settings" -a "get set"`,
101
108
  `complete -c frontpage -n "__fish_seen_subcommand_from analytics" -a "dashboard summary query funnel timeseries compare sql"`,
102
109
  `complete -c frontpage -n "__fish_seen_subcommand_from modules" -a "list docs request-setup enable disable archive config callbar forms booking signups payments shop ordering articles google_reviews"`,
103
110
  `complete -c frontpage -n "__fish_seen_subcommand_from modules; and __fish_seen_subcommand_from google_reviews" -a "connect resolve status list-reviews hide show set-stars set-sort set-weight reset-weights write-review-url refresh disconnect"`,
104
- `complete -c frontpage -n "__fish_seen_subcommand_from modules; and __fish_seen_subcommand_from callbar forms booking signups" -a "get set"`,
111
+ `complete -c frontpage -n "__fish_seen_subcommand_from modules; and __fish_seen_subcommand_from forms" -a "get set destinations connect-destination remove-destination disconnect-destination attach-destination detach-destination request-destination-setup"`,
112
+ `complete -c frontpage -n "__fish_seen_subcommand_from modules; and __fish_seen_subcommand_from callbar booking signups" -a "get set"`,
105
113
  `complete -c frontpage -n "__fish_seen_subcommand_from modules; and __fish_seen_subcommand_from payments shop" -a "status connect disconnect"`,
106
114
  `complete -c frontpage -n "__fish_seen_subcommand_from modules; and __fish_seen_subcommand_from ordering" -a "status connect disconnect sync"`,
107
115
  `complete -c frontpage -n "__fish_seen_subcommand_from extensions" -a "list docs enable disable config"`,
@@ -63,6 +63,13 @@ export const MODULE_COMMANDS = [
63
63
  extraActions: [
64
64
  "get [--instance <slug>]",
65
65
  "set --instance <slug> [--notify-email] [--confirm yes|no]",
66
+ "destinations [--instance <slug>]",
67
+ "connect-destination <privyr|hubspot|pipedrive> --token \"…\" [--lead-source \"…\"]",
68
+ "attach-destination --instance <slug> --provider <id>",
69
+ "detach-destination --instance <slug> --provider <id>",
70
+ "remove-destination <id> --yes",
71
+ "disconnect-destination <id> --yes",
72
+ "request-destination-setup <id> --reason \"…\"",
66
73
  ],
67
74
  notYet: [],
68
75
  examples: [
@@ -70,12 +77,16 @@ export const MODULE_COMMANDS = [
70
77
  "frontpage modules forms get",
71
78
  "frontpage modules forms get --instance lead-gen",
72
79
  "frontpage modules forms set --instance lead-gen --notify-email hi@example.com",
80
+ "frontpage modules forms destinations",
81
+ "frontpage modules forms connect-destination privyr --token \"…\"",
82
+ "frontpage modules forms attach-destination --instance lead-gen --provider privyr",
73
83
  "frontpage modules forms docs",
74
84
  `frontpage modules config forms --instance lead-gen --config '{"notifyEmail":"hi@example.com"}'`,
75
85
  ],
76
86
  notes: [
77
87
  "Without --instance, get lists each form (id, status, notifyEmail). Do not target `default`.",
78
88
  "Confirmation emails require a paid plan (same as Modules).",
89
+ "Destinations copy leads to HubSpot, Pipedrive, or Privyr. Connect once, then attach each form. Token write is admin (Privyr: paste the Incoming Webhook URL, not a token). request-destination-setup lights the editor amber bar (editor key). remove-destination requires --yes. Locations: https://frontpage.host/developers/modules#forms-destinations",
79
90
  ],
80
91
  },
81
92
  {
@@ -139,8 +150,8 @@ export const MODULE_COMMANDS = [
139
150
  'frontpage modules payments request-setup --reason "Deposit button needs Stripe"',
140
151
  ],
141
152
  notes: [
142
- "Connect opens Stripe. Finish in the browser, then re-run status. This CLI cannot complete OAuth.",
143
- "payments and shop share one Stripe account — disconnect says so.",
153
+ "Connect needs an admin API key. It opens Frontpage (you must be logged in there), then Stripe. Already-connected sites stay live — disconnect first to switch accounts.",
154
+ "payments and shop share one Stripe account. Disconnecting payments fails if the store still uses that account.",
144
155
  "request-setup still lights the editor amber bar when you want the owner to finish there.",
145
156
  ],
146
157
  },
@@ -164,8 +175,8 @@ export const MODULE_COMMANDS = [
164
175
  'frontpage modules shop request-setup --reason "You asked for a store — Stripe setup makes checkout live"',
165
176
  ],
166
177
  notes: [
167
- "Connect opens Stripe. Finish in the browser, then re-run status. This CLI cannot complete OAuth.",
168
- "payments and shop share one Stripe account — disconnect says so.",
178
+ "Connect needs an admin API key. It opens Frontpage (you must be logged in there), then Stripe. Already-connected sites stay live.",
179
+ "payments and shop share one Stripe account. Shop disconnect keeps the account on payments.",
169
180
  "Shop still needs the shipping step in Modules if you have not set it.",
170
181
  "request-setup still lights the editor amber bar when you want the owner to finish there.",
171
182
  ],
@@ -192,7 +203,7 @@ export const MODULE_COMMANDS = [
192
203
  'frontpage modules ordering request-setup --reason "You asked for online ordering — Square setup makes it live"',
193
204
  ],
194
205
  notes: [
195
- "Connect opens Square. Finish in the browser, then re-run status. This CLI cannot complete OAuth.",
206
+ "Connect needs an editor API key. It opens Frontpage (you must be logged in there), then Square. Already-connected sites stay live.",
196
207
  "sync refreshes the Square catalog (same as the hub Sync button).",
197
208
  "request-setup still lights the editor amber bar when you want the owner to finish there.",
198
209
  "Fulfillment (pickup/delivery) is not in this CLI yet — use Modules or the editor.",
@@ -275,7 +286,19 @@ export function isCliModuleAction(key, action) {
275
286
  if (key === "google_reviews") {
276
287
  return GOOGLE_REVIEWS_CLI_ACTIONS.includes(action);
277
288
  }
278
- if (key === "callbar" || key === "forms" || key === "booking" || key === "signups") {
289
+ if (key === "forms") {
290
+ return (TYPED_CONFIG_CLI_ACTIONS.includes(action) ||
291
+ [
292
+ "destinations",
293
+ "connect-destination",
294
+ "remove-destination",
295
+ "disconnect-destination",
296
+ "attach-destination",
297
+ "detach-destination",
298
+ "request-destination-setup",
299
+ ].includes(action));
300
+ }
301
+ if (key === "callbar" || key === "booking" || key === "signups") {
279
302
  return TYPED_CONFIG_CLI_ACTIONS.includes(action);
280
303
  }
281
304
  if (key === "payments" || key === "shop") {
@@ -0,0 +1,3 @@
1
+ export declare const FORMS_DESTINATION_CLI_ACTIONS: readonly ["destinations", "connect-destination", "remove-destination", "disconnect-destination", "attach-destination", "detach-destination", "request-destination-setup"];
2
+ export declare function isFormsDestinationCliAction(action: string): boolean;
3
+ export declare function runFormsDestinations(slug: string, action: string, flags: string[]): Promise<number>;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * §FD.CLI — form destination verbs.
3
+ */
4
+ import { assertNoUnknownFlags, hasFlag, takeFlag } from "../argv.js";
5
+ import { EXIT_OK, EXIT_USAGE, v1Get, v1Post } from "../http/client.js";
6
+ import { emitOk, emitUsage, isJson } from "../runtime.js";
7
+ import { formatModuleUsage } from "./commands.js";
8
+ const USAGE = formatModuleUsage("forms");
9
+ const REMOVE_CONFIRM = "This deletes the destination from Frontpage and stops every form from sending a copy. Your leads stay in Frontpage and still email you.";
10
+ function pathFor(slug) {
11
+ return `/api/v1/sites/${encodeURIComponent(slug)}/modules/forms`;
12
+ }
13
+ export const FORMS_DESTINATION_CLI_ACTIONS = [
14
+ "destinations",
15
+ "connect-destination",
16
+ "remove-destination",
17
+ "disconnect-destination",
18
+ "attach-destination",
19
+ "detach-destination",
20
+ "request-destination-setup",
21
+ ];
22
+ export function isFormsDestinationCliAction(action) {
23
+ return FORMS_DESTINATION_CLI_ACTIONS.includes(action);
24
+ }
25
+ function printDestinations(data, instanceFilter) {
26
+ const providers = data.destinations?.providers ?? [];
27
+ const connected = providers.filter((p) => p.connected);
28
+ if (!connected.length) {
29
+ console.log("(no destinations)");
30
+ return;
31
+ }
32
+ const by = data.destinations?.byInstance ?? {};
33
+ for (const p of connected) {
34
+ console.log(`${p.id.padEnd(10)} connected ${p.last4 ? `••••${p.last4}` : ""}`.trimEnd());
35
+ const slugs = instanceFilter ? [instanceFilter] : Object.keys(by);
36
+ for (const slug of slugs) {
37
+ const row = (by[slug] ?? []).find((r) => r.provider === p.id);
38
+ const sending = row?.enabled ? "sending" : "—";
39
+ const err = row?.lastError ? ` error ${row.lastError}` : "";
40
+ if (instanceFilter && !row) {
41
+ console.log(` ${slug.padEnd(16)} —`);
42
+ continue;
43
+ }
44
+ if (!instanceFilter && !row)
45
+ continue;
46
+ console.log(` ${slug.padEnd(16)} ${sending}${err}`);
47
+ }
48
+ }
49
+ }
50
+ export async function runFormsDestinations(slug, action, flags) {
51
+ const work = [...flags];
52
+ if (action === "destinations") {
53
+ const instanceId = takeFlag(work, "--instance");
54
+ assertNoUnknownFlags(work, "modules");
55
+ const data = await v1Get(pathFor(slug));
56
+ if (isJson()) {
57
+ emitOk("modules", data.destinations ?? { providers: [], byInstance: {} });
58
+ return EXIT_OK;
59
+ }
60
+ printDestinations(data, instanceId);
61
+ return EXIT_OK;
62
+ }
63
+ if (action === "connect-destination") {
64
+ const provider = work.shift();
65
+ const token = takeFlag(work, "--token");
66
+ const leadSource = takeFlag(work, "--lead-source");
67
+ assertNoUnknownFlags(work, "modules");
68
+ if (!provider || !token) {
69
+ return emitUsage("modules", `Usage: frontpage modules forms connect-destination <privyr|hubspot|pipedrive> --token "…"\n\n${USAGE}`);
70
+ }
71
+ const data = await v1Post(pathFor(slug), {
72
+ action: "destination_connect",
73
+ provider,
74
+ token,
75
+ ...(leadSource ? { leadSource } : {}),
76
+ });
77
+ if (isJson()) {
78
+ emitOk("modules", data);
79
+ return EXIT_OK;
80
+ }
81
+ const last4 = typeof data.last4 === "string" ? data.last4 : "";
82
+ console.log(`Connected ${provider}${last4 ? ` ••••${last4}` : ""}.`);
83
+ return EXIT_OK;
84
+ }
85
+ if (action === "remove-destination" || action === "disconnect-destination") {
86
+ const provider = work.shift();
87
+ const yes = hasFlag(work, "--yes");
88
+ assertNoUnknownFlags(work, "modules");
89
+ if (!provider) {
90
+ return emitUsage("modules", `Usage: frontpage modules forms ${action} <privyr|hubspot|pipedrive> --yes\n\n${USAGE}`);
91
+ }
92
+ if (!yes) {
93
+ return emitUsage("modules", REMOVE_CONFIRM, EXIT_USAGE);
94
+ }
95
+ const data = await v1Post(pathFor(slug), {
96
+ action: "destination_remove",
97
+ provider,
98
+ });
99
+ if (isJson()) {
100
+ emitOk("modules", data);
101
+ return EXIT_OK;
102
+ }
103
+ console.log(`Removed ${provider}. Leads still land in Frontpage.`);
104
+ return EXIT_OK;
105
+ }
106
+ if (action === "attach-destination" || action === "detach-destination") {
107
+ const instanceId = takeFlag(work, "--instance");
108
+ const provider = takeFlag(work, "--provider") ?? work.shift();
109
+ assertNoUnknownFlags(work, "modules");
110
+ if (!instanceId || !provider) {
111
+ return emitUsage("modules", `Usage: frontpage modules forms ${action} --instance <slug> --provider <privyr|hubspot|pipedrive>\n\n${USAGE}`);
112
+ }
113
+ const data = await v1Post(pathFor(slug), {
114
+ action: action === "attach-destination" ? "destination_attach" : "destination_detach",
115
+ provider,
116
+ instanceId,
117
+ });
118
+ if (isJson()) {
119
+ emitOk("modules", data);
120
+ return EXIT_OK;
121
+ }
122
+ console.log(action === "attach-destination"
123
+ ? `${instanceId} will also send a copy to ${provider}.`
124
+ : `Stopped sending ${instanceId} to ${provider}.`);
125
+ return EXIT_OK;
126
+ }
127
+ if (action === "request-destination-setup") {
128
+ const provider = work.shift();
129
+ const reason = takeFlag(work, "--reason") ?? "";
130
+ assertNoUnknownFlags(work, "modules");
131
+ if (!provider || !reason) {
132
+ return emitUsage("modules", `Usage: frontpage modules forms request-destination-setup <privyr|hubspot|pipedrive> --reason "…"\n\n${USAGE}`);
133
+ }
134
+ const data = await v1Post(pathFor(slug), {
135
+ action: "destination_request_setup",
136
+ provider,
137
+ reason,
138
+ });
139
+ if (isJson()) {
140
+ emitOk("modules", data);
141
+ return EXIT_OK;
142
+ }
143
+ console.log(`Setup requested for ${provider}. The owner sees an amber prompt in the Frontpage editor.`);
144
+ return EXIT_OK;
145
+ }
146
+ return emitUsage("modules", `${USAGE}\n\nUnknown action "${action}".`);
147
+ }
@@ -1,4 +1,12 @@
1
1
  export declare const OAUTH_CLI_KEYS: readonly ["payments", "shop", "ordering"];
2
2
  export type OauthCliKey = (typeof OAUTH_CLI_KEYS)[number];
3
3
  export declare function isOauthCliKey(key: string): key is OauthCliKey;
4
+ export declare function resolveOpenUrl(raw: unknown): string | null;
5
+ /** argv for the platform opener. Windows quotes the URL so `&` stays in the query. */
6
+ export declare function browserOpenCommand(url: string, platform?: NodeJS.Platform): {
7
+ command: string;
8
+ args: string[];
9
+ windowsVerbatimArguments?: boolean;
10
+ };
11
+ export declare function openBrowser(url: string): void;
4
12
  export declare function runOauthHandoff(key: OauthCliKey, slug: string, action: string, flags: string[]): Promise<number>;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * §CLI-MOD-OAUTH — payments / shop / ordering connect, status, disconnect, sync.
2
+ * §CLI-MOD-OAUTH / §CLI-MOD-HARD — payments / shop / ordering connect, status, disconnect, sync.
3
3
  */
4
4
  import { spawn } from "node:child_process";
5
5
  import { assertNoUnknownFlags, hasFlag } from "../argv.js";
@@ -10,11 +10,11 @@ export const OAUTH_CLI_KEYS = ["payments", "shop", "ordering"];
10
10
  export function isOauthCliKey(key) {
11
11
  return OAUTH_CLI_KEYS.includes(key);
12
12
  }
13
- const FINISH = "Finish in the browser, then re-run status. This CLI cannot complete OAuth.";
13
+ const FINISH = "Open the Frontpage URL while logged in, finish Stripe or Square in the browser, then re-run status. This CLI cannot complete OAuth.";
14
14
  function pathFor(slug, key) {
15
15
  return `/api/v1/sites/${encodeURIComponent(slug)}/modules/${encodeURIComponent(key)}`;
16
16
  }
17
- function resolveOpenUrl(raw) {
17
+ export function resolveOpenUrl(raw) {
18
18
  if (typeof raw !== "string" || !raw.trim())
19
19
  return null;
20
20
  const trimmed = raw.trim();
@@ -24,10 +24,25 @@ function resolveOpenUrl(raw) {
24
24
  return `${apiBase()}${trimmed}`;
25
25
  return null;
26
26
  }
27
- function openBrowser(url) {
28
- const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
29
- const openerArgs = process.platform === "win32" ? ["/c", "start", "", url] : [url];
30
- spawn(opener, openerArgs, { detached: true, stdio: "ignore" }).unref();
27
+ /** argv for the platform opener. Windows quotes the URL so `&` stays in the query. */
28
+ export function browserOpenCommand(url, platform = process.platform) {
29
+ if (platform === "darwin")
30
+ return { command: "open", args: [url] };
31
+ if (platform !== "win32")
32
+ return { command: "xdg-open", args: [url] };
33
+ return {
34
+ command: "cmd",
35
+ args: ["/c", "start", '""', `"${url}"`],
36
+ windowsVerbatimArguments: true,
37
+ };
38
+ }
39
+ export function openBrowser(url) {
40
+ const spec = browserOpenCommand(url);
41
+ spawn(spec.command, spec.args, {
42
+ detached: true,
43
+ stdio: "ignore",
44
+ ...(spec.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
45
+ }).unref();
31
46
  }
32
47
  function printStatus(key, data) {
33
48
  const connection = (data.connection ?? {});
@@ -68,6 +83,7 @@ export async function runOauthHandoff(key, slug, action, flags) {
68
83
  const noOpen = hasFlag(work, "--no-open");
69
84
  assertNoUnknownFlags(work, "modules");
70
85
  const data = await v1Post(pathFor(slug, key), { action: "connect" });
86
+ const already = data.alreadyConnected === true;
71
87
  if (isJson()) {
72
88
  emitOk("modules", data);
73
89
  }
@@ -75,12 +91,12 @@ export async function runOauthHandoff(key, slug, action, flags) {
75
91
  const url = resolveOpenUrl(data.connectUrl);
76
92
  if (url)
77
93
  console.log(url);
78
- console.log(typeof data.message === "string" ? data.message : FINISH);
94
+ console.log(typeof data.message === "string" ? data.message : already ? "Already connected." : FINISH);
79
95
  if (typeof data.warning === "string")
80
96
  console.log(data.warning);
81
97
  }
82
98
  const url = resolveOpenUrl(data.connectUrl);
83
- if (url && !noOpen)
99
+ if (url && !noOpen && !already)
84
100
  openBrowser(url);
85
101
  return EXIT_OK;
86
102
  }
@@ -46,17 +46,29 @@ function printConfirmable(config, confirmations) {
46
46
  else
47
47
  printLine("confirm", yesNo(on));
48
48
  }
49
- function printFormsList(instances) {
49
+ function printFormsList(instances, destinations) {
50
50
  if (!instances.length) {
51
51
  console.log("(no forms)");
52
- return;
53
52
  }
54
- for (const i of instances) {
55
- const id = i.instance ?? "";
56
- const label = i.label && i.label !== id ? i.label : "";
57
- const status = String(i.status ?? "—");
58
- const email = typeof i.config?.notifyEmail === "string" ? i.config.notifyEmail : "(account email)";
59
- console.log(`${id.padEnd(20)} ${status.padEnd(12)} ${label ? `${label} ` : ""}${email}`);
53
+ else {
54
+ for (const i of instances) {
55
+ const id = i.instance ?? "";
56
+ const label = i.label && i.label !== id ? i.label : "";
57
+ const status = String(i.status ?? "—");
58
+ const email = typeof i.config?.notifyEmail === "string" ? i.config.notifyEmail : "(account email)";
59
+ const attached = (destinations?.byInstance?.[id] ?? [])
60
+ .filter((r) => r.enabled)
61
+ .map((r) => r.provider)
62
+ .join(",");
63
+ console.log(`${id.padEnd(20)} ${status.padEnd(12)} ${label ? `${label} ` : ""}${email.padEnd(22)} ${attached || "—"}`);
64
+ }
65
+ }
66
+ const connected = (destinations?.providers ?? []).filter((p) => p.connected);
67
+ if (connected.length) {
68
+ console.log("");
69
+ for (const p of connected) {
70
+ console.log(`${p.id} connected${p.last4 ? ` ••••${p.last4}` : ""}`);
71
+ }
60
72
  }
61
73
  }
62
74
  function parseYesNoFlag(raw, flag) {
@@ -81,7 +93,7 @@ export async function runTypedConfig(key, slug, action, flags) {
81
93
  return EXIT_OK;
82
94
  }
83
95
  if (key === "forms" && !instanceId) {
84
- printFormsList(data.instances ?? []);
96
+ printFormsList(data.instances ?? [], data.destinations);
85
97
  return EXIT_OK;
86
98
  }
87
99
  if (key === "callbar")