@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.
package/package.json CHANGED
@@ -1,16 +1,13 @@
1
1
  {
2
2
  "name": "@getjack/jack",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Ship before you forget why you started. The vibecoder's deployment CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "jack": "./src/index.ts"
9
9
  },
10
- "files": [
11
- "src",
12
- "templates"
13
- ],
10
+ "files": ["src", "templates"],
14
11
  "engines": {
15
12
  "bun": ">=1.0.0"
16
13
  },
@@ -88,13 +88,10 @@ export default async function down(projectName?: string, flags: DownFlags = {}):
88
88
  info(`Found "${name}" on jack cloud, linking locally...`);
89
89
  }
90
90
 
91
-
92
91
  // Guard against mismatched resolutions when an explicit name is provided
93
92
  if (hasExplicitName && resolved) {
94
93
  const matches =
95
- name === resolved.slug ||
96
- name === resolved.name ||
97
- name === resolved.remote?.projectId;
94
+ name === resolved.slug || name === resolved.name || name === resolved.remote?.projectId;
98
95
  if (!matches) {
99
96
  error(`Refusing to undeploy '${name}' because it resolves to '${resolved.slug}'.`);
100
97
  info("Use the exact slug/name shown by 'jack info' and try again.");
@@ -1,13 +1,4 @@
1
- import { input } from "@inquirer/prompts";
2
- import { type DeviceAuthResponse, pollDeviceToken, startDeviceAuth } from "../lib/auth/client.ts";
3
- import { type AuthCredentials, saveCredentials } from "../lib/auth/store.ts";
4
- import {
5
- checkUsernameAvailable,
6
- getCurrentUserProfile,
7
- setUsername,
8
- } from "../lib/control-plane.ts";
9
- import { celebrate, error, info, spinner, success, warn } from "../lib/output.ts";
10
- import { identifyUser } from "../lib/telemetry.ts";
1
+ import { type LoginFlowOptions, runLoginFlow } from "../lib/auth/login-flow.ts";
11
2
 
12
3
  interface LoginOptions {
13
4
  /** Skip the initial "Logging in..." message (used when called from auto-login) */
@@ -15,197 +6,13 @@ interface LoginOptions {
15
6
  }
16
7
 
17
8
  export default async function login(options: LoginOptions = {}): Promise<void> {
18
- if (!options.silent) {
19
- info("Logging in to jack cloud...");
20
- console.error("");
21
- }
9
+ const flowOptions: LoginFlowOptions = {
10
+ silent: options.silent,
11
+ };
22
12
 
23
- const spin = spinner("Starting login...");
24
- let deviceAuth: DeviceAuthResponse;
13
+ const result = await runLoginFlow(flowOptions);
25
14
 
26
- try {
27
- deviceAuth = await startDeviceAuth();
28
- spin.stop();
29
- } catch (err) {
30
- spin.stop();
31
- error(err instanceof Error ? err.message : "Failed to start login");
15
+ if (!result.success) {
32
16
  process.exit(1);
33
17
  }
34
-
35
- celebrate("Your code:", [deviceAuth.user_code]);
36
- info(`Opening ${deviceAuth.verification_uri} in your browser...`);
37
- console.error("");
38
-
39
- // Open browser - use Bun.spawn for cross-platform
40
- try {
41
- const platform = process.platform;
42
- const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
43
- Bun.spawn([cmd, deviceAuth.verification_uri_complete]);
44
- } catch {
45
- info(`If the browser didn't open, go to: ${deviceAuth.verification_uri_complete}`);
46
- }
47
-
48
- const pollSpin = spinner("Waiting for you to complete login in browser...");
49
- const interval = (deviceAuth.interval || 5) * 1000;
50
- const expiresAt = Date.now() + deviceAuth.expires_in * 1000;
51
-
52
- while (Date.now() < expiresAt) {
53
- await sleep(interval);
54
-
55
- try {
56
- const tokens = await pollDeviceToken(deviceAuth.device_code);
57
-
58
- if (tokens) {
59
- pollSpin.stop();
60
-
61
- // Default to 5 minutes if expires_in not provided
62
- const expiresIn = tokens.expires_in ?? 300;
63
- const creds: AuthCredentials = {
64
- access_token: tokens.access_token,
65
- refresh_token: tokens.refresh_token,
66
- expires_at: Math.floor(Date.now() / 1000) + expiresIn,
67
- user: tokens.user,
68
- };
69
- await saveCredentials(creds);
70
-
71
- // Link user identity for cross-platform analytics
72
- await identifyUser(tokens.user.id, { email: tokens.user.email });
73
-
74
- console.error("");
75
- const displayName = tokens.user.first_name || "Logged in";
76
- success(tokens.user.first_name ? `Welcome back, ${displayName}` : displayName);
77
-
78
- // Prompt for username if not set
79
- await promptForUsername(tokens.user.email);
80
- return;
81
- }
82
- } catch (err) {
83
- pollSpin.stop();
84
- error(err instanceof Error ? err.message : "Login failed");
85
- process.exit(1);
86
- }
87
- }
88
-
89
- pollSpin.stop();
90
- error("Login timed out. Please try again.");
91
- process.exit(1);
92
- }
93
-
94
- function sleep(ms: number): Promise<void> {
95
- return new Promise((resolve) => setTimeout(resolve, ms));
96
18
  }
97
-
98
- async function promptForUsername(email: string): Promise<void> {
99
- // Skip in non-TTY environments
100
- if (!process.stdout.isTTY) {
101
- return;
102
- }
103
-
104
- const spin = spinner("Checking account...");
105
-
106
- try {
107
- const profile = await getCurrentUserProfile();
108
- spin.stop();
109
-
110
- // If user already has a username, skip
111
- if (profile?.username) {
112
- return;
113
- }
114
-
115
- console.error("");
116
- info("Choose a username for your jack cloud account.");
117
- info("URLs will look like: alice-vibes.runjack.xyz");
118
- console.error("");
119
-
120
- // Generate suggestions from $USER env var and email
121
- const suggestions = generateUsernameSuggestions(email);
122
-
123
- let username: string | null = null;
124
-
125
- while (!username) {
126
- // Show suggestions if available
127
- if (suggestions.length > 0) {
128
- info(`Suggestions: ${suggestions.join(", ")}`);
129
- }
130
-
131
- const inputUsername = await input({
132
- message: "Username:",
133
- default: suggestions[0],
134
- validate: (value) => {
135
- if (!value || value.length < 3) {
136
- return "Username must be at least 3 characters";
137
- }
138
- if (value.length > 39) {
139
- return "Username must be 39 characters or less";
140
- }
141
- if (value !== value.toLowerCase()) {
142
- return "Username must be lowercase";
143
- }
144
- if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]{1,2}$/.test(value)) {
145
- return "Use only lowercase letters, numbers, and hyphens";
146
- }
147
- return true;
148
- },
149
- });
150
-
151
- // Check availability
152
- const checkSpin = spinner("Checking availability...");
153
- const availability = await checkUsernameAvailable(inputUsername);
154
- checkSpin.stop();
155
-
156
- if (!availability.available) {
157
- warn(availability.error || `Username "${inputUsername}" is already taken. Try another.`);
158
- continue;
159
- }
160
-
161
- // Try to set the username
162
- const setSpin = spinner("Setting username...");
163
- try {
164
- await setUsername(inputUsername);
165
- setSpin.stop();
166
- username = inputUsername;
167
- success(`Username set to "${username}"`);
168
- } catch (err) {
169
- setSpin.stop();
170
- warn(err instanceof Error ? err.message : "Failed to set username");
171
- }
172
- }
173
- } catch (err) {
174
- spin.stop();
175
- // Non-fatal - user can set username later
176
- warn("Could not set username. You can set it later.");
177
- }
178
- }
179
-
180
- function generateUsernameSuggestions(email: string): string[] {
181
- const suggestions: string[] = [];
182
-
183
- // Try $USER environment variable first
184
- const envUser = process.env.USER || process.env.USERNAME;
185
- if (envUser) {
186
- const normalized = normalizeToUsername(envUser);
187
- if (normalized && normalized.length >= 3) {
188
- suggestions.push(normalized);
189
- }
190
- }
191
-
192
- // Try email local part
193
- const emailLocal = email.split("@")[0];
194
- if (emailLocal) {
195
- const normalized = normalizeToUsername(emailLocal);
196
- if (normalized && normalized.length >= 3 && !suggestions.includes(normalized)) {
197
- suggestions.push(normalized);
198
- }
199
- }
200
-
201
- return suggestions.slice(0, 3); // Max 3 suggestions
202
- }
203
-
204
- function normalizeToUsername(input: string): string {
205
- return input
206
- .toLowerCase()
207
- .replace(/[^a-z0-9]+/g, "-")
208
- .replace(/^-+|-+$/g, "")
209
- .slice(0, 39);
210
- }
211
-
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Unit tests for ensure-auth.ts
3
+ *
4
+ * Tests the auth gate decision tree for project creation.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
8
+
9
+ // Mock modules before importing the module under test
10
+ const mockIsLoggedIn = mock(() => Promise.resolve(false));
11
+ const mockHasWrangler = mock(() => Promise.resolve(false));
12
+ const mockIsAuthenticated = mock(() => Promise.resolve(false));
13
+ const mockRunLoginFlow = mock(() => Promise.resolve({ success: true }));
14
+ const mockPromptSelect = mock(() => Promise.resolve(0));
15
+ const mockTrack = mock(() => {});
16
+
17
+ mock.module("./store.ts", () => ({
18
+ isLoggedIn: mockIsLoggedIn,
19
+ }));
20
+
21
+ mock.module("../wrangler.ts", () => ({
22
+ hasWrangler: mockHasWrangler,
23
+ isAuthenticated: mockIsAuthenticated,
24
+ }));
25
+
26
+ mock.module("./login-flow.ts", () => ({
27
+ runLoginFlow: mockRunLoginFlow,
28
+ }));
29
+
30
+ mock.module("../hooks.ts", () => ({
31
+ promptSelect: mockPromptSelect,
32
+ }));
33
+
34
+ mock.module("../telemetry.ts", () => ({
35
+ Events: { AUTH_GATE_RESOLVED: "auth_gate_resolved" },
36
+ track: mockTrack,
37
+ }));
38
+
39
+ // Import after mocking
40
+ import { ensureAuthForCreate } from "./ensure-auth.ts";
41
+
42
+ // ============================================================================
43
+ // Tests
44
+ // ============================================================================
45
+
46
+ describe("ensureAuthForCreate", () => {
47
+ beforeEach(() => {
48
+ // Reset all mocks to default state
49
+ mockIsLoggedIn.mockReset();
50
+ mockHasWrangler.mockReset();
51
+ mockIsAuthenticated.mockReset();
52
+ mockRunLoginFlow.mockReset();
53
+ mockPromptSelect.mockReset();
54
+ mockTrack.mockReset();
55
+
56
+ // Set default return values
57
+ mockIsLoggedIn.mockResolvedValue(false);
58
+ mockHasWrangler.mockResolvedValue(false);
59
+ mockIsAuthenticated.mockResolvedValue(false);
60
+ mockRunLoginFlow.mockResolvedValue({ success: true });
61
+ mockPromptSelect.mockResolvedValue(0);
62
+
63
+ // Suppress console.error during tests
64
+ spyOn(console, "error").mockImplementation(() => {});
65
+ });
66
+
67
+ afterEach(() => {
68
+ mock.restore();
69
+ });
70
+
71
+ // ==========================================================================
72
+ // PRD Test Scenarios
73
+ // ==========================================================================
74
+
75
+ describe("PRD Decision Tree", () => {
76
+ it("proceeds immediately if already logged into jack cloud", async () => {
77
+ // Scenario: Existing jack cloud user
78
+ mockIsLoggedIn.mockResolvedValue(true);
79
+
80
+ const result = await ensureAuthForCreate();
81
+
82
+ expect(result.mode).toBe("managed");
83
+ expect(result.didLogin).toBe(false);
84
+ expect(mockRunLoginFlow).not.toHaveBeenCalled();
85
+ expect(mockPromptSelect).not.toHaveBeenCalled();
86
+ expect(mockTrack).toHaveBeenCalledWith("auth_gate_resolved", {
87
+ mode: "managed",
88
+ reason: "already_logged_in",
89
+ });
90
+ });
91
+
92
+ it("proceeds with managed mode if logged in, even with wrangler available", async () => {
93
+ // Scenario: Existing jack cloud user + wrangler
94
+ mockIsLoggedIn.mockResolvedValue(true);
95
+ mockHasWrangler.mockResolvedValue(true);
96
+ mockIsAuthenticated.mockResolvedValue(true);
97
+
98
+ const result = await ensureAuthForCreate();
99
+
100
+ expect(result.mode).toBe("managed");
101
+ expect(result.didLogin).toBe(false);
102
+ expect(mockPromptSelect).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it("prompts for choice when wrangler + CF auth available", async () => {
106
+ // Scenario: Fresh user, wrangler + CF auth
107
+ mockIsLoggedIn.mockResolvedValue(false);
108
+ mockHasWrangler.mockResolvedValue(true);
109
+ mockIsAuthenticated.mockResolvedValue(true);
110
+ mockPromptSelect.mockResolvedValue(0); // User chooses jack cloud
111
+
112
+ const result = await ensureAuthForCreate({ interactive: true });
113
+
114
+ expect(mockPromptSelect).toHaveBeenCalled();
115
+ expect(result.mode).toBe("managed");
116
+ expect(result.didLogin).toBe(true);
117
+ expect(mockRunLoginFlow).toHaveBeenCalled();
118
+ });
119
+
120
+ it("returns BYO mode when user chooses their Cloudflare account", async () => {
121
+ // Scenario: Fresh user with wrangler + CF auth, chooses BYO
122
+ mockIsLoggedIn.mockResolvedValue(false);
123
+ mockHasWrangler.mockResolvedValue(true);
124
+ mockIsAuthenticated.mockResolvedValue(true);
125
+ mockPromptSelect.mockResolvedValue(1); // User chooses BYO
126
+
127
+ const result = await ensureAuthForCreate({ interactive: true });
128
+
129
+ expect(result.mode).toBe("byo");
130
+ expect(result.didLogin).toBe(false);
131
+ expect(mockRunLoginFlow).not.toHaveBeenCalled();
132
+ expect(mockTrack).toHaveBeenCalledWith("auth_gate_resolved", {
133
+ mode: "byo",
134
+ reason: "user_chose_byo",
135
+ });
136
+ });
137
+
138
+ it("auto-starts jack cloud login when no wrangler installed", async () => {
139
+ // Scenario: Fresh user, no wrangler
140
+ mockIsLoggedIn.mockResolvedValue(false);
141
+ mockHasWrangler.mockResolvedValue(false);
142
+
143
+ const result = await ensureAuthForCreate({ interactive: true });
144
+
145
+ expect(result.mode).toBe("managed");
146
+ expect(result.didLogin).toBe(true);
147
+ expect(mockRunLoginFlow).toHaveBeenCalled();
148
+ expect(mockPromptSelect).not.toHaveBeenCalled();
149
+ expect(mockTrack).toHaveBeenCalledWith("auth_gate_resolved", {
150
+ mode: "managed",
151
+ reason: "auto_login_no_wrangler",
152
+ });
153
+ });
154
+
155
+ it("auto-starts jack cloud login when wrangler installed but not authenticated", async () => {
156
+ // Scenario: Fresh user, wrangler installed but not logged in
157
+ mockIsLoggedIn.mockResolvedValue(false);
158
+ mockHasWrangler.mockResolvedValue(true);
159
+ mockIsAuthenticated.mockResolvedValue(false);
160
+
161
+ const result = await ensureAuthForCreate({ interactive: true });
162
+
163
+ expect(result.mode).toBe("managed");
164
+ expect(result.didLogin).toBe(true);
165
+ expect(mockRunLoginFlow).toHaveBeenCalled();
166
+ expect(mockPromptSelect).not.toHaveBeenCalled();
167
+ expect(mockTrack).toHaveBeenCalledWith("auth_gate_resolved", {
168
+ mode: "managed",
169
+ reason: "auto_login_no_cf_auth",
170
+ });
171
+ });
172
+ });
173
+
174
+ // ==========================================================================
175
+ // Force Flags
176
+ // ==========================================================================
177
+
178
+ describe("Force flags", () => {
179
+ it("respects forceByo flag without checking auth", async () => {
180
+ mockIsLoggedIn.mockResolvedValue(true); // Even if logged in
181
+
182
+ const result = await ensureAuthForCreate({ forceByo: true });
183
+
184
+ expect(result.mode).toBe("byo");
185
+ expect(result.didLogin).toBe(false);
186
+ expect(mockTrack).toHaveBeenCalledWith("auth_gate_resolved", {
187
+ mode: "byo",
188
+ reason: "forced_byo",
189
+ });
190
+ });
191
+
192
+ it("respects forceManaged flag and triggers login if needed", async () => {
193
+ mockIsLoggedIn.mockResolvedValue(false);
194
+
195
+ const result = await ensureAuthForCreate({ forceManaged: true });
196
+
197
+ expect(result.mode).toBe("managed");
198
+ expect(result.didLogin).toBe(true);
199
+ expect(mockRunLoginFlow).toHaveBeenCalled();
200
+ });
201
+
202
+ it("respects forceManaged flag without login if already logged in", async () => {
203
+ mockIsLoggedIn.mockResolvedValue(true);
204
+
205
+ const result = await ensureAuthForCreate({ forceManaged: true });
206
+
207
+ expect(result.mode).toBe("managed");
208
+ expect(result.didLogin).toBe(false);
209
+ expect(mockRunLoginFlow).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it("throws error when both forceManaged and forceByo are set", async () => {
213
+ await expect(
214
+ ensureAuthForCreate({ forceManaged: true, forceByo: true }),
215
+ ).rejects.toThrow("Cannot use both --managed and --byo flags");
216
+ });
217
+ });
218
+
219
+ // ==========================================================================
220
+ // Non-interactive mode
221
+ // ==========================================================================
222
+
223
+ describe("Non-interactive mode", () => {
224
+ it("uses BYO mode when wrangler + CF auth available in non-interactive mode", async () => {
225
+ mockIsLoggedIn.mockResolvedValue(false);
226
+ mockHasWrangler.mockResolvedValue(true);
227
+ mockIsAuthenticated.mockResolvedValue(true);
228
+
229
+ const result = await ensureAuthForCreate({ interactive: false });
230
+
231
+ expect(result.mode).toBe("byo");
232
+ expect(result.didLogin).toBe(false);
233
+ expect(mockPromptSelect).not.toHaveBeenCalled();
234
+ expect(mockTrack).toHaveBeenCalledWith("auth_gate_resolved", {
235
+ mode: "byo",
236
+ reason: "non_interactive_fallback",
237
+ });
238
+ });
239
+
240
+ it("throws error in non-interactive mode when no auth available", async () => {
241
+ mockIsLoggedIn.mockResolvedValue(false);
242
+ mockHasWrangler.mockResolvedValue(false);
243
+
244
+ await expect(ensureAuthForCreate({ interactive: false })).rejects.toThrow(
245
+ "Not logged in and wrangler not authenticated",
246
+ );
247
+ });
248
+
249
+ it("proceeds with managed mode in non-interactive if already logged in", async () => {
250
+ mockIsLoggedIn.mockResolvedValue(true);
251
+
252
+ const result = await ensureAuthForCreate({ interactive: false });
253
+
254
+ expect(result.mode).toBe("managed");
255
+ expect(result.didLogin).toBe(false);
256
+ });
257
+ });
258
+
259
+ // ==========================================================================
260
+ // Edge cases
261
+ // ==========================================================================
262
+
263
+ describe("Edge cases", () => {
264
+ it("handles Esc key during prompt (defaults to jack cloud login)", async () => {
265
+ mockIsLoggedIn.mockResolvedValue(false);
266
+ mockHasWrangler.mockResolvedValue(true);
267
+ mockIsAuthenticated.mockResolvedValue(true);
268
+ mockPromptSelect.mockResolvedValue(-1); // Esc key
269
+
270
+ const result = await ensureAuthForCreate({ interactive: true });
271
+
272
+ expect(result.mode).toBe("managed");
273
+ expect(result.didLogin).toBe(true);
274
+ expect(mockRunLoginFlow).toHaveBeenCalled();
275
+ });
276
+
277
+ it("throws error when login flow fails", async () => {
278
+ mockIsLoggedIn.mockResolvedValue(false);
279
+ mockHasWrangler.mockResolvedValue(false);
280
+ mockRunLoginFlow.mockResolvedValue({ success: false });
281
+
282
+ await expect(ensureAuthForCreate({ interactive: true })).rejects.toThrow("Login failed");
283
+ });
284
+ });
285
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Auth gate for project creation
3
+ *
4
+ * Implements the decision tree from PRD-LOGIN-PROMPT-ON-NEW.md:
5
+ * 1. Jack cloud logged in -> return 'managed', no prompt
6
+ * 2. Wrangler installed + CF authenticated -> offer choice via promptSelect
7
+ * 3. Otherwise -> auto-start jack cloud login
8
+ */
9
+
10
+ import type { DeployMode } from "../project-link.ts";
11
+ import { Events, track } from "../telemetry.ts";
12
+ import { hasWrangler, isAuthenticated } from "../wrangler.ts";
13
+ import { isLoggedIn } from "./store.ts";
14
+
15
+ export interface EnsureAuthResult {
16
+ mode: DeployMode;
17
+ didLogin: boolean;
18
+ }
19
+
20
+ export interface EnsureAuthOptions {
21
+ interactive?: boolean;
22
+ forceManaged?: boolean;
23
+ forceByo?: boolean;
24
+ }
25
+
26
+ type AuthGateReason =
27
+ | "already_logged_in"
28
+ | "forced_managed"
29
+ | "forced_byo"
30
+ | "user_chose_managed"
31
+ | "user_chose_byo"
32
+ | "auto_login_no_wrangler"
33
+ | "auto_login_no_cf_auth"
34
+ | "non_interactive_fallback";
35
+
36
+ /**
37
+ * Ensure authentication is in place before project creation.
38
+ *
39
+ * Decision tree:
40
+ * 1. If forceManaged or forceByo flags are set, respect them
41
+ * 2. If already logged into jack cloud -> return 'managed'
42
+ * 3. If wrangler installed AND authenticated to Cloudflare -> offer choice
43
+ * 4. Otherwise -> auto-start jack cloud login
44
+ */
45
+ export async function ensureAuthForCreate(
46
+ options: EnsureAuthOptions = {},
47
+ ): Promise<EnsureAuthResult> {
48
+ const { interactive = true, forceManaged, forceByo } = options;
49
+
50
+ // Handle explicit flags first
51
+ if (forceManaged && forceByo) {
52
+ throw new Error("Cannot use both --managed and --byo flags. Choose one.");
53
+ }
54
+
55
+ if (forceByo) {
56
+ track(Events.AUTH_GATE_RESOLVED, { mode: "byo", reason: "forced_byo" as AuthGateReason });
57
+ return { mode: "byo", didLogin: false };
58
+ }
59
+
60
+ if (forceManaged) {
61
+ // Need to ensure logged in for managed mode
62
+ const loggedIn = await isLoggedIn();
63
+ if (loggedIn) {
64
+ track(Events.AUTH_GATE_RESOLVED, {
65
+ mode: "managed",
66
+ reason: "forced_managed" as AuthGateReason,
67
+ });
68
+ return { mode: "managed", didLogin: false };
69
+ }
70
+ // Force managed but not logged in - run login flow
71
+ await runLoginFlow();
72
+ track(Events.AUTH_GATE_RESOLVED, {
73
+ mode: "managed",
74
+ reason: "forced_managed" as AuthGateReason,
75
+ });
76
+ return { mode: "managed", didLogin: true };
77
+ }
78
+
79
+ // Step 1: Check if already logged into jack cloud
80
+ const loggedIn = await isLoggedIn();
81
+ if (loggedIn) {
82
+ track(Events.AUTH_GATE_RESOLVED, {
83
+ mode: "managed",
84
+ reason: "already_logged_in" as AuthGateReason,
85
+ });
86
+ return { mode: "managed", didLogin: false };
87
+ }
88
+
89
+ // Step 2: Check if wrangler is installed AND authenticated to Cloudflare
90
+ const wranglerInstalled = await hasWrangler();
91
+ const cfAuthenticated = wranglerInstalled && (await isAuthenticated());
92
+
93
+ if (cfAuthenticated && interactive) {
94
+ // Offer choice between jack cloud and BYO
95
+ const { promptSelect } = await import("../hooks.ts");
96
+
97
+ console.error("");
98
+ console.error(" How do you want to deploy?");
99
+ console.error("");
100
+
101
+ const choice = await promptSelect([
102
+ "Jack Cloud (recommended) - instant deploys, no setup",
103
+ "My Cloudflare account - use existing wrangler auth",
104
+ ]);
105
+
106
+ if (choice === 0) {
107
+ // User chose jack cloud - start login
108
+ await runLoginFlow();
109
+ track(Events.AUTH_GATE_RESOLVED, {
110
+ mode: "managed",
111
+ reason: "user_chose_managed" as AuthGateReason,
112
+ });
113
+ return { mode: "managed", didLogin: true };
114
+ }
115
+ if (choice === 1) {
116
+ // User chose BYO
117
+ track(Events.AUTH_GATE_RESOLVED, { mode: "byo", reason: "user_chose_byo" as AuthGateReason });
118
+ return { mode: "byo", didLogin: false };
119
+ }
120
+ // User pressed Esc - default to jack cloud login
121
+ await runLoginFlow();
122
+ track(Events.AUTH_GATE_RESOLVED, {
123
+ mode: "managed",
124
+ reason: "user_chose_managed" as AuthGateReason,
125
+ });
126
+ return { mode: "managed", didLogin: true };
127
+ }
128
+
129
+ // Non-interactive mode with wrangler available - use BYO
130
+ if (cfAuthenticated && !interactive) {
131
+ track(Events.AUTH_GATE_RESOLVED, {
132
+ mode: "byo",
133
+ reason: "non_interactive_fallback" as AuthGateReason,
134
+ });
135
+ return { mode: "byo", didLogin: false };
136
+ }
137
+
138
+ // Step 3: No viable BYO path - auto-start jack cloud login
139
+ const reason: AuthGateReason = !wranglerInstalled
140
+ ? "auto_login_no_wrangler"
141
+ : "auto_login_no_cf_auth";
142
+
143
+ if (!interactive) {
144
+ // Non-interactive and no auth available - this is an error condition
145
+ throw new Error(
146
+ "Not logged in and wrangler not authenticated. Run 'jack login' or 'wrangler login' first.",
147
+ );
148
+ }
149
+
150
+ // Auto-start login (no prompt - there's only one viable path)
151
+ await runLoginFlow();
152
+ track(Events.AUTH_GATE_RESOLVED, { mode: "managed", reason });
153
+ return { mode: "managed", didLogin: true };
154
+ }
155
+
156
+ /**
157
+ * Run the login flow (dynamic import to avoid circular dependency)
158
+ */
159
+ async function runLoginFlow(): Promise<void> {
160
+ const { runLoginFlow: doLogin } = await import("./login-flow.ts");
161
+ const result = await doLogin({ silent: false });
162
+ if (!result.success) {
163
+ throw new Error("Login failed");
164
+ }
165
+ }