@getjack/jack 0.1.4 → 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.
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/login.ts +124 -1
  8. package/src/commands/logs.ts +8 -18
  9. package/src/commands/new.ts +7 -1
  10. package/src/commands/projects.ts +166 -105
  11. package/src/commands/secrets.ts +7 -6
  12. package/src/commands/services.ts +5 -4
  13. package/src/commands/tag.ts +282 -0
  14. package/src/commands/unlink.ts +30 -0
  15. package/src/index.ts +46 -1
  16. package/src/lib/auth/index.ts +2 -0
  17. package/src/lib/auth/store.ts +26 -2
  18. package/src/lib/binding-validator.ts +4 -13
  19. package/src/lib/build-helper.ts +93 -5
  20. package/src/lib/control-plane.ts +137 -0
  21. package/src/lib/deploy-mode.ts +1 -1
  22. package/src/lib/managed-deploy.ts +11 -1
  23. package/src/lib/managed-down.ts +7 -20
  24. package/src/lib/paths-index.test.ts +546 -0
  25. package/src/lib/paths-index.ts +310 -0
  26. package/src/lib/project-link.test.ts +459 -0
  27. package/src/lib/project-link.ts +279 -0
  28. package/src/lib/project-list.test.ts +581 -0
  29. package/src/lib/project-list.ts +449 -0
  30. package/src/lib/project-operations.ts +304 -183
  31. package/src/lib/project-resolver.ts +191 -211
  32. package/src/lib/tags.ts +389 -0
  33. package/src/lib/telemetry.ts +86 -157
  34. package/src/lib/zip-packager.ts +9 -0
  35. package/src/templates/index.ts +5 -3
  36. package/templates/api/.jack/template.json +4 -0
  37. package/templates/hello/.jack/template.json +4 -0
  38. package/templates/miniapp/.jack/template.json +4 -0
  39. package/templates/nextjs/.jack.json +28 -0
  40. package/templates/nextjs/app/globals.css +9 -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,63 @@ 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> = {};
45
43
 
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
- */
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
+ }
65
+
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 (
73
+ process.env.DO_NOT_TRACK === "1" ||
74
+ process.env.CI === "true" ||
75
+ process.env.JACK_TELEMETRY_DISABLED === "1"
76
+ ) {
77
+ enabledCache = false;
78
+ return false;
79
+ }
60
80
 
61
- // Check config file
62
81
  try {
63
82
  const config = await getTelemetryConfig();
64
83
  telemetryConfig = config;
84
+ enabledCache = config.enabled;
65
85
  return config.enabled;
66
86
  } catch {
67
- // If config loading fails, default to enabled
87
+ enabledCache = true;
68
88
  return true;
69
89
  }
70
90
  }
