@getjack/jack 0.1.8 → 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 +1 -1
- package/src/commands/login.ts +9 -9
- package/src/commands/new.ts +1 -0
- package/src/commands/projects.ts +48 -44
- package/src/lib/hooks.ts +239 -101
- package/src/lib/json-edit.ts +56 -0
- package/src/lib/output.ts +62 -11
- package/src/lib/paths-index.test.ts +7 -7
- package/src/lib/project-operations.ts +8 -3
- package/src/lib/telemetry.ts +29 -0
- package/src/mcp/test-utils.ts +2 -1
- 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
package/package.json
CHANGED
package/src/commands/login.ts
CHANGED
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
getCurrentUserProfile,
|
|
7
7
|
setUsername,
|
|
8
8
|
} from "../lib/control-plane.ts";
|
|
9
|
-
import { error, info, spinner, success, warn } from "../lib/output.ts";
|
|
9
|
+
import { celebrate, error, info, spinner, success, warn } from "../lib/output.ts";
|
|
10
|
+
import { identifyUser } from "../lib/telemetry.ts";
|
|
10
11
|
|
|
11
12
|
interface LoginOptions {
|
|
12
13
|
/** Skip the initial "Logging in..." message (used when called from auto-login) */
|
|
@@ -31,13 +32,7 @@ export default async function login(options: LoginOptions = {}): Promise<void> {
|
|
|
31
32
|
process.exit(1);
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
console.error(" ┌────────────────────────────────────┐");
|
|
36
|
-
console.error(" │ │");
|
|
37
|
-
console.error(` │ Your code: ${deviceAuth.user_code.padEnd(12)} │`);
|
|
38
|
-
console.error(" │ │");
|
|
39
|
-
console.error(" └────────────────────────────────────┘");
|
|
40
|
-
console.error("");
|
|
35
|
+
celebrate("Your code:", [deviceAuth.user_code]);
|
|
41
36
|
info(`Opening ${deviceAuth.verification_uri} in your browser...`);
|
|
42
37
|
console.error("");
|
|
43
38
|
|
|
@@ -73,8 +68,12 @@ export default async function login(options: LoginOptions = {}): Promise<void> {
|
|
|
73
68
|
};
|
|
74
69
|
await saveCredentials(creds);
|
|
75
70
|
|
|
71
|
+
// Link user identity for cross-platform analytics
|
|
72
|
+
await identifyUser(tokens.user.id, { email: tokens.user.email });
|
|
73
|
+
|
|
76
74
|
console.error("");
|
|
77
|
-
|
|
75
|
+
const displayName = tokens.user.first_name || "Logged in";
|
|
76
|
+
success(tokens.user.first_name ? `Welcome back, ${displayName}` : displayName);
|
|
78
77
|
|
|
79
78
|
// Prompt for username if not set
|
|
80
79
|
await promptForUsername(tokens.user.email);
|
|
@@ -209,3 +208,4 @@ function normalizeToUsername(input: string): string {
|
|
|
209
208
|
.replace(/^-+|-+$/g, "")
|
|
210
209
|
.slice(0, 39);
|
|
211
210
|
}
|
|
211
|
+
|
package/src/commands/new.ts
CHANGED
package/src/commands/projects.ts
CHANGED
|
@@ -18,11 +18,7 @@ import {
|
|
|
18
18
|
sortByUpdated,
|
|
19
19
|
toListItems,
|
|
20
20
|
} from "../lib/project-list.ts";
|
|
21
|
-
import {
|
|
22
|
-
cleanupStaleProjects,
|
|
23
|
-
getProjectStatus,
|
|
24
|
-
scanStaleProjects,
|
|
25
|
-
} from "../lib/project-operations.ts";
|
|
21
|
+
import { cleanupStaleProjects, scanStaleProjects } from "../lib/project-operations.ts";
|
|
26
22
|
import {
|
|
27
23
|
type ResolvedProject,
|
|
28
24
|
listAllProjects,
|
|
@@ -104,7 +100,7 @@ async function listProjects(args: string[]): Promise<void> {
|
|
|
104
100
|
const cloudOnly = args.includes("--cloud");
|
|
105
101
|
|
|
106
102
|
// Fetch all projects from registry and control plane
|
|
107
|
-
outputSpinner.start("
|
|
103
|
+
outputSpinner.start("Loading projects...");
|
|
108
104
|
const projects: ResolvedProject[] = await listAllProjects();
|
|
109
105
|
outputSpinner.stop();
|
|
110
106
|
|
|
@@ -154,6 +150,7 @@ async function listProjects(args: string[]): Promise<void> {
|
|
|
154
150
|
function renderGroupedView(items: ProjectListItem[]): void {
|
|
155
151
|
const groups = groupProjects(items);
|
|
156
152
|
const total = items.length;
|
|
153
|
+
const CLOUD_LIMIT = 5;
|
|
157
154
|
|
|
158
155
|
// Build consistent tag color map across all projects
|
|
159
156
|
const tagColorMap = buildTagColorMap(items);
|
|
@@ -178,7 +175,6 @@ function renderGroupedView(items: ProjectListItem[]): void {
|
|
|
178
175
|
|
|
179
176
|
// Section 3: Cloud-only (show last N by updatedAt)
|
|
180
177
|
if (groups.cloudOnly.length > 0) {
|
|
181
|
-
const CLOUD_LIMIT = 5;
|
|
182
178
|
const sorted = sortByUpdated(groups.cloudOnly);
|
|
183
179
|
|
|
184
180
|
console.error("");
|
|
@@ -191,9 +187,14 @@ function renderGroupedView(items: ProjectListItem[]): void {
|
|
|
191
187
|
);
|
|
192
188
|
}
|
|
193
189
|
|
|
194
|
-
// Footer hint
|
|
190
|
+
// Footer hint - only show --all hint if there are hidden cloud projects
|
|
195
191
|
console.error("");
|
|
196
|
-
|
|
192
|
+
const hasHiddenCloudProjects = groups.cloudOnly.length > CLOUD_LIMIT;
|
|
193
|
+
if (hasHiddenCloudProjects) {
|
|
194
|
+
info(`jack ls --all to see all ${groups.cloudOnly.length} cloud projects`);
|
|
195
|
+
} else {
|
|
196
|
+
info("jack ls --status error to filter, --json for machine output");
|
|
197
|
+
}
|
|
197
198
|
console.error("");
|
|
198
199
|
}
|
|
199
200
|
|
|
@@ -247,6 +248,7 @@ function renderFlatTable(items: ProjectListItem[]): void {
|
|
|
247
248
|
* Show detailed project info
|
|
248
249
|
*/
|
|
249
250
|
async function infoProject(args: string[]): Promise<void> {
|
|
251
|
+
const hasExplicitName = Boolean(args[0]);
|
|
250
252
|
let name = args[0];
|
|
251
253
|
|
|
252
254
|
// If no name provided, try to get from cwd
|
|
@@ -261,80 +263,82 @@ async function infoProject(args: string[]): Promise<void> {
|
|
|
261
263
|
}
|
|
262
264
|
}
|
|
263
265
|
|
|
264
|
-
//
|
|
266
|
+
// Resolve project using the same pattern as down.ts
|
|
265
267
|
outputSpinner.start("Fetching project info...");
|
|
266
|
-
const
|
|
268
|
+
const resolved = await resolveProject(name, {
|
|
269
|
+
preferLocalLink: !hasExplicitName,
|
|
270
|
+
includeResources: true,
|
|
271
|
+
});
|
|
267
272
|
outputSpinner.stop();
|
|
268
273
|
|
|
269
|
-
|
|
270
|
-
|
|
274
|
+
// Guard against mismatched resolutions when an explicit name is provided
|
|
275
|
+
if (hasExplicitName && resolved) {
|
|
276
|
+
const matches =
|
|
277
|
+
name === resolved.slug || name === resolved.name || name === resolved.remote?.projectId;
|
|
278
|
+
if (!matches) {
|
|
279
|
+
error(`Project '${name}' resolves to '${resolved.slug}'.`);
|
|
280
|
+
info("Use the exact slug/name and try again.");
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!resolved) {
|
|
286
|
+
error(`Project "${name}" not found`);
|
|
271
287
|
info("List projects with: jack projects list");
|
|
272
288
|
process.exit(1);
|
|
273
289
|
}
|
|
274
290
|
|
|
275
291
|
console.error("");
|
|
276
|
-
info(`Project: ${
|
|
292
|
+
info(`Project: ${resolved.name}`);
|
|
277
293
|
console.error("");
|
|
278
294
|
|
|
279
295
|
// Status section
|
|
280
296
|
const statuses: string[] = [];
|
|
281
|
-
if (
|
|
297
|
+
if (resolved.sources.filesystem) {
|
|
282
298
|
statuses.push("local");
|
|
283
299
|
}
|
|
284
|
-
if (status
|
|
300
|
+
if (resolved.status === "live") {
|
|
285
301
|
statuses.push("deployed");
|
|
286
302
|
}
|
|
287
|
-
if (status.backedUp) {
|
|
288
|
-
statuses.push("backup");
|
|
289
|
-
}
|
|
290
303
|
|
|
291
304
|
item(`Status: ${statuses.join(", ") || "none"}`);
|
|
292
305
|
console.error("");
|
|
293
306
|
|
|
294
307
|
// Workspace info (only shown if running from project directory)
|
|
295
|
-
if (
|
|
296
|
-
item(`Workspace path: ${
|
|
308
|
+
if (resolved.localPath) {
|
|
309
|
+
item(`Workspace path: ${resolved.localPath}`);
|
|
297
310
|
console.error("");
|
|
298
311
|
}
|
|
299
312
|
|
|
300
313
|
// Deployment info
|
|
301
|
-
if (
|
|
302
|
-
item(`Worker URL: ${
|
|
303
|
-
}
|
|
304
|
-
if (status.lastDeployed) {
|
|
305
|
-
item(`Last deployed: ${new Date(status.lastDeployed).toLocaleString()}`);
|
|
314
|
+
if (resolved.url) {
|
|
315
|
+
item(`Worker URL: ${resolved.url}`);
|
|
306
316
|
}
|
|
307
|
-
if (
|
|
308
|
-
|
|
317
|
+
if (resolved.updatedAt) {
|
|
318
|
+
item(`Last deployed: ${new Date(resolved.updatedAt).toLocaleString()}`);
|
|
309
319
|
}
|
|
310
|
-
|
|
311
|
-
// Backup info
|
|
312
|
-
if (status.backedUp && status.backupFiles !== null) {
|
|
313
|
-
item(`Backup: ${status.backupFiles} files`);
|
|
314
|
-
if (status.backupLastSync) {
|
|
315
|
-
item(`Last synced: ${new Date(status.backupLastSync).toLocaleString()}`);
|
|
316
|
-
}
|
|
320
|
+
if (resolved.status === "live") {
|
|
317
321
|
console.error("");
|
|
318
322
|
}
|
|
319
323
|
|
|
320
324
|
// Account info
|
|
321
|
-
if (
|
|
322
|
-
item(`Account ID: ${
|
|
325
|
+
if (resolved.remote?.orgId) {
|
|
326
|
+
item(`Account ID: ${resolved.remote.orgId}`);
|
|
323
327
|
}
|
|
324
|
-
if (
|
|
325
|
-
item(`Worker ID: ${
|
|
328
|
+
if (resolved.slug) {
|
|
329
|
+
item(`Worker ID: ${resolved.slug}`);
|
|
326
330
|
}
|
|
327
331
|
console.error("");
|
|
328
332
|
|
|
329
333
|
// Resources
|
|
330
|
-
if (
|
|
331
|
-
item(`Database: ${
|
|
334
|
+
if (resolved.resources?.d1?.name) {
|
|
335
|
+
item(`Database: ${resolved.resources.d1.name}`);
|
|
332
336
|
console.error("");
|
|
333
337
|
}
|
|
334
338
|
|
|
335
339
|
// Timestamps
|
|
336
|
-
if (
|
|
337
|
-
item(`Created: ${new Date(
|
|
340
|
+
if (resolved.createdAt) {
|
|
341
|
+
item(`Created: ${new Date(resolved.createdAt).toLocaleString()}`);
|
|
338
342
|
}
|
|
339
343
|
console.error("");
|
|
340
344
|
}
|
|
@@ -406,7 +410,7 @@ async function removeProjectEntry(args: string[]): Promise<void> {
|
|
|
406
410
|
}
|
|
407
411
|
|
|
408
412
|
// Use resolver to find project anywhere (registry OR control plane)
|
|
409
|
-
outputSpinner.start("
|
|
413
|
+
outputSpinner.start("Finding project...");
|
|
410
414
|
const project = await resolveProject(name);
|
|
411
415
|
outputSpinner.stop();
|
|
412
416
|
|
package/src/lib/hooks.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { HookAction } from "../templates/types";
|
|
4
|
+
import { applyJsonWrite } from "./json-edit";
|
|
4
5
|
import { getSavedSecrets } from "./secrets";
|
|
5
6
|
import { restoreTty } from "./tty";
|
|
6
7
|
|
|
@@ -17,6 +18,7 @@ export interface HookOutput {
|
|
|
17
18
|
error(message: string): void;
|
|
18
19
|
success(message: string): void;
|
|
19
20
|
box(title: string, lines: string[]): void;
|
|
21
|
+
celebrate?(title: string, lines: string[]): void;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface HookOptions {
|
|
@@ -32,6 +34,20 @@ const noopOutput: HookOutput = {
|
|
|
32
34
|
box() {},
|
|
33
35
|
};
|
|
34
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Hook schema quick reference (interactive behavior + fields)
|
|
39
|
+
*
|
|
40
|
+
* - message: { text } -> prints info
|
|
41
|
+
* - box: { title, lines } -> prints boxed text
|
|
42
|
+
* - url: { url, label?, open?, prompt? } -> prints link; optional open prompt
|
|
43
|
+
* - clipboard: { text, message? } -> copy to clipboard (prints in non-interactive)
|
|
44
|
+
* - pause: { message? } -> waits for enter (skipped in non-interactive)
|
|
45
|
+
* - require: { source, key, message?, setupUrl? } -> validates secret/env
|
|
46
|
+
* - shell: { command, cwd?, message? } -> runs shell command
|
|
47
|
+
* - prompt: { message, validate?, required?, successMessage?, writeJson? } -> input + optional JSON update
|
|
48
|
+
* - writeJson: { path, set, successMessage? } -> JSON update (runs in non-interactive)
|
|
49
|
+
*/
|
|
50
|
+
|
|
35
51
|
/**
|
|
36
52
|
* Prompt user with numbered options (Claude Code style)
|
|
37
53
|
* Returns the selected option index (0-based) or -1 if cancelled
|
|
@@ -121,6 +137,26 @@ function substituteVars(str: string, context: HookContext): string {
|
|
|
121
137
|
.replace(/\{\{name\}\}/g, context.projectName ?? "");
|
|
122
138
|
}
|
|
123
139
|
|
|
140
|
+
function resolveHookPath(filePath: string, context: HookContext): string {
|
|
141
|
+
if (filePath.startsWith("/")) {
|
|
142
|
+
return filePath;
|
|
143
|
+
}
|
|
144
|
+
if (!context.projectDir) {
|
|
145
|
+
return filePath;
|
|
146
|
+
}
|
|
147
|
+
return join(context.projectDir, filePath);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isAccountAssociation(value: unknown): value is { header: string; payload: string; signature: string } {
|
|
151
|
+
if (!value || typeof value !== "object") return false;
|
|
152
|
+
const obj = value as { header?: unknown; payload?: unknown; signature?: unknown };
|
|
153
|
+
return (
|
|
154
|
+
typeof obj.header === "string" &&
|
|
155
|
+
typeof obj.payload === "string" &&
|
|
156
|
+
typeof obj.signature === "string"
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
124
160
|
/**
|
|
125
161
|
* Open a URL in the default browser
|
|
126
162
|
*/
|
|
@@ -209,153 +245,255 @@ async function checkEnvExists(env: string, projectDir?: string): Promise<boolean
|
|
|
209
245
|
* Execute a single hook action
|
|
210
246
|
* Returns true if should continue, false if should abort
|
|
211
247
|
*/
|
|
212
|
-
|
|
213
|
-
action: HookAction,
|
|
248
|
+
type ActionHandler<T extends HookAction["action"]> = (
|
|
249
|
+
action: Extract<HookAction, { action: T }>,
|
|
214
250
|
context: HookContext,
|
|
215
|
-
options
|
|
216
|
-
)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
251
|
+
options: HookOptions,
|
|
252
|
+
) => Promise<boolean>;
|
|
253
|
+
|
|
254
|
+
const actionHandlers: {
|
|
255
|
+
[T in HookAction["action"]]: ActionHandler<T>;
|
|
256
|
+
} = {
|
|
257
|
+
message: async (action, context, options) => {
|
|
258
|
+
const ui = options.output ?? noopOutput;
|
|
259
|
+
ui.info(substituteVars(action.text, context));
|
|
260
|
+
return true;
|
|
261
|
+
},
|
|
262
|
+
box: async (action, context, options) => {
|
|
263
|
+
const ui = options.output ?? noopOutput;
|
|
264
|
+
const title = substituteVars(action.title, context);
|
|
265
|
+
const lines = action.lines.map((line) => substituteVars(line, context));
|
|
266
|
+
ui.box(title, lines);
|
|
267
|
+
return true;
|
|
268
|
+
},
|
|
269
|
+
url: async (action, context, options) => {
|
|
270
|
+
const ui = options.output ?? noopOutput;
|
|
271
|
+
const interactive = options.interactive !== false;
|
|
272
|
+
const url = substituteVars(action.url, context);
|
|
273
|
+
const label = action.label ?? "Link";
|
|
274
|
+
if (!interactive) {
|
|
275
|
+
ui.info(`${label}: ${url}`);
|
|
223
276
|
return true;
|
|
224
277
|
}
|
|
278
|
+
console.error("");
|
|
279
|
+
console.error(` ${label}: \x1b[36m${url}\x1b[0m`);
|
|
225
280
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
ui.box(title, lines);
|
|
281
|
+
if (action.open) {
|
|
282
|
+
ui.info(`Opening: ${url}`);
|
|
283
|
+
await openBrowser(url);
|
|
230
284
|
return true;
|
|
231
285
|
}
|
|
232
286
|
|
|
233
|
-
|
|
234
|
-
const url = substituteVars(action.url, context);
|
|
235
|
-
const label = action.label ?? "Link";
|
|
236
|
-
if (!interactive) {
|
|
237
|
-
ui.info(`${label}: ${url}`);
|
|
238
|
-
return true;
|
|
239
|
-
}
|
|
287
|
+
if (action.prompt !== false) {
|
|
240
288
|
console.error("");
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
if (action.open) {
|
|
244
|
-
ui.info(`Opening: ${url}`);
|
|
289
|
+
const choice = await promptSelect(["Open in browser", "Skip"]);
|
|
290
|
+
if (choice === 0) {
|
|
245
291
|
await openBrowser(url);
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if (action.prompt !== false) {
|
|
250
|
-
console.error("");
|
|
251
|
-
const choice = await promptSelect(["Open in browser", "Skip"]);
|
|
252
|
-
if (choice === 0) {
|
|
253
|
-
await openBrowser(url);
|
|
254
|
-
ui.success("Opened in browser");
|
|
255
|
-
}
|
|
292
|
+
ui.success("Opened in browser");
|
|
256
293
|
}
|
|
294
|
+
}
|
|
295
|
+
return true;
|
|
296
|
+
},
|
|
297
|
+
clipboard: async (action, context, options) => {
|
|
298
|
+
const ui = options.output ?? noopOutput;
|
|
299
|
+
const interactive = options.interactive !== false;
|
|
300
|
+
const text = substituteVars(action.text, context);
|
|
301
|
+
if (!interactive) {
|
|
302
|
+
ui.info(text);
|
|
257
303
|
return true;
|
|
258
304
|
}
|
|
305
|
+
const success = await copyToClipboard(text);
|
|
306
|
+
if (success) {
|
|
307
|
+
const message = action.message ?? "Copied to clipboard";
|
|
308
|
+
ui.success(message);
|
|
309
|
+
} else {
|
|
310
|
+
ui.warn("Could not copy to clipboard");
|
|
311
|
+
}
|
|
312
|
+
return true;
|
|
313
|
+
},
|
|
314
|
+
pause: async (action, _context, options) => {
|
|
315
|
+
const interactive = options.interactive !== false;
|
|
316
|
+
if (!interactive) {
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
await waitForEnter(action.message);
|
|
320
|
+
return true;
|
|
321
|
+
},
|
|
322
|
+
require: async (action, context, options) => {
|
|
323
|
+
const ui = options.output ?? noopOutput;
|
|
324
|
+
const interactive = options.interactive !== false;
|
|
325
|
+
if (action.source === "secret") {
|
|
326
|
+
const result = await checkSecretExists(action.key, context.projectDir);
|
|
327
|
+
if (!result.exists) {
|
|
328
|
+
const message = action.message ?? `Missing required secret: ${action.key}`;
|
|
329
|
+
ui.error(message);
|
|
330
|
+
ui.info(`Run: jack secrets add ${action.key}`);
|
|
259
331
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
ui.info(`Run: jack secrets add ${action.key}`);
|
|
267
|
-
|
|
268
|
-
if (action.setupUrl) {
|
|
269
|
-
if (interactive) {
|
|
270
|
-
console.error("");
|
|
271
|
-
const choice = await promptSelect(["Open setup page", "Skip"]);
|
|
272
|
-
if (choice === 0) {
|
|
273
|
-
await openBrowser(action.setupUrl);
|
|
274
|
-
}
|
|
275
|
-
} else {
|
|
276
|
-
ui.info(`Setup: ${action.setupUrl}`);
|
|
332
|
+
if (action.setupUrl) {
|
|
333
|
+
if (interactive) {
|
|
334
|
+
console.error("");
|
|
335
|
+
const choice = await promptSelect(["Open setup page", "Skip"]);
|
|
336
|
+
if (choice === 0) {
|
|
337
|
+
await openBrowser(action.setupUrl);
|
|
277
338
|
}
|
|
339
|
+
} else {
|
|
340
|
+
ui.info(`Setup: ${action.setupUrl}`);
|
|
278
341
|
}
|
|
279
|
-
return false;
|
|
280
|
-
}
|
|
281
|
-
return true;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const exists = await checkEnvExists(action.key, context.projectDir);
|
|
285
|
-
if (!exists) {
|
|
286
|
-
const message = action.message ?? `Missing required env var: ${action.key}`;
|
|
287
|
-
ui.error(message);
|
|
288
|
-
if (action.setupUrl) {
|
|
289
|
-
ui.info(`Setup: ${action.setupUrl}`);
|
|
290
342
|
}
|
|
291
343
|
return false;
|
|
292
344
|
}
|
|
293
345
|
return true;
|
|
294
346
|
}
|
|
295
347
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const success = await copyToClipboard(text);
|
|
303
|
-
if (success) {
|
|
304
|
-
const message = action.message ?? "Copied to clipboard";
|
|
305
|
-
ui.success(message);
|
|
306
|
-
} else {
|
|
307
|
-
ui.warn("Could not copy to clipboard");
|
|
348
|
+
const exists = await checkEnvExists(action.key, context.projectDir);
|
|
349
|
+
if (!exists) {
|
|
350
|
+
const message = action.message ?? `Missing required env var: ${action.key}`;
|
|
351
|
+
ui.error(message);
|
|
352
|
+
if (action.setupUrl) {
|
|
353
|
+
ui.info(`Setup: ${action.setupUrl}`);
|
|
308
354
|
}
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
return true;
|
|
358
|
+
},
|
|
359
|
+
prompt: async (action, context, options) => {
|
|
360
|
+
const ui = options.output ?? noopOutput;
|
|
361
|
+
const interactive = options.interactive !== false;
|
|
362
|
+
if (!interactive) {
|
|
309
363
|
return true;
|
|
310
364
|
}
|
|
311
365
|
|
|
312
|
-
|
|
313
|
-
|
|
366
|
+
const { input } = await import("@inquirer/prompts");
|
|
367
|
+
|
|
368
|
+
let rawValue = "";
|
|
369
|
+
try {
|
|
370
|
+
rawValue = await input({ message: action.message });
|
|
371
|
+
} catch (err) {
|
|
372
|
+
if (err instanceof Error && err.name === "ExitPromptError") {
|
|
314
373
|
return true;
|
|
315
374
|
}
|
|
316
|
-
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (!rawValue.trim()) {
|
|
317
379
|
return true;
|
|
318
380
|
}
|
|
319
381
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
382
|
+
let parsedInput: unknown = rawValue;
|
|
383
|
+
if (action.validate === "json" || action.validate === "accountAssociation") {
|
|
384
|
+
try {
|
|
385
|
+
parsedInput = JSON.parse(rawValue);
|
|
386
|
+
} catch {
|
|
387
|
+
ui.error("Invalid JSON input");
|
|
388
|
+
return action.required ? false : true;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (action.validate === "accountAssociation" && !isAccountAssociation(parsedInput)) {
|
|
393
|
+
ui.error("Invalid accountAssociation JSON (expected header, payload, signature)");
|
|
394
|
+
return action.required ? false : true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (action.writeJson) {
|
|
398
|
+
const targetPath = resolveHookPath(action.writeJson.path, context);
|
|
399
|
+
const ok = await applyJsonWrite(
|
|
400
|
+
targetPath,
|
|
401
|
+
action.writeJson.set,
|
|
402
|
+
(value) => substituteVars(value, context),
|
|
403
|
+
parsedInput,
|
|
404
|
+
);
|
|
405
|
+
if (!ok) {
|
|
406
|
+
ui.error(`Invalid JSON file: ${targetPath}`);
|
|
407
|
+
return action.required ? false : true;
|
|
324
408
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if (interactive) {
|
|
328
|
-
process.stdin.resume();
|
|
409
|
+
if (action.successMessage) {
|
|
410
|
+
ui.success(substituteVars(action.successMessage, context));
|
|
329
411
|
}
|
|
330
|
-
const proc = Bun.spawn(["sh", "-c", command], {
|
|
331
|
-
cwd,
|
|
332
|
-
stdin: interactive ? "inherit" : "ignore",
|
|
333
|
-
stdout: "inherit",
|
|
334
|
-
stderr: "inherit",
|
|
335
|
-
});
|
|
336
|
-
await proc.exited;
|
|
337
|
-
return proc.exitCode === 0;
|
|
338
412
|
}
|
|
339
413
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
414
|
+
return true;
|
|
415
|
+
},
|
|
416
|
+
writeJson: async (action, context, options) => {
|
|
417
|
+
const ui = options.output ?? noopOutput;
|
|
418
|
+
const targetPath = resolveHookPath(action.path, context);
|
|
419
|
+
const ok = await applyJsonWrite(
|
|
420
|
+
targetPath,
|
|
421
|
+
action.set,
|
|
422
|
+
(value) => substituteVars(value, context),
|
|
423
|
+
);
|
|
424
|
+
if (!ok) {
|
|
425
|
+
ui.error(`Invalid JSON file: ${targetPath}`);
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
if (action.successMessage) {
|
|
429
|
+
ui.success(substituteVars(action.successMessage, context));
|
|
430
|
+
}
|
|
431
|
+
return true;
|
|
432
|
+
},
|
|
433
|
+
shell: async (action, context, options) => {
|
|
434
|
+
const ui = options.output ?? noopOutput;
|
|
435
|
+
const interactive = options.interactive !== false;
|
|
436
|
+
const command = substituteVars(action.command, context);
|
|
437
|
+
if (action.message) {
|
|
438
|
+
ui.info(action.message);
|
|
439
|
+
}
|
|
440
|
+
const cwd = action.cwd === "project" ? context.projectDir : undefined;
|
|
441
|
+
// Resume stdin in case previous prompts paused it
|
|
442
|
+
if (interactive) {
|
|
443
|
+
process.stdin.resume();
|
|
444
|
+
}
|
|
445
|
+
const proc = Bun.spawn(["sh", "-c", command], {
|
|
446
|
+
cwd,
|
|
447
|
+
stdin: interactive ? "inherit" : "ignore",
|
|
448
|
+
stdout: "inherit",
|
|
449
|
+
stderr: "inherit",
|
|
450
|
+
});
|
|
451
|
+
await proc.exited;
|
|
452
|
+
return proc.exitCode === 0;
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
async function executeAction(
|
|
457
|
+
action: HookAction,
|
|
458
|
+
context: HookContext,
|
|
459
|
+
options?: HookOptions,
|
|
460
|
+
): Promise<boolean> {
|
|
461
|
+
const handler = actionHandlers[action.action] as (
|
|
462
|
+
action: HookAction,
|
|
463
|
+
context: HookContext,
|
|
464
|
+
options: HookOptions,
|
|
465
|
+
) => Promise<boolean>;
|
|
466
|
+
return handler(action, context, options ?? {});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export interface HookResult {
|
|
470
|
+
success: boolean;
|
|
471
|
+
hadInteractiveActions: boolean;
|
|
343
472
|
}
|
|
344
473
|
|
|
345
474
|
/**
|
|
346
475
|
* Run a list of hook actions
|
|
347
|
-
* Returns
|
|
476
|
+
* Returns success status and whether any interactive actions were executed
|
|
348
477
|
*/
|
|
349
478
|
export async function runHook(
|
|
350
479
|
actions: HookAction[],
|
|
351
480
|
context: HookContext,
|
|
352
481
|
options?: HookOptions,
|
|
353
|
-
): Promise<
|
|
482
|
+
): Promise<HookResult> {
|
|
483
|
+
const interactive = options?.interactive !== false;
|
|
484
|
+
// Track if we had any interactive actions (prompt, pause) that ran
|
|
485
|
+
const interactiveActionTypes = ["prompt", "pause"];
|
|
486
|
+
let hadInteractiveActions = false;
|
|
487
|
+
|
|
354
488
|
for (const action of actions) {
|
|
489
|
+
// Check if this is an interactive action that will actually run
|
|
490
|
+
if (interactive && interactiveActionTypes.includes(action.action)) {
|
|
491
|
+
hadInteractiveActions = true;
|
|
492
|
+
}
|
|
355
493
|
const shouldContinue = await executeAction(action, context, options);
|
|
356
494
|
if (!shouldContinue) {
|
|
357
|
-
return false;
|
|
495
|
+
return { success: false, hadInteractiveActions };
|
|
358
496
|
}
|
|
359
497
|
}
|
|
360
|
-
return true;
|
|
498
|
+
return { success: true, hadInteractiveActions };
|
|
361
499
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export function setJsonPath(
|
|
4
|
+
target: Record<string, unknown>,
|
|
5
|
+
path: string,
|
|
6
|
+
value: unknown,
|
|
7
|
+
): void {
|
|
8
|
+
const keys = path.split(".").filter(Boolean);
|
|
9
|
+
let current: Record<string, unknown> = target;
|
|
10
|
+
|
|
11
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
12
|
+
const key = keys[i];
|
|
13
|
+
if (key === undefined) continue;
|
|
14
|
+
const next = current[key];
|
|
15
|
+
if (!next || typeof next !== "object" || Array.isArray(next)) {
|
|
16
|
+
current[key] = {};
|
|
17
|
+
}
|
|
18
|
+
current = current[key] as Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const lastKey = keys[keys.length - 1];
|
|
22
|
+
if (lastKey) {
|
|
23
|
+
current[lastKey] = value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function applyJsonWrite(
|
|
28
|
+
targetPath: string,
|
|
29
|
+
updates: Record<string, string | { from: "input" }>,
|
|
30
|
+
substitute: (value: string) => string,
|
|
31
|
+
inputValue?: unknown,
|
|
32
|
+
): Promise<boolean> {
|
|
33
|
+
let jsonData: Record<string, unknown> = {};
|
|
34
|
+
|
|
35
|
+
if (existsSync(targetPath)) {
|
|
36
|
+
try {
|
|
37
|
+
const content = await Bun.file(targetPath).text();
|
|
38
|
+
if (content.trim()) {
|
|
39
|
+
jsonData = JSON.parse(content) as Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const [path, value] of Object.entries(updates)) {
|
|
47
|
+
if (typeof value === "string") {
|
|
48
|
+
setJsonPath(jsonData, path, substitute(value));
|
|
49
|
+
} else if (value?.from === "input") {
|
|
50
|
+
setJsonPath(jsonData, path, inputValue);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await Bun.write(targetPath, `${JSON.stringify(jsonData, null, 2)}\n`);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
package/src/lib/output.ts
CHANGED
|
@@ -26,6 +26,7 @@ export const output = {
|
|
|
26
26
|
warn,
|
|
27
27
|
item,
|
|
28
28
|
box,
|
|
29
|
+
celebrate,
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -94,25 +95,75 @@ export function item(message: string): void {
|
|
|
94
95
|
console.error(` ${message}`);
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// Random neon purple for cyberpunk styling
|
|
99
|
+
function getRandomPurple(): string {
|
|
100
|
+
const purples = [177, 165, 141, 129];
|
|
101
|
+
const colorCode = purples[Math.floor(Math.random() * purples.length)];
|
|
102
|
+
return `\x1b[38;5;${colorCode}m`;
|
|
103
|
+
}
|
|
104
|
+
|
|
97
105
|
/**
|
|
98
|
-
* Print a boxed message for important info
|
|
106
|
+
* Print a boxed message for important info (cyberpunk style)
|
|
99
107
|
*/
|
|
100
108
|
export function box(title: string, lines: string[]): void {
|
|
101
109
|
const maxLen = Math.max(title.length, ...lines.map((l) => l.length));
|
|
102
|
-
const
|
|
103
|
-
|
|
110
|
+
const innerWidth = maxLen + 4;
|
|
111
|
+
|
|
112
|
+
const purple = isColorEnabled ? getRandomPurple() : "";
|
|
113
|
+
const bold = isColorEnabled ? "\x1b[1m" : "";
|
|
114
|
+
const reset = isColorEnabled ? "\x1b[0m" : "";
|
|
115
|
+
|
|
116
|
+
const bar = "═".repeat(innerWidth);
|
|
117
|
+
const fill = "▓".repeat(innerWidth);
|
|
118
|
+
const gradient = "░".repeat(innerWidth);
|
|
119
|
+
|
|
120
|
+
const pad = (text: string) => ` ${text.padEnd(maxLen)} `;
|
|
121
|
+
|
|
122
|
+
console.error("");
|
|
123
|
+
console.error(` ${purple}╔${bar}╗${reset}`);
|
|
124
|
+
console.error(` ${purple}║${fill}║${reset}`);
|
|
125
|
+
console.error(` ${purple}║${pad(bold + title + reset + purple)}║${reset}`);
|
|
126
|
+
console.error(` ${purple}║${"─".repeat(innerWidth)}║${reset}`);
|
|
127
|
+
for (const line of lines) {
|
|
128
|
+
console.error(` ${purple}║${pad(line)}║${reset}`);
|
|
129
|
+
}
|
|
130
|
+
console.error(` ${purple}║${gradient}║${reset}`);
|
|
131
|
+
console.error(` ${purple}╚${bar}╝${reset}`);
|
|
132
|
+
console.error("");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Print a celebration box (for final success after setup)
|
|
137
|
+
*/
|
|
138
|
+
export function celebrate(title: string, lines: string[]): void {
|
|
139
|
+
const maxLen = Math.max(title.length, ...lines.map((l) => l.length));
|
|
140
|
+
const innerWidth = maxLen + 4;
|
|
141
|
+
|
|
142
|
+
const purple = isColorEnabled ? getRandomPurple() : "";
|
|
143
|
+
const bold = isColorEnabled ? "\x1b[1m" : "";
|
|
104
144
|
const reset = isColorEnabled ? "\x1b[0m" : "";
|
|
105
|
-
|
|
145
|
+
|
|
146
|
+
const bar = "═".repeat(innerWidth);
|
|
147
|
+
const fill = "▓".repeat(innerWidth);
|
|
148
|
+
const gradient = "░".repeat(innerWidth);
|
|
149
|
+
const space = " ".repeat(innerWidth);
|
|
150
|
+
|
|
151
|
+
const center = (text: string) => {
|
|
152
|
+
const left = Math.floor((innerWidth - text.length) / 2);
|
|
153
|
+
return " ".repeat(left) + text + " ".repeat(innerWidth - text.length - left);
|
|
154
|
+
};
|
|
106
155
|
|
|
107
156
|
console.error("");
|
|
108
|
-
console.error(
|
|
109
|
-
console.error(
|
|
110
|
-
|
|
111
|
-
);
|
|
112
|
-
console.error(
|
|
157
|
+
console.error(` ${purple}╔${bar}╗${reset}`);
|
|
158
|
+
console.error(` ${purple}║${fill}║${reset}`);
|
|
159
|
+
console.error(` ${purple}║${space}║${reset}`);
|
|
160
|
+
console.error(` ${purple}║${center(bold + title + reset + purple)}║${reset}`);
|
|
161
|
+
console.error(` ${purple}║${space}║${reset}`);
|
|
113
162
|
for (const line of lines) {
|
|
114
|
-
console.error(
|
|
163
|
+
console.error(` ${purple}║${center(line)}║${reset}`);
|
|
115
164
|
}
|
|
116
|
-
console.error(
|
|
165
|
+
console.error(` ${purple}║${space}║${reset}`);
|
|
166
|
+
console.error(` ${purple}║${gradient}║${reset}`);
|
|
167
|
+
console.error(` ${purple}╚${bar}╝${reset}`);
|
|
117
168
|
console.error("");
|
|
118
169
|
}
|
|
@@ -187,7 +187,7 @@ describe("paths-index", () => {
|
|
|
187
187
|
const index = await readPathsIndex();
|
|
188
188
|
|
|
189
189
|
// Should store absolute path
|
|
190
|
-
expect(index.paths.proj_rel[0]
|
|
190
|
+
expect(index.paths.proj_rel?.[0]?.startsWith("/")).toBe(true);
|
|
191
191
|
} finally {
|
|
192
192
|
process.chdir(originalCwd);
|
|
193
193
|
}
|
|
@@ -351,7 +351,7 @@ describe("paths-index", () => {
|
|
|
351
351
|
const discovered = await scanAndRegisterProjects(testDir);
|
|
352
352
|
|
|
353
353
|
expect(discovered).toHaveLength(1);
|
|
354
|
-
expect(discovered[0]
|
|
354
|
+
expect(discovered[0]!.projectId).toBe("proj_linked");
|
|
355
355
|
});
|
|
356
356
|
|
|
357
357
|
it("respects maxDepth", async () => {
|
|
@@ -366,7 +366,7 @@ describe("paths-index", () => {
|
|
|
366
366
|
const discovered = await scanAndRegisterProjects(testDir, 2);
|
|
367
367
|
|
|
368
368
|
expect(discovered).toHaveLength(1);
|
|
369
|
-
expect(discovered[0]
|
|
369
|
+
expect(discovered[0]!.projectId).toBe("proj_shallow");
|
|
370
370
|
});
|
|
371
371
|
|
|
372
372
|
it("skips node_modules", async () => {
|
|
@@ -379,7 +379,7 @@ describe("paths-index", () => {
|
|
|
379
379
|
const discovered = await scanAndRegisterProjects(testDir);
|
|
380
380
|
|
|
381
381
|
expect(discovered).toHaveLength(1);
|
|
382
|
-
expect(discovered[0]
|
|
382
|
+
expect(discovered[0]!.projectId).toBe("proj_valid");
|
|
383
383
|
});
|
|
384
384
|
|
|
385
385
|
it("skips .git directory", async () => {
|
|
@@ -392,7 +392,7 @@ describe("paths-index", () => {
|
|
|
392
392
|
const discovered = await scanAndRegisterProjects(testDir);
|
|
393
393
|
|
|
394
394
|
expect(discovered).toHaveLength(1);
|
|
395
|
-
expect(discovered[0]
|
|
395
|
+
expect(discovered[0]!.projectId).toBe("proj_valid");
|
|
396
396
|
});
|
|
397
397
|
|
|
398
398
|
it("does not recurse into linked projects", async () => {
|
|
@@ -407,7 +407,7 @@ describe("paths-index", () => {
|
|
|
407
407
|
|
|
408
408
|
// Should only find parent, not nested child
|
|
409
409
|
expect(discovered).toHaveLength(1);
|
|
410
|
-
expect(discovered[0]
|
|
410
|
+
expect(discovered[0]!.projectId).toBe("proj_parent");
|
|
411
411
|
});
|
|
412
412
|
|
|
413
413
|
it("registers discovered projects in index", async () => {
|
|
@@ -517,7 +517,7 @@ describe("paths-index", () => {
|
|
|
517
517
|
|
|
518
518
|
const discovered = await scanAndRegisterProjects(testDir);
|
|
519
519
|
expect(discovered).toHaveLength(1);
|
|
520
|
-
expect(discovered[0]
|
|
520
|
+
expect(discovered[0]!.projectId).toBe("proj_spaces");
|
|
521
521
|
});
|
|
522
522
|
|
|
523
523
|
it("handles permission errors gracefully", async () => {
|
|
@@ -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,
|
|
@@ -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 {
|
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
|
// ============================================
|
package/src/mcp/test-utils.ts
CHANGED
|
@@ -22,7 +22,7 @@ export async function openMcpTestClient(options: McpClientOptions): Promise<McpT
|
|
|
22
22
|
command: options.command,
|
|
23
23
|
args: options.args ?? [],
|
|
24
24
|
cwd: options.cwd,
|
|
25
|
-
env: options.env,
|
|
25
|
+
env: options.env as Record<string, string> | undefined,
|
|
26
26
|
stderr: "pipe",
|
|
27
27
|
});
|
|
28
28
|
|
|
@@ -53,6 +53,7 @@ export async function openMcpTestClient(options: McpClientOptions): Promise<McpT
|
|
|
53
53
|
|
|
54
54
|
export function parseMcpToolResult(toolResult: {
|
|
55
55
|
content?: Array<{ type: string; text?: string }>;
|
|
56
|
+
[key: string]: unknown;
|
|
56
57
|
}): unknown {
|
|
57
58
|
const toolText = toolResult.content?.[0]?.type === "text" ? toolResult.content[0].text : null;
|
|
58
59
|
if (!toolText) {
|
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
|
|