@getjack/jack 0.1.6 → 0.1.7

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.
@@ -23,8 +23,22 @@ import {
23
23
  runAgentOneShot,
24
24
  validateAgentPaths,
25
25
  } from "./agents.ts";
26
- import { ensureR2Buckets, needsOpenNextBuild, needsViteBuild, runOpenNextBuild, runViteBuild } from "./build-helper.ts";
26
+ import {
27
+ checkWranglerVersion,
28
+ getWranglerVersion,
29
+ needsOpenNextBuild,
30
+ needsViteBuild,
31
+ parseWranglerConfig,
32
+ runOpenNextBuild,
33
+ runViteBuild,
34
+ } from "./build-helper.ts";
27
35
  import { checkWorkerExists, getAccountId, listD1Databases } from "./cloudflare-api.ts";
36
+ import {
37
+ generateWranglerConfig,
38
+ getDefaultProjectName,
39
+ slugify,
40
+ writeWranglerConfig,
41
+ } from "./config-generator.ts";
28
42
  import { getSyncConfig } from "./config.ts";
29
43
  import { deleteManagedProject } from "./control-plane.ts";
30
44
  import { debug, isDebug } from "./debug.ts";
@@ -40,6 +54,7 @@ import {
40
54
  } from "./managed-deploy.ts";
41
55
  import { generateProjectName } from "./names.ts";
42
56
  import { getAllPaths, registerPath, unregisterPath } from "./paths-index.ts";
