@getjack/jack 0.1.5 → 0.1.7

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.5",
3
+ "version": "0.1.7",
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",
@@ -44,6 +44,7 @@
44
44
  "@inquirer/prompts": "^7.0.0",
45
45
  "@modelcontextprotocol/sdk": "^1.25.1",
46
46
  "archiver": "^7.0.1",
47
+ "fflate": "^0.8.2",
47
48
  "human-id": "^4.1.3",
48
49
  "meow": "^14.0.0",
49
50
  "yocto-spinner": "^1.0.0",
@@ -1,6 +1,12 @@
1
+ import { input } from "@inquirer/prompts";
1
2
  import { type DeviceAuthResponse, pollDeviceToken, startDeviceAuth } from "../lib/auth/client.ts";
2
3
  import { type AuthCredentials, saveCredentials } from "../lib/auth/store.ts";
3
- import { error, info, spinner, success } from "../lib/output.ts";
4
+ import {
5
+ checkUsernameAvailable,
6
+ getCurrentUserProfile,
7
+ setUsername,
8
+ } from "../lib/control-plane.ts";
9
+ import { error, info, spinner, success, warn } from "../lib/output.ts";
4
10
 
5
11
  interface LoginOptions {
6
12
  /** Skip the initial "Logging in..." message (used when called from auto-login) */
@@ -69,6 +75,9 @@ export default async function login(options: LoginOptions = {}): Promise<void> {
69
75
 
70
76
  console.error("");
71
77
  success(`Logged in as ${tokens.user.email}`);
78
+
79
+ // Prompt for username if not set
80
+ await promptForUsername(tokens.user.email);
72
81
  return;
73
82
  }
74
83
  } catch (err) {
@@ -86,3 +95,117 @@ export default async function login(options: LoginOptions = {}): Promise<void> {
86
95
  function sleep(ms: number): Promise<void> {
87
96
  return new Promise((resolve) => setTimeout(resolve, ms));
88
97
  }
98
+
99
+ async function promptForUsername(email: string): Promise<void> {
100
+ // Skip in non-TTY environments
101
+ if (!process.stdout.isTTY) {
102
+ return;
103
+ }
104
+
105
+ const spin = spinner("Checking account...");
106
+
107
+ try {
108
+ const profile = await getCurrentUserProfile();
109
+ spin.stop();
110
+
111
+ // If user already has a username, skip
112
+ if (profile?.username) {
113
+ return;
114
+ }
115
+
116
+ console.error("");
117
+ info("Choose a username for your jack cloud account.");
118
+ info("URLs will look like: alice-vibes.runjack.xyz");
119
+ console.error("");
120
+
121
+ // Generate suggestions from $USER env var and email
122
+ const suggestions = generateUsernameSuggestions(email);
123
+
124
+ let username: string | null = null;
125
+
126
+ while (!username) {
127
+ // Show suggestions if available
128
+ if (suggestions.length > 0) {
129
+ info(`Suggestions: ${suggestions.join(", ")}`);
130
+ }
131
+
132
+ const inputUsername = await input({
133
+ message: "Username:",
134
+ default: suggestions[0],
135
+ validate: (value) => {
136
+ if (!value || value.length < 3) {
137
+ return "Username must be at least 3 characters";
138
+ }
139
+ if (value.length > 39) {
140
+ return "Username must be 39 characters or less";
141
+ }
142
+ if (value !== value.toLowerCase()) {
143
+ return "Username must be lowercase";
144
+ }
145
+ if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]{1,2}$/.test(value)) {
146
+ return "Use only lowercase letters, numbers, and hyphens";
147
+ }
148
+ return true;
149
+ },
150
+ });
151
+
152
+ // Check availability
153
+ const checkSpin = spinner("Checking availability...");
154
+ const availability = await checkUsernameAvailable(inputUsername);
155
+ checkSpin.stop();
156
+
157
+ if (!availability.available) {
158
+ warn(availability.error || `Username "${inputUsername}" is already taken. Try another.`);
159
+ continue;
160
+ }
161
+
162
+ // Try to set the username
163
+ const setSpin = spinner("Setting username...");
164
+ try {
165
+ await setUsername(inputUsername);
166
+ setSpin.stop();
167
+ username = inputUsername;
168
+ success(`Username set to "${username}"`);
169
+ } catch (err) {
170
+ setSpin.stop();
171
+ warn(err instanceof Error ? err.message : "Failed to set username");
172
+ }
173
+ }
174
+ } catch (err) {
175
+ spin.stop();
176
+ // Non-fatal - user can set username later
177
+ warn("Could not set username. You can set it later.");
178
+ }
179
+ }
180
+
181
+ function generateUsernameSuggestions(email: string): string[] {
182
+ const suggestions: string[] = [];
183
+
184
+ // Try $USER environment variable first
185
+ const envUser = process.env.USER || process.env.USERNAME;
186
+ if (envUser) {
187
+ const normalized = normalizeToUsername(envUser);
188
+ if (normalized && normalized.length >= 3) {
189
+ suggestions.push(normalized);
190
+ }
191
+ }
192
+
193
+ // Try email local part
194
+ const emailLocal = email.split("@")[0];
195
+ if (emailLocal) {
196
+ const normalized = normalizeToUsername(emailLocal);
197
+ if (normalized && normalized.length >= 3 && !suggestions.includes(normalized)) {
198
+ suggestions.push(normalized);
199
+ }
200
+ }
201
+
202
+ return suggestions.slice(0, 3); // Max 3 suggestions
203
+ }
204
+
205
+ function normalizeToUsername(input: string): string {
206
+ return input
207
+ .toLowerCase()
208
+ .replace(/[^a-z0-9]+/g, "-")
209
+ .replace(/^-+|-+$/g, "")
210
+ .slice(0, 39);
211
+ }
@@ -183,7 +183,11 @@ function renderGroupedView(items: ProjectListItem[]): void {
183
183
 
184
184
  console.error("");
185
185
  console.error(
186
- formatCloudSection(sorted, { limit: CLOUD_LIMIT, total: groups.cloudOnly.length, tagColorMap }),
186
+ formatCloudSection(sorted, {
187
+ limit: CLOUD_LIMIT,
188
+ total: groups.cloudOnly.length,
189
+ tagColorMap,
190
+ }),
187
191
  );
188
192
  }
