@getjack/jack 0.1.4 → 0.1.5

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.
Files changed (54) hide show
  1. package/package.json +2 -6
  2. package/src/commands/agents.ts +9 -24
  3. package/src/commands/clone.ts +27 -0
  4. package/src/commands/down.ts +31 -57
  5. package/src/commands/feedback.ts +4 -5
  6. package/src/commands/link.ts +147 -0
  7. package/src/commands/logs.ts +8 -18
  8. package/src/commands/new.ts +7 -1
  9. package/src/commands/projects.ts +162 -105
  10. package/src/commands/secrets.ts +7 -6
  11. package/src/commands/services.ts +5 -4
  12. package/src/commands/tag.ts +282 -0
  13. package/src/commands/unlink.ts +30 -0
  14. package/src/index.ts +46 -1
  15. package/src/lib/auth/index.ts +2 -0
  16. package/src/lib/auth/store.ts +26 -2
  17. package/src/lib/binding-validator.ts +4 -13
  18. package/src/lib/build-helper.ts +93 -5
  19. package/src/lib/control-plane.ts +48 -0
  20. package/src/lib/deploy-mode.ts +1 -1
  21. package/src/lib/managed-deploy.ts +11 -1
  22. package/src/lib/managed-down.ts +7 -20
  23. package/src/lib/paths-index.test.ts +546 -0
  24. package/src/lib/paths-index.ts +310 -0
  25. package/src/lib/project-link.test.ts +459 -0
  26. package/src/lib/project-link.ts +279 -0
  27. package/src/lib/project-list.test.ts +581 -0
  28. package/src/lib/project-list.ts +445 -0
  29. package/src/lib/project-operations.ts +304 -183
  30. package/src/lib/project-resolver.ts +191 -211
  31. package/src/lib/tags.ts +389 -0
  32. package/src/lib/telemetry.ts +81 -168
  33. package/src/lib/zip-packager.ts +9 -0
  34. package/src/templates/index.ts +5 -3
  35. package/templates/api/.jack/template.json +4 -0
  36. package/templates/hello/.jack/template.json +4 -0
  37. package/templates/miniapp/.jack/template.json +4 -0
  38. package/templates/nextjs/.jack.json +28 -0
  39. package/templates/nextjs/app/globals.css +9 -0
  40. package/templates/nextjs/app/isr-test/page.tsx +22 -0
  41. package/templates/nextjs/app/layout.tsx +19 -0
  42. package/templates/nextjs/app/page.tsx +8 -0
  43. package/templates/nextjs/bun.lock +2232 -0
  44. package/templates/nextjs/cloudflare-env.d.ts +3 -0
  45. package/templates/nextjs/next-env.d.ts +6 -0
  46. package/templates/nextjs/next.config.ts +8 -0
  47. package/templates/nextjs/open-next.config.ts +6 -0
  48. package/templates/nextjs/package.json +24 -0
  49. package/templates/nextjs/public/_headers +2 -0
  50. package/templates/nextjs/tsconfig.json +44 -0
  51. package/templates/nextjs/wrangler.jsonc +17 -0
  52. package/src/lib/local-paths.test.ts +0 -902
  53. package/src/lib/local-paths.ts +0 -258
  54. package/src/lib/registry.ts +0 -181
@@ -0,0 +1,30 @@
1
+ /**
2
+ * jack unlink - Remove .jack/ directory from current project
3
+ *
4
+ * Removes the local project link but does NOT delete the project from cloud.
5
+ * You can re-link anytime with: jack link
6
+ */
7
+
8
+ import { error, info, success } from "../lib/output.ts";
9
+ import { unregisterPath } from "../lib/paths-index.ts";
10
+ import { readProjectLink, unlinkProject } from "../lib/project-link.ts";
11
+
12
+ export default async function unlink(): Promise<void> {
13
+ // Check if linked
14
+ const link = await readProjectLink(process.cwd());
15
+
16
+ if (!link) {
17
+ error("This directory is not linked");
18
+ info("Nothing to unlink");
19
+ process.exit(1);
20
+ }
21
+
22
+ // Remove from paths index
23
+ await unregisterPath(link.project_id, process.cwd());
24
+
25
+ // Remove .jack/ directory
26
+ await unlinkProject(process.cwd());
27
+
28
+ success("Project unlinked");
29
+ info("You can re-link with: jack link");
30
+ }
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import pkg from "../package.json";
4
4
  import { enableDebug } from "./lib/debug.ts";