57
+ import { detectProjectType, validateProject } from "./project-detection.ts";
43
58
  import {
44
59
  type DeployMode,
45
60
  type TemplateMetadata as TemplateOrigin,
@@ -83,6 +98,7 @@ export interface DeployOptions {
83
98
  includeSync?: boolean;
84
99
  managed?: boolean; // Force managed deploy mode
85
100
  byo?: boolean; // Force BYO deploy mode
101
+ dryRun?: boolean; // Stop before actual deployment
86
102
  }
87
103
 
88
104
  export interface DeployResult {
@@ -293,6 +309,213 @@ async function preflightD1Capacity(
293
309
  }
294
310
  }
295
311
 
312
+ // ============================================================================
313
+ // Auto-detect Flow for Ship Command
314
+ // ============================================================================
315
+
316
+ interface AutoDetectResult {
317
+ projectName: string;
318
+ projectId: string | null; // null when dry run (no cloud project created)
319
+ deployMode: DeployMode;
320
+ }
321
+
322
+ /**
323
+ * Run the auto-detect flow when no wrangler config exists.
324
+ * Detects project type, prompts user for confirmation, generates config,
325
+ * and creates managed project on jack cloud.
326
+ *
327
+ * @param dryRun - If true, skip cloud project creation and linking
328
+ */
329
+ async function runAutoDetectFlow(
330
+ projectPath: string,
331
+ reporter: OperationReporter,
332
+ interactive: boolean,
333
+ dryRun = false,
334
+ ): Promise<AutoDetectResult> {
335
+ // Step 1: Validate project (file count, size limits)
336
+ const validation = await validateProject(projectPath);
337
+ if (!validation.valid) {
338
+ track(Events.AUTO_DETECT_REJECTED, { reason: "validation_failed" });
339
+ throw new JackError(
340
+ JackErrorCode.VALIDATION_ERROR,
341
+ validation.error || "Project validation failed",
342
+ );
343
+ }
344
+
345
+ // Step 2: Detect project type
346
+ const detection = detectProjectType(projectPath);
347
+
348
+ // Step 3: Handle unsupported frameworks
349
+ if (detection.unsupportedFramework) {
350
+ track(Events.AUTO_DETECT_FAILED, {
351
+ reason: "unsupported_framework",
352
+ framework: detection.unsupportedFramework,
353
+ });
354
+
355
+ // Use the detailed error message from detection (includes setup instructions)
356
+ throw new JackError(
357
+ JackErrorCode.VALIDATION_ERROR,
358
+ detection.error || `${detection.unsupportedFramework} is not yet supported`,
359
+ );
360
+ }
361
+
362
+ // Step 4: Handle unknown project type
363
+ if (detection.type === "unknown") {
364
+ track(Events.AUTO_DETECT_FAILED, { reason: "unknown_type" });
365
+
366
+ throw new JackError(
367
+ JackErrorCode.VALIDATION_ERROR,
368
+ "Could not detect project type\n\nSupported types:\n - Vite (React, Vue, etc.)\n - Hono API\n - SvelteKit (with @sveltejs/adapter-cloudflare)\n\nTo deploy manually, create a wrangler.jsonc file.\nDocs: https://docs.getjack.org/guides/manual-setup",
369
+ );
370
+ }
371
+
372
+ // Step 5: Handle detection errors (e.g., missing adapter)
373
+ if (detection.error) {
374
+ track(Events.AUTO_DETECT_FAILED, {
375
+ reason: "detection_error",
376
+ type: detection.type,
377
+ });
378
+ throw new JackError(JackErrorCode.VALIDATION_ERROR, detection.error);
379
+ }
380
+
381
+ // Step 6: Detection succeeded - show what was detected
382
+ const typeLabels: Record<string, string> = {
383
+ vite: "Vite",
384
+ hono: "Hono API",
385
+ sveltekit: "SvelteKit",
386
+ };
387
+ const typeLabel = typeLabels[detection.type] || detection.type;
388
+ const configInfo = detection.configFile || detection.entryPoint || "";
389
+ reporter.info(`Detected: ${typeLabel} project${configInfo ? ` (${configInfo})` : ""}`);
390
+
391
+ // Step 7: Fetch username for URL preview (skip for dry run)
392
+ let ownerUsername: string | null = null;
393
+ if (!dryRun) {
394
+ const { getCurrentUserProfile } = await import("./control-plane.ts");
395
+ const profile = await getCurrentUserProfile();
396
+ ownerUsername = profile?.username ?? null;
397
+ }
398
+
399
+ // Step 8: Get default project name and prompt user
400
+ const defaultName = getDefaultProjectName(projectPath);
401
+
402
+ if (!interactive) {
403
+ // Non-interactive mode - use defaults
404
+ const projectName = defaultName;
405
+ const runjackUrl = ownerUsername
406
+ ? `https://${ownerUsername}-${projectName}.runjack.xyz`
407
+ : `https://${projectName}.runjack.xyz`;
408
+
409
+ reporter.info(`Project name: ${projectName}`);
410
+ reporter.info(`Will deploy to: ${runjackUrl}`);
411
+
412
+ // Generate and write wrangler config
413
+ const wranglerConfig = generateWranglerConfig(
414
+ detection.type,
415
+ projectName,
416
+ detection.entryPoint,
417
+ );
418
+ writeWranglerConfig(projectPath, wranglerConfig);
419
+ reporter.success("Created wrangler.jsonc");
420
+
421
+ // Skip cloud creation and linking for dry run
422
+ if (dryRun) {
423
+ track(Events.AUTO_DETECT_SUCCESS, { type: detection.type });
424
+ return {
425
+ projectName,
426
+ projectId: null,
427
+ deployMode: "managed",
428
+ };
429
+ }
430
+
431
+ // Create managed project on jack cloud
432
+ const remoteResult = await createManagedProjectRemote(projectName, reporter, {
433
+ usePrebuilt: false,
434
+ });
435
+
436
+ // Link project locally (include username for correct URL display)
437
+ await linkProject(projectPath, remoteResult.projectId, "managed", ownerUsername ?? undefined);
438
+ await registerPath(remoteResult.projectId, projectPath);
439
+
440
+ track(Events.AUTO_DETECT_SUCCESS, { type: detection.type });
441
+
442
+ return {
443
+ projectName,
444
+ projectId: remoteResult.projectId,
445
+ deployMode: "managed",
446
+ };
447
+ }
448
+
449
+ // Interactive mode - prompt for project name
450
+ const { input } = await import("@inquirer/prompts");
451
+
452
+ console.error("");
453
+ const projectName = await input({
454
+ message: "Project name:",
455
+ default: defaultName,
456
+ });
457
+
458
+ const slugifiedName = slugify(projectName.trim());
459
+ const runjackUrl = ownerUsername
460
+ ? `https://${ownerUsername}-${slugifiedName}.runjack.xyz`
461
+ : `https://${slugifiedName}.runjack.xyz`;
462
+
463
+ // Confirmation prompt
464
+ console.error("");
465
+ console.error(" This will:");
466
+ console.error(" - Create wrangler.jsonc");
467
+ console.error(" - Create project on jack cloud");
468
+ console.error(` - Deploy to ${runjackUrl}`);
469
+ console.error("");
470
+
471
+ const { promptSelect } = await import("./hooks.ts");
472
+ const choice = await promptSelect(["Continue", "Cancel"]);
473
+
474
+ if (choice !== 0) {
475
+ track(Events.AUTO_DETECT_REJECTED, { reason: "user_cancelled" });
476
+ throw new JackError(JackErrorCode.VALIDATION_ERROR, "Deployment cancelled", undefined, {
477
+ exitCode: 0,
478
+ reported: true,
479
+ });
480
+ }
481
+
482
+ // Generate and write wrangler config
483
+ const wranglerConfig = generateWranglerConfig(
484
+ detection.type,
485
+ slugifiedName,
486
+ detection.entryPoint,
487
+ );
488
+ writeWranglerConfig(projectPath, wranglerConfig);
489
+ reporter.success("Created wrangler.jsonc");
490
+
491
+ // Skip cloud creation and linking for dry run
492
+ if (dryRun) {
493
+ track(Events.AUTO_DETECT_SUCCESS, { type: detection.type });
494
+ return {
495
+ projectName: slugifiedName,
496
+ projectId: null,
497
+ deployMode: "managed",
498
+ };
499
+ }
500
+
501
+ // Create managed project on jack cloud
502
+ const remoteResult = await createManagedProjectRemote(slugifiedName, reporter, {
503
+ usePrebuilt: false,
504
+ });
505
+
506
+ // Link project locally (include username for correct URL display)
507
+ await linkProject(projectPath, remoteResult.projectId, "managed", ownerUsername ?? undefined);
508
+ await registerPath(remoteResult.projectId, projectPath);
509
+
510
+ track(Events.AUTO_DETECT_SUCCESS, { type: detection.type });
511
+
512
+ return {
513
+ projectName: slugifiedName,
514
+ projectId: remoteResult.projectId,
515
+ deployMode: "managed",
516
+ };
517
+ }
518
+
296
519
  // ============================================================================
297
520
  // Create Project Operation
298
521
  // ============================================================================
@@ -674,7 +897,7 @@ export async function createProject(
674
897
  try {
675
898
  const result = await runParallelSetup(targetDir, projectName, {
676
899
  template: resolvedTemplate || "hello",
677
- usePrebuilt: true,
900
+ usePrebuilt: templateOrigin.type === "builtin", // Only builtin templates have prebuilt bundles
678
901
  });
679
902
  remoteResult = result.remoteResult;
680
903
  reporter.stop();
@@ -787,9 +1010,14 @@ export async function createProject(
787
1010
  );
788
1011
  }
789
1012
 
1013
+ // Fetch username for link storage
1014
+ const { getCurrentUserProfile } = await import("./control-plane.ts");
1015
+ const profile = await getCurrentUserProfile();
1016
+ const ownerUsername = profile?.username ?? undefined;
1017
+
790
1018
  // Link project locally and register path
791
1019
  try {
792
- await linkProject(targetDir, remoteResult.projectId, "managed");
1020
+ await linkProject(targetDir, remoteResult.projectId, "managed", ownerUsername);
793
1021
  await writeTemplateMetadata(targetDir, templateOrigin);
794
1022
  await registerPath(remoteResult.projectId, targetDir);
795
1023
  } catch (err) {
@@ -957,6 +1185,7 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
957
1185
  interactive: interactiveOption,
958
1186
  includeSecrets = false,
959
1187
  includeSync = false,
1188
+ dryRun = false,
960
1189
  } = options;
961
1190
  const reporter = providedReporter ?? noopReporter;
962
1191
  const hasReporter = Boolean(providedReporter);
@@ -974,7 +1203,14 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
974
1203
  existsSync(join(projectPath, "wrangler.jsonc")) ||
975
1204
  existsSync(join(projectPath, "wrangler.json"));
976
1205
 
977
- if (!hasWranglerConfig) {
1206
+ // Check for existing project link
1207
+ const hasProjectLink = existsSync(join(projectPath, ".jack", "project.json"));
1208
+
1209
+ // Auto-detect flow: no wrangler config and no project link
1210
+ let autoDetectResult: AutoDetectResult | null = null;
1211
+ if (!hasWranglerConfig && !hasProjectLink) {
1212
+ autoDetectResult = await runAutoDetectFlow(projectPath, reporter, interactive, dryRun);
1213
+ } else if (!hasWranglerConfig) {
978
1214
  throw new JackError(
979
1215
  JackErrorCode.PROJECT_NOT_FOUND,
980
1216
  "No wrangler config found in current directory",
@@ -982,18 +1218,20 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
982
1218
  );
983
1219
  }
984
1220
 
985
- // Get project name from directory
986
- const projectName = await getProjectNameFromDir(projectPath);
1221
+ // Get project name from directory (or auto-detect result)
1222
+ const projectName = autoDetectResult?.projectName ?? (await getProjectNameFromDir(projectPath));
987
1223
 
988
1224
  // Read local project link for stored mode and project ID
989
1225
  const link = await readProjectLink(projectPath);
990
1226
 
991
- // Determine effective mode: explicit flag > stored mode > default BYO
1227
+ // Determine effective mode: explicit flag > auto-detect > stored mode > default BYO
992
1228
  let deployMode: DeployMode;
993
1229
  if (options.managed) {
994
1230
  deployMode = "managed";
995
1231
  } else if (options.byo) {
996
1232
  deployMode = "byo";
1233
+ } else if (autoDetectResult) {
1234
+ deployMode = autoDetectResult.deployMode;
997
1235
  } else {
998
1236
  deployMode = link?.deploy_mode ?? "byo";
999
1237
  }
@@ -1010,7 +1248,11 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1010
1248
  // Deploy based on mode
1011
1249
  if (deployMode === "managed") {
1012
1250
  // Managed mode: deploy via jack cloud
1013
- if (!link?.project_id || link.deploy_mode !== "managed") {
1251
+ // Use autoDetectResult.projectId if available, otherwise require existing link
1252
+ const managedProjectId = autoDetectResult?.projectId ?? link?.project_id;
1253
+
1254
+ // For dry run, skip the project ID check since we didn't create a cloud project
1255
+ if (!dryRun && (!managedProjectId || (!autoDetectResult && link?.deploy_mode !== "managed"))) {
1014
1256
  throw new JackError(
1015
1257
  JackErrorCode.VALIDATION_ERROR,
1016
1258
  "Project not linked to jack cloud",
@@ -1018,11 +1260,43 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1018
1260
  );
1019
1261
  }
1020
1262
 
1263
+ // Dry run: build for validation then stop before actual deployment
1264
+ // (deployToManagedProject handles its own build, so only build here for dry-run)
1265
+ if (dryRun) {
1266
+ if (await needsOpenNextBuild(projectPath)) {
1267
+ const buildSpin = reporter.spinner("Building...");
1268
+ try {
1269
+ await runOpenNextBuild(projectPath);
1270
+ buildSpin.success("Built");
1271
+ } catch (err) {
1272
+ buildSpin.error("Build failed");
1273
+ throw err;
1274
+ }
1275
+ } else if (await needsViteBuild(projectPath)) {
1276
+ const buildSpin = reporter.spinner("Building...");
1277
+ try {
1278
+ await runViteBuild(projectPath);
1279
+ buildSpin.success("Built");
1280
+ } catch (err) {
1281
+ buildSpin.error("Build failed");
1282
+ throw err;
1283
+ }
1284
+ }
1285
+ reporter.success("Dry run complete - config generated, build verified");
1286
+ return {
1287
+ workerUrl: null,
1288
+ projectName,
1289
+ deployMode,
1290
+ };
1291
+ }
1292
+
1021
1293
  // deployToManagedProject now handles both template and code deploy
1022
- await deployToManagedProject(link.project_id, projectPath, reporter);
1294
+ await deployToManagedProject(managedProjectId as string, projectPath, reporter);
1023
1295
 
1024
- // Get the URL from the resolver or construct it
1025
- workerUrl = `https://${projectName}.runjack.xyz`;
1296
+ // Construct URL with username if available
1297
+ workerUrl = link?.owner_username
1298
+ ? `https://${link.owner_username}-${projectName}.runjack.xyz`
1299
+ : `https://${projectName}.runjack.xyz`;
1026
1300
  } else {
1027
1301
  // BYO mode: deploy via wrangler
1028
1302
 
@@ -1047,15 +1321,32 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1047
1321
  }
1048
1322
  }
1049
1323
 
1050
- // Ensure R2 buckets exist before deploying (omakase - auto-provision)
1051
- try {
1052
- const buckets = await ensureR2Buckets(projectPath);
1053
- if (buckets.length > 0) {
1054
- reporter.info(`R2 buckets ready: ${buckets.join(", ")}`);
1324
+ // Dry run: stop before actual deployment
1325
+ if (dryRun) {
1326
+ reporter.success("Dry run complete - build verified");
1327
+ return {
1328
+ workerUrl: null,
1329
+ projectName,
1330
+ deployMode,
1331
+ };
1332
+ }
1333
+
1334
+ // Check wrangler version for auto-provisioning (KV/R2/D1 without IDs)
1335
+ const config = await parseWranglerConfig(projectPath);
1336
+ const needsAutoProvision =
1337
+ config.kv_namespaces?.some((kv) => !kv.id) ||
1338
+ config.r2_buckets?.some((r2) => r2.bucket_name?.startsWith("jack-template-")) ||
1339
+ config.d1_databases?.some((d1) => !d1.database_id);
1340
+
1341
+ if (needsAutoProvision) {
1342
+ try {
1343
+ const wranglerVersion = await getWranglerVersion();
1344
+ checkWranglerVersion(wranglerVersion);
1345
+ } catch (err) {
1346
+ if (err instanceof JackError) {
1347
+ throw err;
1348
+ }
1055
1349
  }
1056
- } catch (err) {
1057
- // Non-fatal: let wrangler deploy fail with a clearer error if bucket is missing
1058
- debug("R2 preflight failed:", err);
1059
1350
  }
1060
1351
 
1061
1352
  const spin = reporter.spinner("Deploying...");
@@ -1134,7 +1425,11 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1134
1425
  if (!existingLink) {
1135
1426
  // Not linked yet - create link
1136
1427
  if (deployMode === "managed" && link?.project_id) {
1137
- await linkProject(projectPath, link.project_id, "managed");
1428
+ // Fetch username for link storage
1429
+ const { getCurrentUserProfile } = await import("./control-plane.ts");
1430
+ const profile = await getCurrentUserProfile();
1431
+ const ownerUsername = profile?.username ?? undefined;
1432
+ await linkProject(projectPath, link.project_id, "managed", ownerUsername);
1138
1433
  await registerPath(link.project_id, projectPath);
1139
1434
  } else {
1140
1435
  // BYO mode - generate new ID
@@ -1216,7 +1511,9 @@ export async function getProjectStatus(
1216
1511
  // Determine URL based on mode
1217
1512
  let workerUrl: string | null = null;
1218
1513
  if (link?.deploy_mode === "managed") {
1219
- workerUrl = `https://${projectName}.runjack.xyz`;
1514
+ workerUrl = link.owner_username
1515
+ ? `https://${link.owner_username}-${projectName}.runjack.xyz`
1516
+ : `https://${projectName}.runjack.xyz`;
1220
1517
  }
1221
1518
 
1222
1519
  // Get database name on-demand
@@ -121,11 +121,15 @@ function fromManagedProject(managed: ManagedProject): ResolvedProject {
121
121
  }
122
122
  }
123
123
 
124
+ const url = managed.owner_username
125
+ ? `https://${managed.owner_username}-${managed.slug}.runjack.xyz`
126
+ : `https://${managed.slug}.runjack.xyz`;
127
+
124
128
  return {
125
129
  name: managed.name,
126
130
  slug: managed.slug,
127
131
  status,
128
- url: `https://${managed.slug}.runjack.xyz`,
132
+ url,
129
133
  errorMessage: managed.status === "error" ? "deployment failed" : undefined,
130
134
  sources: {
131
135
  controlPlane: true,
@@ -25,6 +25,7 @@ export const DEFAULT_INCLUDES: string[] = [
25
25
  "*.mjs",
26
26
  "*.cjs",
27
27
  "*.json",
28
+ "*.jsonc",
28
29
  "*.toml",
29
30
  "*.md",
30
31
  "*.css",
@@ -48,6 +49,10 @@ export const DEFAULT_EXCLUDES: string[] = [
48
49
  ".DS_Store",
49
50
  "dist/**",
50
51
  "build/**",
52
+ ".next/**",
53
+ ".nuxt/**",
54
+ ".output/**",
55
+ ".svelte-kit/**",
51
56
  "coverage/**",
52
57
  ".wrangler/**",
53
58
  "*.lock",
@@ -28,6 +28,10 @@ export const Events = {
28
28
  MANAGED_DEPLOY_STARTED: "managed_deploy_started",
29
29
  MANAGED_DEPLOY_COMPLETED: "managed_deploy_completed",
30
30
  MANAGED_DEPLOY_FAILED: "managed_deploy_failed",
31
+ // Auto-detect events
32
+ AUTO_DETECT_SUCCESS: "auto_detect_success",
33
+ AUTO_DETECT_FAILED: "auto_detect_failed",
34
+ AUTO_DETECT_REJECTED: "auto_detect_rejected",
31
35
  } as const;
32
36
 
33
37
  type EventName = (typeof Events)[keyof typeof Events];
@@ -42,6 +42,7 @@ export interface ManifestData {
42
42
  };
43
43
  vars?: Record<string, string>;
44
44
  r2?: Array<{ binding: string; bucket_name: string }>;
45
+ kv?: Array<{ binding: string }>;
45
46
  };
46
47
  }
47
48
 
@@ -169,6 +170,13 @@ function extractBindingsFromConfig(config?: WranglerConfig): ManifestData["bindi
169
170
  }));
170
171
  }
171
172
 
173
+ // Extract KV namespace bindings
174
+ if (config.kv_namespaces && config.kv_namespaces.length > 0) {
175
+ bindings.kv = config.kv_namespaces.map((kv) => ({
176
+ binding: kv.binding,
177
+ }));
178
+ }
179
+
172
180
  // Return undefined if no bindings were extracted
173
181
  return Object.keys(bindings).length > 0 ? bindings : undefined;
174
182
  }
@@ -1,6 +1,8 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { readFile, readdir } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
+ import { unzipSync } from "fflate";
5
+ import { getControlApiUrl } from "../lib/control-plane.ts";
4
6
  import { parseJsonc } from "../lib/jsonc.ts";
5
7
  import type { TemplateMetadata as TemplateOrigin } from "../lib/project-link.ts";
6
8
  import type { Template } from "./types";
@@ -97,8 +99,118 @@ async function loadTemplate(name: string): Promise<Template> {
97
99
  };
98
100
  }
99
101
 
102
+ // Internal files that should be excluded from templates
103
+ const INTERNAL_FILES = [".jack.json", ".jack/template.json"];
104
+
105
+ /**
106
+ * Extract zip buffer to file map, excluding internal files
107
+ */
108
+ function extractZipToFiles(zipData: ArrayBuffer): Record<string, string> {
109
+ const files: Record<string, string> = {};
110
+ const unzipped = unzipSync(new Uint8Array(zipData));
111
+
112
+ for (const [path, content] of Object.entries(unzipped)) {
113
+ // Skip directories (they have zero-length content or end with /)
114
+ if (content.length === 0 || path.endsWith("/")) continue;
115
+
116
+ // Skip internal files
117
+ if (path && !INTERNAL_FILES.includes(path)) {
118
+ files[path] = new TextDecoder().decode(content);
119
+ }
120
+ }
121
+
122
+ return files;
123
+ }
124
+
125
+ /**
126
+ * Read metadata from extracted files (before they're filtered)
127
+ */
128
+ function extractMetadataFromZip(zipData: ArrayBuffer): Record<string, unknown> {
129
+ const unzipped = unzipSync(new Uint8Array(zipData));
130
+
131
+ for (const [path, content] of Object.entries(unzipped)) {
132
+ // Skip directories
133
+ if (content.length === 0 || path.endsWith("/")) continue;
134
+
135
+ if (path === ".jack.json") {
136
+ return parseJsonc(new TextDecoder().decode(content));
137
+ }
138
+ }
139
+
140
+ return {};
141
+ }
142
+
143
+ /**
144
+ * Fetch a published template from jack cloud (public endpoint, no auth)
145
+ */
146
+ async function fetchPublishedTemplate(username: string, slug: string): Promise<Template> {
147
+ const response = await fetch(
148
+ `${getControlApiUrl()}/v1/projects/${encodeURIComponent(username)}/${encodeURIComponent(slug)}/source`,
149
+ );
150
+
151
+ if (!response.ok) {
152
+ if (response.status === 404) {
153
+ throw new Error(
154
+ `Template not found: ${username}/${slug}\n\nMake sure the project exists and is published with: jack publish`,
155
+ );
156
+ }
157
+ throw new Error(`Failed to fetch template: ${response.status}`);
158
+ }
159
+
160
+ const zipData = await response.arrayBuffer();
161
+ const metadata = extractMetadataFromZip(zipData);
162
+ const files = extractZipToFiles(zipData);
163
+
164
+ return {
165
+ description: (metadata.description as string) || `Fork of ${username}/${slug}`,
166
+ secrets: (metadata.secrets as string[]) || [],
167
+ optionalSecrets: metadata.optionalSecrets as Template["optionalSecrets"],
168
+ capabilities: metadata.capabilities as Template["capabilities"],
169
+ requires: metadata.requires as Template["requires"],
170
+ hooks: metadata.hooks as Template["hooks"],
171
+ agentContext: metadata.agentContext as Template["agentContext"],
172
+ intent: metadata.intent as Template["intent"],
173
+ files,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Fetch user's own project as a template (authenticated)
179
+ */
180
+ async function fetchUserTemplate(slug: string): Promise<Template | null> {
181
+ const { authFetch } = await import("../lib/auth/index.ts");
182
+
183
+ const response = await authFetch(
184
+ `${getControlApiUrl()}/v1/me/projects/${encodeURIComponent(slug)}/source`,
185
+ );
186
+
187
+ if (response.status === 404) {
188
+ return null; // Not found, will try other sources
189
+ }
190
+
191
+ if (!response.ok) {
192
+ throw new Error(`Failed to fetch your project: ${response.status}`);
193
+ }
194
+
195
+ const zipData = await response.arrayBuffer();
196
+ const metadata = extractMetadataFromZip(zipData);
197
+ const files = extractZipToFiles(zipData);
198
+
199
+ return {
200
+ description: (metadata.description as string) || `Your project: ${slug}`,
201
+ secrets: (metadata.secrets as string[]) || [],
202
+ optionalSecrets: metadata.optionalSecrets as Template["optionalSecrets"],
203
+ capabilities: metadata.capabilities as Template["capabilities"],
204
+ requires: metadata.requires as Template["requires"],
205
+ hooks: metadata.hooks as Template["hooks"],
206
+ agentContext: metadata.agentContext as Template["agentContext"],
207
+ intent: metadata.intent as Template["intent"],
208
+ files,
209
+ };
210
+ }
211
+
100
212
  /**
101
- * Resolve template by name or GitHub URL
213
+ * Resolve template by name
102
214
  */
103
215
  export async function resolveTemplate(template?: string): Promise<Template> {
104
216
  // No template → hello (omakase default)
@@ -111,14 +223,26 @@ export async function resolveTemplate(template?: string): Promise<Template> {
111
223
  return loadTemplate(template);
112
224
  }
113
225
 
114
- // GitHub: user/repo or full URL → fetch from network
226
+ // username/slug format - fetch from jack cloud
115
227
  if (template.includes("/")) {
116
- const { fetchFromGitHub } = await import("../lib/github");
117
- return fetchFromGitHub(template);
228
+ const [username, slug] = template.split("/", 2);
229
+ return fetchPublishedTemplate(username, slug);
230
+ }
231
+
232
+ // Try as user's own project first
233
+ try {
234
+ const userTemplate = await fetchUserTemplate(template);
235
+ if (userTemplate) {
236
+ return userTemplate;
237
+ }
238
+ } catch (_err) {
239
+ // If auth fails or project not found, fall through to error
118
240
  }
119
241
 
120
242
  // Unknown template
121
- throw new Error(`Unknown template: ${template}\n\nAvailable: ${BUILTIN_TEMPLATES.join(", ")}`);
243
+ throw new Error(
244
+ `Unknown template: ${template}\n\nAvailable built-in templates: ${BUILTIN_TEMPLATES.join(", ")}\nOr use username/slug format for published projects`,
245
+ );
122
246
  }
123
247
 
124
248
  /**
@@ -131,9 +255,15 @@ export async function resolveTemplateWithOrigin(
131
255
  const templateName = templateOption || "hello";
132
256
 
133
257
  // Determine origin type
134
- const isGitHub = templateName.includes("/");
258
+ let originType: "builtin" | "user" | "published" = "builtin";
259
+ if (templateOption?.includes("/")) {
260
+ originType = "published";
261
+ } else if (templateOption && !BUILTIN_TEMPLATES.includes(templateOption)) {
262
+ originType = "user";
263
+ }
264
+
135
265
  const origin: TemplateOrigin = {
136
- type: isGitHub ? "github" : "builtin",
266
+ type: originType,
137
267
  name: templateName,
138
268
  };
139
269