189
193
 
@@ -0,0 +1,50 @@
1
+ import { getCurrentUserProfile, publishProject } from "../lib/control-plane.ts";
2
+ import { output, spinner } from "../lib/output.ts";
3
+ import { readProjectLink } from "../lib/project-link.ts";
4
+
5
+ export default async function publish(): Promise<void> {
6
+ // Check we're in a project directory
7
+ const link = await readProjectLink(process.cwd());
8
+ if (!link) {
9
+ output.error("Not in a jack project directory");
10
+ output.info("Run this command from a directory with a .jack folder");
11
+ process.exit(1);
12
+ }
13
+
14
+ if (link.deploy_mode !== "managed") {
15
+ output.error("Only managed projects can be published");
16
+ output.info("Projects deployed with BYOC (bring your own cloud) cannot be published");
17
+ process.exit(1);
18
+ }
19
+
20
+ if (!link.project_id) {
21
+ output.error("Project not linked to jack cloud");
22
+ output.info("Run: jack ship (to deploy and link the project)");
23
+ process.exit(1);
24
+ }
25
+
26
+ // Check user has username
27
+ const profile = await getCurrentUserProfile();
28
+ if (!profile?.username) {
29
+ output.error("You need a username to publish projects");
30
+ output.info("Run: jack login (to set up your username)");
31
+ process.exit(1);
32
+ }
33
+
34
+ const spin = spinner("Publishing project...");
35
+
36
+ try {
37
+ const result = await publishProject(link.project_id);
38
+ spin.stop();
39
+ output.success(`Published as ${result.published_as}`);
40
+
41
+ console.error("");
42
+ output.info("Others can now fork your project:");
43
+ output.info(` ${result.fork_command}`);
44
+ } catch (err) {
45
+ spin.stop();
46
+ const message = err instanceof Error ? err.message : "Unknown error";
47
+ output.error(`Publish failed: ${message}`);
48
+ process.exit(1);
49
+ }
50
+ }
@@ -3,7 +3,7 @@ import { output, spinner } from "../lib/output.ts";
3
3
  import { deployProject } from "../lib/project-operations.ts";
4
4
 
