@getjack/jack 0.1.16 → 0.1.17

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.
@@ -14,7 +14,7 @@ import {
14
14
  renderTemplate,
15
15
  resolveTemplateWithOrigin,
16
16
  } from "../templates/index.ts";
17
- import type { Template } from "../templates/types.ts";
17
+ import type { EnvVar, Template } from "../templates/types.ts";
18
18
  import { generateAgentFiles } from "./agent-files.ts";
19
19
  import {
20
20
  getActiveAgents,
@@ -168,6 +168,129 @@ const noopReporter: OperationReporter = {
168
168
  box() {},
169
169
  };
170
170
 
171
+ /**
172
+ * Check if an environment variable already exists in a .env file
173
+ * Returns the existing value if found, null otherwise
174
+ */
175
+ async function checkEnvVarExists(envPath: string, key: string): Promise<string | null> {
176
+ if (!existsSync(envPath)) {
177
+ return null;
178
+ }
179
+
180
+ const content = await Bun.file(envPath).text();
181
+ for (const line of content.split("\n")) {
182
+ const trimmed = line.trim();
183
+ if (!trimmed || trimmed.startsWith("#")) {
184
+ continue;
185
+ }
186
+
187
+ const eqIndex = trimmed.indexOf("=");
188
+ if (eqIndex === -1) {
189
+ continue;
190
+ }
191
+
192
+ const lineKey = trimmed.slice(0, eqIndex).trim();
193
+ if (lineKey === key) {
194
+ let value = trimmed.slice(eqIndex + 1).trim();
195
+ // Remove surrounding quotes
196
+ if (
197
+ (value.startsWith('"') && value.endsWith('"')) ||
198
+ (value.startsWith("'") && value.endsWith("'"))
199
+ ) {
200
+ value = value.slice(1, -1);
201
+ }
202
+ return value;
203
+ }
204
+ }
205
+
206
+ return null;
207
+ }
208
+
209
+ /**
210
+ * Prompt for environment variables defined in a template
211
+ * Returns a record of env var name -> value for vars that were provided
212
+ */
213
+ async function promptEnvVars(
214
+ envVars: EnvVar[],
215
+ targetDir: string,
216
+ reporter: OperationReporter,
217
+ interactive: boolean,
218
+ ): Promise<Record<string, string>> {
219
+ const result: Record<string, string> = {};
220
+ const envPath = join(targetDir, ".env");
221
+
222
+ for (const envVar of envVars) {
223
+ // Check if already exists in .env
224
+ const existingValue = await checkEnvVarExists(envPath, envVar.name);
225
+ if (existingValue) {
226
+ reporter.stop();
227
+ reporter.success(`${envVar.name}: already configured`);
228
+ reporter.start("Creating project...");
229
+ result[envVar.name] = existingValue;
230
+ continue;
231
+ }
232
+
233
+ if (!interactive) {
234
+ // Non-interactive mode: use default if available, otherwise warn
235
+ if (envVar.defaultValue !== undefined) {
236
+ result[envVar.name] = envVar.defaultValue;
237
+ reporter.stop();
238
+ reporter.info(`${envVar.name}: using default value`);
239
+ reporter.start("Creating project...");
240
+ } else if (envVar.required !== false) {
241
+ reporter.stop();
242
+ reporter.warn(`${envVar.name}: required but not set (no default available)`);
243
+ reporter.start("Creating project...");
244
+ }
245
+ continue;
246
+ }
247
+
248
+ // Interactive mode: prompt user
249
+ reporter.stop();
250
+ const { isCancel, text } = await import("@clack/prompts");
251
+
252
+ console.error("");
253
+ console.error(` ${envVar.description}`);
254
+ if (envVar.setupUrl) {
255
+ console.error(` Get it at: ${envVar.setupUrl}`);
256
+ }
257
+ if (envVar.example) {
258
+ console.error(` Example: ${envVar.example}`);
259
+ }
260
+ console.error("");
261
+
262
+ const value = await text({
263
+ message: `${envVar.name}:`,
264
+ defaultValue: envVar.defaultValue,
265
+ placeholder: envVar.defaultValue ?? (envVar.example ? `e.g. ${envVar.example}` : undefined),
266
+ });
267
+
268
+ if (isCancel(value)) {
269
+ // User cancelled - skip this var
270
+ if (envVar.required !== false) {
271
+ reporter.warn(`Skipped required env var: ${envVar.name}`);
272
+ }
273
+ reporter.start("Creating project...");
274
+ continue;
275
+ }
276
+
277
+ const trimmedValue = value.trim();
278
+ if (trimmedValue) {
279
+ result[envVar.name] = trimmedValue;
280
+ reporter.success(`Set ${envVar.name}`);
281
+ } else if (envVar.defaultValue !== undefined) {
282
+ result[envVar.name] = envVar.defaultValue;
283
+ reporter.info(`${envVar.name}: using default value`);
284
+ } else if (envVar.required !== false) {
285
+ reporter.warn(`Skipped required env var: ${envVar.name}`);
286
+ }
287
+
288
+ reporter.start("Creating project...");
289
+ }
290
+
291
+ return result;
292
+ }
293
+
171
294
  /**
172
295
  * Get the wrangler config file path for a project
173
296
  * Returns the first found: wrangler.jsonc, wrangler.toml, wrangler.json
@@ -198,9 +321,36 @@ async function runWranglerDeploy(
198
321
  return await $`wrangler deploy ${configArg} ${dryRunArgs}`.cwd(projectPath).nothrow().quiet();
199
322
  }
200
323
 
324
+ /**
325
+ * Ensure Cloudflare authentication is in place before BYO operations.
326
+ * Checks wrangler auth and CLOUDFLARE_API_TOKEN env var.
327
+ */
328
+ async function ensureCloudflareAuth(
329
+ interactive: boolean,
330
+ reporter: OperationReporter,
331
+ ): Promise<void> {
332
+ const { isAuthenticated, ensureAuth } = await import("./wrangler.ts");
333
+ const cfAuthenticated = await isAuthenticated();
334
+ const hasApiToken = Boolean(process.env.CLOUDFLARE_API_TOKEN);
335
+
336
+ if (!cfAuthenticated && !hasApiToken) {
337
+ if (interactive) {
338
+ reporter.info("Cloudflare authentication required");
339
+ await ensureAuth();
340
+ } else {
341
+ throw new JackError(
342
+ JackErrorCode.AUTH_FAILED,
343
+ "Not authenticated with Cloudflare",
344
+ "Run: wrangler login\nOr set CLOUDFLARE_API_TOKEN environment variable",
345
+ );
346
+ }
347
+ }
348
+ }
349
+
201
350
  /**
202
351
  * Run bun install and managed project creation in parallel.
203
352
  * Handles partial failures with cleanup.
353
+ * Optionally reports URL early via onRemoteReady callback.
204
354
  */
205
355
  async function runParallelSetup(
206
356
  targetDir: string,
@@ -208,32 +358,41 @@ async function runParallelSetup(
208
358
  options: {
209
359
  template?: string;
210
360
  usePrebuilt?: boolean;
361
+ onRemoteReady?: (result: ManagedCreateResult) => void;
211
362
  },
212
363
  ): Promise<{
213
364
  installSuccess: boolean;
214
365
  remoteResult: ManagedCreateResult;
215
366
  }> {
216
- const [installResult, remoteResult] = await Promise.allSettled([
217
- // Install dependencies
218
- (async () => {
219
- const install = Bun.spawn(["bun", "install"], {
220
- cwd: targetDir,
221
- stdout: "ignore",
222
- stderr: "ignore",
223
- });
224
- await install.exited;
225
- if (install.exitCode !== 0) {
226
- throw new Error("Dependency installation failed");
367
+ // Start both operations
368
+ const installPromise = (async () => {
369
+ const install = Bun.spawn(["bun", "install", "--prefer-offline"], {
370
+ cwd: targetDir,
371
+ stdout: "ignore",
372
+ stderr: "ignore",
373
+ });
374
+ await install.exited;
375
+ if (install.exitCode !== 0) {
376
+ throw new Error("Dependency installation failed");
377
+ }
378
+ return true;
379
+ })();
380
+
381
+ const remotePromise = createManagedProjectRemote(projectName, undefined, {
382
+ template: options.template || "hello",
383
+ usePrebuilt: options.usePrebuilt ?? true,
384
+ });
385
+
386
+ // Report URL as soon as remote is ready (don't wait for install)
387
+ remotePromise
388
+ .then((result) => {
389
+ if (result.status === "live" && options.onRemoteReady) {
390
+ options.onRemoteReady(result);
227
391
  }
228
- return true;
229
- })(),
230
-
231
- // Create managed project remote (no reporter to avoid spinner conflicts)
232
- createManagedProjectRemote(projectName, undefined, {
233
- template: options.template || "hello",
234
- usePrebuilt: options.usePrebuilt ?? true,
235
- }),
236
- ]);
392
+ })
393
+ .catch(() => {}); // Errors handled below in allSettled
394
+
395
+ const [installResult, remoteResult] = await Promise.allSettled([installPromise, remotePromise]);
237
396
 
238
397
  const installFailed = installResult.status === "rejected";
239
398
  const remoteFailed = remoteResult.status === "rejected";
@@ -900,6 +1059,12 @@ export async function createProject(
900
1059
  }
901
1060
  }
902
1061
 
1062
+ // Handle environment variables (non-secret configuration)
1063
+ let envVarsToUse: Record<string, string> = {};
1064
+ if (template.envVars?.length) {
1065
+ envVarsToUse = await promptEnvVars(template.envVars, targetDir, reporter, interactive);
1066
+ }
1067
+
903
1068
  // Track if we created the directory (for cleanup on failure)
904
1069
  let directoryCreated = false;
905
1070
 
@@ -918,13 +1083,24 @@ export async function createProject(
918
1083
  }
919
1084
  reporter.start("Creating project...");
920
1085
 
921
- // Write secrets files (.env for Vite, .dev.vars for wrangler local, .secrets.json for wrangler bulk)
922
- if (Object.keys(secretsToUse).length > 0) {
923
- const envContent = generateEnvFile(secretsToUse);
924
- const jsonContent = generateSecretsJson(secretsToUse);
1086
+ // Write secrets and env vars files
1087
+ // - Secrets go to: .env, .dev.vars, .secrets.json (for wrangler bulk upload)
1088
+ // - Env vars go to: .env, .dev.vars only (not secrets.json - they're not secrets)
1089
+ const hasSecrets = Object.keys(secretsToUse).length > 0;
1090
+ const hasEnvVars = Object.keys(envVarsToUse).length > 0;
1091
+
1092
+ if (hasSecrets || hasEnvVars) {
1093
+ // Combine secrets and env vars for .env and .dev.vars
1094
+ const allEnvVars = { ...secretsToUse, ...envVarsToUse };
1095
+ const envContent = generateEnvFile(allEnvVars);
925
1096
  await Bun.write(join(targetDir, ".env"), envContent);
926
1097
  await Bun.write(join(targetDir, ".dev.vars"), envContent);
927
- await Bun.write(join(targetDir, ".secrets.json"), jsonContent);
1098
+
1099
+ // Only write secrets to .secrets.json (for wrangler secret bulk)
1100
+ if (hasSecrets) {
1101
+ const jsonContent = generateSecretsJson(secretsToUse);
1102
+ await Bun.write(join(targetDir, ".secrets.json"), jsonContent);
1103
+ }
928
1104
 
929
1105
  const gitignorePath = join(targetDir, ".gitignore");
930
1106
  const gitignoreExists = existsSync(gitignorePath);
@@ -970,6 +1146,7 @@ export async function createProject(
970
1146
 
971
1147
  // Parallel setup for managed mode: install + remote creation
972
1148
  let remoteResult: ManagedCreateResult | undefined;
1149
+ let urlShownEarly = false;
973
1150
 
974
1151
  if (deployMode === "managed") {
975
1152
  // Run install and remote creation in parallel
@@ -980,11 +1157,22 @@ export async function createProject(
980
1157
  const result = await runParallelSetup(targetDir, projectName, {
981
1158
  template: resolvedTemplate || "hello",
982
1159
  usePrebuilt: templateOrigin.type === "builtin", // Only builtin templates have prebuilt bundles
1160
+ onRemoteReady: (remote) => {
1161
+ // Show URL immediately when prebuilt succeeds
1162
+ reporter.stop();
1163
+ reporter.success(`Live: ${remote.runjackUrl}`);
1164
+ reporter.start("Installing dependencies locally...");
1165
+ urlShownEarly = true;
1166
+ },
983
1167
  });
984
1168
  remoteResult = result.remoteResult;
985
1169
  timings.push({ label: "Parallel setup", duration: timerEnd("parallel-setup") });
986
1170
  reporter.stop();
987
- reporter.success("Project setup complete");
1171
+ if (urlShownEarly) {
1172
+ reporter.success("Ready for local development");
1173
+ } else {
1174
+ reporter.success("Project setup complete");
1175
+ }
988
1176
  } catch (err) {
989
1177
  timerEnd("parallel-setup");
990
1178
  reporter.stop();
@@ -995,11 +1183,11 @@ export async function createProject(
995
1183
  throw err;
996
1184
  }
997
1185
  } else {
998
- // BYO mode: just install dependencies (unchanged from current)
1186
+ // BYO mode: just install dependencies
999
1187
  timerStart("bun-install");
1000
1188
  reporter.start("Installing dependencies...");
1001
1189
 
1002
- const install = Bun.spawn(["bun", "install"], {
1190
+ const install = Bun.spawn(["bun", "install", "--prefer-offline"], {
1003
1191
  cwd: targetDir,
1004
1192
  stdout: "ignore",
1005
1193
  stderr: "ignore",
@@ -1109,6 +1297,7 @@ export async function createProject(
1109
1297
  await writeTemplateMetadata(targetDir, templateOrigin);
1110
1298
  await registerPath(remoteResult.projectId, targetDir);
1111
1299
  } catch (err) {
1300
+ reporter.warn("Could not save project link (deploy still works)");
1112
1301
  debug("Failed to link managed project:", err);
1113
1302
  }
1114
1303
 
@@ -1116,7 +1305,10 @@ export async function createProject(
1116
1305
  if (remoteResult.status === "live") {
1117
1306
  // Prebuilt succeeded - skip the fresh build
1118
1307
  workerUrl = remoteResult.runjackUrl;
1119
- reporter.success(`Deployed: ${workerUrl}`);
1308
+ // Only show if not already shown by parallel setup
1309
+ if (!urlShownEarly) {
1310
+ reporter.success(`Deployed: ${workerUrl}`);
1311
+ }
1120
1312
  } else {
1121
1313
  // Prebuilt not available - fall back to fresh build
1122
1314
  if (remoteResult.prebuiltFailed) {
@@ -1226,6 +1418,7 @@ export async function createProject(
1226
1418
  await writeTemplateMetadata(targetDir, templateOrigin);
1227
1419
  await registerPath(byoProjectId, targetDir);
1228
1420
  } catch (err) {
1421
+ reporter.warn("Could not save project link (deploy still works)");
1229
1422
  debug("Failed to link BYO project:", err);
1230
1423
  }
1231
1424
  }
@@ -1249,7 +1442,7 @@ export async function createProject(
1249
1442
 
1250
1443
  // Show final celebration if there were interactive prompts (URL might have scrolled away)
1251
1444
  if (hookResult.hadInteractiveActions && reporter.celebrate) {
1252
- reporter.celebrate("You're live!", [domain]);
1445
+ reporter.celebrate("You're live!", [workerUrl]);
1253
1446
  }
1254
1447
  }
1255
1448
 
@@ -1327,6 +1520,57 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1327
1520
  "No wrangler config found in current directory",
1328
1521
  "Run: jack new <project-name>",
1329
1522
  );
1523
+ } else if (hasWranglerConfig && !hasProjectLink) {
1524
+ // Orphaned state: wrangler config exists but no project link
1525
+ // This happens when: linking failed during jack new, user has existing wrangler project,
1526
+ // or project was moved/copied without .jack directory
1527
+ const { isLoggedIn } = await import("./auth/store.ts");
1528
+ const loggedIn = await isLoggedIn();
1529
+
1530
+ if (loggedIn && !options.byo) {
1531
+ // User is logged into Jack Cloud - create managed project
1532
+ const orphanedProjectName = await getProjectNameFromDir(projectPath);
1533
+
1534
+ reporter.info(`Linking "${orphanedProjectName}" to jack cloud...`);
1535
+
1536
+ // Get username for URL construction
1537
+ const { getCurrentUserProfile } = await import("./control-plane.ts");
1538
+ const profile = await getCurrentUserProfile();
1539
+ const ownerUsername = profile?.username ?? undefined;
1540
+
1541
+ // Create managed project on jack cloud
1542
+ const remoteResult = await createManagedProjectRemote(orphanedProjectName, reporter, {
1543
+ usePrebuilt: false,
1544
+ });
1545
+
1546
+ // Link project locally
1547
+ await linkProject(projectPath, remoteResult.projectId, "managed", ownerUsername);
1548
+ await registerPath(remoteResult.projectId, projectPath);
1549
+
1550
+ // Set autoDetectResult so the rest of the flow uses managed mode
1551
+ autoDetectResult = {
1552
+ projectName: orphanedProjectName,
1553
+ projectId: remoteResult.projectId,
1554
+ deployMode: "managed",
1555
+ };
1556
+
1557
+ reporter.success("Linked to jack cloud");
1558
+ } else if (!options.managed) {
1559
+ // BYO path - ensure wrangler auth before proceeding
1560
+ await ensureCloudflareAuth(interactive, reporter);
1561
+
1562
+ // Create BYO link for tracking (non-blocking)
1563
+ const orphanedProjectName = await getProjectNameFromDir(projectPath);
1564
+ const byoProjectId = generateByoProjectId();
1565
+
1566
+ try {
1567
+ await linkProject(projectPath, byoProjectId, "byo");
1568
+ await registerPath(byoProjectId, projectPath);
1569
+ debug("Created BYO project link for orphaned project");
1570
+ } catch (err) {
1571
+ debug("Failed to create BYO project link:", err);
1572
+ }
1573
+ }
1330
1574
  }
1331
1575
 
1332
1576
  // Get project name from directory (or auto-detect result)
@@ -1475,6 +1719,9 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1475
1719
  }
1476
1720
  }
1477
1721
 
1722
+ // Ensure Cloudflare auth before BYO deploy
1723
+ await ensureCloudflareAuth(interactive, reporter);
1724
+
1478
1725
  const spin = reporter.spinner("Deploying...");
1479
1726
  const result = await runWranglerDeploy(projectPath);
1480
1727