@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getjack/jack",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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",
@@ -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 { error, info, spinner, success } from "../lib/output.ts";
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
+ }
@@ -183,7 +183,11 @@ function renderGroupedView(items: ProjectListItem[]): void {
183
183
 
184
184
  console.error("");
185
185
  console.error(
186
- formatCloudSection(sorted, { limit: CLOUD_LIMIT, total: groups.cloudOnly.length, tagColorMap }),
186
+ formatCloudSection(sorted, {
187
+ limit: CLOUD_LIMIT,
188
+ total: groups.cloudOnly.length,
189
+ tagColorMap,
190
+ }),
187
191
  );
188
192
  }
189
193
 
@@ -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
+ }
@@ -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 (showUrl && (item.status === "error" || item.status === "auth-expired") && item.errorMessage) {
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
 
@@ -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 (process.env.DO_NOT_TRACK === "1" || process.env.CI === "true" || process.env.JACK_TELEMETRY_DISABLED === "1") {
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<UserProperties, "shell" | "terminal" | "terminal_width" | "is_tty" | "locale"> {
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, { command: commandName, platform, duration_ms: Date.now() - start });
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, { command: commandName, platform, error_type: classifyError(error), duration_ms: Date.now() - start });
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
- }