@getjack/jack 0.1.8 → 0.1.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getjack/jack",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Ship before you forget why you started. The vibecoder's deployment CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -6,7 +6,8 @@ import {
6
6
  getCurrentUserProfile,
7
7
  setUsername,
8
8
  } from "../lib/control-plane.ts";
9
- import { error, info, spinner, success, warn } from "../lib/output.ts";
9
+ import { celebrate, error, info, spinner, success, warn } from "../lib/output.ts";
10
+ import { identifyUser } from "../lib/telemetry.ts";
10
11
 
11
12
  interface LoginOptions {
12
13
  /** Skip the initial "Logging in..." message (used when called from auto-login) */
@@ -31,13 +32,7 @@ export default async function login(options: LoginOptions = {}): Promise<void> {
31
32
  process.exit(1);
32
33
  }
33
34
 
34
- console.error("");
35
- console.error(" ┌────────────────────────────────────┐");
36
- console.error(" │ │");
37
- console.error(` │ Your code: ${deviceAuth.user_code.padEnd(12)} │`);
38
- console.error(" │ │");
39
- console.error(" └────────────────────────────────────┘");
40
- console.error("");
35
+ celebrate("Your code:", [deviceAuth.user_code]);
41
36
  info(`Opening ${deviceAuth.verification_uri} in your browser...`);
42
37
  console.error("");
43
38
 
@@ -73,8 +68,12 @@ export default async function login(options: LoginOptions = {}): Promise<void> {
73
68
  };
74
69
  await saveCredentials(creds);
75
70
 
71
+ // Link user identity for cross-platform analytics
72
+ await identifyUser(tokens.user.id, { email: tokens.user.email });
73
+
76
74
  console.error("");
77
- success(`Logged in as ${tokens.user.email}`);
75
+ const displayName = tokens.user.first_name || "Logged in";
76
+ success(tokens.user.first_name ? `Welcome back, ${displayName}` : displayName);
78
77
 
79
78
  // Prompt for username if not set
80
79
  await promptForUsername(tokens.user.email);
@@ -209,3 +208,4 @@ function normalizeToUsername(input: string): string {
209
208
  .replace(/^-+|-+$/g, "")
210
209
  .slice(0, 39);
211
210
  }
211
+
@@ -59,6 +59,7 @@ export default async function newProject(
59
59
  error: output.error,
60
60
  success: output.success,
61
61
  box: output.box,
62
+ celebrate: output.celebrate,
62
63
  },
63
64
  interactive: !isCi,
64
65
  managed: options.managed,
@@ -18,11 +18,7 @@ import {
18
18
  sortByUpdated,
19
19
  toListItems,
20
20
  } from "../lib/project-list.ts";
21
- import {
22
- cleanupStaleProjects,
23
- getProjectStatus,
24
- scanStaleProjects,
25
- } from "../lib/project-operations.ts";
21
+ import { cleanupStaleProjects, scanStaleProjects } from "../lib/project-operations.ts";
26
22
  import {
27
23
  type ResolvedProject,
28
24
  listAllProjects,
@@ -104,7 +100,7 @@ async function listProjects(args: string[]): Promise<void> {
104
100
  const cloudOnly = args.includes("--cloud");
105
101
 
106
102
  // Fetch all projects from registry and control plane
107
- outputSpinner.start("Checking project status...");
103
+ outputSpinner.start("Loading projects...");
108
104
  const projects: ResolvedProject[] = await listAllProjects();
109
105
  outputSpinner.stop();
110
106
 
@@ -154,6 +150,7 @@ async function listProjects(args: string[]): Promise<void> {
154
150
  function renderGroupedView(items: ProjectListItem[]): void {
155
151
  const groups = groupProjects(items);
156
152
  const total = items.length;
153
+ const CLOUD_LIMIT = 5;
157
154
 
158
155
  // Build consistent tag color map across all projects
159
156
  const tagColorMap = buildTagColorMap(items);
@@ -178,7 +175,6 @@ function renderGroupedView(items: ProjectListItem[]): void {
178
175
 
179
176
  // Section 3: Cloud-only (show last N by updatedAt)
180
177
  if (groups.cloudOnly.length > 0) {
181
- const CLOUD_LIMIT = 5;
182
178
  const sorted = sortByUpdated(groups.cloudOnly);
183
179
 
184
180
  console.error("");
@@ -191,9 +187,14 @@ function renderGroupedView(items: ProjectListItem[]): void {
191
187
  );
192
188
  }
193
189
 
194
- // Footer hint
190
+ // Footer hint - only show --all hint if there are hidden cloud projects
195
191
  console.error("");
196
- info("jack ls --all for full list, --status error to filter");
192
+ const hasHiddenCloudProjects = groups.cloudOnly.length > CLOUD_LIMIT;
193
+ if (hasHiddenCloudProjects) {
194
+ info(`jack ls --all to see all ${groups.cloudOnly.length} cloud projects`);
195
+ } else {
196
+ info("jack ls --status error to filter, --json for machine output");
197
+ }
197
198
  console.error("");
198
199
  }
199
200
 
@@ -247,6 +248,7 @@ function renderFlatTable(items: ProjectListItem[]): void {
247
248
  * Show detailed project info
248
249
  */
249
250
  async function infoProject(args: string[]): Promise<void> {
251
+ const hasExplicitName = Boolean(args[0]);
250
252
  let name = args[0];
251
253
 
252
254
  // If no name provided, try to get from cwd
@@ -261,80 +263,82 @@ async function infoProject(args: string[]): Promise<void> {
261
263
  }
262
264
  }
263
265
 
264
- // Check actual status (with spinner for API calls)
266
+ // Resolve project using the same pattern as down.ts
265
267
  outputSpinner.start("Fetching project info...");
266
- const status = await getProjectStatus(name);
268
+ const resolved = await resolveProject(name, {
269
+ preferLocalLink: !hasExplicitName,
270
+ includeResources: true,
271
+ });
267
272
  outputSpinner.stop();
268
273
 
269
- if (!status) {
270
- error(`Project "${name}" not found in registry`);
274
+ // Guard against mismatched resolutions when an explicit name is provided
275
+ if (hasExplicitName && resolved) {
276
+ const matches =
277
+ name === resolved.slug || name === resolved.name || name === resolved.remote?.projectId;
278
+ if (!matches) {
279
+ error(`Project '${name}' resolves to '${resolved.slug}'.`);
280
+ info("Use the exact slug/name and try again.");
281
+ process.exit(1);
282
+ }
283
+ }
284
+
285
+ if (!resolved) {
286
+ error(`Project "${name}" not found`);
271
287
  info("List projects with: jack projects list");
272
288
  process.exit(1);
273
289
  }
274
290
 
275
291
  console.error("");
276
- info(`Project: ${status.name}`);
292
+ info(`Project: ${resolved.name}`);
277
293
  console.error("");
278
294
 
279
295
  // Status section
280
296
  const statuses: string[] = [];
281
- if (status.local) {
297
+ if (resolved.sources.filesystem) {
282
298
  statuses.push("local");
283
299
  }
284
- if (status.deployed) {
300
+ if (resolved.status === "live") {
285
301
  statuses.push("deployed");
286
302
  }
287
- if (status.backedUp) {
288
- statuses.push("backup");
289
- }
290
303
 
291
304
  item(`Status: ${statuses.join(", ") || "none"}`);
292
305
  console.error("");
293
306
 
294
307
  // Workspace info (only shown if running from project directory)
295
- if (status.localPath) {
296
- item(`Workspace path: ${status.localPath}`);
308
+ if (resolved.localPath) {
309
+ item(`Workspace path: ${resolved.localPath}`);
297
310
  console.error("");
298
311
  }
299
312
 
300
313
  // Deployment info
301
- if (status.workerUrl) {
302
- item(`Worker URL: ${status.workerUrl}`);
303
- }
304
- if (status.lastDeployed) {
305
- item(`Last deployed: ${new Date(status.lastDeployed).toLocaleString()}`);
314
+ if (resolved.url) {
315
+ item(`Worker URL: ${resolved.url}`);
306
316
  }
307
- if (status.deployed) {
308
- console.error("");
317
+ if (resolved.updatedAt) {
318
+ item(`Last deployed: ${new Date(resolved.updatedAt).toLocaleString()}`);
309
319
  }
310
-
311
- // Backup info
312
- if (status.backedUp && status.backupFiles !== null) {
313
- item(`Backup: ${status.backupFiles} files`);
314
- if (status.backupLastSync) {
315
- item(`Last synced: ${new Date(status.backupLastSync).toLocaleString()}`);
316
- }
320
+ if (resolved.status === "live") {
317
321
  console.error("");
318
322
  }
319
323
 
320
324
  // Account info
321
- if (status.accountId) {
322
- item(`Account ID: ${status.accountId}`);
325
+ if (resolved.remote?.orgId) {
326
+ item(`Account ID: ${resolved.remote.orgId}`);
323
327
  }
324
- if (status.workerId) {
325
- item(`Worker ID: ${status.workerId}`);
328
+ if (resolved.slug) {
329
+ item(`Worker ID: ${resolved.slug}`);
326
330
  }
327
331
  console.error("");
328
332
 
329
333
  // Resources
330
- if (status.dbName) {
331
- item(`Database: ${status.dbName}`);
334
+ if (resolved.resources?.d1?.name) {
335
+ item(`Database: ${resolved.resources.d1.name}`);
332
336
  console.error("");
333
337
  }
334
338
 
335
339
  // Timestamps
336
- if (status.createdAt) {
337
- item(`Created: ${new Date(status.createdAt).toLocaleString()}`);
340
+ if (resolved.createdAt) {
341
+ item(`Created: ${new Date(resolved.createdAt).toLocaleString()}`);
338
342
  }
339
343
  console.error("");
340
344
  }
@@ -406,7 +410,7 @@ async function removeProjectEntry(args: string[]): Promise<void> {
406
410
  }
407
411
 
408
412
  // Use resolver to find project anywhere (registry OR control plane)
409
- outputSpinner.start("Checking project status...");
413
+ outputSpinner.start("Finding project...");
410
414
  const project = await resolveProject(name);
411
415
  outputSpinner.stop();
412
416
 
package/src/lib/hooks.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { HookAction } from "../templates/types";
4
+ import { applyJsonWrite } from "./json-edit";
4
5
  import { getSavedSecrets } from "./secrets";
5
6
  import { restoreTty } from "./tty";
6
7
 
@@ -17,6 +18,7 @@ export interface HookOutput {
17
18
  error(message: string): void;
18
19
  success(message: string): void;
19
20
  box(title: string, lines: string[]): void;
21
+ celebrate?(title: string, lines: string[]): void;
20
22
  }
21
23
 
22
24
  export interface HookOptions {
@@ -32,6 +34,20 @@ const noopOutput: HookOutput = {
32
34
  box() {},
33
35
  };
34
36
 
37
+ /**
38
+ * Hook schema quick reference (interactive behavior + fields)
39
+ *
40
+ * - message: { text } -> prints info
41
+ * - box: { title, lines } -> prints boxed text
42
+ * - url: { url, label?, open?, prompt? } -> prints link; optional open prompt
43
+ * - clipboard: { text, message? } -> copy to clipboard (prints in non-interactive)
44
+ * - pause: { message? } -> waits for enter (skipped in non-interactive)
45
+ * - require: { source, key, message?, setupUrl? } -> validates secret/env
46
+ * - shell: { command, cwd?, message? } -> runs shell command
47
+ * - prompt: { message, validate?, required?, successMessage?, writeJson? } -> input + optional JSON update
48
+ * - writeJson: { path, set, successMessage? } -> JSON update (runs in non-interactive)
49
+ */
50
+
35
51
  /**
36
52
  * Prompt user with numbered options (Claude Code style)
37
53
  * Returns the selected option index (0-based) or -1 if cancelled
@@ -121,6 +137,26 @@ function substituteVars(str: string, context: HookContext): string {
121
137
  .replace(/\{\{name\}\}/g, context.projectName ?? "");
122
138
  }
123
139
 
140
+ function resolveHookPath(filePath: string, context: HookContext): string {
141
+ if (filePath.startsWith("/")) {
142
+ return filePath;
143
+ }
144
+ if (!context.projectDir) {
145
+ return filePath;
146
+ }
147
+ return join(context.projectDir, filePath);
148
+ }
149
+
150
+ function isAccountAssociation(value: unknown): value is { header: string; payload: string; signature: string } {
151
+ if (!value || typeof value !== "object") return false;
152
+ const obj = value as { header?: unknown; payload?: unknown; signature?: unknown };
153
+ return (
154
+ typeof obj.header === "string" &&
155
+ typeof obj.payload === "string" &&
156
+ typeof obj.signature === "string"
157
+ );
158
+ }
159
+
124
160
  /**
125
161
  * Open a URL in the default browser
126
162
  */
@@ -209,153 +245,255 @@ async function checkEnvExists(env: string, projectDir?: string): Promise<boolean
209
245
  * Execute a single hook action
210
246
  * Returns true if should continue, false if should abort
211
247
  */
212
- async function executeAction(
213
- action: HookAction,
248
+ type ActionHandler<T extends HookAction["action"]> = (
249
+ action: Extract<HookAction, { action: T }>,
214
250
  context: HookContext,
215
- options?: HookOptions,
216
- ): Promise<boolean> {
217
- const interactive = options?.interactive !== false;
218
- const ui = options?.output ?? noopOutput;
219
-
220
- switch (action.action) {
221
- case "message": {
222
- ui.info(substituteVars(action.text, context));
251
+ options: HookOptions,
252
+ ) => Promise<boolean>;
253
+
254
+ const actionHandlers: {
255
+ [T in HookAction["action"]]: ActionHandler<T>;
256
+ } = {
257
+ message: async (action, context, options) => {
258
+ const ui = options.output ?? noopOutput;
259
+ ui.info(substituteVars(action.text, context));
260
+ return true;
261
+ },
262
+ box: async (action, context, options) => {
263
+ const ui = options.output ?? noopOutput;
264
+ const title = substituteVars(action.title, context);
265
+ const lines = action.lines.map((line) => substituteVars(line, context));
266
+ ui.box(title, lines);
267
+ return true;
268
+ },
269
+ url: async (action, context, options) => {
270
+ const ui = options.output ?? noopOutput;
271
+ const interactive = options.interactive !== false;
272
+ const url = substituteVars(action.url, context);
273
+ const label = action.label ?? "Link";
274
+ if (!interactive) {
275
+ ui.info(`${label}: ${url}`);
223
276
  return true;
224
277
  }
278
+ console.error("");
279
+ console.error(` ${label}: \x1b[36m${url}\x1b[0m`);
225
280
 
226
- case "box": {
227
- const title = substituteVars(action.title, context);
228
- const lines = action.lines.map((line) => substituteVars(line, context));
229
- ui.box(title, lines);
281
+ if (action.open) {
282
+ ui.info(`Opening: ${url}`);
283
+ await openBrowser(url);
230
284
  return true;
231
285
  }
232
286
 
233
- case "url": {
234
- const url = substituteVars(action.url, context);
235
- const label = action.label ?? "Link";
236
- if (!interactive) {
237
- ui.info(`${label}: ${url}`);
238
- return true;
239
- }
287
+ if (action.prompt !== false) {
240
288
  console.error("");
241
- console.error(` ${label}: \x1b[36m${url}\x1b[0m`);
242
-
243
- if (action.open) {
244
- ui.info(`Opening: ${url}`);
289
+ const choice = await promptSelect(["Open in browser", "Skip"]);
290
+ if (choice === 0) {
245
291
  await openBrowser(url);
246
- return true;
247
- }
248
-
249
- if (action.prompt !== false) {
250
- console.error("");
251
- const choice = await promptSelect(["Open in browser", "Skip"]);
252
- if (choice === 0) {
253
- await openBrowser(url);
254
- ui.success("Opened in browser");
255
- }
292
+ ui.success("Opened in browser");
256
293
  }
294
+ }
295
+ return true;
296
+ },
297
+ clipboard: async (action, context, options) => {
298
+ const ui = options.output ?? noopOutput;
299
+ const interactive = options.interactive !== false;
300
+ const text = substituteVars(action.text, context);
301
+ if (!interactive) {
302
+ ui.info(text);
257
303
  return true;
258
304
  }
305
+ const success = await copyToClipboard(text);
306
+ if (success) {
307
+ const message = action.message ?? "Copied to clipboard";
308
+ ui.success(message);
309
+ } else {
310
+ ui.warn("Could not copy to clipboard");
311
+ }
312
+ return true;
313
+ },
314
+ pause: async (action, _context, options) => {
315
+ const interactive = options.interactive !== false;
316
+ if (!interactive) {
317
+ return true;
318
+ }
319
+ await waitForEnter(action.message);
320
+ return true;
321
+ },
322
+ require: async (action, context, options) => {
323
+ const ui = options.output ?? noopOutput;
324
+ const interactive = options.interactive !== false;
325
+ if (action.source === "secret") {
326
+ const result = await checkSecretExists(action.key, context.projectDir);
327
+ if (!result.exists) {
328
+ const message = action.message ?? `Missing required secret: ${action.key}`;
329
+ ui.error(message);
330
+ ui.info(`Run: jack secrets add ${action.key}`);
259
331
 
260
- case "require": {
261
- if (action.source === "secret") {
262
- const result = await checkSecretExists(action.key, context.projectDir);
263
- if (!result.exists) {
264
- const message = action.message ?? `Missing required secret: ${action.key}`;
265
- ui.error(message);
266
- ui.info(`Run: jack secrets add ${action.key}`);
267
-
268
- if (action.setupUrl) {
269
- if (interactive) {
270
- console.error("");
271
- const choice = await promptSelect(["Open setup page", "Skip"]);
272
- if (choice === 0) {
273
- await openBrowser(action.setupUrl);
274
- }
275
- } else {
276
- ui.info(`Setup: ${action.setupUrl}`);
332
+ if (action.setupUrl) {
333
+ if (interactive) {
334
+ console.error("");
335
+ const choice = await promptSelect(["Open setup page", "Skip"]);
336
+ if (choice === 0) {
337
+ await openBrowser(action.setupUrl);
277
338
  }
339
+ } else {
340
+ ui.info(`Setup: ${action.setupUrl}`);
278
341
  }
279
- return false;
280
- }
281
- return true;
282
- }
283
-
284
- const exists = await checkEnvExists(action.key, context.projectDir);
285
- if (!exists) {
286
- const message = action.message ?? `Missing required env var: ${action.key}`;
287
- ui.error(message);
288
- if (action.setupUrl) {
289
- ui.info(`Setup: ${action.setupUrl}`);
290
342
  }
291
343
  return false;
292
344
  }
293
345
  return true;
294
346
  }
295
347
 
296
- case "clipboard": {
297
- const text = substituteVars(action.text, context);
298
- if (!interactive) {
299
- ui.info(text);
300
- return true;
301
- }
302
- const success = await copyToClipboard(text);
303
- if (success) {
304
- const message = action.message ?? "Copied to clipboard";
305
- ui.success(message);
306
- } else {
307
- ui.warn("Could not copy to clipboard");
348
+ const exists = await checkEnvExists(action.key, context.projectDir);
349
+ if (!exists) {
350
+ const message = action.message ?? `Missing required env var: ${action.key}`;
351
+ ui.error(message);
352
+ if (action.setupUrl) {
353
+ ui.info(`Setup: ${action.setupUrl}`);
308
354
  }
355
+ return false;
356
+ }
357
+ return true;
358
+ },
359
+ prompt: async (action, context, options) => {
360
+ const ui = options.output ?? noopOutput;
361
+ const interactive = options.interactive !== false;
362
+ if (!interactive) {
309
363
  return true;
310
364
  }
311
365
 
312
- case "pause": {
313
- if (!interactive) {
366
+ const { input } = await import("@inquirer/prompts");
367
+
368
+ let rawValue = "";
369
+ try {
370
+ rawValue = await input({ message: action.message });
371
+ } catch (err) {
372
+ if (err instanceof Error && err.name === "ExitPromptError") {
314
373
  return true;
315
374
  }
316
- await waitForEnter(action.message);
375
+ throw err;
376
+ }
377
+
378
+ if (!rawValue.trim()) {
317
379
  return true;
318
380
  }
319
381
 
320
- case "shell": {
321
- const command = substituteVars(action.command, context);
322
- if (action.message) {
323
- ui.info(action.message);
382
+ let parsedInput: unknown = rawValue;
383
+ if (action.validate === "json" || action.validate === "accountAssociation") {
384
+ try {
385
+ parsedInput = JSON.parse(rawValue);
386
+ } catch {
387
+ ui.error("Invalid JSON input");
388
+ return action.required ? false : true;
389
+ }
390
+ }
391
+
392
+ if (action.validate === "accountAssociation" && !isAccountAssociation(parsedInput)) {
393
+ ui.error("Invalid accountAssociation JSON (expected header, payload, signature)");
394
+ return action.required ? false : true;
395
+ }
396
+
397
+ if (action.writeJson) {
398
+ const targetPath = resolveHookPath(action.writeJson.path, context);
399
+ const ok = await applyJsonWrite(
400
+ targetPath,
401
+ action.writeJson.set,
402
+ (value) => substituteVars(value, context),
403
+ parsedInput,
404
+ );
405
+ if (!ok) {
406
+ ui.error(`Invalid JSON file: ${targetPath}`);
407
+ return action.required ? false : true;
324
408
  }
325
- const cwd = action.cwd === "project" ? context.projectDir : undefined;
326
- // Resume stdin in case previous prompts paused it
327
- if (interactive) {
328
- process.stdin.resume();
409
+ if (action.successMessage) {
410
+ ui.success(substituteVars(action.successMessage, context));
329
411
  }
330
- const proc = Bun.spawn(["sh", "-c", command], {
331
- cwd,
332
- stdin: interactive ? "inherit" : "ignore",
333
- stdout: "inherit",
334
- stderr: "inherit",
335
- });
336
- await proc.exited;
337
- return proc.exitCode === 0;
338
412
  }
339
413
 
340
- default:
341
- return true;
342
- }
414
+ return true;
415
+ },
416
+ writeJson: async (action, context, options) => {
417
+ const ui = options.output ?? noopOutput;
418
+ const targetPath = resolveHookPath(action.path, context);
419
+ const ok = await applyJsonWrite(
420
+ targetPath,
421
+ action.set,
422
+ (value) => substituteVars(value, context),
423
+ );
424
+ if (!ok) {
425
+ ui.error(`Invalid JSON file: ${targetPath}`);
426
+ return false;
427
+ }
428
+ if (action.successMessage) {
429
+ ui.success(substituteVars(action.successMessage, context));
430
+ }
431
+ return true;
432
+ },
433
+ shell: async (action, context, options) => {
434
+ const ui = options.output ?? noopOutput;
435
+ const interactive = options.interactive !== false;
436
+ const command = substituteVars(action.command, context);
437
+ if (action.message) {
438
+ ui.info(action.message);
439
+ }
440
+ const cwd = action.cwd === "project" ? context.projectDir : undefined;
441
+ // Resume stdin in case previous prompts paused it
442
+ if (interactive) {
443
+ process.stdin.resume();
444
+ }
445
+ const proc = Bun.spawn(["sh", "-c", command], {
446
+ cwd,
447
+ stdin: interactive ? "inherit" : "ignore",
448
+ stdout: "inherit",
449
+ stderr: "inherit",
450
+ });
451
+ await proc.exited;
452
+ return proc.exitCode === 0;
453
+ },
454
+ };
455
+
456
+ async function executeAction(
457
+ action: HookAction,
458
+ context: HookContext,
459
+ options?: HookOptions,
460
+ ): Promise<boolean> {
461
+ const handler = actionHandlers[action.action] as (
462
+ action: HookAction,
463
+ context: HookContext,
464
+ options: HookOptions,
465
+ ) => Promise<boolean>;
466
+ return handler(action, context, options ?? {});
467
+ }
468
+
469
+ export interface HookResult {
470
+ success: boolean;
471
+ hadInteractiveActions: boolean;
343
472
  }
344
473
 
345
474
  /**
346
475
  * Run a list of hook actions
347
- * Returns true if all succeeded, false if any failed (for preDeploy checks)
476
+ * Returns success status and whether any interactive actions were executed
348
477
  */
349
478
  export async function runHook(
350
479
  actions: HookAction[],
351
480
  context: HookContext,
352
481
  options?: HookOptions,
353
- ): Promise<boolean> {
482
+ ): Promise<HookResult> {
483
+ const interactive = options?.interactive !== false;
484
+ // Track if we had any interactive actions (prompt, pause) that ran
485
+ const interactiveActionTypes = ["prompt", "pause"];
486
+ let hadInteractiveActions = false;
487
+
354
488
  for (const action of actions) {
489
+ // Check if this is an interactive action that will actually run
490
+ if (interactive && interactiveActionTypes.includes(action.action)) {
491
+ hadInteractiveActions = true;
492
+ }
355
493
  const shouldContinue = await executeAction(action, context, options);
356
494
  if (!shouldContinue) {
357
- return false;
495
+ return { success: false, hadInteractiveActions };
358
496
  }
359
497
  }
360
- return true;
498
+ return { success: true, hadInteractiveActions };
361
499
  }
@@ -0,0 +1,56 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ export function setJsonPath(
4
+ target: Record<string, unknown>,
5
+ path: string,
6
+ value: unknown,
7
+ ): void {
8
+ const keys = path.split(".").filter(Boolean);
9
+ let current: Record<string, unknown> = target;
10
+
11
+ for (let i = 0; i < keys.length - 1; i++) {
12
+ const key = keys[i];
13
+ if (key === undefined) continue;
14
+ const next = current[key];
15
+ if (!next || typeof next !== "object" || Array.isArray(next)) {
16
+ current[key] = {};
17
+ }
18
+ current = current[key] as Record<string, unknown>;
19
+ }
20
+
21
+ const lastKey = keys[keys.length - 1];
22
+ if (lastKey) {
23
+ current[lastKey] = value;
24
+ }
25
+ }
26
+
27
+ export async function applyJsonWrite(
28
+ targetPath: string,
29
+ updates: Record<string, string | { from: "input" }>,
30
+ substitute: (value: string) => string,
31
+ inputValue?: unknown,
32
+ ): Promise<boolean> {
33
+ let jsonData: Record<string, unknown> = {};
34
+
35
+ if (existsSync(targetPath)) {
36
+ try {
37
+ const content = await Bun.file(targetPath).text();
38
+ if (content.trim()) {
39
+ jsonData = JSON.parse(content) as Record<string, unknown>;
40
+ }
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ for (const [path, value] of Object.entries(updates)) {
47
+ if (typeof value === "string") {
48
+ setJsonPath(jsonData, path, substitute(value));
49
+ } else if (value?.from === "input") {
50
+ setJsonPath(jsonData, path, inputValue);
51
+ }
52
+ }
53
+
54
+ await Bun.write(targetPath, `${JSON.stringify(jsonData, null, 2)}\n`);
55
+ return true;
56
+ }
package/src/lib/output.ts CHANGED
@@ -26,6 +26,7 @@ export const output = {
26
26
  warn,
27
27
  item,
28
28
  box,
29
+ celebrate,
29
30
  };
30
31
 
31
32
  /**
@@ -94,25 +95,78 @@ export function item(message: string): void {
94
95
  console.error(` ${message}`);
95
96
  }
96
97
 
98
+ // Random neon purple for cyberpunk styling
99
+ function getRandomPurple(): string {
100
+ const purples = [177, 165, 141, 129];
101
+ const colorCode = purples[Math.floor(Math.random() * purples.length)];
102
+ return `\x1b[38;5;${colorCode}m`;
103
+ }
104
+
97
105
  /**
98
- * Print a boxed message for important info
106
+ * Print a boxed message for important info (cyberpunk style)
99
107
  */
100
108
  export function box(title: string, lines: string[]): void {
101
109
  const maxLen = Math.max(title.length, ...lines.map((l) => l.length));
102
- const width = maxLen + 4;
103
- const border = isColorEnabled ? "\x1b[90m" : "";
110
+ const innerWidth = maxLen + 4;
111
+
112
+ const purple = isColorEnabled ? getRandomPurple() : "";
113
+ const bold = isColorEnabled ? "\x1b[1m" : "";
114
+ const reset = isColorEnabled ? "\x1b[0m" : "";
115
+
116
+ const bar = "═".repeat(innerWidth);
117
+ const fill = "▓".repeat(innerWidth);
118
+ const gradient = "░".repeat(innerWidth);
119
+
120
+ const pad = (text: string) => ` ${text.padEnd(maxLen)} `;
121
+
122
+ console.error("");
123
+ console.error(` ${purple}╔${bar}╗${reset}`);
124
+ console.error(` ${purple}║${fill}║${reset}`);
125
+ console.error(` ${purple}║${pad(bold + title + reset + purple)}║${reset}`);
126
+ console.error(` ${purple}║${"─".repeat(innerWidth)}║${reset}`);
127
+ for (const line of lines) {
128
+ console.error(` ${purple}║${pad(line)}║${reset}`);
129
+ }
130
+ console.error(` ${purple}║${gradient}║${reset}`);
131
+ console.error(` ${purple}╚${bar}╝${reset}`);
132
+ console.error("");
133
+ }
134
+
135
+ /**
136
+ * Print a celebration box (for final success after setup)
137
+ */
138
+ export function celebrate(title: string, lines: string[]): void {
139
+ const maxLen = Math.max(title.length, ...lines.map((l) => l.length));
140
+ const innerWidth = maxLen + 4;
141
+
142
+ const purple = isColorEnabled ? getRandomPurple() : "";
143
+ const bold = isColorEnabled ? "\x1b[1m" : "";
104
144
  const reset = isColorEnabled ? "\x1b[0m" : "";
105
- const titleColor = isColorEnabled ? "\x1b[1m" : "";
145
+
146
+ const bar = "═".repeat(innerWidth);
147
+ const fill = "▓".repeat(innerWidth);
148
+ const gradient = "░".repeat(innerWidth);
149
+ const space = " ".repeat(innerWidth);
150
+
151
+ // Center text based on visual length, then apply colors
152
+ const center = (text: string, applyBold = false) => {
153
+ const left = Math.floor((innerWidth - text.length) / 2);
154
+ const right = innerWidth - text.length - left;
155
+ const centered = " ".repeat(left) + text + " ".repeat(right);
156
+ return applyBold ? centered.replace(text, bold + text + reset + purple) : centered;
157
+ };
106
158
 
107
159
  console.error("");
108
- console.error(`${border}┌${"─".repeat(width)}┐${reset}`);
109
- console.error(
110
- `${border}│${reset} ${titleColor}${title.padEnd(maxLen)}${reset} ${border}│${reset}`,
111
- );
112
- console.error(`${border}├${"─".repeat(width)}┤${reset}`);
160
+ console.error(` ${purple}╔${bar}╗${reset}`);
161
+ console.error(` ${purple}║${fill}║${reset}`);
162
+ console.error(` ${purple}║${space}║${reset}`);
163
+ console.error(` ${purple}║${center(title, true)}║${reset}`);
164
+ console.error(` ${purple}║${space}║${reset}`);
113
165
  for (const line of lines) {
114
- console.error(`${border}│${reset} ${line.padEnd(maxLen)} ${border}│${reset}`);
166
+ console.error(` ${purple}║${center(line)}║${reset}`);
115
167
  }
116
- console.error(`${border}└${"─".repeat(width)}┘${reset}`);
168
+ console.error(` ${purple}║${space}║${reset}`);
169
+ console.error(` ${purple}║${gradient}║${reset}`);
170
+ console.error(` ${purple}╚${bar}╝${reset}`);
117
171
  console.error("");
118
172
  }
@@ -187,7 +187,7 @@ describe("paths-index", () => {
187
187
  const index = await readPathsIndex();
188
188
 
189
189
  // Should store absolute path
190
- expect(index.paths.proj_rel[0].startsWith("/")).toBe(true);
190
+ expect(index.paths.proj_rel?.[0]?.startsWith("/")).toBe(true);
191
191
  } finally {
192
192
  process.chdir(originalCwd);
193
193
  }
@@ -351,7 +351,7 @@ describe("paths-index", () => {
351
351
  const discovered = await scanAndRegisterProjects(testDir);
352
352
 
353
353
  expect(discovered).toHaveLength(1);
354
- expect(discovered[0].projectId).toBe("proj_linked");
354
+ expect(discovered[0]!.projectId).toBe("proj_linked");
355
355
  });
356
356
 
357
357
  it("respects maxDepth", async () => {
@@ -366,7 +366,7 @@ describe("paths-index", () => {
366
366
  const discovered = await scanAndRegisterProjects(testDir, 2);
367
367
 
368
368
  expect(discovered).toHaveLength(1);
369
- expect(discovered[0].projectId).toBe("proj_shallow");
369
+ expect(discovered[0]!.projectId).toBe("proj_shallow");
370
370
  });
371
371
 
372
372
  it("skips node_modules", async () => {
@@ -379,7 +379,7 @@ describe("paths-index", () => {
379
379
  const discovered = await scanAndRegisterProjects(testDir);
380
380
 
381
381
  expect(discovered).toHaveLength(1);
382
- expect(discovered[0].projectId).toBe("proj_valid");
382
+ expect(discovered[0]!.projectId).toBe("proj_valid");
383
383
  });
384
384
 
385
385
  it("skips .git directory", async () => {
@@ -392,7 +392,7 @@ describe("paths-index", () => {
392
392
  const discovered = await scanAndRegisterProjects(testDir);
393
393
 
394
394
  expect(discovered).toHaveLength(1);
395
- expect(discovered[0].projectId).toBe("proj_valid");
395
+ expect(discovered[0]!.projectId).toBe("proj_valid");
396
396
  });
397
397
 
398
398
  it("does not recurse into linked projects", async () => {
@@ -407,7 +407,7 @@ describe("paths-index", () => {
407
407
 
408
408
  // Should only find parent, not nested child
409
409
  expect(discovered).toHaveLength(1);
410
- expect(discovered[0].projectId).toBe("proj_parent");
410
+ expect(discovered[0]!.projectId).toBe("proj_parent");
411
411
  });
412
412
 
413
413
  it("registers discovered projects in index", async () => {
@@ -517,7 +517,7 @@ describe("paths-index", () => {
517
517
 
518
518
  const discovered = await scanAndRegisterProjects(testDir);
519
519
  expect(discovered).toHaveLength(1);
520
- expect(discovered[0].projectId).toBe("proj_spaces");
520
+ expect(discovered[0]!.projectId).toBe("proj_spaces");
521
521
  });
522
522
 
523
523
  it("handles permission errors gracefully", async () => {
@@ -939,11 +939,11 @@ export async function createProject(
939
939
  // Run pre-deploy hooks
940
940
  if (template.hooks?.preDeploy?.length) {
941
941
  const hookContext = { projectName, projectDir: targetDir };
942
- const passed = await runHook(template.hooks.preDeploy, hookContext, {
942
+ const hookResult = await runHook(template.hooks.preDeploy, hookContext, {
943
943
  interactive,
944
944
  output: reporter,
945
945
  });
946
- if (!passed) {
946
+ if (!hookResult.success) {
947
947
  reporter.error("Pre-deploy checks failed");
948
948
  throw new JackError(JackErrorCode.VALIDATION_ERROR, "Pre-deploy checks failed", undefined, {
949
949
  exitCode: 0,
@@ -1145,7 +1145,7 @@ export async function createProject(
1145
1145
  // Run post-deploy hooks (for both modes)
1146
1146
  if (template.hooks?.postDeploy?.length && workerUrl) {
1147
1147
  const domain = workerUrl.replace(/^https?:\/\//, "");
1148
- await runHook(
1148
+ const hookResult = await runHook(
1149
1149
  template.hooks.postDeploy,
1150
1150
  {
1151
1151
  domain,
@@ -1155,6 +1155,11 @@ export async function createProject(
1155
1155
  },
1156
1156
  { interactive, output: reporter },
1157
1157
  );
1158
+
1159
+ // Show final celebration if there were interactive prompts (URL might have scrolled away)
1160
+ if (hookResult.hadInteractiveActions && reporter.celebrate) {
1161
+ reporter.celebrate("You're live!", [domain]);
1162
+ }
1158
1163
  }
1159
1164
 
1160
1165
  return {
@@ -147,6 +147,35 @@ export async function identify(properties: Partial<UserProperties>): Promise<voi
147
147
  }
148
148
  }
149
149
 
150
+ /**
151
+ * Link a logged-in user to their pre-login anonymous events.
152
+ * This should be called after successful authentication.
153
+ *
154
+ * @param userId - The WorkOS user ID (user.id)
155
+ * @param properties - Optional user properties like email
156
+ */
157
+ export async function identifyUser(userId: string, properties?: { email?: string }): Promise<void> {
158
+ if (!(await isEnabled())) return;
159
+
160
+ try {
161
+ const anonymousId = await getAnonymousId();
162
+
163
+ // Identify with real user ID
164
+ send(`${TELEMETRY_PROXY}/identify`, {
165
+ distinctId: userId,
166
+ properties: { ...properties, ...userProps },
167
+ });
168
+
169
+ // Alias to merge pre-login anonymous events with identified user
170
+ send(`${TELEMETRY_PROXY}/alias`, {
171
+ distinctId: userId,
172
+ alias: anonymousId,
173
+ });
174
+ } catch {
175
+ // Silent
176
+ }
177
+ }
178
+
150
179
  // ============================================
151
180
  // TRACK
152
181
  // ============================================
@@ -22,7 +22,7 @@ export async function openMcpTestClient(options: McpClientOptions): Promise<McpT
22
22
  command: options.command,
23
23
  args: options.args ?? [],
24
24
  cwd: options.cwd,
25
- env: options.env,
25
+ env: options.env as Record<string, string> | undefined,
26
26
  stderr: "pipe",
27
27
  });
28
28
 
@@ -53,6 +53,7 @@ export async function openMcpTestClient(options: McpClientOptions): Promise<McpT
53
53
 
54
54
  export function parseMcpToolResult(toolResult: {
55
55
  content?: Array<{ type: string; text?: string }>;
56
+ [key: string]: unknown;
56
57
  }): unknown {
57
58
  const toolText = toolResult.content?.[0]?.type === "text" ? toolResult.content[0].text : null;
58
59
  if (!toolText) {
@@ -225,7 +225,7 @@ export async function resolveTemplate(template?: string): Promise<Template> {
225
225
 
226
226
  // username/slug format - fetch from jack cloud
227
227
  if (template.includes("/")) {
228
- const [username, slug] = template.split("/", 2);
228
+ const [username, slug] = template.split("/", 2) as [string, string];
229
229
  return fetchPublishedTemplate(username, slug);
230
230
  }
231
231
 
@@ -6,6 +6,23 @@ export type HookAction =
6
6
  | { action: "clipboard"; text: string; message?: string }
7
7
  | { action: "shell"; command: string; cwd?: "project"; message?: string }
8
8
  | { action: "pause"; message?: string } // press enter to continue
9
+ | {
10
+ action: "prompt";
11
+ message: string;
12
+ validate?: "json" | "accountAssociation";
13
+ required?: boolean;
14
+ successMessage?: string;
15
+ writeJson?: {
16
+ path: string;
17
+ set: Record<string, string | { from: "input" }>;
18
+ };
19
+ }
20
+ | {
21
+ action: "writeJson";
22
+ path: string;
23
+ set: Record<string, string>;
24
+ successMessage?: string;
25
+ }
9
26
  | {
10
27
  action: "require";
11
28
  source: "secret" | "env";
@@ -110,6 +110,20 @@ name = "jack-template" → name = "my-app"
110
110
 
111
111
  Templates can define hooks in `.jack.json` that run at specific lifecycle points.
112
112
 
113
+ ### Hook Schema (Quick Reference)
114
+
115
+ | Action | Required Fields | Non-Interactive Behavior |
116
+ |--------|------------------|--------------------------|
117
+ | `message` | `text` | Prints message |
118
+ | `box` | `title`, `lines` | Prints box |
119
+ | `url` | `url` | Prints label + URL |
120
+ | `clipboard` | `text` | Prints text |
121
+ | `pause` | _(none)_ | Skipped |
122
+ | `require` | `source`, `key` | Validates, prints setup if provided |
123
+ | `shell` | `command` | Runs with stdin ignored |
124
+ | `prompt` | `message` | Skipped (supports `validate: "json" | "accountAssociation"`) |
125
+ | `writeJson` | `path`, `set` | Runs (safe in CI) |
126
+
113
127
  ### Hook Lifecycle
114
128
 
115
129
  ```json
@@ -132,6 +146,8 @@ Templates can define hooks in `.jack.json` that run at specific lifecycle points
132
146
  | `shell` | Execute shell command | `{"action": "shell", "command": "curl {{url}}/health"}` |
133
147
  | `pause` | Wait for Enter key | `{"action": "pause", "message": "Press Enter..."}` |
134
148
  | `require` | Verify secret or env | `{"action": "require", "source": "secret", "key": "API_KEY"}` |
149
+ | `prompt` | Prompt for input and update JSON file | `{"action": "prompt", "message": "Paste JSON", "validate": "json", "successMessage": "Saved", "writeJson": {"path": "public/data.json", "set": {"data": {"from": "input"}}}}` |
150
+ | `writeJson` | Update JSON file with template vars | `{"action": "writeJson", "path": "public/data.json", "set": {"siteUrl": "{{url}}"}}` |
135
151
 
136
152
  ### Non-Interactive Mode
137
153
 
@@ -141,7 +157,9 @@ Hooks run in a non-interactive mode for MCP/silent execution. In this mode:
141
157
  - `clipboard` prints the text (no clipboard access)
142
158
  - `pause` is skipped
143
159
  - `require` still validates; if `setupUrl` exists it prints `Setup: ...`
160
+ - `prompt` is skipped
144
161
  - `shell` runs with stdin ignored to avoid hangs
162
+ - `writeJson` still runs (non-interactive safe)
145
163
 
146
164
  ### Hook Variables
147
165
 
@@ -178,7 +196,9 @@ These variables are substituted at runtime (different from template placeholders
178
196
  "postDeploy": [
179
197
  {"action": "clipboard", "text": "{{url}}"},
180
198
  {"action": "box", "title": "Deployed: {{name}}", "lines": ["URL: {{url}}"]},
181
- {"action": "url", "url": "https://farcaster.xyz/.../manifest?domain={{domain}}", "label": "Generate manifest"},
199
+ {"action": "url", "url": "https://farcaster.xyz/.../manifest?domain={{domain}}", "label": "Sign manifest"},
200
+ {"action": "writeJson", "path": "public/.well-known/farcaster.json", "set": {"miniapp.homeUrl": "{{url}}"}},
201
+ {"action": "prompt", "message": "Paste accountAssociation JSON", "validate": "accountAssociation", "successMessage": "Saved domain association", "writeJson": {"path": "public/.well-known/farcaster.json", "set": {"accountAssociation": {"from": "input"}}}},
182
202
  {"action": "url", "url": "https://farcaster.xyz/.../preview?url={{url}}", "label": "Preview"}
183
203
  ]
184
204
  }
@@ -38,12 +38,41 @@
38
38
  {
39
39
  "action": "box",
40
40
  "title": "Deployed: {{name}}",
41
- "lines": ["URL: {{url}}", "", "Next: Generate a manifest, then preview your miniapp"]
41
+ "lines": [
42
+ "URL: {{url}}",
43
+ "Manifest: {{url}}/.well-known/farcaster.json",
44
+ "",
45
+ "Next: Sign the manifest and paste accountAssociation when prompted"
46
+ ]
47
+ },
48
+ {
49
+ "action": "writeJson",
50
+ "path": "public/.well-known/farcaster.json",
51
+ "successMessage": "Updated manifest URLs to {{url}}",
52
+ "set": {
53
+ "miniapp.name": "{{name}}",
54
+ "miniapp.homeUrl": "{{url}}",
55
+ "miniapp.iconUrl": "{{url}}/icon.png",
56
+ "miniapp.imageUrl": "{{url}}/og.png",
57
+ "miniapp.splashImageUrl": "{{url}}/icon.png"
58
+ }
42
59
  },
43
60
  {
44
61
  "action": "url",
45
62
  "url": "https://farcaster.xyz/~/developers/mini-apps/manifest?domain={{domain}}",
46
- "label": "Generate manifest"
63
+ "label": "Sign manifest"
64
+ },
65
+ {
66
+ "action": "prompt",
67
+ "message": "Paste accountAssociation JSON from Farcaster (or press Enter to skip):",
68
+ "validate": "accountAssociation",
69
+ "successMessage": "Saved domain association to public/.well-known/farcaster.json",
70
+ "writeJson": {
71
+ "path": "public/.well-known/farcaster.json",
72
+ "set": {
73
+ "accountAssociation": { "from": "input" }
74
+ }
75
+ }
47
76
  },
48
77
  {
49
78
  "action": "url",
@@ -4,7 +4,6 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>jack-template</title>
7
- <meta name="fc:miniapp" content='{"version":"1","imageUrl":"/og.png","button":{"title":"Open App","action":{"type":"launch_miniapp","name":"jack-template","splashImageUrl":"/icon.png","splashBackgroundColor":"#0a0a0a"}}}' />
8
7
  </head>
9
8
  <body>
10
9
  <div id="root"></div>
@@ -1,17 +1,17 @@
1
1
  {
2
- "accountAssociation": {
3
- "header": "",
4
- "payload": "",
5
- "signature": ""
6
- },
7
- "miniapp": {
8
- "version": "1",
9
- "name": "jack-template",
10
- "iconUrl": "/icon.png",
11
- "homeUrl": "/",
12
- "imageUrl": "/og.png",
13
- "splashImageUrl": "/icon.png",
14
- "splashBackgroundColor": "#0a0a0a",
15
- "buttonTitle": "Open App"
16
- }
2
+ "accountAssociation": {
3
+ "header": "",
4
+ "payload": "",
5
+ "signature": ""
6
+ },
7
+ "miniapp": {
8
+ "version": "1",
9
+ "name": "jack-template",
10
+ "iconUrl": "https://example.com/icon.png",
11
+ "homeUrl": "https://example.com",
12
+ "imageUrl": "https://example.com/og.png",
13
+ "buttonTitle": "Open App",
14
+ "splashImageUrl": "https://example.com/icon.png",
15
+ "splashBackgroundColor": "#0a0a0a"
16
+ }
17
17
  }
@@ -13,6 +13,13 @@ type Env = {
13
13
  APP_URL?: string; // Production URL for share embeds (e.g., https://my-app.workers.dev)
14
14
  };
15
15
 
16
+ function isIpAddress(hostname: string): boolean {
17
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) {
18
+ return true;
19
+ }
20
+ return hostname.includes(":");
21
+ }
22
+
16
23
  // Get production base URL - required for valid Farcaster embeds
17
24
  // Farcaster requires absolute https:// URLs (no localhost, no relative paths)
18
25
  // See: https://miniapps.farcaster.xyz/docs/embeds
@@ -23,8 +30,13 @@ function getBaseUrl(
23
30
  // 1. Prefer explicit APP_URL if set (most reliable for custom domains)
24
31
  if (env.APP_URL && env.APP_URL.trim() !== "") {
25
32
  const url = env.APP_URL.replace(/\/$/, "");
26
- if (url.startsWith("https://")) {
27
- return url;
33
+ try {
34
+ const parsed = new URL(url);
35
+ if (parsed.protocol === "https:" && !isIpAddress(parsed.hostname)) {
36
+ return parsed.origin;
37
+ }
38
+ } catch {
39
+ // Ignore parse errors
28
40
  }
29
41
  // If APP_URL is set but not https, warn and continue
30
42
  console.warn(`APP_URL should be https, got: ${url}`);
@@ -33,8 +45,19 @@ function getBaseUrl(
33
45
  // 2. Use Host header (always set by Cloudflare in production)
34
46
  const host = c.req.header("host");
35
47
  if (host) {
36
- // Reject localhost - embeds won't work in local dev
37
- if (host.startsWith("localhost") || host.startsWith("127.0.0.1")) {
48
+ let hostname = host;
49
+ try {
50
+ hostname = new URL(`https://${host}`).hostname;
51
+ } catch {
52
+ // Ignore parse errors and fall back to raw host
53
+ }
54
+
55
+ // Reject localhost or IPs - embeds won't work in local dev or IP domains
56
+ if (
57
+ hostname === "localhost" ||
58
+ hostname === "127.0.0.1" ||
59
+ isIpAddress(hostname)
60
+ ) {
38
61
  return null; // Signal that we can't generate valid embed URLs
39
62
  }
40
63