5
5
  export default async function ship(
6
- options: { managed?: boolean; byo?: boolean } = {},
6
+ options: { managed?: boolean; byo?: boolean; dryRun?: boolean } = {},
7
7
  ): Promise<void> {
8
8
  const isCi = process.env.CI === "true" || process.env.CI === "1";
9
9
  try {
@@ -20,10 +20,11 @@ export default async function ship(
20
20
  box: output.box,
21
21
  },
22
22
  interactive: !isCi,
23
- includeSecrets: true,
24
- includeSync: true,
23
+ includeSecrets: !options.dryRun,
24
+ includeSync: !options.dryRun,
25
25
  managed: options.managed,
26
26
  byo: options.byo,
27
+ dryRun: options.dryRun,
27
28
  });
28
29
 
29
30
  if (!result.workerUrl && result.deployOutput) {
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ const cli = meow(
25
25
  down [name] Undeploy from cloud
26
26
  ls List all projects
27
27
  info [name] Show project details
28
+ publish Make your project forkable by others
28
29
 
29
30
  Cloud & Sync
30
31
  clone <project> Pull from cloud backup
@@ -206,6 +207,7 @@ try {
206
207
  )({
207
208
  managed: cli.flags.managed,
208
209
  byo: cli.flags.byo,
210
+ dryRun: cli.flags.dryRun,
209
211
  });
210
212
  break;
211
213
  }
@@ -264,6 +266,11 @@ try {
264
266
  await withTelemetry("down", down)(args[0], { force: cli.flags.force });
265
267
  break;
266
268
  }
269
+ case "publish": {
270
+ const { default: publish } = await import("./commands/publish.ts");
271
+ await withTelemetry("publish", publish)();
272
+ break;
273
+ }
267
274
  case "open": {
268
275
  const { default: open } = await import("./commands/open.ts");
269
276
  await withTelemetry("open", open)(args[0], { dash: cli.flags.dash, logs: cli.flags.logs });
@@ -12,14 +12,20 @@ import type { WranglerConfig } from "./build-helper.ts";
12
12
  /**
13
13
  * Bindings supported by jack cloud managed deployments.
14
14
  */
15
- export const SUPPORTED_BINDINGS = ["d1_databases", "ai", "assets", "vars", "r2_buckets"] as const;
15
+ export const SUPPORTED_BINDINGS = [
16
+ "d1_databases",
17
+ "ai",
18
+ "assets",
19
+ "vars",
20
+ "r2_buckets",
21
+ "kv_namespaces",
22
+ ] as const;
16
23
 
17
24
  /**
18
25
  * Bindings not yet supported by jack cloud.
19
26
  * These will cause validation errors if present in wrangler config.
20
27
  */
21
28
  export const UNSUPPORTED_BINDINGS = [
22
- "kv_namespaces",
23
29
  "durable_objects",
24
30
  "queues",
25
31
  "services",
@@ -33,7 +39,6 @@ export const UNSUPPORTED_BINDINGS = [
33
39
  * Human-readable names for unsupported bindings.
34
40
  */
35
41
  const BINDING_DISPLAY_NAMES: Record<string, string> = {
36
- kv_namespaces: "KV Namespaces",
37
42
  durable_objects: "Durable Objects",
38
43
  queues: "Queues",
39
44
  services: "Service Bindings",
@@ -67,7 +72,7 @@ export function validateBindings(
67
72
  if (value !== undefined && value !== null) {
68
73
  const displayName = BINDING_DISPLAY_NAMES[binding] || binding;
69
74
  errors.push(
70
- `✗ ${displayName} not supported in managed deploy.\n Managed deploy supports: D1, AI, Assets, R2, vars.\n Fix: Remove ${binding} from wrangler.jsonc, or use 'wrangler deploy' for full control.`,
75
+ `✗ ${displayName} not supported in managed deploy.\n Managed deploy supports: D1, AI, Assets, R2, KV, vars.\n Fix: Remove ${binding} from wrangler.jsonc, or use 'wrangler deploy' for full control.`,
71
76
  );
72
77
  }
73
78
  }
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { mkdir, readFile, readdir } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { basename, join } from "node:path";
@@ -40,8 +40,11 @@ export interface WranglerConfig {
40
40
  binding: string;
41
41
  bucket_name: string;
42
42
  }>;
43
+ kv_namespaces?: Array<{
44
+ binding: string;
45
+ id?: string; // Optional - wrangler auto-provisions if missing
46
+ }>;
43
47
  // Unsupported bindings (for validation)
44
- kv_namespaces?: unknown;
45
48
  durable_objects?: unknown;
46
49
  queues?: unknown;
47
50
  services?: unknown;
@@ -103,9 +106,20 @@ export async function needsOpenNextBuild(projectPath: string): Promise<boolean>
103
106
  * @throws JackError if build fails
104
107
  */
105
108
  export async function runViteBuild(projectPath: string): Promise<void> {
106
- // Jack controls the build for managed projects (omakase)
107
- // Users wanting tsc can run `bun run build` manually before shipping
108
- const buildResult = await $`bunx vite build`.cwd(projectPath).nothrow().quiet();
109
+ // Use local vite if installed to avoid module resolution issues
110
+ // bunx vite installs to temp dir, but vite.config.js may require('vite') from node_modules
111
+ // Don't use project's build script - it might do more than just vite build (e.g., Tauri)
112
+ let buildCommand: string[];
113
+
114
+ if (existsSync(join(projectPath, "node_modules", ".bin", "vite"))) {
115
+ // Local vite installed - use it directly
116
+ buildCommand = ["bun", "run", "vite", "build"];
117
+ } else {
118
+ // Fallback to bunx
119
+ buildCommand = ["bunx", "vite", "build"];
120
+ }
121
+
122
+ const buildResult = await $`${buildCommand}`.cwd(projectPath).nothrow().quiet();
109
123
 
110
124
  if (buildResult.exitCode !== 0) {
111
125
  throw new JackError(
@@ -258,42 +272,50 @@ async function resolveEntrypoint(outDir: string, main?: string): Promise<string>
258
272
  }
259
273
 
260
274
  /**
261
- * Ensures R2 buckets exist for BYO deploy.
262
- * Creates buckets via wrangler if they don't exist.
263
- * @param projectPath - Absolute path to project directory
264
- * @returns Array of bucket names that were created or already existed
275
+ * Gets the installed wrangler version.
276
+ * @returns Version string (e.g., "4.55.0")
265
277
  */
266
- export async function ensureR2Buckets(projectPath: string): Promise<string[]> {
267
- const config = await parseWranglerConfig(projectPath);
268
-
269
- if (!config.r2_buckets || config.r2_buckets.length === 0) {
270
- return [];
278
+ export async function getWranglerVersion(): Promise<string> {
279
+ const result = await $`wrangler --version`.nothrow().quiet();
280
+ if (result.exitCode !== 0) {
281
+ throw new JackError(
282
+ JackErrorCode.VALIDATION_ERROR,
283
+ "wrangler not found",
284
+ "Install wrangler: npm install -g wrangler",
285
+ );
271
286
  }
287
+ // Parse "wrangler 4.55.0" -> "4.55.0"
288
+ const match = result.stdout.toString().match(/(\d+\.\d+\.\d+)/);
289
+ return match?.[1] ?? "0.0.0";
290
+ }
272
291
 
273
- const results: string[] = [];
274
-
275
- for (const bucket of config.r2_buckets) {
276
- const bucketName = bucket.bucket_name;
277
-
278
- // Try to create the bucket (wrangler handles "already exists" gracefully)
279
- const result = await $`wrangler r2 bucket create ${bucketName}`
280
- .cwd(projectPath)
281
- .nothrow()
282
- .quiet();
283
-
284
- // Exit code 0 = created, non-zero with "already exists" = fine
285
- const stderr = result.stderr.toString();
286
- if (result.exitCode === 0 || stderr.includes("already exists")) {
287
- results.push(bucketName);
288
- } else {
289
- throw new JackError(
290
- JackErrorCode.RESOURCE_ERROR,
291
- `Failed to create R2 bucket: ${bucketName}`,
292
- "Check your Cloudflare account has R2 enabled",
293
- { stderr },
294
- );
295
- }
296
- }
292
+ const MIN_WRANGLER_VERSION = "4.45.0";
297
293
 
298
- return results;
294
+ /**
295
+ * Checks if wrangler version meets minimum requirement for auto-provisioning.
296
+ * @throws JackError if version is too old
297
+ */
298
+ export function checkWranglerVersion(version: string): void {
299
+ const parts = version.split(".").map(Number);
300
+ const minParts = MIN_WRANGLER_VERSION.split(".").map(Number);
301
+
302
+ const major = parts[0] ?? 0;
303
+ const minor = parts[1] ?? 0;
304
+ const patch = parts[2] ?? 0;
305
+ const minMajor = minParts[0] ?? 0;
306
+ const minMinor = minParts[1] ?? 0;
307
+ const minPatch = minParts[2] ?? 0;
308
+
309
+ const isValid =
310
+ major > minMajor ||
311
+ (major === minMajor && minor > minMinor) ||
312
+ (major === minMajor && minor === minMinor && patch >= minPatch);
313
+
314
+ if (!isValid) {
315
+ throw new JackError(
316
+ JackErrorCode.VALIDATION_ERROR,
317
+ `wrangler ${MIN_WRANGLER_VERSION}+ required (found ${version})`,
318
+ "Run: npm install -g wrangler@latest",
319
+ );
320
+ }
299
321
  }
@@ -0,0 +1,107 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ export type ProjectType = "nextjs" | "vite" | "hono" | "sveltekit" | "worker" | "unknown";
5
+
6
+ export interface WranglerConfig {
7
+ name: string;
8
+ main?: string;
9
+ compatibility_date: string;
10
+ assets?: {
11
+ directory: string;
12
+ binding?: string;
13
+ };
14
+ }
15
+
16
+ const COMPATIBILITY_DATE = "2024-12-01";
17
+
18
+ export function generateWranglerConfig(
19
+ projectType: ProjectType,
20
+ projectName: string,
21
+ entryPoint?: string,
22
+ ): WranglerConfig {
23
+ switch (projectType) {
24
+ case "nextjs":
25
+ return {
26
+ name: projectName,
27
+ main: ".open-next/worker.js",
28
+ compatibility_date: COMPATIBILITY_DATE,
29
+ assets: {
30
+ directory: ".open-next/assets",
31
+ binding: "ASSETS",
32
+ },
33
+ };
34
+
35
+ case "vite":
36
+ // Pure Vite SPAs use assets-only mode (no worker entry)
37
+ // Cloudflare auto-generates a worker that serves static files
38
+ return {
39
+ name: projectName,
40
+ compatibility_date: COMPATIBILITY_DATE,
41
+ assets: {
42
+ directory: "./dist",
43
+ },
44
+ };
45
+
46
+ case "hono":
47
+ return {
48
+ name: projectName,
49
+ main: entryPoint || "src/index.ts",
50
+ compatibility_date: COMPATIBILITY_DATE,
51
+ };
52
+
53
+ case "sveltekit":
54
+ return {
55
+ name: projectName,
56
+ compatibility_date: COMPATIBILITY_DATE,
57
+ assets: {
58
+ directory: "./.svelte-kit/cloudflare",
59
+ },
60
+ };
61
+
62
+ default:
63
+ return {
64
+ name: projectName,
65
+ main: entryPoint || "src/index.ts",
66
+ compatibility_date: COMPATIBILITY_DATE,
67
+ };
68
+ }
69
+ }
70
+
71
+ export function writeWranglerConfig(projectPath: string, config: WranglerConfig): void {
72
+ const header = "// wrangler.jsonc (auto-generated by jack)\n";
73
+ const json = JSON.stringify(config, null, 2);
74
+ const content = `${header}${json}\n`;
75
+ const filePath = path.join(projectPath, "wrangler.jsonc");
76
+ fs.writeFileSync(filePath, content, "utf-8");
77
+ }
78
+
79
+ export function getDefaultProjectName(projectPath: string): string {
80
+ const packageJsonPath = path.join(projectPath, "package.json");
81
+
82
+ if (fs.existsSync(packageJsonPath)) {
83
+ try {
84
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
85
+ if (packageJson.name && typeof packageJson.name === "string") {
86
+ const slugified = slugify(packageJson.name);
87
+ if (slugified) {
88
+ return slugified;
89
+ }
90
+ }
91
+ } catch {
92
+ // Fall through to folder name
93
+ }
94
+ }
95
+
96
+ const folderName = path.basename(path.resolve(projectPath));
97
+ return slugify(folderName) || "my-project";
98
+ }
99
+
100
+ export function slugify(name: string): string {
101
+ return name
102
+ .toLowerCase()
103
+ .replace(/[\s_]+/g, "-")
104
+ .replace(/[^a-z0-9-]/g, "")
105
+ .replace(/-+/g, "-")
106
+ .replace(/^-+|-+$/g, "");
107
+ }