@vellumai/cli 0.10.11-dev.202607222325.35fc367 → 0.10.11-dev.202607230140.b414d01

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.
@@ -100,3 +100,93 @@ export interface RemoteWebPairingTokenApprovedResponse {
100
100
  export type RemoteWebPairingTokenResponse =
101
101
  | RemoteWebPairingTokenPendingResponse
102
102
  | RemoteWebPairingTokenApprovedResponse;
103
+
104
+ // ── Shared pairing URL helpers ──────────────────────────────────────────────
105
+ //
106
+ // Every surface that mints a pairing (the `vellum pair --qr` CLI, the web
107
+ // settings "Pair a device" card) must accept the same public URLs and build
108
+ // the same scannable links. The helpers are environment-neutral (WHATWG URL
109
+ // only) so both Node and browser callers share one implementation.
110
+
111
+ /** Why a public base URL can't be advertised in a pairing challenge. */
112
+ export type PublicBaseUrlRejection = "unparseable" | "loopback" | "non-https";
113
+
114
+ export type PublicBaseUrlResult =
115
+ | { ok: true; url: string }
116
+ | { ok: false; reason: PublicBaseUrlRejection };
117
+
118
+ /**
119
+ * A loopback URL — `localhost`, `[::1]`, or `127.x.x.x`. A pairing link that
120
+ * encodes a loopback address is unreachable from the scanning device.
121
+ */
122
+ export function isLoopbackPublicUrl(url: string): boolean {
123
+ try {
124
+ // WHATWG URL canonicalizes hostnames, so IPv6 loopback is always "[::1]".
125
+ const hostname = new URL(url).hostname;
126
+ return (
127
+ hostname === "localhost" ||
128
+ hostname === "[::1]" ||
129
+ /^127(?:\.\d{1,3}){3}$/.test(hostname)
130
+ );
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Normalize an address to the public base a scanning device opens:
138
+ * query/hash stripped, the `assistant` path segment (and everything after it)
139
+ * removed so a pasted pair-page URL collapses to its base, and trailing
140
+ * slashes trimmed. Throws if the value is not a parseable URL.
141
+ */
142
+ export function normalizePublicBaseUrl(value: string): string {
143
+ const url = new URL(value);
144
+ url.search = "";
145
+ url.hash = "";
146
+ const parts = url.pathname.split("/").filter(Boolean);
147
+ const assistantIndex = parts.indexOf("assistant");
148
+ if (assistantIndex >= 0) {
149
+ parts.splice(assistantIndex);
150
+ }
151
+ url.pathname = parts.length ? `/${parts.join("/")}` : "/";
152
+ return url.toString().replace(/\/+$/, "");
153
+ }
154
+
155
+ /**
156
+ * Resolve an address to the public https base URL to advertise in a pairing
157
+ * challenge, or report why it can't be used. Loopback and non-https links are
158
+ * refused with a specific reason callers turn into their own guidance.
159
+ */
160
+ export function resolvePublicBaseUrl(raw: string): PublicBaseUrlResult {
161
+ let normalized: string;
162
+ try {
163
+ normalized = normalizePublicBaseUrl(raw);
164
+ } catch {
165
+ return { ok: false, reason: "unparseable" };
166
+ }
167
+ if (isLoopbackPublicUrl(normalized)) {
168
+ return { ok: false, reason: "loopback" };
169
+ }
170
+ if (new URL(normalized).protocol !== "https:") {
171
+ return { ok: false, reason: "non-https" };
172
+ }
173
+ return { ok: true, url: normalized };
174
+ }
175
+
176
+ /**
177
+ * The scannable pair URL: the challenge's verification URI with the device
178
+ * code carried in the fragment (`#device_code=…`), matching what the pair
179
+ * page reads on load. Fragments never reach the wire.
180
+ */
181
+ export function buildRemoteWebPairingUrl(
182
+ challenge: Pick<
183
+ RemoteWebPairingChallengeResponse,
184
+ "verificationUri" | "deviceCode"
185
+ >,
186
+ ): string {
187
+ const url = new URL(challenge.verificationUri);
188
+ url.hash = new URLSearchParams({
189
+ device_code: challenge.deviceCode,
190
+ }).toString();
191
+ return url.toString();
192
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.10.11-dev.202607222325.35fc367",
3
+ "version": "0.10.11-dev.202607230140.b414d01",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -15,11 +15,15 @@ import { nanoid } from "nanoid";
15
15
  // error-correction level off `this`, so a destructured import renders nothing.
16
16
  import qrcodeTerminal from "qrcode-terminal";
17
17
 
18
- import type {
19
- RemoteWebPairingChallengeRequest,
20
- RemoteWebPairingChallengeResponse,
21
- RemoteWebPairingVerificationRequest,
22
- RemoteWebPairingVerificationResponse,
18
+ import {
19
+ buildRemoteWebPairingUrl,
20
+ normalizePublicBaseUrl,
21
+ resolvePublicBaseUrl,
22
+ type PublicBaseUrlRejection,
23
+ type RemoteWebPairingChallengeRequest,
24
+ type RemoteWebPairingChallengeResponse,
25
+ type RemoteWebPairingVerificationRequest,
26
+ type RemoteWebPairingVerificationResponse,
23
27
  } from "@vellumai/service-contracts/remote-web-pairing";
24
28
 
25
29
  import { extractFlag } from "../lib/arg-utils.js";
@@ -127,29 +131,6 @@ interface PairResponse {
127
131
  refreshAfter?: string;
128
132
  }
129
133
 
130
- function normalizePublicBaseUrl(value: string): string {
131
- const url = new URL(value);
132
- url.search = "";
133
- url.hash = "";
134
- const parts = url.pathname.split("/").filter(Boolean);
135
- const assistantIndex = parts.indexOf("assistant");
136
- if (assistantIndex >= 0) {
137
- parts.splice(assistantIndex);
138
- }
139
- url.pathname = parts.length ? `/${parts.join("/")}` : "/";
140
- return url.toString().replace(/\/+$/, "");
141
- }
142
-
143
- function buildRemoteWebPairingUrl(
144
- challenge: RemoteWebPairingChallengeResponse,
145
- ): string {
146
- const url = new URL(challenge.verificationUri);
147
- url.hash = new URLSearchParams({
148
- device_code: challenge.deviceCode,
149
- }).toString();
150
- return url.toString();
151
- }
152
-
153
134
  /** Default URL scheme registered by production builds of the iOS app. */
154
135
  const DEFAULT_APP_CONNECT_SCHEME = "vellum-assistant";
155
136
 
@@ -167,35 +148,6 @@ export function buildAppConnectUrl(
167
148
  return `${scheme}://connect?${params.toString()}`;
168
149
  }
169
150
 
170
- type QrPublicBaseUrlFailureReason = "unparseable" | "loopback" | "non-https";
171
-
172
- type QrPublicBaseUrlResult =
173
- | { ok: true; url: string }
174
- | { ok: false; reason: QrPublicBaseUrlFailureReason };
175
-
176
- /**
177
- * Normalize the advertised URL to the public https origin a scanning phone can
178
- * open, or report why it isn't internet-reachable. Stricter than the copy-paste
179
- * bundle path's loopback guard: a QR that encodes a loopback or plain-http link
180
- * is unusable from another device, so both are refused. The failure reason is
181
- * returned so the caller can emit an accurate message per case.
182
- */
183
- function resolveQrPublicBaseUrl(advertisedUrl: string): QrPublicBaseUrlResult {
184
- let normalized: string;
185
- try {
186
- normalized = normalizePublicBaseUrl(advertisedUrl);
187
- } catch {
188
- return { ok: false, reason: "unparseable" };
189
- }
190
- if (isLoopbackUrl(normalized)) {
191
- return { ok: false, reason: "loopback" };
192
- }
193
- if (new URL(normalized).protocol !== "https:") {
194
- return { ok: false, reason: "non-https" };
195
- }
196
- return { ok: true, url: normalized };
197
- }
198
-
199
151
  /**
200
152
  * POST a JSON body to a loopback gateway route, exiting with a clear message
201
153
  * when the gateway is unreachable or answers non-2xx. Every pairing subcommand
@@ -493,9 +445,9 @@ export async function pair(): Promise<void> {
493
445
  if (qrPairing) {
494
446
  // Validate the public URL before any network call — a QR that encodes a
495
447
  // loopback or plain-http link is unscannable from another device.
496
- const qrResult = resolveQrPublicBaseUrl(advertisedUrl);
448
+ const qrResult = resolvePublicBaseUrl(advertisedUrl);
497
449
  if (!qrResult.ok) {
498
- const detailByReason: Record<QrPublicBaseUrlFailureReason, string> = {
450
+ const detailByReason: Record<PublicBaseUrlRejection, string> = {
499
451
  unparseable: `${advertisedUrl} isn't a valid URL`,
500
452
  loopback: `${advertisedUrl} is a loopback address`,
501
453
  "non-https": `${advertisedUrl} is not https`,