@getjack/jack 0.1.12 → 0.1.14

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.
@@ -6,7 +6,17 @@ export {
6
6
  refreshToken,
7
7
  startDeviceAuth,
8
8
  } from "./client.ts";
9
+ export {
10
+ ensureAuthForCreate,
11
+ type EnsureAuthOptions,
12
+ type EnsureAuthResult,
13
+ } from "./ensure-auth.ts";
9
14
  export { requireAuth, requireAuthOrLogin, getCurrentUser } from "./guard.ts";
15
+ export {
16
+ runLoginFlow,
17
+ type LoginFlowOptions,
18
+ type LoginFlowResult,
19
+ } from "./login-flow.ts";
10
20
  export {
11
21
  deleteCredentials,
12
22
  getAuthState,
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Shared login flow for CLI and programmatic use
3
+ */
4
+ import { input } from "@inquirer/prompts";
5
+ import {
6
+ checkUsernameAvailable,
7
+ getCurrentUserProfile,
8
+ registerUser,
9
+ setUsername,
10
+ } from "../control-plane.ts";
11
+ import { promptSelect } from "../hooks.ts";
12
+ import { celebrate, error, info, spinner, success, warn } from "../output.ts";
13
+ import { identifyUser } from "../telemetry.ts";
14
+ import { type DeviceAuthResponse, pollDeviceToken, startDeviceAuth } from "./client.ts";
15
+ import { type AuthCredentials, saveCredentials } from "./store.ts";
16
+
17
+ export interface LoginFlowOptions {
18
+ /** Skip the initial "Logging in..." message (used when called from auto-login) */
19
+ silent?: boolean;
20
+ /** Skip the username prompt after login */
21
+ skipUsernamePrompt?: boolean;
22
+ }
23
+
24
+ export interface LoginFlowResult {
25
+ success: boolean;
26
+ user?: {
27
+ id: string;
28
+ email: string;
29
+ first_name: string | null;
30
+ last_name: string | null;
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Run the complete login flow including device auth, token polling, and user registration.
36
+ * Returns a result object instead of calling process.exit().
37
+ */
38
+ export async function runLoginFlow(options?: LoginFlowOptions): Promise<LoginFlowResult> {
39
+ if (!options?.silent) {
40
+ info("Logging in to jack cloud...");
41
+ console.error("");
42
+ }
43
+
44
+ const spin = spinner("Starting login...");
45
+ let deviceAuth: DeviceAuthResponse;
46
+
47
+ try {
48
+ deviceAuth = await startDeviceAuth();
49
+ spin.stop();
50
+ } catch (err) {
51
+ spin.stop();
52
+ error(err instanceof Error ? err.message : "Failed to start login");
53
+ return { success: false };
54
+ }
55
+
56
+ celebrate("Your code:", [deviceAuth.user_code]);
57
+ info(`Opening ${deviceAuth.verification_uri} in your browser...`);
58
+ console.error("");
59
+
60
+ // Open browser - use Bun.spawn for cross-platform
61
+ try {
62
+ const platform = process.platform;
63
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
64
+ Bun.spawn([cmd, deviceAuth.verification_uri_complete]);
65
+ } catch {
66
+ info(`If the browser didn't open, go to: ${deviceAuth.verification_uri_complete}`);
67
+ }
68
+
69
+ const pollSpin = spinner("Waiting for you to complete login in browser...");
70
+ const interval = (deviceAuth.interval || 5) * 1000;
71
+ const expiresAt = Date.now() + deviceAuth.expires_in * 1000;
72
+
73
+ while (Date.now() < expiresAt) {
74
+ await sleep(interval);
75
+
76
+ try {
77
+ const tokens = await pollDeviceToken(deviceAuth.device_code);
78
+
79
+ if (tokens) {
80
+ pollSpin.stop();
81
+
82
+ // Default to 5 minutes if expires_in not provided
83
+ const expiresIn = tokens.expires_in ?? 300;
84
+ const creds: AuthCredentials = {
85
+ access_token: tokens.access_token,
86
+ refresh_token: tokens.refresh_token,
87
+ expires_at: Math.floor(Date.now() / 1000) + expiresIn,
88
+ user: tokens.user,
89
+ };
90
+ await saveCredentials(creds);
91
+
92
+ // Register user in control plane database (required for subsequent API calls)
93
+ try {
94
+ await registerUser({
95
+ email: tokens.user.email,
96
+ first_name: tokens.user.first_name,
97
+ last_name: tokens.user.last_name,
98
+ });
99
+ } catch (_regError) {
100
+ // Registration is required - without it, all API calls will fail
101
+ error("Failed to complete login - could not reach jack cloud.");
102
+ error("Please check your internet connection and try again.");
103
+ return { success: false };
104
+ }
105
+
106
+ // Link user identity for cross-platform analytics
107
+ await identifyUser(tokens.user.id, { email: tokens.user.email });
108
+
109
+ // Prompt for username if not set (unless explicitly skipped)
110
+ // Do this before welcome message so we know if user is new or returning
111
+ let isNewUser = false;
112
+ if (!options?.skipUsernamePrompt) {
113
+ isNewUser = await promptForUsername(tokens.user.email, tokens.user.first_name);
114
+ }
115
+
116
+ console.error("");
117
+ const displayName = tokens.user.first_name || "you";
118
+ if (isNewUser) {
119
+ success(`Welcome, ${displayName}!`);
120
+ } else {
121
+ success(`Welcome back, ${displayName}`);
122
+ }
123
+
124
+ return {
125
+ success: true,
126
+ user: tokens.user,
127
+ };
128
+ }
129
+ } catch (err) {
130
+ pollSpin.stop();
131
+ error(err instanceof Error ? err.message : "Login failed");
132
+ return { success: false };
133
+ }
134
+ }
135
+
136
+ pollSpin.stop();
137
+ error("Login timed out. Please try again.");
138
+ return { success: false };
139
+ }
140
+
141
+ function sleep(ms: number): Promise<void> {
142
+ return new Promise((resolve) => setTimeout(resolve, ms));
143
+ }
144
+
145
+ /**
146
+ * Prompt user to set their username if not already set.
147
+ * Returns true if this was a new user (username was set), false if returning user.
148
+ */
149
+ async function promptForUsername(email: string, firstName: string | null): Promise<boolean> {
150
+ // Skip in non-TTY environments
151
+ if (!process.stdout.isTTY) {
152
+ return false;
153
+ }
154
+
155
+ const spin = spinner("Checking account...");
156
+
157
+ try {
158
+ const profile = await getCurrentUserProfile();
159
+ spin.stop();
160
+
161
+ // If user already has a username, they're a returning user
162
+ if (profile?.username) {
163
+ return false;
164
+ }
165
+
166
+ console.error("");
167
+ info("Choose a username for your jack cloud account.");
168
+ info("URLs will look like: alice-vibes.runjack.xyz");
169
+ console.error("");
170
+
171
+ // Generate suggestions from first name, $USER env var, and email
172
+ const suggestions = generateUsernameSuggestions(email, firstName);
173
+
174
+ let username: string | null = null;
175
+
176
+ while (!username) {
177
+ // Build options for promptSelect
178
+ const options = [...suggestions, "Type custom username"];
179
+ info("Pick a username:");
180
+ const choice = await promptSelect(options);
181
+
182
+ let inputUsername: string;
183
+
184
+ if (choice === -1) {
185
+ // User pressed Esc - skip username setup
186
+ warn("Skipped username setup. You can set it later.");
187
+ return true; // Still a new user, just skipped
188
+ }
189
+
190
+ if (choice === options.length - 1) {
191
+ // User chose to type custom username
192
+ inputUsername = await input({
193
+ message: "Username:",
194
+ validate: validateUsername,
195
+ });
196
+ } else {
197
+ // User picked a suggestion (choice is guaranteed to be valid index)
198
+ inputUsername = suggestions[choice] as string;
199
+ }
200
+
201
+ // Check availability
202
+ const checkSpin = spinner("Checking availability...");
203
+ const availability = await checkUsernameAvailable(inputUsername);
204
+ checkSpin.stop();
205
+
206
+ if (!availability.available) {
207
+ warn(availability.error || `Username "${inputUsername}" is already taken. Try another.`);
208
+ continue;
209
+ }
210
+
211
+ // Try to set the username
212
+ const setSpin = spinner("Setting username...");
213
+ try {
214
+ await setUsername(inputUsername);
215
+ setSpin.stop();
216
+ username = inputUsername;
217
+ success(`Username set to "${username}"`);
218
+ } catch (err) {
219
+ setSpin.stop();
220
+ warn(err instanceof Error ? err.message : "Failed to set username");
221
+ }
222
+ }
223
+
224
+ return true; // New user - username was set
225
+ } catch (_err) {
226
+ spin.stop();
227
+ // Non-fatal - user can set username later
228
+ warn("Could not set username. You can set it later.");
229
+ return true; // Assume new user if we couldn't check
230
+ }
231
+ }
232
+
233
+ function validateUsername(value: string): string | true {
234
+ if (!value || value.length < 3) {
235
+ return "Username must be at least 3 characters";
236
+ }
237
+ if (value.length > 39) {
238
+ return "Username must be 39 characters or less";
239
+ }
240
+ if (value !== value.toLowerCase()) {
241
+ return "Username must be lowercase";
242
+ }
243
+ if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]{1,2}$/.test(value)) {
244
+ return "Use only lowercase letters, numbers, and hyphens";
245
+ }
246
+ return true;
247
+ }
248
+
249
+ function generateUsernameSuggestions(email: string, firstName: string | null): string[] {
250
+ const suggestions: string[] = [];
251
+
252
+ // Try first name first (most personal)
253
+ if (firstName) {
254
+ const normalized = normalizeToUsername(firstName);
255
+ if (normalized && normalized.length >= 3) {
256
+ suggestions.push(normalized);
257
+ }
258
+ }
259
+
260
+ // Try $USER environment variable
261
+ const envUser = process.env.USER || process.env.USERNAME;
262
+ if (envUser) {
263
+ const normalized = normalizeToUsername(envUser);
264
+ if (normalized && normalized.length >= 3 && !suggestions.includes(normalized)) {
265
+ suggestions.push(normalized);
266
+ }
267
+ }
268
+
269
+ // Try email local part
270
+ const emailLocal = email.split("@")[0];
271
+ if (emailLocal) {
272
+ const normalized = normalizeToUsername(emailLocal);
273
+ if (normalized && normalized.length >= 3 && !suggestions.includes(normalized)) {
274
+ suggestions.push(normalized);
275
+ }
276
+ }
277
+
278
+ return suggestions.slice(0, 3); // Max 3 suggestions
279
+ }
280
+
281
+ function normalizeToUsername(input: string): string {
282
+ return input
283
+ .toLowerCase()
284
+ .replace(/[^a-z0-9]+/g, "-")
285
+ .replace(/^-+|-+$/g, "")
286
+ .slice(0, 39);
287
+ }
@@ -370,6 +370,48 @@ export async function fetchProjectTags(projectId: string): Promise<string[]> {
370
370
  }
371
371
  }
