@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 +2 -5
- package/src/commands/down.ts +1 -4
- package/src/commands/login.ts +6 -199
- package/src/lib/auth/ensure-auth.test.ts +285 -0
- package/src/lib/auth/ensure-auth.ts +165 -0
- package/src/lib/auth/index.ts +10 -0
- package/src/lib/auth/login-flow.ts +287 -0
- package/src/lib/control-plane.ts +42 -0
- package/src/lib/deploy-mode.ts +23 -2
- package/src/lib/deploy-upload.ts +9 -0
- package/src/lib/hooks.ts +25 -9
- package/src/lib/json-edit.ts +1 -5
- package/src/lib/project-operations.ts +391 -310
- package/src/lib/project-resolver.ts +12 -0
- package/src/lib/telemetry.ts +1 -0
- package/templates/miniapp/.jack.json +5 -5
- package/templates/miniapp/public/.well-known/farcaster.json +15 -15
- package/templates/miniapp/src/lib/api.ts +71 -3
- package/templates/miniapp/src/worker.ts +45 -5
package/package.json
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getjack/jack",
|
|
3
|
-
"version": "0.1.
|
|
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
|
},
|
package/src/commands/down.ts
CHANGED
|
@@ -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.");
|
package/src/commands/login.ts
CHANGED
|
@@ -1,13 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
9
|
+
const flowOptions: LoginFlowOptions = {
|
|
10
|
+
silent: options.silent,
|
|
11
|
+
};
|
|
22
12
|
|
|
23
|
-
const
|
|
24
|
-
let deviceAuth: DeviceAuthResponse;
|
|
13
|
+
const result = await runLoginFlow(flowOptions);
|
|
25
14
|
|
|
26
|
-
|
|
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
|
+
}
|