@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 +1 -1
- package/src/commands/secrets.ts +3 -1
- package/src/lib/crypto.ts +84 -0
- package/src/lib/deploy-upload.ts +7 -3
- package/src/lib/hooks.ts +1 -2
- package/src/lib/project-operations.ts +24 -8
- package/src/lib/prompts.ts +2 -2
- package/templates/CLAUDE.md +21 -0
- package/templates/nextjs-clerk/app/layout.tsx +2 -0
package/package.json
CHANGED
package/src/commands/secrets.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/deploy-upload.ts
CHANGED
|
@@ -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([
|
|
77
|
+
new Blob([encryptedPayload], { type: "application/json" }),
|
|
74
78
|
"secrets.json",
|
|
75
79
|
);
|
|
76
|
-
totalSize +=
|
|
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
|
|
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
|
|
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
|
|
1049
|
-
|
|
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...");
|
package/src/lib/prompts.ts
CHANGED
|
@@ -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
|
|
package/templates/CLAUDE.md
CHANGED
|
@@ -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",
|