@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 +1 -1
- 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/hooks.ts +25 -9
- package/src/lib/json-edit.ts +1 -5
- package/src/lib/project-operations.ts +10 -9
- package/src/lib/telemetry.ts +1 -0
- package/templates/miniapp/public/.well-known/farcaster.json +15 -15
- package/templates/miniapp/src/worker.ts +1 -5
package/package.json
CHANGED
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
|
+
}
|
package/src/lib/auth/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/control-plane.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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(
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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}`);
|
package/src/lib/json-edit.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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();
|
package/src/lib/telemetry.ts
CHANGED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|