372
372
 
373
+ export interface RegisterUserRequest {
374
+ email: string;
375
+ first_name?: string | null;
376
+ last_name?: string | null;
377
+ }
378
+
379
+ export interface RegisterUserResponse {
380
+ user: {
381
+ id: string;
382
+ email: string;
383
+ first_name?: string;
384
+ last_name?: string;
385
+ };
386
+ org: {
387
+ id: string;
388
+ workos_org_id: string;
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Register or update user in the control plane after login.
394
+ * This must be called after device auth to create/sync the user in the database.
395
+ */
396
+ export async function registerUser(userInfo: RegisterUserRequest): Promise<RegisterUserResponse> {
397
+ const { authFetch } = await import("./auth/index.ts");
398
+
399
+ const response = await authFetch(`${getControlApiUrl()}/v1/register`, {
400
+ method: "POST",
401
+ headers: { "Content-Type": "application/json" },
402
+ body: JSON.stringify(userInfo),
403
+ });
404
+
405
+ if (!response.ok) {
406
+ const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
407
+ message?: string;
408
+ };
409
+ throw new Error(err.message || `Failed to register user: ${response.status}`);
410
+ }
411
+
412
+ return response.json() as Promise<RegisterUserResponse>;
413
+ }
414
+
373
415
  /**
374
416
  * Check if a username is available on jack cloud.
375
417
  * Does not require authentication.
@@ -24,6 +24,27 @@ export async function isWranglerAvailable(): Promise<boolean> {
24
24
  }
25
25
  }
26
26
 
27
+ /**
28
+ * Ensure wrangler is installed, auto-installing if needed.
29
+ *
30
+ * @param onInstalling - Optional callback when installation starts (for UI feedback)
31
+ * @returns true if wrangler is available, false if installation failed
32
+ */
33
+ export async function ensureWranglerInstalled(onInstalling?: () => void): Promise<boolean> {
34
+ if (await isWranglerAvailable()) {
35
+ return true;
36
+ }
37
+
38
+ // Auto-install wrangler
39
+ onInstalling?.();
40
+ try {
41
+ await $`bun add -g wrangler`.quiet();
42
+ return await isWranglerAvailable();
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
27
48
  /**
28
49
  * Determine deploy mode based on login status and flags.
29
50
  *
@@ -71,14 +92,14 @@ export async function validateModeAvailability(mode: DeployMode): Promise<string
71
92
  }
72
93
  const hasWrangler = await isWranglerAvailable();
73
94
  if (!hasWrangler) {
74
- return "wrangler not found. Install wrangler or use --byo";
95
+ return "wrangler installation failed. Please install manually: bun add -g wrangler";
75
96
  }
76
97
  }
77
98
 
78
99
  if (mode === "byo") {
79
100
  const hasWrangler = await isWranglerAvailable();
80
101
  if (!hasWrangler) {
81
- return "wrangler not found. Install wrangler or run: jack login";
102
+ return "wrangler installation failed. Please install manually: bun add -g wrangler";
82
103
  }
83
104
  }
84
105
 
@@ -84,7 +84,16 @@ export async function uploadDeployment(options: DeployUploadOptions): Promise<De
84
84
  if (!response.ok) {
85
85
  const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
86
86
  message?: string;
87
+ error?: string;
87
88
  };
89
+
90
+ // Provide actionable error for orphaned local links
91
+ if (response.status === 404 && err.error === "not_found") {
92
+ throw new Error(
93
+ "Project not found in jack cloud. The local link may be orphaned.\nFix: jack unlink && jack ship",
94
+ );
95
+ }
96
+
88
97
  throw new Error(err.message || `Upload failed: ${response.status}`);
89
98
  }
90
99
 
package/src/lib/hooks.ts CHANGED
@@ -185,13 +185,21 @@ function isAccountAssociation(value: unknown): boolean {
185
185
  if (!value || typeof value !== "object") return false;
186
186
  // Check direct format: { header, payload, signature }
187
187
  const obj = value as Record<string, unknown>;
188
- if (typeof obj.header === "string" && typeof obj.payload === "string" && typeof obj.signature === "string") {
188
+ if (
189
+ typeof obj.header === "string" &&
190
+ typeof obj.payload === "string" &&
191
+ typeof obj.signature === "string"
192
+ ) {
189
193
  return true;
190
194
  }
191
195
  // Check nested format from Farcaster: { accountAssociation: { header, payload, signature } }
192
196
  if (obj.accountAssociation && typeof obj.accountAssociation === "object") {
193
197
  const inner = obj.accountAssociation as Record<string, unknown>;
194
- return typeof inner.header === "string" && typeof inner.payload === "string" && typeof inner.signature === "string";
198
+ return (
199
+ typeof inner.header === "string" &&
200
+ typeof inner.payload === "string" &&
201
+ typeof inner.signature === "string"
202
+ );
195
203
  }
196
204
  return false;
197
205
  }
@@ -199,17 +207,27 @@ function isAccountAssociation(value: unknown): boolean {
199
207
  /**
200
208
  * Extract the accountAssociation object (handles both nested and flat formats)
201
209
  */
202
- function extractAccountAssociation(value: unknown): { header: string; payload: string; signature: string } | null {
210
+ function extractAccountAssociation(
211
+ value: unknown,
212
+ ): { header: string; payload: string; signature: string } | null {
203
213
  if (!value || typeof value !== "object") return null;
204
214
  const obj = value as Record<string, unknown>;
205
215
  // Direct format
206
- if (typeof obj.header === "string" && typeof obj.payload === "string" && typeof obj.signature === "string") {
216
+ if (
217
+ typeof obj.header === "string" &&
218
+ typeof obj.payload === "string" &&
219
+ typeof obj.signature === "string"
220
+ ) {
207
221
  return { header: obj.header, payload: obj.payload, signature: obj.signature };
208
222
  }
209
223
  // Nested format from Farcaster
210
224
  if (obj.accountAssociation && typeof obj.accountAssociation === "object") {
211
225
  const inner = obj.accountAssociation as Record<string, unknown>;
212
- if (typeof inner.header === "string" && typeof inner.payload === "string" && typeof inner.signature === "string") {
226
+ if (
227
+ typeof inner.header === "string" &&
228
+ typeof inner.payload === "string" &&
229
+ typeof inner.signature === "string"
230
+ ) {
213
231
  return { header: inner.header, payload: inner.payload, signature: inner.signature };
214
232
  }
215
233
  }
@@ -490,10 +508,8 @@ const actionHandlers: {
490
508
  writeJson: async (action, context, options) => {
491
509
  const ui = options.output ?? noopOutput;
492
510
  const targetPath = resolveHookPath(action.path, context);
493
- const ok = await applyJsonWrite(
494
- targetPath,
495
- action.set,
496
- (value) => substituteVars(value, context),
511
+ const ok = await applyJsonWrite(targetPath, action.set, (value) =>
512
+ substituteVars(value, context),
497
513
  );
498
514
  if (!ok) {
499
515
  ui.error(`Invalid JSON file: ${targetPath}`);
@@ -1,10 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
 
3
- export function setJsonPath(
4
- target: Record<string, unknown>,
5
- path: string,
6
- value: unknown,
7
- ): void {
3
+ export function setJsonPath(target: Record<string, unknown>, path: string, value: unknown): void {
8
4
  const keys = path.split(".").filter(Boolean);
9
5
  let current: Record<string, unknown> = target;
10
6