@getjack/jack 0.1.7 → 0.1.9
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 +5 -2
- package/src/commands/down.ts +20 -3
- package/src/commands/login.ts +9 -9
- package/src/commands/mcp.ts +17 -1
- package/src/commands/new.ts +1 -0
- package/src/commands/projects.ts +48 -44
- package/src/lib/agent-files.ts +0 -2
- package/src/lib/build-helper.ts +6 -6
- package/src/lib/config-generator.ts +13 -0
- package/src/lib/config.ts +2 -1
- package/src/lib/hooks.ts +239 -101
- package/src/lib/json-edit.ts +56 -0
- package/src/lib/mcp-config.ts +2 -1
- package/src/lib/output.ts +83 -12
- package/src/lib/paths-index.test.ts +7 -7
- package/src/lib/project-detection.ts +19 -0
- package/src/lib/project-operations.ts +28 -21
- package/src/lib/project-resolver.ts +4 -1
- package/src/lib/secrets.ts +1 -2
- package/src/lib/telemetry-config.ts +3 -3
- package/src/lib/telemetry.ts +29 -0
- package/src/mcp/test-utils.ts +113 -0
- package/src/templates/index.ts +1 -1
- package/src/templates/types.ts +17 -0
- package/templates/CLAUDE.md +21 -1
- package/templates/miniapp/.jack.json +31 -2
- package/templates/miniapp/index.html +0 -1
- package/templates/miniapp/public/.well-known/farcaster.json +15 -15
- package/templates/miniapp/src/worker.ts +27 -4
|
@@ -297,9 +297,28 @@ export function detectProjectType(projectPath: string): DetectionResult {
|
|
|
297
297
|
if (viteConfig && hasDep(pkg, "vite")) {
|
|
298
298
|
configFiles.push(viteConfig);
|
|
299
299
|
detectedDeps.push("vite");
|
|
300
|
+
|
|
301
|
+
// Check for Vite + Worker hybrid pattern
|
|
302
|
+
// This is indicated by @cloudflare/vite-plugin AND a worker entry file
|
|
303
|
+
const hasCloudflarePlugin = hasDep(pkg, "@cloudflare/vite-plugin");
|
|
304
|
+
let workerEntry: string | null = null;
|
|
305
|
+
|
|
306
|
+
if (hasCloudflarePlugin) {
|
|
307
|
+
detectedDeps.push("@cloudflare/vite-plugin");
|
|
308
|
+
// Check common worker entry locations
|
|
309
|
+
const workerCandidates = ["src/worker.ts", "src/worker.js", "worker.ts", "worker.js"];
|
|
310
|
+
for (const candidate of workerCandidates) {
|
|
311
|
+
if (existsSync(join(projectPath, candidate))) {
|
|
312
|
+
workerEntry = candidate;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
300
318
|
return {
|
|
301
319
|
type: "vite",
|
|
302
320
|
configFile: viteConfig,
|
|
321
|
+
entryPoint: workerEntry || undefined,
|
|
303
322
|
detectedDeps,
|
|
304
323
|
configFiles,
|
|
305
324
|
};
|
|
@@ -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
|
|
942
|
+
const hookResult = await runHook(template.hooks.preDeploy, hookContext, {
|
|
943
943
|
interactive,
|
|
944
944
|
output: reporter,
|
|
945
945
|
});
|
|
946
|
-
if (!
|
|
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,
|
|
@@ -1047,22 +1047,22 @@ export async function createProject(
|
|
|
1047
1047
|
|
|
1048
1048
|
// Build first if needed (wrangler needs built assets)
|
|
1049
1049
|
if (await needsOpenNextBuild(targetDir)) {
|
|
1050
|
-
reporter.start("Building...");
|
|
1050
|
+
reporter.start("Building assets...");
|
|
1051
1051
|
try {
|
|
1052
1052
|
await runOpenNextBuild(targetDir);
|
|
1053
1053
|
reporter.stop();
|
|
1054
|
-
reporter.success("Built");
|
|
1054
|
+
reporter.success("Built assets");
|
|
1055
1055
|
} catch (err) {
|
|
1056
1056
|
reporter.stop();
|
|
1057
1057
|
reporter.error("Build failed");
|
|
1058
1058
|
throw err;
|
|
1059
1059
|
}
|
|
1060
1060
|
} else if (await needsViteBuild(targetDir)) {
|
|
1061
|
-
reporter.start("Building...");
|
|
1061
|
+
reporter.start("Building assets...");
|
|
1062
1062
|
try {
|
|
1063
1063
|
await runViteBuild(targetDir);
|
|
1064
1064
|
reporter.stop();
|
|
1065
|
-
reporter.success("Built");
|
|
1065
|
+
reporter.success("Built assets");
|
|
1066
1066
|
} catch (err) {
|
|
1067
1067
|
reporter.stop();
|
|
1068
1068
|
reporter.error("Build failed");
|
|
@@ -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 {
|
|
@@ -1237,9 +1242,11 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
|
|
|
1237
1242
|
}
|
|
1238
1243
|
|
|
1239
1244
|
// Validate mode availability
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1245
|
+
if (!dryRun) {
|
|
1246
|
+
const modeError = await validateModeAvailability(deployMode);
|
|
1247
|
+
if (modeError) {
|
|
1248
|
+
throw new JackError(JackErrorCode.VALIDATION_ERROR, modeError);
|
|
1249
|
+
}
|
|
1243
1250
|
}
|
|
1244
1251
|
|
|
1245
1252
|
let workerUrl: string | null = null;
|
|
@@ -1264,19 +1271,19 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
|
|
|
1264
1271
|
// (deployToManagedProject handles its own build, so only build here for dry-run)
|
|
1265
1272
|
if (dryRun) {
|
|
1266
1273
|
if (await needsOpenNextBuild(projectPath)) {
|
|
1267
|
-
const buildSpin = reporter.spinner("Building...");
|
|
1274
|
+
const buildSpin = reporter.spinner("Building assets...");
|
|
1268
1275
|
try {
|
|
1269
1276
|
await runOpenNextBuild(projectPath);
|
|
1270
|
-
buildSpin.success("Built");
|
|
1277
|
+
buildSpin.success("Built assets");
|
|
1271
1278
|
} catch (err) {
|
|
1272
1279
|
buildSpin.error("Build failed");
|
|
1273
1280
|
throw err;
|
|
1274
1281
|
}
|
|
1275
1282
|
} else if (await needsViteBuild(projectPath)) {
|
|
1276
|
-
const buildSpin = reporter.spinner("Building...");
|
|
1283
|
+
const buildSpin = reporter.spinner("Building assets...");
|
|
1277
1284
|
try {
|
|
1278
1285
|
await runViteBuild(projectPath);
|
|
1279
|
-
buildSpin.success("Built");
|
|
1286
|
+
buildSpin.success("Built assets");
|
|
1280
1287
|
} catch (err) {
|
|
1281
1288
|
buildSpin.error("Build failed");
|
|
1282
1289
|
throw err;
|
|
@@ -1302,19 +1309,19 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
|
|
|
1302
1309
|
|
|
1303
1310
|
// Build first if needed (wrangler needs built assets)
|
|
1304
1311
|
if (await needsOpenNextBuild(projectPath)) {
|
|
1305
|
-
const buildSpin = reporter.spinner("Building...");
|
|
1312
|
+
const buildSpin = reporter.spinner("Building assets...");
|
|
1306
1313
|
try {
|
|
1307
1314
|
await runOpenNextBuild(projectPath);
|
|
1308
|
-
buildSpin.success("Built");
|
|
1315
|
+
buildSpin.success("Built assets");
|
|
1309
1316
|
} catch (err) {
|
|
1310
1317
|
buildSpin.error("Build failed");
|
|
1311
1318
|
throw err;
|
|
1312
1319
|
}
|
|
1313
1320
|
} else if (await needsViteBuild(projectPath)) {
|
|
1314
|
-
const buildSpin = reporter.spinner("Building...");
|
|
1321
|
+
const buildSpin = reporter.spinner("Building assets...");
|
|
1315
1322
|
try {
|
|
1316
1323
|
await runViteBuild(projectPath);
|
|
1317
|
-
buildSpin.success("Built");
|
|
1324
|
+
buildSpin.success("Built assets");
|
|
1318
1325
|
} catch (err) {
|
|
1319
1326
|
buildSpin.error("Build failed");
|
|
1320
1327
|
throw err;
|
|
@@ -1396,16 +1403,16 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
|
|
|
1396
1403
|
}
|
|
1397
1404
|
}
|
|
1398
1405
|
|
|
1399
|
-
if (includeSync) {
|
|
1406
|
+
if (includeSync && deployMode !== "byo") {
|
|
1400
1407
|
const syncConfig = await getSyncConfig();
|
|
1401
1408
|
if (syncConfig.enabled && syncConfig.autoSync) {
|
|
1402
|
-
const syncSpin = reporter.spinner("Syncing source to
|
|
1409
|
+
const syncSpin = reporter.spinner("Syncing source to jack storage...");
|
|
1403
1410
|
try {
|
|
1404
1411
|
const syncResult = await syncToCloud(projectPath);
|
|
1405
1412
|
if (syncResult.success) {
|
|
1406
1413
|
if (syncResult.filesUploaded > 0 || syncResult.filesDeleted > 0) {
|
|
1407
1414
|
syncSpin.success(
|
|
1408
|
-
`
|
|
1415
|
+
`Synced source to jack storage (${syncResult.filesUploaded} uploaded, ${syncResult.filesDeleted} removed)`,
|
|
1409
1416
|
);
|
|
1410
1417
|
} else {
|
|
1411
1418
|
syncSpin.success("Source already synced");
|
|
@@ -179,6 +179,8 @@ export interface ResolveProjectOptions {
|
|
|
179
179
|
projectPath?: string;
|
|
180
180
|
/** Allow fallback lookup by managed project name when slug lookup fails */
|
|
181
181
|
matchByName?: boolean;
|
|
182
|
+
/** Prefer local .jack/project.json when resolving (default true) */
|
|
183
|
+
preferLocalLink?: boolean;
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
/**
|
|
@@ -225,9 +227,10 @@ export async function resolveProject(
|
|
|
225
227
|
let resolved: ResolvedProject | null = null;
|
|
226
228
|
const matchByName = options?.matchByName !== false;
|
|
227
229
|
const projectPath = options?.projectPath || process.cwd();
|
|
230
|
+
const preferLocalLink = options?.preferLocalLink ?? true;
|
|
228
231
|
|
|
229
232
|
// First, check if we're resolving from a local path with .jack/project.json
|
|
230
|
-
const link = await readProjectLink(projectPath);
|
|
233
|
+
const link = preferLocalLink ? await readProjectLink(projectPath) : null;
|
|
231
234
|
|
|
232
235
|
if (link) {
|
|
233
236
|
// We have a local link - start with that
|
package/src/lib/secrets.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { chmod, mkdir, stat } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
3
|
import { join } from "node:path";
|
|
4
|
+
import { CONFIG_DIR } from "./config.ts";
|
|
5
5
|
|
|
6
6
|
export interface SecretEntry {
|
|
7
7
|
value: string;
|
|
@@ -14,7 +14,6 @@ export interface SecretsFile {
|
|
|
14
14
|
secrets: Record<string, SecretEntry>;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
const CONFIG_DIR = join(homedir(), ".config", "jack");
|
|
18
17
|
const SECRETS_PATH = join(CONFIG_DIR, "secrets.json");
|
|
19
18
|
|
|
20
19
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { mkdir } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
3
|
import { join } from "node:path";
|
|
4
|
+
import { CONFIG_DIR } from "./config.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Telemetry configuration structure
|
|
@@ -12,8 +12,8 @@ export interface TelemetryConfig {
|
|
|
12
12
|
version: number; // config schema version (start at 1)
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export const TELEMETRY_CONFIG_DIR =
|
|
16
|
-
export const TELEMETRY_CONFIG_PATH = join(
|
|
15
|
+
export const TELEMETRY_CONFIG_DIR = CONFIG_DIR;
|
|
16
|
+
export const TELEMETRY_CONFIG_PATH = join(CONFIG_DIR, "telemetry.json");
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Cached telemetry config for memoization
|
package/src/lib/telemetry.ts
CHANGED
|
@@ -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
|
// ============================================
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
|
|
4
|
+
export interface McpClientOptions {
|
|
5
|
+
command: string;
|
|
6
|
+
args?: string[];
|
|
7
|
+
cwd?: string;
|
|
8
|
+
env?: NodeJS.ProcessEnv;
|
|
9
|
+
clientName?: string;
|
|
10
|
+
clientVersion?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface McpTestClient {
|
|
14
|
+
client: Client;
|
|
15
|
+
transport: StdioClientTransport;
|
|
16
|
+
getStderr(): string;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function openMcpTestClient(options: McpClientOptions): Promise<McpTestClient> {
|
|
21
|
+
const transport = new StdioClientTransport({
|
|
22
|
+
command: options.command,
|
|
23
|
+
args: options.args ?? [],
|
|
24
|
+
cwd: options.cwd,
|
|
25
|
+
env: options.env as Record<string, string> | undefined,
|
|
26
|
+
stderr: "pipe",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
let stderr = "";
|
|
30
|
+
transport.stderr?.on("data", (chunk) => {
|
|
31
|
+
stderr += chunk.toString();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const client = new Client({
|
|
35
|
+
name: options.clientName ?? "jack-mcp-test",
|
|
36
|
+
version: options.clientVersion ?? "0.1.0",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await client.connect(transport);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
await transport.close();
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
client,
|
|
48
|
+
transport,
|
|
49
|
+
getStderr: () => stderr,
|
|
50
|
+
close: () => transport.close(),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseMcpToolResult(toolResult: {
|
|
55
|
+
content?: Array<{ type: string; text?: string }>;
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
}): unknown {
|
|
58
|
+
const toolText = toolResult.content?.[0]?.type === "text" ? toolResult.content[0].text : null;
|
|
59
|
+
if (!toolText) {
|
|
60
|
+
throw new Error("MCP tool response missing text content");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const parsed = JSON.parse(toolText);
|
|
64
|
+
if (!parsed.success) {
|
|
65
|
+
const message = parsed.error?.message ?? "unknown error";
|
|
66
|
+
throw new Error(`MCP tool failed: ${message}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return parsed.data;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function verifyMcpToolsAndResources(client: Client): Promise<void> {
|
|
73
|
+
const tools = await client.listTools();
|
|
74
|
+
if (!tools.tools?.length) {
|
|
75
|
+
throw new Error("MCP server reported no tools");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const resources = await client.listResources();
|
|
79
|
+
if (!resources.resources?.length) {
|
|
80
|
+
throw new Error("MCP server reported no resources");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
await client.readResource({ uri: "agents://context" });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function callMcpListProjects(
|
|
87
|
+
client: Client,
|
|
88
|
+
filter?: "all" | "local" | "deployed" | "cloud",
|
|
89
|
+
): Promise<unknown[]> {
|
|
90
|
+
const response = await client.callTool({
|
|
91
|
+
name: "list_projects",
|
|
92
|
+
arguments: filter ? { filter } : {},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const data = parseMcpToolResult(response);
|
|
96
|
+
if (!Array.isArray(data)) {
|
|
97
|
+
throw new Error("MCP list_projects returned unexpected data");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return data;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function callMcpGetProjectStatus(
|
|
104
|
+
client: Client,
|
|
105
|
+
args: { name?: string; project_path?: string },
|
|
106
|
+
): Promise<unknown> {
|
|
107
|
+
const response = await client.callTool({
|
|
108
|
+
name: "get_project_status",
|
|
109
|
+
arguments: args,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return parseMcpToolResult(response);
|
|
113
|
+
}
|
package/src/templates/index.ts
CHANGED
|
@@ -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
|
|
package/src/templates/types.ts
CHANGED
|
@@ -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";
|
package/templates/CLAUDE.md
CHANGED
|
@@ -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": "
|
|
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": [
|
|
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": "
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|