@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
@@ -1,30 +1,28 @@
1
- import { PostHog } from "posthog-node";
2
1
  import type { TelemetryConfig } from "./telemetry-config.ts";
3
2
  import { getTelemetryConfig, setTelemetryEnabled } from "./telemetry-config.ts";
4
3
 
4
+ // Telemetry proxy endpoint (keeps PostHog API key secret)
5
+ // Override with TELEMETRY_PROXY_URL for local testing
6
+ const TELEMETRY_PROXY = process.env.TELEMETRY_PROXY_URL || "https://telemetry.getjack.org";
7
+
8
+ // Session ID - unique per CLI invocation, groups related events
9
+ const SESSION_ID = crypto.randomUUID();
10
+
5
11
  // ============================================
6
- // EVENT REGISTRY - Single source of truth
7
- // Add new events here, they become type-safe
12
+ // EVENT REGISTRY
8
13
  // ============================================
9
14
  export const Events = {
10
- // Automatic (via wrapper)
11
15
  COMMAND_INVOKED: "command_invoked",
12
16
  COMMAND_COMPLETED: "command_completed",
13
17
  COMMAND_FAILED: "command_failed",
14
-
15
- // Business events (for future use)
16
18
  PROJECT_CREATED: "project_created",
17
19
  DEPLOY_STARTED: "deploy_started",
18
20
  CONFIG_CHANGED: "config_changed",
19
-
20
- // Intent-driven creation events
21
21
  INTENT_MATCHED: "intent_matched",
22
22
  INTENT_NO_MATCH: "intent_no_match",
23
23
  INTENT_CUSTOMIZATION_STARTED: "intent_customization_started",
24
24
  INTENT_CUSTOMIZATION_COMPLETED: "intent_customization_completed",
25
25
  INTENT_CUSTOMIZATION_FAILED: "intent_customization_failed",
26
-
27
- // Deploy mode events
28
26
  DEPLOY_MODE_SELECTED: "deploy_mode_selected",
29
27
  MANAGED_PROJECT_CREATED: "managed_project_created",
30
28
  MANAGED_DEPLOY_STARTED: "managed_deploy_started",
@@ -34,72 +32,59 @@ export const Events = {
34
32
 
35
33
  type EventName = (typeof Events)[keyof typeof Events];
36
34
 
37
- // Re-export config functions for convenience
38
35
  export { getTelemetryConfig, setTelemetryEnabled };
39
36
 
40
37
  // ============================================
41
- // CLIENT SETUP
38
+ // STATE
42
39
  // ============================================
43
- let client: PostHog | null = null;
44
40
  let telemetryConfig: TelemetryConfig | null = null;
41
+ let enabledCache: boolean | null = null;
42
+ let userProps: Partial<UserProperties> = {};
43
+
44
+ // ============================================
45
+ // FIRE-AND-FORGET SEND (detached subprocess)
46
+ // ============================================
47
+ function send(url: string, data: object): void {
48
+ const payload = Buffer.from(JSON.stringify(data)).toString("base64");
49
+
50
+ // Spawn detached process that sends HTTP and exits
51
+ // Parent doesn't wait - child outlives parent
52
+ const proc = Bun.spawn(
53
+ [
54
+ "bun",
55
+ "-e",
56
+ `await fetch("${url}",{method:"POST",headers:{"Content-Type":"application/json"},body:Buffer.from("${payload}","base64").toString()}).catch(()=>{})`,
57
+ ],
58
+ {
59
+ detached: true,
60
+ stdio: ["ignore", "ignore", "ignore"],
61
+ },
62
+ );
63
+ proc.unref();
64
+ }
45
65
 
46
- /**
47
- * Check if telemetry is enabled based on environment and config
48
- * Priority order:
49
- * 1. DO_NOT_TRACK=1 -> disabled
50
- * 2. CI=true -> disabled
51
- * 3. JACK_TELEMETRY_DISABLED=1 -> disabled
52
- * 4. Config file enabled: false -> disabled
53
- * 5. Default -> enabled
54
- */
66
+ // ============================================
67
+ // HELPERS
68
+ // ============================================
55
69
  async function isEnabled(): Promise<boolean> {
56
- // Environment variable checks (highest priority)
57
- if (process.env.DO_NOT_TRACK === "1") return false;
58
- if (process.env.CI === "true") return false;
59
- if (process.env.JACK_TELEMETRY_DISABLED === "1") return false;
70
+ if (enabledCache !== null) return enabledCache;
71
+
72
+ if (process.env.DO_NOT_TRACK === "1" || process.env.CI === "true" || process.env.JACK_TELEMETRY_DISABLED === "1") {
73
+ enabledCache = false;
74
+ return false;
75
+ }
60
76
 
61
- // Check config file
62
77
  try {
63
78
  const config = await getTelemetryConfig();
64
79
  telemetryConfig = config;
80
+ enabledCache = config.enabled;
65
81
  return config.enabled;
66
82
  } catch {
67
- // If config loading fails, default to enabled
83
+ enabledCache = true;
68
84
  return true;
69
85
  }
70
86
  }
71
87
 
72
- /**
73
- * Get or initialize PostHog client
74
- * Returns null if telemetry is disabled or API key is missing
75
- */
76
- async function getClient(): Promise<PostHog | null> {
77
- const enabled = await isEnabled();
78
- if (!enabled) return null;
79
-
80
- // Lazy initialization
81
- if (!client) {
82
- const apiKey = process.env.POSTHOG_API_KEY;
83
- if (!apiKey) return null;
84
-
85
- try {
86
- client = new PostHog(apiKey, {
87
- host: "https://us.i.posthog.com",
88
- flushAt: 1, // Flush immediately (CLI is short-lived)
89
- flushInterval: 0, // No delay
90
- });
91
- } catch {
92
- // Silent failure - never block execution
93
- return null;
94
- }
95
- }
96
-
97
- return client;
98
- }
99
-
100
- /**
101
- * Get anonymous ID from config
102
- */
103
88
  async function getAnonymousId(): Promise<string> {
104
89
  if (!telemetryConfig) {
105
90
  telemetryConfig = await getTelemetryConfig();
@@ -108,7 +93,7 @@ async function getAnonymousId(): Promise<string> {
108
93
  }
109
94
 
110
95
  // ============================================
111
- // USER PROPERTIES - Set once, sent with all events
96
+ // USER PROPERTIES
112
97
  // ============================================
113
98
  export interface UserProperties {
114
99
  jack_version: string;
@@ -116,71 +101,71 @@ export interface UserProperties {
116
101
  arch: string;
117
102
  node_version: string;
118
103
  is_ci: boolean;
104
+ shell?: string;
105
+ terminal?: string;
106
+ terminal_width?: number;
107
+ is_tty?: boolean;
108
+ locale?: string;
119
109
  config_style?: "byoc" | "jack-cloud";
120
110
  }
121
111
 
122
- let userProps: Partial<UserProperties> = {};
112
+ // Detect environment properties
113
+ export function getEnvironmentProps(): Pick<UserProperties, "shell" | "terminal" | "terminal_width" | "is_tty" | "locale"> {
114
+ return {
115
+ shell: process.env.SHELL?.split("/").pop(), // e.g., /bin/zsh -> zsh
116
+ terminal: process.env.TERM_PROGRAM, // e.g., iTerm.app, vscode, Apple_Terminal
117
+ terminal_width: process.stdout.columns,
118
+ is_tty: process.stdout.isTTY ?? false,
119
+ locale: Intl.DateTimeFormat().resolvedOptions().locale,
120
+ };
121
+ }
123
122
 
124
- /**
125
- * Set user properties (sent with all events)
126
- * Safe to call multiple times - properties are merged
127
- */
128
123
  export async function identify(properties: Partial<UserProperties>): Promise<void> {
129
124
  userProps = { ...userProps, ...properties };
130
-
131
- const ph = await getClient();
132
- if (!ph) return;
125
+ if (!(await isEnabled())) return;
133
126
 
134
127
  try {
135
128
  const distinctId = await getAnonymousId();
136
- ph.identify({
129
+ send(`${TELEMETRY_PROXY}/identify`, {
137
130
  distinctId,
138
131
  properties: userProps,
132
+ setOnce: { first_seen: new Date().toISOString() }, // Only sets on first identify
139
133
  });
140
134
  } catch {
141
- // Silent failure - never block execution
135
+ // Silent
142
136
  }
143
137
  }
144
138
 
145
139
  // ============================================
146
- // TRACK - Fire-and-forget event tracking
140
+ // TRACK
147
141
  // ============================================
148
- /**
149
- * Track an event with optional properties
150
- * This is fire-and-forget and will never throw or block
151
- */
152
142
  export async function track(event: EventName, properties?: Record<string, unknown>): Promise<void> {
153
- const ph = await getClient();
154
- if (!ph) return;
143
+ if (!(await isEnabled())) return;
155
144
 
156
145
  try {
157
146
  const distinctId = await getAnonymousId();
158
- ph.capture({
147
+ send(`${TELEMETRY_PROXY}/t`, {
159
148
  distinctId,
160
149
  event,
161
150
  properties: {
162
151
  ...properties,
163
152
  ...userProps,
164
- timestamp: Date.now(),
153
+ $session_id: SESSION_ID, // Groups events from same CLI invocation
165
154
  },
155
+ timestamp: Date.now(),
166
156
  });
167
157
  } catch {
168
- // Silent failure - never block execution
158
+ // Silent
169
159
  }
170
160
  }
171
161
 
172
162
  // ============================================
173
- // THE MAGIC: withTelemetry() wrapper
174
- // Commands wrapped with this get automatic tracking
163
+ // WRAPPER
175
164
  // ============================================
176
165
  export interface TelemetryOptions {
177
166
  platform?: "cli" | "mcp";
178
167
  }
179
168
 
180
- /**
181
- * Wrap a command function with automatic telemetry tracking
182
- * Tracks command_invoked, command_completed, and command_failed events
183
- */
184
169
  // biome-ignore lint/suspicious/noExplicitAny: Required for flexible command wrapping
185
170
  export function withTelemetry<T extends (...args: any[]) => Promise<any>>(
186
171
  commandName: string,
@@ -190,109 +175,37 @@ export function withTelemetry<T extends (...args: any[]) => Promise<any>>(
190
175
  const platform = options?.platform ?? "cli";
191
176
 
192
177
  return (async (...args: Parameters<T>) => {
193
- // Fire-and-forget: don't await track() to avoid blocking command execution
194
178
  track(Events.COMMAND_INVOKED, { command: commandName, platform });
195
179
  const start = Date.now();
196
180
 
197
181
  try {
198
182
  const result = await fn(...args);
199
- track(Events.COMMAND_COMPLETED, {
200
- command: commandName,
201
- platform,
202
- duration_ms: Date.now() - start,
203
- });
183
+ track(Events.COMMAND_COMPLETED, { command: commandName, platform, duration_ms: Date.now() - start });
204
184
  return result;
205
185
  } catch (error) {
206
- track(Events.COMMAND_FAILED, {
207
- command: commandName,
208
- platform,
209
- error_type: classifyError(error),
210
- duration_ms: Date.now() - start,
211
- });
186
+ track(Events.COMMAND_FAILED, { command: commandName, platform, error_type: classifyError(error), duration_ms: Date.now() - start });
212
187
  throw error;
213
188
  }
214
189
  }) as T;
215
190
  }
216
191
 
217
192
  // ============================================
218
- // SHUTDOWN - Call before process exit
193
+ // SHUTDOWN - No-op (detached processes handle themselves)
219
194
  // ============================================
220
- /**
221
- * Gracefully shutdown telemetry client
222
- * Times out after 500ms to never block CLI exit
223
- */
224
195
  export async function shutdown(): Promise<void> {
225
- if (!client) return;
226
-
227
- try {
228
- await Promise.race([
229
- client.shutdown(),
230
- new Promise((_, reject) =>
231
- setTimeout(() => reject(new Error("Telemetry shutdown timeout")), 500),
232
- ),
233
- ]);
234
- } catch {
235
- // Silent failure - never block exit
236
- }
196
+ // No-op - detached subprocesses send telemetry independently
237
197
  }
238
198
 
239
199
  // ============================================
240
200
  // ERROR CLASSIFICATION
241
201
  // ============================================
242
- /**
243
- * Classify error into broad categories for analytics
244
- * Returns: 'validation' | 'network' | 'build' | 'deploy' | 'config' | 'unknown'
245
- */
246
202
  function classifyError(error: unknown): string {
247
- const msg = (error as Error)?.message || "";
248
- const errorStr = String(error).toLowerCase();
249
- const combined = `${msg} ${errorStr}`.toLowerCase();
250
-
251
- // Check for validation errors
252
- if (
253
- combined.includes("validation") ||
254
- combined.includes("invalid") ||
255
- combined.includes("required") ||
256
- combined.includes("missing")
257
- ) {
258
- return "validation";
259
- }
260
-
261
- // Check for network errors
262
- if (
263
- combined.includes("enotfound") ||
264
- combined.includes("etimedout") ||
265
- combined.includes("econnrefused") ||
266
- combined.includes("network") ||
267
- combined.includes("fetch")
268
- ) {
269
- return "network";
270
- }
271
-
272
- // Check for build errors
273
- if (
274
- combined.includes("vite") ||
275
- combined.includes("build") ||
276
- combined.includes("compile") ||
277
- combined.includes("bundle")
278
- ) {
279
- return "build";
280
- }
281
-
282
- // Check for deploy errors
283
- if (combined.includes("deploy") || combined.includes("publish") || combined.includes("upload")) {
284
- return "deploy";
285
- }
286
-
287
- // Check for config errors
288
- if (
289
- combined.includes("wrangler") ||
290
- combined.includes("config") ||
291
- combined.includes("toml") ||
292
- combined.includes("json")
293
- ) {
294
- return "config";
295
- }
203
+ const combined = `${(error as Error)?.message || ""} ${String(error)}`.toLowerCase();
296
204
 
205
+ if (/validation|invalid|required|missing/.test(combined)) return "validation";
206
+ if (/enotfound|etimedout|econnrefused|network|fetch/.test(combined)) return "network";
207
+ if (/vite|build|compile|bundle/.test(combined)) return "build";
208
+ if (/deploy|publish|upload/.test(combined)) return "deploy";
209
+ if (/wrangler|config|toml|json/.test(combined)) return "config";
297
210
  return "unknown";
298
211
  }
@@ -41,6 +41,7 @@ export interface ManifestData {
41
41
  | "none";
42
42
  };
43
43
  vars?: Record<string, string>;
44
+ r2?: Array<{ binding: string; bucket_name: string }>;
44
45
  };
45
46
  }
46
47
 
@@ -160,6 +161,14 @@ function extractBindingsFromConfig(config?: WranglerConfig): ManifestData["bindi
160
161
  bindings.vars = config.vars;
161
162
  }
162
163
 
164
+ // Extract R2 bucket bindings (support multiple)
165
+ if (config.r2_buckets && config.r2_buckets.length > 0) {
166
+ bindings.r2 = config.r2_buckets.map((bucket) => ({
167
+ binding: bucket.binding,
168
+ bucket_name: bucket.bucket_name,
169
+ }));
170
+ }
171
+
163
172
  // Return undefined if no bindings were extracted
164
173
  return Object.keys(bindings).length > 0 ? bindings : undefined;
165
174
  }
@@ -2,13 +2,13 @@ import { existsSync } from "node:fs";
2
2
  import { readFile, readdir } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import { parseJsonc } from "../lib/jsonc.ts";
5
- import type { TemplateOrigin } from "../lib/registry.ts";
5
+ import type { TemplateMetadata as TemplateOrigin } from "../lib/project-link.ts";
6
6
  import type { Template } from "./types";
7
7
 
8
8
  // Resolve templates directory relative to this file (src/templates -> templates)
9
9
  const TEMPLATES_DIR = join(dirname(dirname(import.meta.dir)), "templates");
10
10
 
11
- export const BUILTIN_TEMPLATES = ["hello", "miniapp", "api"];
11
+ export const BUILTIN_TEMPLATES = ["hello", "miniapp", "api", "nextjs"];
12
12
 
13
13
  /**
14
14
  * Resolved template with origin tracking for lineage
@@ -28,6 +28,7 @@ async function readTemplateFiles(dir: string, base = ""): Promise<Record<string,
28
28
  // Skip these directories/files (but keep bun.lock for faster installs)
29
29
  const SKIP = [
30
30
  ".jack.json",
31
+ ".jack", // Skip .jack directory (template.json is for origin tracking, not project files)
31
32
  "node_modules",
32
33
  ".git",
33
34
  "package-lock.json",
@@ -149,9 +150,10 @@ export async function resolveTemplateWithOrigin(
149
150
  export function renderTemplate(template: Template, vars: { name: string }): Template {
150
151
  const rendered: Record<string, string> = {};
151
152
  for (const [path, content] of Object.entries(template.files)) {
152
- // Replace -db variant first to avoid partial matches
153
+ // Replace suffixed variants first to avoid partial matches
153
154
  rendered[path] = content
154
155
  .replace(/jack-template-db/g, `${vars.name}-db`)
156
+ .replace(/jack-template-cache/g, `${vars.name}-cache`)
155
157
  .replace(/jack-template/g, vars.name);
156
158
  }
157
159
  return { ...template, files: rendered };
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "builtin",
3
+ "name": "api"
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "builtin",
3
+ "name": "hello"
4
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "type": "builtin",
3
+ "name": "miniapp"
4
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "nextjs",
3
+ "description": "Next.js App (SSR on Cloudflare)",
4
+ "secrets": [],
5
+ "capabilities": [],
6
+ "intent": {
7
+ "keywords": ["next", "nextjs", "react", "ssr", "full-stack", "server components", "rsc"],
8
+ "examples": ["next.js app", "server-rendered react", "react ssr"]
9
+ },
10
+ "agentContext": {
11
+ "summary": "A Next.js app deployed with jack. Supports SSR, SSG, and React Server Components.",
12
+ "full_text": "## Project Structure\n\n- `app/` - Next.js App Router pages and layouts\n- `public/` - Static assets\n- `open-next.config.ts` - OpenNext configuration\n- `wrangler.jsonc` - Worker configuration\n\n## Commands\n\n- `bun run dev` - Local development\n- `jack ship` - Deploy to production\n- `bun run preview` - Preview production build locally\n\n## Conventions\n\n- Uses App Router (not Pages Router)\n- SSR and SSG work out of the box\n- Server Components supported\n\n## Resources\n\n- [OpenNext Docs](https://opennext.js.org/cloudflare)\n- [Next.js App Router](https://nextjs.org/docs/app)"
13
+ },
14
+ "hooks": {
15
+ "postDeploy": [
16
+ {
17
+ "action": "clipboard",
18
+ "text": "{{url}}",
19
+ "message": "Deploy URL copied to clipboard"
20
+ },
21
+ {
22
+ "action": "box",
23
+ "title": "Deployed: {{name}}",
24
+ "lines": ["URL: {{url}}", "", "Next.js app is live!"]
25
+ }
26
+ ]
27
+ }
28
+ }
@@ -0,0 +1,9 @@
1
+ * {
2
+ box-sizing: border-box;
3
+ margin: 0;
4
+ padding: 0;
5
+ }
6
+
7
+ body {
8
+ font-family: system-ui, -apple-system, sans-serif;
9
+ }
@@ -0,0 +1,22 @@
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
+ }
@@ -0,0 +1,19 @@
1
+ import type { Metadata } from 'next';
2
+ import './globals.css';
3
+
4
+ export const metadata: Metadata = {
5
+ title: 'jack-template',
6
+ description: 'Next.js app built with jack',
7
+ };
8
+
9
+ export default function RootLayout({
10
+ children,
11
+ }: {
12
+ children: React.ReactNode;
13
+ }) {
14
+ return (
15
+ <html lang="en">
16
+ <body>{children}</body>
17
+ </html>
18
+ );
19
+ }
@@ -0,0 +1,8 @@
1
+ export default function Home() {
2
+ return (
3
+ <main style={{ padding: '2rem', fontFamily: 'system-ui' }}>
4
+ <h1>jack-template</h1>
5
+ <p>Ship it with <code>jack ship</code></p>
6
+ </main>
7
+ );
8
+ }