@getjack/jack 0.1.33 → 0.1.34

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.33",
3
+ "version": "0.1.34",
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",
@@ -219,11 +219,13 @@ async function setSecret(args: string[], options: SecretsOptions): Promise<void>
219
219
  */
220
220
  async function setSecretManaged(projectId: string, name: string, value: string): Promise<void> {
221
221
  const { authFetch } = await import("../lib/auth/index.ts");
222
+ const { encryptSecretValue } = await import("../lib/crypto.ts");
222
223
 
224
+ const encryptedValue = await encryptSecretValue(value);
223
225
  const response = await authFetch(`${getControlApiUrl()}/v1/projects/${projectId}/secrets`, {
224
226
  method: "POST",
225
227
  headers: { "Content-Type": "application/json" },
226
- body: JSON.stringify({ name, value }),
228
+ body: JSON.stringify({ name, value: encryptedValue }),
227
229
  });
228
230
 
229
231
  if (!response.ok) {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Client-side encryption for secrets sent to the control plane.
3
+ *
4
+ * Uses hybrid RSA-OAEP + AES-GCM so secrets of any size can be encrypted.
5
+ * The control plane holds the matching RSA private key as a worker secret.
6
+ */
7
+
8
+ export interface EncryptedEnvelope {
9
+ __encrypted: true;
10
+ kid: string;
11
+ wrappedKey: string; // base64url
12
+ iv: string; // base64url
13
+ ciphertext: string; // base64url
14
+ }
15
+
16
+ const KEY_ID = "v1";
17
+
18
+ /** RSA-OAEP-256 public key for secrets encryption (generated by scripts/generate-encryption-keypair.ts) */
19
+ const PUBLIC_KEY_JWK: JsonWebKey = {
20
+ alg: "RSA-OAEP-256",
21
+ e: "AQAB",
22
+ ext: true,
23
+ key_ops: ["encrypt"],
24
+ kty: "RSA",
25
+ n: "q2Y4K6heGkv_ABFOYokNXcwHFLAG3JScxEhjnZQTi7K8JEdCM9inqcy3gGhtT4lP6YWqhF4IHRMFU4qhPuByLASNp3bMWzDDKlckyDeWyPRnJqjb6IvwPYLw0ky1WumjjypAX_OSpNKhuYHx1X1hu7KQq9oa3f6sHFM5XbofMM2f__HvcEHnBVgkJvjTL2dn94DPgnsmtTLSRUAde34DQnXAKjVJ2jDuoC_sDAUmcmsEZKt3AUaCTkLBtbfW-ZI6_4VD2yNw-ySuOEprhhsNi6UpbjPY1ncduB5nkNhb276kVsjWo8w89KvDlhNCRyyZ_c0QRYSxn-nYEIE3vtS_h9FC9keMcDnH_fE4VPn14cjPV_G-eiUAoow8q5qBnFEp9DaaOswZ8IwEhpaxN6jvgk1WikZIBd58WB4HHSFWQ-W-096_5FA4cltQE7Qgwy86AgPnhpuCLLTqwpx8XF3GLbWtt9h4QYpfjrLyGuj4gJWCI4AJSDY1bvqiZtTfO1LdhyiZteEH0XhSBvXjXb1dJHbNXIcrIa_owtfEKqb53AxxwTvPaZazkigT0MqZ-141e7x6kuDkG_gSSFyCGrESaAyGYRh2K4wcGuV4jyZlQ6dzbQd0DPn8uRW3kC_vpToyZxZVqWGXFD6TtMYQwo_zWK3IaYCYMB-TYBFJj8a41z8",
26
+ };
27
+
28
+ function base64urlEncode(bytes: ArrayBuffer | Uint8Array): string {
29
+ const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
30
+ let bin = "";
31
+ for (let i = 0; i < u8.length; i++) {
32
+ bin += String.fromCharCode(u8[i]);
33
+ }
34
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
35
+ }
36
+
37
+ let _cachedPublicKey: CryptoKey | null = null;
38
+
39
+ async function getPublicKey(): Promise<CryptoKey> {
40
+ if (_cachedPublicKey) return _cachedPublicKey;
41
+ _cachedPublicKey = await crypto.subtle.importKey(
42
+ "jwk",
43
+ PUBLIC_KEY_JWK,
44
+ { name: "RSA-OAEP", hash: "SHA-256" },
45
+ false,
46
+ ["encrypt"],
47
+ );
48
+ return _cachedPublicKey;
49
+ }
50
+
51
+ /** Encrypt a single plaintext string into an encrypted envelope. */
52
+ export async function encryptSecretValue(plaintext: string): Promise<EncryptedEnvelope> {
53
+ const rsaKey = await getPublicKey();
54
+
55
+ // Generate ephemeral AES-256-GCM key
56
+ const aesKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [
57
+ "encrypt",
58
+ ]);
59
+
60
+ // Encrypt plaintext with AES-GCM
61
+ const iv = crypto.getRandomValues(new Uint8Array(12));
62
+ const plainBytes = new TextEncoder().encode(plaintext);
63
+ const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, plainBytes);
64
+
65
+ // Wrap the AES key with RSA-OAEP
66
+ const rawAesKey = await crypto.subtle.exportKey("raw", aesKey);
67
+ const wrappedKey = await crypto.subtle.encrypt({ name: "RSA-OAEP" }, rsaKey, rawAesKey);
68
+
69
+ return {
70
+ __encrypted: true,
71
+ kid: KEY_ID,
72
+ wrappedKey: base64urlEncode(wrappedKey),
73
+ iv: base64urlEncode(iv),
74
+ ciphertext: base64urlEncode(ciphertext),
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Encrypt a full secrets map (Record<string, string>) as a single envelope.
80
+ * The entire JSON object is serialized then encrypted as one blob.
81
+ */
82
+ export async function encryptSecrets(secrets: Record<string, string>): Promise<EncryptedEnvelope> {
83
+ return encryptSecretValue(JSON.stringify(secrets));
84
+ }
@@ -6,6 +6,7 @@ import { readFile } from "node:fs/promises";
6
6
  import type { AssetManifest } from "./asset-hash.ts";
7
7
  import { authFetch } from "./auth/index.ts";
8
8
  import { getControlApiUrl } from "./control-plane.ts";
9
+ import { encryptSecrets } from "./crypto.ts";
9
10
  import { debug } from "./debug.ts";
10
11
  import { formatSize } from "./format.ts";
11
12
 
@@ -67,13 +68,16 @@ export async function uploadDeployment(options: DeployUploadOptions): Promise<De
67
68
  }
68
69
 
69
70
  if (options.secretsPath) {
70
- const secretsContent = await readFile(options.secretsPath);
71
+ const secretsContent = await readFile(options.secretsPath, "utf-8");
72
+ const secretsJson = JSON.parse(secretsContent) as Record<string, string>;
73
+ const encryptedEnvelope = await encryptSecrets(secretsJson);
74
+ const encryptedPayload = JSON.stringify(encryptedEnvelope);
71
75
  formData.append(
72
76
  "secrets",
73
- new Blob([secretsContent], { type: "application/json" }),
77
+ new Blob([encryptedPayload], { type: "application/json" }),
74
78
  "secrets.json",
75
79
  );
76
- totalSize += secretsContent.length;
80
+ totalSize += encryptedPayload.length;
77
81
  }
78
82
 
79
83
  if (options.assetsZipPath) {
package/src/lib/hooks.ts CHANGED
@@ -533,8 +533,7 @@ const actionHandlers: {
533
533
  const { isCancel, text } = await import("@clack/prompts");
534
534
  const value = await text({ message: promptMsg });
535
535
 
536
- if (isCancel(value) || !value.trim()) {
537
- ui.warn(`Skipped ${action.key}`);
536
+ if (isCancel(value) || !value || !value.trim()) {
538
537
  return false;
539
538
  }
540
539
 
@@ -797,10 +797,18 @@ export async function createProject(
797
797
  ? resolve(targetDirOption, name)
798
798
  : join(getJackHome(), name);
799
799
  if (existsSync(effectiveTargetDir)) {
800
+ const existingLink = await readProjectLink(effectiveTargetDir);
801
+ if (existingLink) {
802
+ throw new JackError(
803
+ JackErrorCode.VALIDATION_ERROR,
804
+ `'${name}' already exists`,
805
+ `cd ${effectiveTargetDir} && jack ship to deploy it, or rm -rf ${effectiveTargetDir} to start fresh.`,
806
+ );
807
+ }
800
808
  throw new JackError(
801
809
  JackErrorCode.VALIDATION_ERROR,
802
- `Folder exists at ${effectiveTargetDir}/`,
803
- "Remove it first, or use 'jack ship' if it's a project.",
810
+ `Folder already exists at ${effectiveTargetDir}/`,
811
+ "Remove it or pick a different name.",
804
812
  );
805
813
  }
806
814
  }
@@ -873,10 +881,18 @@ export async function createProject(
873
881
 
874
882
  // Check directory doesn't exist (only needed for auto-generated names now)
875
883
  if (!nameWasProvided && existsSync(targetDir)) {
884
+ const existingLink = await readProjectLink(targetDir);
885
+ if (existingLink) {
886
+ throw new JackError(
887
+ JackErrorCode.VALIDATION_ERROR,
888
+ `'${projectName}' already exists`,
889
+ `cd ${targetDir} && jack ship to deploy it, or rm -rf ${targetDir} to start fresh.`,
890
+ );
891
+ }
876
892
  throw new JackError(
877
893
  JackErrorCode.VALIDATION_ERROR,
878
- `Folder exists at ${targetDir}/`,
879
- "Remove it first, or use 'jack ship' if it's a project.",
894
+ `Folder already exists at ${targetDir}/`,
895
+ "Remove it first or pick a different name.",
880
896
  );
881
897
  }
882
898
 
@@ -1045,8 +1061,8 @@ export async function createProject(
1045
1061
  if (!hookResult.success) {
1046
1062
  throw new JackError(
1047
1063
  JackErrorCode.VALIDATION_ERROR,
1048
- "Project setup incomplete",
1049
- "Missing required configuration",
1064
+ "Project setup cancelled",
1065
+ `Run the same command again when you're ready — jack new ${projectName} -t ${resolvedTemplate}`,
1050
1066
  );
1051
1067
  }
1052
1068
  }
@@ -1111,7 +1127,7 @@ export async function createProject(
1111
1127
  placeholder: "paste value or press Esc to skip",
1112
1128
  });
1113
1129
 
1114
- if (!isCancel(value) && value.trim()) {
1130
+ if (!isCancel(value) && value && value.trim()) {
1115
1131
  secretsToUse[optionalSecret.name] = value.trim();
1116
1132
  // Save to global secrets for reuse
1117
1133
  await saveSecrets([
@@ -1123,7 +1139,7 @@ export async function createProject(
1123
1139
  ]);
1124
1140
  reporter.success(`Saved ${optionalSecret.name}`);
1125
1141
  } else {
1126
- reporter.info(`Skipped ${optionalSecret.name}`);
1142
+ reporter.info(`Skipped ${optionalSecret.name} — add it later with: jack secrets add ${optionalSecret.name}`);
1127
1143
  }
1128
1144
 
1129
1145
  reporter.start("Creating project...");
@@ -68,7 +68,7 @@ async function promptAdditionalSecrets(): Promise<
68
68
  message: "Enter secret name (or press enter to finish):",
69
69
  });
70
70
 
71
- if (isCancel(key) || !key.trim()) {
71
+ if (isCancel(key) || !key || !key.trim()) {
72
72
  break;
73
73
  }
74
74
 
@@ -76,7 +76,7 @@ async function promptAdditionalSecrets(): Promise<
76
76
  message: `Enter value for ${key}:`,
77
77
  });
78
78
 
79
- if (isCancel(value)) {
79
+ if (isCancel(value) || !value) {
80
80
  break;
81
81
  }
82
82
 
@@ -114,6 +114,27 @@ Before shipping a new template:
114
114
  - [ ] Install time < 5s (cold cache + lockfile)
115
115
  - [ ] All scripts work without global tools except wrangler
116
116
  - [ ] `.gitignore` includes `.env`, `.dev.vars`, `.secrets.json`
117
+ - [ ] Prebuilt builds without env vars: `bun run build` succeeds with zero secrets set
118
+ - [ ] If template has `schema.sql`, verify it's included in prebuild output
119
+
120
+ ## Prebuild Compatibility (IMPORTANT)
121
+
122
+ Templates are prebuilt at release time **without any env vars or secrets**. The prebuild runs `bun run build` in a clean environment. If the build depends on runtime env vars, it will fail silently (only discovered when someone runs `prebuild-templates.ts`).
123
+
124
+ **Common failure:** A provider component (e.g., `ClerkProvider`, `AuthProvider`) in the root layout requires an API key at import time. Next.js tries to statically prerender pages and the provider throws because the key is missing.
125
+
126
+ **Fix:** Add `export const dynamic = "force-dynamic"` to the root layout. This tells Next.js to skip static prerendering for all pages, deferring to runtime where env vars are available.
127
+
128
+ ```tsx
129
+ // app/layout.tsx
130
+ export const dynamic = "force-dynamic"; // Required: provider needs runtime env vars
131
+
132
+ export default function RootLayout({ children }) {
133
+ return <SomeProvider>{children}</SomeProvider>;
134
+ }
135
+ ```
136
+
137
+ **General rule:** If a template imports any SDK/provider that reads `process.env` or `env.*` at initialization, that template's layout or pages must be force-dynamic to survive a zero-env-var prebuild.
117
138
 
118
139
  ## Placeholder System
119
140
 
@@ -3,6 +3,8 @@ import type { Metadata } from "next";
3
3
  import { Header } from "@/components/header";
4
4
  import "./globals.css";
5
5
 
6
+ export const dynamic = "force-dynamic";
7
+
6
8
  export const metadata: Metadata = {
7
9
  title: "jack-template",
8
10
  description: "Next.js + Clerk auth app built with jack",