5
5
  import { isJackError } from "./lib/errors.ts";
6
6
  import { info, error as printError } from "./lib/output.ts";
7
- import { identify, shutdown, withTelemetry } from "./lib/telemetry.ts";
7
+ import { getEnvironmentProps, identify, shutdown, withTelemetry } from "./lib/telemetry.ts";
8
8
 
9
9
  const cli = meow(
10
10
  `
@@ -35,6 +35,11 @@ const cli = meow(
35
35
  logout Sign out
36
36
  whoami Show current user
37
37
 
38
+ Project Management
39
+ link [name] Link directory to a project
40
+ unlink Remove project link
41
+ tag Manage project tags
42
+
38
43
  Advanced
39
44
  agents Manage AI agent configs
40
45
  secrets Manage project secrets
@@ -105,6 +110,18 @@ const cli = meow(
105
110
  type: "boolean",
106
111
  default: false,
107
112
  },
113
+ all: {
114
+ type: "boolean",
115
+ shortFlag: "a",
116
+ default: false,
117
+ },
118
+ status: {
119
+ type: "string",
120
+ },
121
+ json: {
122
+ type: "boolean",
123
+ default: false,
124
+ },
108
125
  project: {
109
126
  type: "string",
110
127
  shortFlag: "p",
@@ -125,6 +142,10 @@ const cli = meow(
125
142
  type: "boolean",
126
143
  default: false,
127
144
  },
145
+ tag: {
146
+ type: "string",
147
+ isMultiple: true,
148
+ },
128
149
  },
129
150
  },
130
151
  );
@@ -143,6 +164,7 @@ identify({
143
164
  arch: process.arch,
144
165
  node_version: process.version,
145
166
  is_ci: !!process.env.CI,
167
+ ...getEnvironmentProps(),
146
168
  });
147
169
 
148
170
  try {
@@ -200,6 +222,11 @@ try {
200
222
  });
201
223
  break;
202
224
  }
225
+ case "tag": {
226
+ const { default: tag } = await import("./commands/tag.ts");
227
+ await withTelemetry("tag", tag)(args[0], args.slice(1));
228
+ break;
229
+ }
203
230
  case "sync": {
204
231
  const { default: sync } = await import("./commands/sync.ts");
205
232
  await withTelemetry(
@@ -279,6 +306,14 @@ try {
279
306
  if (cli.flags.local) lsArgs.push("--local");
280
307
  if (cli.flags.deployed) lsArgs.push("--deployed");
281
308
  if (cli.flags.cloud) lsArgs.push("--cloud");
309
+ if (cli.flags.all) lsArgs.push("--all");
310
+ if (cli.flags.json) lsArgs.push("--json");
311
+ if (cli.flags.status) lsArgs.push("--status", cli.flags.status);
312
+ if (cli.flags.tag) {
313
+ for (const t of cli.flags.tag) {
314
+ lsArgs.push("--tag", t);
315
+ }
316
+ }
282
317
  await withTelemetry("projects", projects)("list", lsArgs);
283
318
  break;
284
319
  }
@@ -307,6 +342,16 @@ try {
307
342
  await withTelemetry("feedback", feedback)();
308
343
  break;
309
344
  }
345
+ case "link": {
346
+ const { default: link } = await import("./commands/link.ts");
347
+ await withTelemetry("link", link)(args[0], { byo: cli.flags.byo });
348
+ break;
349
+ }
350
+ case "unlink": {
351
+ const { default: unlink } = await import("./commands/unlink.ts");
352
+ await withTelemetry("unlink", unlink)();
353
+ break;
354
+ }
310
355
  default:
311
356
  cli.showHelp(command ? 1 : 0);
312
357
  }
@@ -9,10 +9,12 @@ export {
9
9
  export { requireAuth, requireAuthOrLogin, getCurrentUser } from "./guard.ts";
10
10
  export {
11
11
  deleteCredentials,
12
+ getAuthState,
12
13
  getCredentials,
13
14
  isLoggedIn,
14
15
  isTokenExpired,
15
16
  saveCredentials,
16
17
  type AuthCredentials,
18
+ type AuthState,
17
19
  type AuthUser,
18
20
  } from "./store.ts";
@@ -42,9 +42,33 @@ export async function deleteCredentials(): Promise<void> {
42
42
  }
43
43
  }
44
44
 
45
- export async function isLoggedIn(): Promise<boolean> {
45
+ export type AuthState = "logged-in" | "not-logged-in" | "session-expired";
46
+
47
+ /**
48
+ * Get detailed auth state
49
+ * - "logged-in": valid token (or successfully refreshed)
50
+ * - "not-logged-in": no credentials stored
51
+ * - "session-expired": had credentials but refresh failed
52
+ */
53
+ export async function getAuthState(): Promise<AuthState> {
46
54
  const creds = await getCredentials();
47
- return creds !== null;
55
+ if (!creds) return "not-logged-in";
56
+
57
+ // If token is not expired, we're logged in
58
+ if (!isTokenExpired(creds)) return "logged-in";
59
+
60
+ // If expired, try to refresh (dynamic import to avoid circular dep)
61
+ try {
62
+ const { getValidAccessToken } = await import("./client.ts");
63
+ const token = await getValidAccessToken();
64
+ return token !== null ? "logged-in" : "session-expired";
65
+ } catch {
66
+ return "session-expired";
67
+ }
68
+ }
69
+
70
+ export async function isLoggedIn(): Promise<boolean> {
71
+ return (await getAuthState()) === "logged-in";
48
72
  }
49
73
 
50
74
  export function isTokenExpired(creds: AuthCredentials): boolean {
@@ -12,7 +12,7 @@ import type { WranglerConfig } from "./build-helper.ts";
12
12
  /**
13
13
  * Bindings supported by jack cloud managed deployments.
14
14
  */
15
- export const SUPPORTED_BINDINGS = ["d1_databases", "ai", "assets", "vars"] as const;
15
+ export const SUPPORTED_BINDINGS = ["d1_databases", "ai", "assets", "vars", "r2_buckets"] as const;
16
16
 
17
17
  /**
18
18
  * Bindings not yet supported by jack cloud.
@@ -23,7 +23,6 @@ export const UNSUPPORTED_BINDINGS = [
23
23
  "durable_objects",
24
24
  "queues",
25
25
  "services",
26
- "r2_buckets",
27
26
  "hyperdrive",
28
27
  "vectorize",
29
28
  "browser",
@@ -38,7 +37,6 @@ const BINDING_DISPLAY_NAMES: Record<string, string> = {
38
37
  durable_objects: "Durable Objects",
39
38
  queues: "Queues",
40
39
  services: "Service Bindings",
41
- r2_buckets: "R2 Buckets",
42
40
  hyperdrive: "Hyperdrive",
43
41
  vectorize: "Vectorize",
44
42
  browser: "Browser Rendering",
@@ -68,16 +66,9 @@ export function validateBindings(
68
66
  const value = config[binding as keyof WranglerConfig];
69
67
  if (value !== undefined && value !== null) {
70
68
  const displayName = BINDING_DISPLAY_NAMES[binding] || binding;
71
- // Special message for R2 - suggest using Workers Assets instead
72
- if (binding === "r2_buckets") {
73
- errors.push(
74
- `✗ R2 buckets not supported in managed deploy.\n For static files, use Workers Assets instead (assets.directory in wrangler.jsonc).\n Fix: Replace r2_buckets with assets config, or use 'wrangler deploy' for full control.`,
75
- );
76
- } else {
77
- errors.push(
78
- `✗ ${displayName} not supported in managed deploy.\n Managed deploy supports: D1, AI, Assets, vars.\n Fix: Remove ${binding} from wrangler.jsonc, or use 'wrangler deploy' for full control.`,
79
- );
80
- }
69
+ errors.push(
70
+ `✗ ${displayName} not supported in managed deploy.\n Managed deploy supports: D1, AI, Assets, R2, vars.\n Fix: Remove ${binding} from wrangler.jsonc, or use 'wrangler deploy' for full control.`,
71
+ );
81
72
  }
82
73
  }
83
74
 
@@ -36,12 +36,15 @@ export interface WranglerConfig {
36
36
  run_worker_first?: boolean;
37
37
  };
38
38
  vars?: Record<string, string>;
39
+ r2_buckets?: Array<{
40
+ binding: string;
41
+ bucket_name: string;
42
+ }>;
39
43
  // Unsupported bindings (for validation)
40
44
  kv_namespaces?: unknown;
41
45
  durable_objects?: unknown;
42
46
  queues?: unknown;
43
47
  services?: unknown;
44
- r2_buckets?: unknown;
45
48
  hyperdrive?: unknown;
46
49
  vectorize?: unknown;
47
50
  browser?: unknown;
@@ -82,6 +85,18 @@ export async function needsViteBuild(projectPath: string): Promise<boolean> {
82
85
  );
83
86
  }
84
87
 
88
+ /**
89
+ * Checks if project requires OpenNext build by detecting open-next config files
90
+ * @param projectPath - Absolute path to project directory
91
+ * @returns true if open-next.config.ts or open-next.config.js exists
92
+ */
93
+ export async function needsOpenNextBuild(projectPath: string): Promise<boolean> {
94
+ return (
95
+ existsSync(join(projectPath, "open-next.config.ts")) ||
96
+ existsSync(join(projectPath, "open-next.config.js"))
97
+ );
98
+ }
99
+
85
100
  /**
86
101
  * Runs Vite build for the project
87
102
  * @param projectPath - Absolute path to project directory
@@ -105,6 +120,29 @@ export async function runViteBuild(projectPath: string): Promise<void> {
105
120
  }
106
121
  }
107
122
 
123
+ /**
124
+ * Runs OpenNext build for Next.js projects targeting Cloudflare
125
+ * @param projectPath - Absolute path to project directory
126
+ * @throws JackError if build fails
127
+ */
128
+ export async function runOpenNextBuild(projectPath: string): Promise<void> {
129
+ // OpenNext builds Next.js for Cloudflare Workers
130
+ // Outputs to .open-next/worker.js and .open-next/assets/
131
+ const buildResult = await $`bunx opennextjs-cloudflare build`.cwd(projectPath).nothrow().quiet();
132
+
133
+ if (buildResult.exitCode !== 0) {
134
+ throw new JackError(
135
+ JackErrorCode.BUILD_FAILED,
136
+ "OpenNext build failed",
137
+ "Check your next.config and source files for errors",
138
+ {
139
+ exitCode: buildResult.exitCode,
140
+ stderr: buildResult.stderr.toString(),
141
+ },
142
+ );
143
+ }
144
+ }
145
+
108
146
  /**
109
147
  * Builds a Cloudflare Worker project using wrangler dry-run
110
148
  * @param options - Build options with project path and optional reporter
@@ -117,13 +155,22 @@ export async function buildProject(options: BuildOptions): Promise<BuildOutput>
117
155
  // Parse wrangler config first
118
156
  const config = await parseWranglerConfig(projectPath);
119
157
 
120
- // Check if Vite build is needed and run it
158
+ // Check if OpenNext build is needed (Next.js + Cloudflare)
159
+ const hasOpenNext = await needsOpenNextBuild(projectPath);
160
+ if (hasOpenNext) {
161
+ reporter?.start("Building...");
162
+ await runOpenNextBuild(projectPath);
163
+ reporter?.stop();
164
+ reporter?.success("Built");
165
+ }
166
+
167
+ // Check if Vite build is needed and run it (skip if OpenNext already built)
121
168
  const hasVite = await needsViteBuild(projectPath);
122
- if (hasVite) {
123
- reporter?.start("Building with Vite...");
169
+ if (hasVite && !hasOpenNext) {
170
+ reporter?.start("Building...");
124
171
  await runViteBuild(projectPath);
125
172
  reporter?.stop();
126
- reporter?.success("Built with Vite");
173
+ reporter?.success("Built");
127
174
  }
128
175
 
129
176
  // Create unique temp directory for build output
@@ -209,3 +256,44 @@ async function resolveEntrypoint(outDir: string, main?: string): Promise<string>
209
256
  "Ensure wrangler outputs a single entry file (index.js or worker.js)",
210
257
  );
211
258
  }
259
+
260
+ /**
261
+ * Ensures R2 buckets exist for BYO deploy.
262
+ * Creates buckets via wrangler if they don't exist.
263
+ * @param projectPath - Absolute path to project directory
264
+ * @returns Array of bucket names that were created or already existed
265
+ */
266
+ export async function ensureR2Buckets(projectPath: string): Promise<string[]> {
267
+ const config = await parseWranglerConfig(projectPath);
268
+
269
+ if (!config.r2_buckets || config.r2_buckets.length === 0) {
270
+ return [];
271
+ }
272
+
273
+ const results: string[] = [];
274
+
275
+ for (const bucket of config.r2_buckets) {
276
+ const bucketName = bucket.bucket_name;
277
+
278
+ // Try to create the bucket (wrangler handles "already exists" gracefully)
279
+ const result = await $`wrangler r2 bucket create ${bucketName}`
280
+ .cwd(projectPath)
281
+ .nothrow()
282
+ .quiet();
283
+
284
+ // Exit code 0 = created, non-zero with "already exists" = fine
285
+ const stderr = result.stderr.toString();
286
+ if (result.exitCode === 0 || stderr.includes("already exists")) {
287
+ results.push(bucketName);
288
+ } else {
289
+ throw new JackError(
290
+ JackErrorCode.RESOURCE_ERROR,
291
+ `Failed to create R2 bucket: ${bucketName}`,
292
+ "Check your Cloudflare account has R2 enabled",
293
+ { stderr },
294
+ );
295
+ }
296
+ }
297
+
298
+ return results;
299
+ }
@@ -40,6 +40,7 @@ export interface CreateProjectResponse {
40
40
  status?: "live" | "created";
41
41
  url?: string;
42
42
  prebuilt_failed?: boolean;
43
+ prebuilt_error?: string;
43
44
  }
44
45
 
45
46
  export interface SlugAvailabilityResponse {
@@ -164,6 +165,7 @@ export interface ManagedProject {
164
165
  status: "active" | "error" | "deleted";
165
166
  created_at: string;
166
167
  updated_at: string;
168
+ tags?: string; // JSON string array from DB, e.g., '["backend", "api"]'
167
169
  }
168
170
 
169
171
  /**
@@ -293,3 +295,49 @@ export async function fetchProjectResources(projectId: string): Promise<ProjectR
293
295
  const data = (await response.json()) as { resources: ProjectResource[] };
294
296
  return data.resources;
295
297
  }
298
+
299
+ /**
300
+ * Sync project tags to the control plane.
301
+ * Fire-and-forget: errors are logged but not thrown.
302
+ */
303
+ export async function syncProjectTags(projectId: string, tags: string[]): Promise<void> {
304
+ const { authFetch } = await import("./auth/index.ts");
305
+ const { debug } = await import("./debug.ts");
306
+
307
+ try {
308
+ const response = await authFetch(`${getControlApiUrl()}/v1/projects/${projectId}/tags`, {
309
+ method: "PUT",
310
+ headers: { "Content-Type": "application/json" },
311
+ body: JSON.stringify({ tags }),
312
+ });
313
+
314
+ if (!response.ok) {
315
+ // Log but don't throw - tag sync is non-critical
316
+ debug(`Tag sync failed: ${response.status}`);
317
+ }
318
+ } catch (error) {
319
+ // Log but don't throw - tag sync is non-critical
320
+ debug(`Tag sync failed: ${error instanceof Error ? error.message : String(error)}`);
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Fetch project tags from the control plane.
326
+ * Returns empty array on error.
327
+ */
328
+ export async function fetchProjectTags(projectId: string): Promise<string[]> {
329
+ const { authFetch } = await import("./auth/index.ts");
330
+
331
+ try {
332
+ const response = await authFetch(`${getControlApiUrl()}/v1/projects/${projectId}/tags`);
333
+
334
+ if (!response.ok) {
335
+ return [];
336
+ }
337
+
338
+ const data = (await response.json()) as { tags: string[] };
339
+ return data.tags ?? [];
340
+ } catch {
341
+ return [];
342
+ }
343
+ }
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { $ } from "bun";
6
6
  import { isLoggedIn } from "./auth/index.ts";
7
- import type { DeployMode } from "./registry.ts";
7
+ import type { DeployMode } from "./project-link.ts";
8
8
  import { Events, track } from "./telemetry.ts";
9
9
 
10
10
  export interface ModeFlags {
@@ -6,10 +6,11 @@
6
6
 
7
7
  import { validateBindings } from "./binding-validator.ts";
8
8
  import { buildProject, parseWranglerConfig } from "./build-helper.ts";
9
- import { createManagedProject } from "./control-plane.ts";
9
+ import { createManagedProject, syncProjectTags } from "./control-plane.ts";
10
10
  import { uploadDeployment } from "./deploy-upload.ts";
11
11
  import { JackError, JackErrorCode } from "./errors.ts";
12
12
  import type { OperationReporter } from "./project-operations.ts";
13
+ import { getProjectTags } from "./tags.ts";
13
14
  import { Events, track } from "./telemetry.ts";
14
15
  import { packageForDeploy } from "./zip-packager.ts";
15
16
 
@@ -138,6 +139,15 @@ export async function deployCodeToManagedProject(
138
139
  duration_ms: Date.now() - startTime,
139
140
  });
140
141
 
142
+ // Fire-and-forget tag sync (non-blocking)
143
+ getProjectTags(projectPath)
144
+ .then((tags) => {
145
+ if (tags.length > 0) {
146
+ void syncProjectTags(projectId, tags);
147
+ }
148
+ })
149
+ .catch(() => {});
150
+
141
151
  return {
142
152
  deploymentId: result.id,
143
153
  status: result.status,
@@ -8,25 +8,22 @@ import { join } from "node:path";
8
8
  import { deleteManagedProject, exportManagedDatabase } from "./control-plane.ts";
9
9
  import { promptSelect } from "./hooks.ts";
10
10
  import { error, info, item, output, success, warn } from "./output.ts";
11
- import type { Project } from "./registry.ts";
12
- import { updateProject } from "./registry.ts";
13
11
 
14
12
  export interface ManagedDownFlags {
15
13
  force?: boolean;
16
14
  }
17
15
 
16
+ export interface ManagedProjectInfo {
17
+ projectId: string;
18
+ runjackUrl: string | null;
19
+ }
20
+
18
21
  export async function managedDown(
19
- project: Project,
22
+ project: ManagedProjectInfo,
20
23
  projectName: string,
21
24
  flags: ManagedDownFlags = {},
22
25
  ): Promise<boolean> {
23
- const remote = project.remote;
24
- if (!remote?.project_id) {
25
- throw new Error("Project is not linked to jack cloud");
26
- }
27
-
28
- const runjackUrl = remote.runjack_url;
29
- const projectId = remote.project_id;
26
+ const { projectId, runjackUrl } = project;
30
27
 
31
28
  // Force mode - quick deletion without prompts
32
29
  if (flags.force) {
@@ -39,11 +36,6 @@ export async function managedDown(
39
36
  await deleteManagedProject(projectId);
40
37
  output.stop();
41
38
 
42
- await updateProject(projectName, {
43
- workerUrl: null,
44
- lastDeployed: null,
45
- });
46
-
47
39
  console.error("");
48
40
  success(`'${projectName}' undeployed`);
49
41
  info("Database and backups were deleted");
@@ -142,11 +134,6 @@ export async function managedDown(
142
134
  }
143
135
  }
144
136
 
145
- await updateProject(projectName, {
146
- workerUrl: null,
147
- lastDeployed: null,
148
- });
149
-
150
137
  console.error("");
151
138
  success(`Project '${projectName}' undeployed`);
152
139
  console.error("");