@getjack/jack 0.1.5 → 0.1.6
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
package/src/commands/login.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import { input } from "@inquirer/prompts";
|
|
1
2
|
import { type DeviceAuthResponse, pollDeviceToken, startDeviceAuth } from "../lib/auth/client.ts";
|
|
2
3
|
import { type AuthCredentials, saveCredentials } from "../lib/auth/store.ts";
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
checkUsernameAvailable,
|
|
6
|
+
getCurrentUserProfile,
|
|
7
|
+
setUsername,
|
|
8
|
+
} from "../lib/control-plane.ts";
|
|
9
|
+
import { error, info, spinner, success, warn } from "../lib/output.ts";
|
|
4
10
|
|
|
5
11
|
interface LoginOptions {
|
|
6
12
|
/** Skip the initial "Logging in..." message (used when called from auto-login) */
|
|
@@ -69,6 +75,9 @@ export default async function login(options: LoginOptions = {}): Promise<void> {
|
|
|
69
75
|
|
|
70
76
|
console.error("");
|
|
71
77
|
success(`Logged in as ${tokens.user.email}`);
|
|
78
|
+
|
|
79
|
+
// Prompt for username if not set
|
|
80
|
+
await promptForUsername(tokens.user.email);
|
|
72
81
|
return;
|
|
73
82
|
}
|
|
74
83
|
} catch (err) {
|
|
@@ -86,3 +95,117 @@ export default async function login(options: LoginOptions = {}): Promise<void> {
|
|
|
86
95
|
function sleep(ms: number): Promise<void> {
|
|
87
96
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
88
97
|
}
|
|
98
|
+
|
|
99
|
+
async function promptForUsername(email: string): Promise<void> {
|
|
100
|
+
// Skip in non-TTY environments
|
|
101
|
+
if (!process.stdout.isTTY) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const spin = spinner("Checking account...");
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const profile = await getCurrentUserProfile();
|
|
109
|
+
spin.stop();
|
|
110
|
+
|
|
111
|
+
// If user already has a username, skip
|
|
112
|
+
if (profile?.username) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.error("");
|
|
117
|
+
info("Choose a username for your jack cloud account.");
|
|
118
|
+
info("URLs will look like: alice-vibes.runjack.xyz");
|
|
119
|
+
console.error("");
|
|
120
|
+
|
|
121
|
+
// Generate suggestions from $USER env var and email
|
|
122
|
+
const suggestions = generateUsernameSuggestions(email);
|
|
123
|
+
|
|
124
|
+
let username: string | null = null;
|
|
125
|
+
|
|
126
|
+
while (!username) {
|
|
127
|
+
// Show suggestions if available
|
|
128
|
+
if (suggestions.length > 0) {
|
|
129
|
+
info(`Suggestions: ${suggestions.join(", ")}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const inputUsername = await input({
|
|
133
|
+
message: "Username:",
|
|
134
|
+
default: suggestions[0],
|
|
135
|
+
validate: (value) => {
|
|
136
|
+
if (!value || value.length < 3) {
|
|
137
|
+
return "Username must be at least 3 characters";
|
|
138
|
+
}
|
|
139
|
+
if (value.length > 39) {
|
|
140
|
+
return "Username must be 39 characters or less";
|
|
141
|
+
}
|
|
142
|
+
if (value !== value.toLowerCase()) {
|
|
143
|
+
return "Username must be lowercase";
|
|
144
|
+
}
|
|
145
|
+
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]{1,2}$/.test(value)) {
|
|
146
|
+
return "Use only lowercase letters, numbers, and hyphens";
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Check availability
|
|
153
|
+
const checkSpin = spinner("Checking availability...");
|
|
154
|
+
const availability = await checkUsernameAvailable(inputUsername);
|
|
155
|
+
checkSpin.stop();
|
|
156
|
+
|
|
157
|
+
if (!availability.available) {
|
|
158
|
+
warn(availability.error || `Username "${inputUsername}" is already taken. Try another.`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Try to set the username
|
|
163
|
+
const setSpin = spinner("Setting username...");
|
|
164
|
+
try {
|
|
165
|
+
await setUsername(inputUsername);
|
|
166
|
+
setSpin.stop();
|
|
167
|
+
username = inputUsername;
|
|
168
|
+
success(`Username set to "${username}"`);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
setSpin.stop();
|
|
171
|
+
warn(err instanceof Error ? err.message : "Failed to set username");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
} catch (err) {
|
|
175
|
+
spin.stop();
|
|
176
|
+
// Non-fatal - user can set username later
|
|
177
|
+
warn("Could not set username. You can set it later.");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function generateUsernameSuggestions(email: string): string[] {
|
|
182
|
+
const suggestions: string[] = [];
|
|
183
|
+
|
|
184
|
+
// Try $USER environment variable first
|
|
185
|
+
const envUser = process.env.USER || process.env.USERNAME;
|
|
186
|
+
if (envUser) {
|
|
187
|
+
const normalized = normalizeToUsername(envUser);
|
|
188
|
+
if (normalized && normalized.length >= 3) {
|
|
189
|
+
suggestions.push(normalized);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Try email local part
|
|
194
|
+
const emailLocal = email.split("@")[0];
|
|
195
|
+
if (emailLocal) {
|
|
196
|
+
const normalized = normalizeToUsername(emailLocal);
|
|
197
|
+
if (normalized && normalized.length >= 3 && !suggestions.includes(normalized)) {
|
|
198
|
+
suggestions.push(normalized);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return suggestions.slice(0, 3); // Max 3 suggestions
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function normalizeToUsername(input: string): string {
|
|
206
|
+
return input
|
|
207
|
+
.toLowerCase()
|
|
208
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
209
|
+
.replace(/^-+|-+$/g, "")
|
|
210
|
+
.slice(0, 39);
|
|
211
|
+
}
|
package/src/commands/projects.ts
CHANGED
|
@@ -183,7 +183,11 @@ function renderGroupedView(items: ProjectListItem[]): void {
|
|
|
183
183
|
|
|
184
184
|
console.error("");
|
|
185
185
|
console.error(
|
|
186
|
-
formatCloudSection(sorted, {
|
|
186
|
+
formatCloudSection(sorted, {
|
|
187
|
+
limit: CLOUD_LIMIT,
|
|
188
|
+
total: groups.cloudOnly.length,
|
|
189
|
+
tagColorMap,
|
|
190
|
+
}),
|
|
187
191
|
);
|
|
188
192
|
}
|
|
189
193
|
|
package/src/lib/control-plane.ts
CHANGED
|
@@ -49,6 +49,27 @@ export interface SlugAvailabilityResponse {
|
|
|
49
49
|
error?: string;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export interface UsernameAvailabilityResponse {
|
|
53
|
+
available: boolean;
|
|
54
|
+
username: string;
|
|
55
|
+
error?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SetUsernameResponse {
|
|
59
|
+
success: boolean;
|
|
60
|
+
username: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface UserProfile {
|
|
64
|
+
id: string;
|
|
65
|
+
email: string;
|
|
66
|
+
first_name: string | null;
|
|
67
|
+
last_name: string | null;
|
|
68
|
+
username: string | null;
|
|
69
|
+
created_at: string;
|
|
70
|
+
updated_at: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
52
73
|
export interface CreateDeploymentRequest {
|
|
53
74
|
source: string;
|
|
54
75
|
}
|
|
@@ -341,3 +362,71 @@ export async function fetchProjectTags(projectId: string): Promise<string[]> {
|
|
|
341
362
|
return [];
|
|
342
363
|
}
|
|
343
364
|
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Check if a username is available on jack cloud.
|
|
368
|
+
* Does not require authentication.
|
|
369
|
+
*/
|
|
370
|
+
export async function checkUsernameAvailable(
|
|
371
|
+
username: string,
|
|
372
|
+
): Promise<UsernameAvailabilityResponse> {
|
|
373
|
+
const response = await fetch(
|
|
374
|
+
`${getControlApiUrl()}/v1/usernames/${encodeURIComponent(username)}/available`,
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
if (!response.ok) {
|
|
378
|
+
throw new Error(`Failed to check username availability: ${response.status}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return response.json() as Promise<UsernameAvailabilityResponse>;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Set the current user's username.
|
|
386
|
+
* Can only be called once per user.
|
|
387
|
+
*/
|
|
388
|
+
export async function setUsername(username: string): Promise<SetUsernameResponse> {
|
|
389
|
+
const { authFetch } = await import("./auth/index.ts");
|
|
390
|
+
|
|
391
|
+
const response = await authFetch(`${getControlApiUrl()}/v1/me/username`, {
|
|
392
|
+
method: "PUT",
|
|
393
|
+
headers: { "Content-Type": "application/json" },
|
|
394
|
+
body: JSON.stringify({ username }),
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
if (response.status === 409) {
|
|
398
|
+
const err = (await response.json().catch(() => ({ message: "Username taken" }))) as {
|
|
399
|
+
message?: string;
|
|
400
|
+
};
|
|
401
|
+
throw new Error(err.message || "Username is already taken");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!response.ok) {
|
|
405
|
+
const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
|
|
406
|
+
message?: string;
|
|
407
|
+
};
|
|
408
|
+
throw new Error(err.message || `Failed to set username: ${response.status}`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return response.json() as Promise<SetUsernameResponse>;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Get the current user's profile including username.
|
|
416
|
+
*/
|
|
417
|
+
export async function getCurrentUserProfile(): Promise<UserProfile | null> {
|
|
418
|
+
const { authFetch } = await import("./auth/index.ts");
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
const response = await authFetch(`${getControlApiUrl()}/v1/me`);
|
|
422
|
+
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const data = (await response.json()) as { user: UserProfile };
|
|
428
|
+
return data.user;
|
|
429
|
+
} catch {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
}
|
package/src/lib/project-list.ts
CHANGED
|
@@ -307,7 +307,11 @@ export function formatProjectLine(item: ProjectListItem, options: FormatLineOpti
|
|
|
307
307
|
let url = "";
|
|
308
308
|
if (showUrl && item.url) {
|
|
309
309
|
url = item.url.replace("https://", "");
|
|
310
|
-
} else if (
|
|
310
|
+
} else if (
|
|
311
|
+
showUrl &&
|
|
312
|
+
(item.status === "error" || item.status === "auth-expired") &&
|
|
313
|
+
item.errorMessage
|
|
314
|
+
) {
|
|
311
315
|
url = `${colors.dim}${item.errorMessage}${colors.reset}`;
|
|
312
316
|
}
|
|
313
317
|
|
package/src/lib/telemetry.ts
CHANGED
|
@@ -69,7 +69,11 @@ function send(url: string, data: object): void {
|
|
|
69
69
|
async function isEnabled(): Promise<boolean> {
|
|
70
70
|
if (enabledCache !== null) return enabledCache;
|
|
71
71
|
|
|
72
|
-
if (
|
|
72
|
+
if (
|
|
73
|
+
process.env.DO_NOT_TRACK === "1" ||
|
|
74
|
+
process.env.CI === "true" ||
|
|
75
|
+
process.env.JACK_TELEMETRY_DISABLED === "1"
|
|
76
|
+
) {
|
|
73
77
|
enabledCache = false;
|
|
74
78
|
return false;
|
|
75
79
|
}
|
|
@@ -110,7 +114,10 @@ export interface UserProperties {
|
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
// Detect environment properties
|
|
113
|
-
export function getEnvironmentProps(): Pick<
|
|
117
|
+
export function getEnvironmentProps(): Pick<
|
|
118
|
+
UserProperties,
|
|
119
|
+
"shell" | "terminal" | "terminal_width" | "is_tty" | "locale"
|
|
120
|
+
> {
|
|
114
121
|
return {
|
|
115
122
|
shell: process.env.SHELL?.split("/").pop(), // e.g., /bin/zsh -> zsh
|
|
116
123
|
terminal: process.env.TERM_PROGRAM, // e.g., iTerm.app, vscode, Apple_Terminal
|
|
@@ -180,10 +187,19 @@ export function withTelemetry<T extends (...args: any[]) => Promise<any>>(
|
|
|
180
187
|
|
|
181
188
|
try {
|
|
182
189
|
const result = await fn(...args);
|
|
183
|
-
track(Events.COMMAND_COMPLETED, {
|
|
190
|
+
track(Events.COMMAND_COMPLETED, {
|
|
191
|
+
command: commandName,
|
|
192
|
+
platform,
|
|
193
|
+
duration_ms: Date.now() - start,
|
|
194
|
+
});
|
|
184
195
|
return result;
|
|
185
196
|
} catch (error) {
|
|
186
|
-
track(Events.COMMAND_FAILED, {
|
|
197
|
+
track(Events.COMMAND_FAILED, {
|
|
198
|
+
command: commandName,
|
|
199
|
+
platform,
|
|
200
|
+
error_type: classifyError(error),
|
|
201
|
+
duration_ms: Date.now() - start,
|
|
202
|
+
});
|
|
187
203
|
throw error;
|
|
188
204
|
}
|
|
189
205
|
}) as T;
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
// ISR Test Page - regenerates every 10 seconds
|
|
2
|
-
// After first request, page is cached in R2
|
|
3
|
-
// Subsequent requests serve cached version while revalidating in background
|
|
4
|
-
|
|
5
|
-
export const revalidate = 10; // seconds
|
|
6
|
-
|
|
7
|
-
export default async function ISRTestPage() {
|
|
8
|
-
const timestamp = new Date().toISOString();
|
|
9
|
-
|
|
10
|
-
return (
|
|
11
|
-
<main style={{ padding: '2rem', fontFamily: 'system-ui' }}>
|
|
12
|
-
<h1>ISR Test</h1>
|
|
13
|
-
<p>Generated at: <strong>{timestamp}</strong></p>
|
|
14
|
-
<p style={{ color: '#888', marginTop: '1rem' }}>
|
|
15
|
-
Refresh the page - timestamp updates every ~10 seconds (cached in R2)
|
|
16
|
-
</p>
|
|
17
|
-
<p style={{ marginTop: '2rem' }}>
|
|
18
|
-
<a href="/" style={{ color: '#0070f3' }}>← Back home</a>
|
|
19
|
-
</p>
|
|
20
|
-
</main>
|
|
21
|
-
);
|
|
22
|
-
}
|