@getjack/jack 0.1.16 → 0.1.19

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.
@@ -2,6 +2,7 @@
2
2
  * jack update - Self-update to the latest version
3
3
  */
4
4
 
5
+ import { debug } from "../lib/debug.ts";
5
6
  import { error, info, success, warn } from "../lib/output.ts";
6
7
  import {
7
8
  checkForUpdate,
@@ -13,8 +14,13 @@ import {
13
14
  export default async function update(): Promise<void> {
14
15
  const currentVersion = getCurrentVersion();
15
16
 
17
+ debug("Update check started");
18
+ debug(`Current version: ${currentVersion}`);
19
+ debug(`Exec path: ${process.argv[1]}`);
20
+
16
21
  // Check if running via bunx
17
22
  if (isRunningViaBunx()) {
23
+ debug("Detected bunx execution");
18
24
  info(`Running via bunx (current: v${currentVersion})`);
19
25
  info("bunx automatically uses cached packages.");
20
26
  info("To get the latest version, run:");
@@ -28,7 +34,9 @@ export default async function update(): Promise<void> {
28
34
  info(`Current version: v${currentVersion}`);
29
35
 
30
36
  // Check for updates
37
+ debug("Fetching latest version from npm...");
31
38
  const latestVersion = await checkForUpdate();
39
+ debug(`Latest version from npm: ${latestVersion ?? "none (you're up to date)"}`);
32
40
 
33
41
  if (!latestVersion) {
34
42
  success("You're on the latest version!");
@@ -38,7 +46,9 @@ export default async function update(): Promise<void> {
38
46
  info(`New version available: v${latestVersion}`);
39
47
  info("Updating...");
40
48
 
49
+ debug("Running: bun add -g @getjack/jack@latest");
41
50
  const result = await performUpdate();
51
+ debug(`Update result: ${JSON.stringify(result)}`);
42
52
 
43
53
  if (result.success) {
44
54
  success(`Updated to v${result.version ?? latestVersion}`);
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ const cli = meow(
49
49
  mcp MCP server for AI agents
50
50
  telemetry Usage data settings
51
51
  feedback Share feedback or report issues
52
+ community Join the jack Discord
52
53
 
53
54
  Run 'jack <command> --help' for command-specific options.
54
55
 
@@ -370,6 +371,12 @@ try {
370
371
  await withTelemetry("feedback", feedback)();
371
372
  break;
372
373
  }
374
+ case "community":
375
+ case "discord": {
376
+ const { default: community } = await import("./commands/community.ts");
377
+ await withTelemetry("community", community)();
378
+ break;
379
+ }
373
380
  case "link": {
374
381
  const { default: link } = await import("./commands/link.ts");
375
382
  await withTelemetry("link", link)(args[0], { byo: cli.flags.byo });
@@ -371,6 +371,46 @@ export async function createProjectResource(
371
371
  return data.resource;
372
372
  }
373
373
 
374
+ export interface DeleteResourceResponse {
375
+ success: boolean;
376
+ resource_id: string;
377
+ deleted_at: string;
378
+ }
379
+
380
+ /**
381
+ * Delete a resource from a managed project.
382
+ * Uses DELETE /v1/projects/:id/resources/:id endpoint.
383
+ */
384
+ export async function deleteProjectResource(
385
+ projectId: string,
386
+ resourceId: string,
387
+ ): Promise<DeleteResourceResponse> {
388
+ const { authFetch } = await import("./auth/index.ts");
389
+
390
+ const response = await authFetch(
391
+ `${getControlApiUrl()}/v1/projects/${projectId}/resources/${resourceId}`,
392
+ { method: "DELETE" },
393
+ );
394
+
395
+ // Handle 404 gracefully - resource may already be deleted
396
+ if (response.status === 404) {
397
+ return {
398
+ success: true,
399
+ resource_id: resourceId,
400
+ deleted_at: new Date().toISOString(),
401
+ };
402
+ }
403
+
404
+ if (!response.ok) {
405
+ const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
406
+ message?: string;
407
+ };
408
+ throw new Error(err.message || `Failed to delete resource: ${response.status}`);
409
+ }
410
+
411
+ return response.json() as Promise<DeleteResourceResponse>;
412
+ }
413
+
374
414
  /**
375
415
  * Sync project tags to the control plane.
376
416
  * Fire-and-forget: errors are logged but not thrown.
@@ -580,3 +620,25 @@ export async function publishProject(projectId: string): Promise<PublishProjectR
580
620
 
581
621
  return response.json() as Promise<PublishProjectResponse>;
582
622
  }
623
+
624
+ /**
625
+ * Download the source snapshot for a project.
626
+ * Returns the zip file contents as a Buffer.
627
+ * Used by jack clone to restore managed projects.
628
+ */
629
+ export async function downloadProjectSource(slug: string): Promise<Buffer> {
630
+ const { authFetch } = await import("./auth/index.ts");
631
+
632
+ const response = await authFetch(
633
+ `${getControlApiUrl()}/v1/projects/by-slug/${encodeURIComponent(slug)}/source`,
634
+ );
635
+
636
+ if (!response.ok) {
637
+ if (response.status === 404) {
638
+ throw new Error("Project source not found. Deploy first with 'jack ship'.");
639
+ }
640
+ throw new Error(`Failed to download source: ${response.status}`);
641
+ }
642
+
643
+ return Buffer.from(await response.arrayBuffer());
644
+ }
package/src/lib/hooks.ts CHANGED
@@ -478,6 +478,26 @@ const actionHandlers: {
478
478
  if (action.successMessage) {
479
479
  ui.success(substituteVars(action.successMessage, context));
480
480
  }
481
+
482
+ // Redeploy if deployAfter is set and we have a valid project directory
483
+ if (action.deployAfter && context.projectDir) {
484
+ const deployMsg = action.deployMessage || "Deploying...";
485
+ ui.info(deployMsg);
486
+
487
+ const proc = Bun.spawn(["wrangler", "deploy"], {
488
+ cwd: context.projectDir,
489
+ stdout: "ignore",
490
+ stderr: "pipe",
491
+ });
492
+ await proc.exited;
493
+
494
+ if (proc.exitCode === 0) {
495
+ ui.success("Deployed");
496
+ } else {
497
+ const stderr = await new Response(proc.stderr).text();
498
+ ui.warn(`Deploy failed: ${stderr.slice(0, 200)}`);
499
+ }
500
+ }
481
501
  }
482
502
 
483
503
  return true;
@@ -4,12 +4,15 @@
4
4
  * Isolates managed deployment logic from BYO (wrangler) path.
5
5
  */
6
6
 
7
+ import { stat } from "node:fs/promises";
7
8
  import { validateBindings } from "./binding-validator.ts";
8
9
  import { buildProject, parseWranglerConfig } from "./build-helper.ts";
9
10
  import { createManagedProject, syncProjectTags, uploadSourceSnapshot } from "./control-plane.ts";
10
11
  import { debug } from "./debug.ts";
11
12
  import { uploadDeployment } from "./deploy-upload.ts";
12
13
  import { JackError, JackErrorCode } from "./errors.ts";
14
+ import { formatSize } from "./format.ts";
15
+ import { createUploadProgress } from "./progress.ts";
13
16
  import type { OperationReporter } from "./project-operations.ts";
14
17
  import { getProjectTags } from "./tags.ts";
15
18
  import { Events, track } from "./telemetry.ts";
@@ -120,7 +123,28 @@ export async function deployCodeToManagedProject(
120
123
  reporter?.success("Packaged artifacts");
121
124
 
122
125
  // Step 4: Upload to control plane
123
- reporter?.start("Uploading to jack cloud...");
126
+ // Calculate total upload size for progress display
127
+ const fileSizes = await Promise.all([
128
+ stat(pkg.bundleZipPath).then((s) => s.size),
129
+ stat(pkg.sourceZipPath).then((s) => s.size),
130
+ stat(pkg.manifestPath).then((s) => s.size),
131
+ pkg.schemaPath ? stat(pkg.schemaPath).then((s) => s.size) : Promise.resolve(0),
132
+ pkg.secretsPath ? stat(pkg.secretsPath).then((s) => s.size) : Promise.resolve(0),
133
+ pkg.assetsZipPath ? stat(pkg.assetsZipPath).then((s) => s.size) : Promise.resolve(0),
134
+ ]);
135
+ const totalUploadSize = fileSizes.reduce((sum, size) => sum + size, 0);
136
+ debug(`Upload size: ${formatSize(totalUploadSize)}`);
137
+
138
+ // Stop the reporter spinner - we'll use our own progress display
139
+ reporter?.stop();
140
+
141
+ // Use custom progress with pulsing bar (since fetch doesn't support upload progress)
142
+ const uploadProgress = createUploadProgress({
143
+ totalSize: totalUploadSize,
144
+ label: "Uploading to jack cloud",
145
+ });
146
+ uploadProgress.start();
147
+
124
148
  const result = await uploadDeployment({
125
149
  projectId,
126
150
  bundleZipPath: pkg.bundleZipPath,
@@ -132,7 +156,7 @@ export async function deployCodeToManagedProject(
132
156
  assetManifest: pkg.assetManifest ?? undefined,
133
157
  });
134
158
 
135
- reporter?.stop();
159
+ uploadProgress.complete();
136
160
  reporter?.success("Deployed to jack cloud");
137
161
 
138
162
  // Track success
package/src/lib/output.ts CHANGED
@@ -2,6 +2,19 @@ import yoctoSpinner from "yocto-spinner";
2
2
 
3
3
  const isColorEnabled = !process.env.NO_COLOR && process.stderr.isTTY !== false;
4
4
 
5
+ /**
6
+ * ANSI color codes for terminal output
7
+ */
8
+ export const colors = {
9
+ green: isColorEnabled ? "\x1b[32m" : "",
10
+ red: isColorEnabled ? "\x1b[31m" : "",
11
+ yellow: isColorEnabled ? "\x1b[33m" : "",
12
+ cyan: isColorEnabled ? "\x1b[36m" : "",
13
+ dim: isColorEnabled ? "\x1b[90m" : "",
14
+ bold: isColorEnabled ? "\x1b[1m" : "",
15
+ reset: isColorEnabled ? "\x1b[0m" : "",
16
+ };
17
+
5
18
  let currentSpinner: ReturnType<typeof yoctoSpinner> | null = null;
6
19
 
7
20
  /**
@@ -95,8 +108,10 @@ export function item(message: string): void {
95
108
  console.error(` ${message}`);
96
109
  }
97
110
 
98
- // Random neon purple for cyberpunk styling
99
- function getRandomPurple(): string {
111
+ /**
112
+ * Random neon purple for cyberpunk styling
113
+ */
114
+ export function getRandomPurple(): string {
100
115
  const purples = [177, 165, 141, 129];
101
116
  const colorCode = purples[Math.floor(Math.random() * purples.length)];
102
117
  return `\x1b[38;5;${colorCode}m`;
@@ -117,12 +132,15 @@ export function box(title: string, lines: string[]): void {
117
132
  const fill = "▓".repeat(innerWidth);
118
133
  const gradient = "░".repeat(innerWidth);
119
134
 
135
+ // Pad plain text first, then apply colors (ANSI codes break padEnd calculation)
120
136
  const pad = (text: string) => ` ${text.padEnd(maxLen)} `;
137
+ const padTitle = (text: string) =>
138
+ ` ${bold}${text}${reset}${purple}${" ".repeat(maxLen - text.length)} `;
121
139
 
122
140
  console.error("");
123
141
  console.error(` ${purple}╔${bar}╗${reset}`);
124
142
  console.error(` ${purple}║${fill}║${reset}`);
125
- console.error(` ${purple}║${pad(bold + title + reset + purple)}║${reset}`);
143
+ console.error(` ${purple}║${padTitle(title)}║${reset}`);
126
144
  console.error(` ${purple}║${"─".repeat(innerWidth)}║${reset}`);
127
145
  for (const line of lines) {
128
146
  console.error(` ${purple}║${pad(line)}║${reset}`);
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Progress tracker with delayed bar display
3
+ * Shows a simple spinner initially, then reveals a progress bar
4
+ * after a configurable delay (aligned with SPIRIT.md principles)
5
+ */
6
+
7
+ import { formatSize } from "./format.ts";
8
+ import { colors, getRandomPurple } from "./output.ts";
9
+
10
+ export interface ProgressOptions {
11
+ total: number;
12
+ delayMs?: number;
13
+ barWidth?: number;
14
+ label?: string;
15
+ }
16
+
17
+ export interface UploadProgressOptions {
18
+ totalSize: number;
19
+ delayMs?: number;
20
+ barWidth?: number;
21
+ label?: string;
22
+ }
23
+
24
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
25
+
26
+ /**
27
+ * Creates a progress tracker that shows a spinner initially,
28
+ * then reveals a progress bar after a delay.
29
+ *
30
+ * Usage:
31
+ * const progress = createProgressTracker({ total: fileSize });
32
+ * progress.start();
33
+ * // ... during upload
34
+ * progress.update(bytesUploaded);
35
+ * // ... when done
36
+ * progress.complete();
37
+ */
38
+ export function createProgressTracker(options: ProgressOptions) {
39
+ const { total, delayMs = 2000, barWidth = 25, label = "Uploading" } = options;
40
+ const startTime = Date.now();
41
+ let frame = 0;
42
+ let intervalId: Timer | null = null;
43
+ let current = 0;
44
+
45
+ function render() {
46
+ const elapsed = Date.now() - startTime;
47
+ const pct = Math.min(Math.round((current / total) * 100), 100);
48
+
49
+ clearLine();
50
+
51
+ if (elapsed < delayMs) {
52
+ // Just spinner for first N seconds
53
+ process.stderr.write(
54
+ `${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label}...`,
55
+ );
56
+ } else {
57
+ // Show progress bar after delay
58
+ const purple = getRandomPurple();
59
+ const filled = Math.round((pct / 100) * barWidth);
60
+ const empty = barWidth - filled;
61
+ const bar = "▓".repeat(filled) + "░".repeat(empty);
62
+
63
+ process.stderr.write(
64
+ `${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label} ${purple}[${bar}]${colors.reset} ${pct}% ${colors.dim}(${formatSize(current)} / ${formatSize(total)})${colors.reset}`,
65
+ );
66
+ }
67
+ }
68
+
69
+ return {
70
+ start() {
71
+ render();
72
+ intervalId = setInterval(render, 80);
73
+ },
74
+
75
+ update(bytes: number) {
76
+ current = bytes;
77
+ },
78
+
79
+ complete() {
80
+ if (intervalId) {
81
+ clearInterval(intervalId);
82
+ intervalId = null;
83
+ }
84
+ clearLine();
85
+ },
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Creates an upload progress indicator for operations where we know the total
91
+ * size but can't track byte-level progress (e.g., fetch uploads).
92
+ *
93
+ * Shows spinner first, then after delay shows an animated bar with size info.
94
+ * The bar pulses to indicate activity without false progress claims.
95
+ */
96
+ export function createUploadProgress(options: UploadProgressOptions) {
97
+ const { totalSize, delayMs = 2000, barWidth = 25, label = "Uploading" } = options;
98
+ const startTime = Date.now();
99
+ let frame = 0;
100
+ let pulsePos = 0;
101
+ let intervalId: Timer | null = null;
102
+
103
+ function render() {
104
+ const elapsed = Date.now() - startTime;
105
+ const elapsedSec = (elapsed / 1000).toFixed(1);
106
+
107
+ clearLine();
108
+
109
+ if (elapsed < delayMs) {
110
+ // Just spinner for first N seconds
111
+ process.stderr.write(
112
+ `${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label}...`,
113
+ );
114
+ } else {
115
+ // Show pulsing bar after delay (indicates activity without false progress)
116
+ const purple = getRandomPurple();
117
+
118
+ // Create pulsing effect - a bright section that moves across the bar
119
+ const pulseWidth = 5;
120
+ pulsePos = (pulsePos + 1) % (barWidth + pulseWidth);
121
+
122
+ let bar = "";
123
+ for (let i = 0; i < barWidth; i++) {
124
+ const distFromPulse = Math.abs(i - pulsePos);
125
+ if (distFromPulse < pulseWidth) {
126
+ bar += "▓";
127
+ } else {
128
+ bar += "░";
129
+ }
130
+ }
131
+
132
+ process.stderr.write(
133
+ `${colors.cyan}${frames[frame++ % frames.length]}${colors.reset} ${label} ${purple}[${bar}]${colors.reset} ${colors.dim}${formatSize(totalSize)} • ${elapsedSec}s${colors.reset}`,
134
+ );
135
+ }
136
+ }
137
+
138
+ return {
139
+ start() {
140
+ if (process.stderr.isTTY) {
141
+ render();
142
+ intervalId = setInterval(render, 80);
143
+ }
144
+ },
145
+
146
+ complete() {
147
+ if (intervalId) {
148
+ clearInterval(intervalId);
149
+ intervalId = null;
150
+ }
151
+ clearLine();
152
+ },
153
+ };
154
+ }
155
+
156
+ function clearLine() {
157
+ if (process.stderr.isTTY) {
158
+ process.stderr.write("\r\x1b[K");
159
+ }
160
+ }