@getjack/jack 0.1.12 → 0.1.13

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@getjack/jack",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
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",
@@ -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
+ }
@@ -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.
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
 
@@ -42,7 +42,7 @@ import {
42
42
  import { getSyncConfig } from "./config.ts";
43
43
  import { deleteManagedProject } from "./control-plane.ts";
44
44
  import { debug, isDebug } from "./debug.ts";
45
- import { resolveDeployMode, validateModeAvailability } from "./deploy-mode.ts";
45
+ import { validateModeAvailability } from "./deploy-mode.ts";
46
46
  import { detectSecrets, generateEnvFile, generateSecretsJson } from "./env-parser.ts";
47
47
  import { JackError, JackErrorCode } from "./errors.ts";
48
48
  import { type HookOutput, runHook } from "./hooks.ts";
@@ -587,15 +587,16 @@ export async function createProject(
587
587
  throw new JackError(JackErrorCode.VALIDATION_ERROR, "jack is not set up yet", "Run: jack init");
588
588
  }
589
589
 
590
- // Resolve deploy mode (omakase: logged in => managed, logged out => BYO)
591
- const deployMode = await resolveDeployMode({
592
- managed: options.managed,
593
- byo: options.byo,
590
+ // Auth gate - check/prompt for authentication before any work
591
+ const { ensureAuthForCreate } = await import("./auth/ensure-auth.ts");
592
+ const authResult = await ensureAuthForCreate({
593
+ interactive,
594
+ forceManaged: options.managed,
595
+ forceByo: options.byo,
594
596
  });
595
- const modeError = await validateModeAvailability(deployMode);
596
- if (modeError) {
597
- throw new JackError(JackErrorCode.VALIDATION_ERROR, modeError);
598
- }
597
+
598
+ // Use authResult.mode (auth gate handles mode resolution)
599
+ const deployMode = authResult.mode;
599
600
 
600
601
  // Close the "Starting..." spinner from new.ts
601
602
  reporter.stop();
@@ -12,6 +12,7 @@ const SESSION_ID = crypto.randomUUID();
12
12
  // EVENT REGISTRY
13
13
  // ============================================
14
14
  export const Events = {
15
+ AUTH_GATE_RESOLVED: "auth_gate_resolved",
15
16
  COMMAND_INVOKED: "command_invoked",
16
17
  COMMAND_COMPLETED: "command_completed",
17
18
  COMMAND_FAILED: "command_failed",
@@ -1,17 +1,17 @@
1
1
  {
2
- "accountAssociation": {
3
- "header": "",
4
- "payload": "",
5
- "signature": ""
6
- },
7
- "miniapp": {
8
- "version": "1",
9
- "name": "jack-template",
10
- "iconUrl": "https://example.com/icon.png",
11
- "homeUrl": "https://example.com",
12
- "imageUrl": "https://example.com/og.png",
13
- "buttonTitle": "Open App",
14
- "splashImageUrl": "https://example.com/icon.png",
15
- "splashBackgroundColor": "#0a0a0a"
16
- }
2
+ "accountAssociation": {
3
+ "header": "",
4
+ "payload": "",
5
+ "signature": ""
6
+ },
7
+ "miniapp": {
8
+ "version": "1",
9
+ "name": "jack-template",
10
+ "iconUrl": "https://example.com/icon.png",
11
+ "homeUrl": "https://example.com",
12
+ "imageUrl": "https://example.com/og.png",
13
+ "buttonTitle": "Open App",
14
+ "splashImageUrl": "https://example.com/icon.png",
15
+ "splashBackgroundColor": "#0a0a0a"
16
+ }
17
17
  }
@@ -53,11 +53,7 @@ function getBaseUrl(
53
53
  }
54
54
 
55
55
  // Reject localhost or IPs - embeds won't work in local dev or IP domains
56
- if (
57
- hostname === "localhost" ||
58
- hostname === "127.0.0.1" ||
59
- isIpAddress(hostname)
60
- ) {
56
+ if (hostname === "localhost" || hostname === "127.0.0.1" || isIpAddress(hostname)) {
61
57
  return null; // Signal that we can't generate valid embed URLs
62
58
  }
63
59