71
91
 
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
92
  async function getAnonymousId(): Promise<string> {
104
93
  if (!telemetryConfig) {
105
94
  telemetryConfig = await getTelemetryConfig();
@@ -108,7 +97,7 @@ async function getAnonymousId(): Promise<string> {
108
97
  }
109
98
 
110
99
  // ============================================
111
- // USER PROPERTIES - Set once, sent with all events
100
+ // USER PROPERTIES
112
101
  // ============================================
113
102
  export interface UserProperties {
114
103
  jack_version: string;
@@ -116,71 +105,74 @@ export interface UserProperties {
116
105
  arch: string;
117
106
  node_version: string;
118
107
  is_ci: boolean;
108
+ shell?: string;
109
+ terminal?: string;
110
+ terminal_width?: number;
111
+ is_tty?: boolean;
112
+ locale?: string;
119
113
  config_style?: "byoc" | "jack-cloud";
120
114
  }
121
115
 
122
- let userProps: Partial<UserProperties> = {};
116
+ // Detect environment properties
117
+ export function getEnvironmentProps(): Pick<
118
+ UserProperties,
119
+ "shell" | "terminal" | "terminal_width" | "is_tty" | "locale"
120
+ > {
121
+ return {
122
+ shell: process.env.SHELL?.split("/").pop(), // e.g., /bin/zsh -> zsh
123
+ terminal: process.env.TERM_PROGRAM, // e.g., iTerm.app, vscode, Apple_Terminal
124
+ terminal_width: process.stdout.columns,
125
+ is_tty: process.stdout.isTTY ?? false,
126
+ locale: Intl.DateTimeFormat().resolvedOptions().locale,
127
+ };
128
+ }
123
129
 
124
- /**
125
- * Set user properties (sent with all events)
126
- * Safe to call multiple times - properties are merged
127
- */
128
130
  export async function identify(properties: Partial<UserProperties>): Promise<void> {
129
131
  userProps = { ...userProps, ...properties };
130
-
131
- const ph = await getClient();
132
- if (!ph) return;
132
+ if (!(await isEnabled())) return;
133
133
 
134
134
  try {
135
135
  const distinctId = await getAnonymousId();
136
- ph.identify({
136
+ send(`${TELEMETRY_PROXY}/identify`, {
137
137
  distinctId,
138
138
  properties: userProps,
139
+ setOnce: { first_seen: new Date().toISOString() }, // Only sets on first identify
139
140
  });
140
141
  } catch {
141
- // Silent failure - never block execution
142
+ // Silent
142
143
  }
143
144
  }
144
145
 
145
146
  // ============================================
146
- // TRACK - Fire-and-forget event tracking
147
+ // TRACK
147
148
  // ============================================
148
- /**
149
- * Track an event with optional properties
150
- * This is fire-and-forget and will never throw or block
151
- */
152
149
  export async function track(event: EventName, properties?: Record<string, unknown>): Promise<void> {
153
- const ph = await getClient();
154
- if (!ph) return;
150
+ if (!(await isEnabled())) return;
155
151
 
156
152
  try {
157
153
  const distinctId = await getAnonymousId();
158
- ph.capture({
154
+ send(`${TELEMETRY_PROXY}/t`, {
159
155
  distinctId,
160
156
  event,
161
157
  properties: {
162
158
  ...properties,
163
159
  ...userProps,
164
- timestamp: Date.now(),
160
+ $session_id: SESSION_ID, // Groups events from same CLI invocation
165
161
  },
162
+ timestamp: Date.now(),
166
163
  });
167
164
  } catch {
168
- // Silent failure - never block execution
165
+ // Silent
169
166
  }
170
167
  }
171
168
 
172
169
  // ============================================
173
- // THE MAGIC: withTelemetry() wrapper
174
- // Commands wrapped with this get automatic tracking
170
+ // WRAPPER
175
171
  // ============================================
176
172
  export interface TelemetryOptions {
177
173
  platform?: "cli" | "mcp";
178
174
  }
179
175
 
180
- /**
181
- * Wrap a command function with automatic telemetry tracking
182
- * Tracks command_invoked, command_completed, and command_failed events
183
- */
184
176
  // biome-ignore lint/suspicious/noExplicitAny: Required for flexible command wrapping
185
177
  export function withTelemetry<T extends (...args: any[]) => Promise<any>>(
186
178
  commandName: string,
@@ -190,7 +182,6 @@ export function withTelemetry<T extends (...args: any[]) => Promise<any>>(
190
182
  const platform = options?.platform ?? "cli";
191
183
 
192
184
  return (async (...args: Parameters<T>) => {
193
- // Fire-and-forget: don't await track() to avoid blocking command execution
194
185
  track(Events.COMMAND_INVOKED, { command: commandName, platform });
195
186
  const start = Date.now();
196
187
 
@@ -215,84 +206,22 @@ export function withTelemetry<T extends (...args: any[]) => Promise<any>>(
215
206
  }
216
207
 
217
208
  // ============================================
218
- // SHUTDOWN - Call before process exit
209
+ // SHUTDOWN - No-op (detached processes handle themselves)
219
210
  // ============================================
220
- /**
221
- * Gracefully shutdown telemetry client
222
- * Times out after 500ms to never block CLI exit
223
- */
224
211
  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
- }
212
+ // No-op - detached subprocesses send telemetry independently
237
213
  }
238
214
 
239
215
  // ============================================
240
216
  // ERROR CLASSIFICATION
241
217
  // ============================================
242
- /**
243
- * Classify error into broad categories for analytics
244
- * Returns: 'validation' | 'network' | 'build' | 'deploy' | 'config' | 'unknown'
245
- */
246
218
  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
- }
219
+ const combined = `${(error as Error)?.message || ""} ${String(error)}`.toLowerCase();
296
220
 
221
+ if (/validation|invalid|required|missing/.test(combined)) return "validation";
222
+ if (/enotfound|etimedout|econnrefused|network|fetch/.test(combined)) return "network";
223
+ if (/vite|build|compile|bundle/.test(combined)) return "build";
224
+ if (/deploy|publish|upload/.test(combined)) return "deploy";
225
+ if (/wrangler|config|toml|json/.test(combined)) return "config";
297
226
  return "unknown";
298
227
  }
@@ -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,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
+ }