@getjack/jack 0.1.2 → 0.1.4

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.
Files changed (91) hide show
  1. package/README.md +77 -29
  2. package/package.json +54 -47
  3. package/src/commands/agents.ts +145 -10
  4. package/src/commands/down.ts +110 -102
  5. package/src/commands/feedback.ts +189 -0
  6. package/src/commands/init.ts +8 -12
  7. package/src/commands/login.ts +88 -0
  8. package/src/commands/logout.ts +14 -0
  9. package/src/commands/logs.ts +21 -0
  10. package/src/commands/mcp.ts +134 -7
  11. package/src/commands/new.ts +43 -17
  12. package/src/commands/open.ts +13 -6
  13. package/src/commands/projects.ts +269 -143
  14. package/src/commands/secrets.ts +413 -0
  15. package/src/commands/services.ts +96 -123
  16. package/src/commands/ship.ts +5 -1
  17. package/src/commands/whoami.ts +31 -0
  18. package/src/index.ts +218 -144
  19. package/src/lib/agent-files.ts +34 -0
  20. package/src/lib/agents.ts +390 -22
  21. package/src/lib/asset-hash.ts +50 -0
  22. package/src/lib/auth/client.ts +115 -0
  23. package/src/lib/auth/constants.ts +5 -0
  24. package/src/lib/auth/guard.ts +57 -0
  25. package/src/lib/auth/index.ts +18 -0
  26. package/src/lib/auth/store.ts +54 -0
  27. package/src/lib/binding-validator.ts +136 -0
  28. package/src/lib/build-helper.ts +211 -0
  29. package/src/lib/cloudflare-api.ts +24 -0
  30. package/src/lib/config.ts +5 -6
  31. package/src/lib/control-plane.ts +295 -0
  32. package/src/lib/debug.ts +3 -1
  33. package/src/lib/deploy-mode.ts +93 -0
  34. package/src/lib/deploy-upload.ts +92 -0
  35. package/src/lib/errors.ts +2 -0
  36. package/src/lib/github.ts +31 -1
  37. package/src/lib/hooks.ts +4 -12
  38. package/src/lib/intent.ts +88 -0
  39. package/src/lib/jsonc.ts +125 -0
  40. package/src/lib/local-paths.test.ts +902 -0
  41. package/src/lib/local-paths.ts +258 -0
  42. package/src/lib/managed-deploy.ts +175 -0
  43. package/src/lib/managed-down.ts +159 -0
  44. package/src/lib/mcp-config.ts +55 -34
  45. package/src/lib/names.ts +9 -29
  46. package/src/lib/project-operations.ts +676 -249
  47. package/src/lib/project-resolver.ts +476 -0
  48. package/src/lib/registry.ts +76 -37
  49. package/src/lib/resources.ts +196 -0
  50. package/src/lib/schema.ts +30 -1
  51. package/src/lib/storage/file-filter.ts +1 -0
  52. package/src/lib/storage/index.ts +5 -1
  53. package/src/lib/telemetry.ts +14 -0
  54. package/src/lib/tty.ts +15 -0
  55. package/src/lib/zip-packager.ts +255 -0
  56. package/src/mcp/resources/index.ts +8 -2
  57. package/src/mcp/server.ts +32 -4
  58. package/src/mcp/tools/index.ts +35 -13
  59. package/src/mcp/types.ts +6 -0
  60. package/src/mcp/utils.ts +1 -1
  61. package/src/templates/index.ts +42 -4
  62. package/src/templates/types.ts +13 -0
  63. package/templates/CLAUDE.md +166 -0
  64. package/templates/api/.jack.json +4 -0
  65. package/templates/api/bun.lock +1 -0
  66. package/templates/api/wrangler.jsonc +5 -0
  67. package/templates/hello/.jack.json +28 -0
  68. package/templates/hello/package.json +10 -0
  69. package/templates/hello/src/index.ts +11 -0
  70. package/templates/hello/tsconfig.json +11 -0
  71. package/templates/hello/wrangler.jsonc +5 -0
  72. package/templates/miniapp/.jack.json +15 -4
  73. package/templates/miniapp/bun.lock +135 -40
  74. package/templates/miniapp/index.html +1 -0
  75. package/templates/miniapp/package.json +3 -1
  76. package/templates/miniapp/public/.well-known/farcaster.json +7 -5
  77. package/templates/miniapp/public/icon.png +0 -0
  78. package/templates/miniapp/public/og.png +0 -0
  79. package/templates/miniapp/schema.sql +8 -0
  80. package/templates/miniapp/src/App.tsx +254 -3
  81. package/templates/miniapp/src/components/ShareSheet.tsx +147 -0
  82. package/templates/miniapp/src/hooks/useAI.ts +35 -0
  83. package/templates/miniapp/src/hooks/useGuestbook.ts +11 -1
  84. package/templates/miniapp/src/hooks/useShare.ts +76 -0
  85. package/templates/miniapp/src/index.css +15 -0
  86. package/templates/miniapp/src/lib/api.ts +2 -1
  87. package/templates/miniapp/src/worker.ts +515 -1
  88. package/templates/miniapp/wrangler.jsonc +15 -3
  89. package/LICENSE +0 -190
  90. package/src/commands/cloud.ts +0 -230
  91. package/templates/api/wrangler.toml +0 -3
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Control Plane API client for jack cloud
3
+ */
4
+
5
+ const DEFAULT_CONTROL_API_URL = "https://control.getjack.org";
6
+
7
+ export function getControlApiUrl(): string {
8
+ return process.env.JACK_CONTROL_URL || DEFAULT_CONTROL_API_URL;
9
+ }
10
+
11
+ export interface CreateProjectRequest {
12
+ name: string;
13
+ slug?: string;
14
+ template?: string;
15
+ use_prebuilt?: boolean;
16
+ }
17
+
18
+ export interface CreateManagedProjectOptions {
19
+ slug?: string;
20
+ template?: string;
21
+ usePrebuilt?: boolean;
22
+ }
23
+
24
+ export interface CreateProjectResponse {
25
+ project: {
26
+ id: string;
27
+ org_id: string;
28
+ name: string;
29
+ slug: string;
30
+ status: string;
31
+ created_at: string;
32
+ updated_at: string;
33
+ };
34
+ resources: Array<{
35
+ id: string;
36
+ resource_type: string;
37
+ resource_name: string;
38
+ status: string;
39
+ }>;
40
+ status?: "live" | "created";
41
+ url?: string;
42
+ prebuilt_failed?: boolean;
43
+ }
44
+
45
+ export interface SlugAvailabilityResponse {
46
+ available: boolean;
47
+ slug: string;
48
+ error?: string;
49
+ }
50
+
51
+ export interface CreateDeploymentRequest {
52
+ source: string;
53
+ }
54
+
55
+ export interface CreateDeploymentResponse {
56
+ id: string;
57
+ project_id: string;
58
+ status: "queued" | "building" | "live" | "failed";
59
+ source: string;
60
+ created_at: string;
61
+ }
62
+
63
+ /**
64
+ * Create a managed project via the control plane.
65
+ */
66
+ export async function createManagedProject(
67
+ name: string,
68
+ options?: CreateManagedProjectOptions,
69
+ ): Promise<CreateProjectResponse> {
70
+ const { authFetch } = await import("./auth/index.ts");
71
+ const pkg = await import("../../package.json");
72
+
73
+ const requestBody: CreateProjectRequest = { name };
74
+ if (options?.slug) {
75
+ requestBody.slug = options.slug;
76
+ }
77
+ if (options?.template) {
78
+ requestBody.template = options.template;
79
+ }
80
+ if (options?.usePrebuilt !== undefined) {
81
+ requestBody.use_prebuilt = options.usePrebuilt;
82
+ }
83
+
84
+ const response = await authFetch(`${getControlApiUrl()}/v1/projects`, {
85
+ method: "POST",
86
+ headers: {
87
+ "Content-Type": "application/json",
88
+ "X-Jack-Version": pkg.version,
89
+ },
90
+ body: JSON.stringify(requestBody),
91
+ });
92
+
93
+ if (!response.ok) {
94
+ const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
95
+ message?: string;
96
+ };
97
+ throw new Error(err.message || `Failed to create managed project: ${response.status}`);
98
+ }
99
+
100
+ return response.json() as Promise<CreateProjectResponse>;
101
+ }
102
+
103
+ /**
104
+ * Deploy to a managed project via the control plane.
105
+ */
106
+ export async function deployManagedProject(
107
+ projectId: string,
108
+ source: string,
109
+ ): Promise<CreateDeploymentResponse> {
110
+ const { authFetch } = await import("./auth/index.ts");
111
+
112
+ const response = await authFetch(`${getControlApiUrl()}/v1/projects/${projectId}/deployments`, {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify({ source } satisfies CreateDeploymentRequest),
116
+ });
117
+
118
+ if (!response.ok) {
119
+ const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
120
+ message?: string;
121
+ };
122
+ throw new Error(err.message || `Managed deploy failed: ${response.status}`);
123
+ }
124
+
125
+ return response.json() as Promise<CreateDeploymentResponse>;
126
+ }
127
+
128
+ /**
129
+ * Check if a project slug is available on jack cloud.
130
+ */
131
+ export async function checkSlugAvailability(slug: string): Promise<SlugAvailabilityResponse> {
132
+ const { authFetch } = await import("./auth/index.ts");
133
+
134
+ const response = await authFetch(
135
+ `${getControlApiUrl()}/v1/slugs/${encodeURIComponent(slug)}/available`,
136
+ );
137
+
138
+ if (!response.ok) {
139
+ throw new Error(`Failed to check slug availability: ${response.status}`);
140
+ }
141
+
142
+ return response.json() as Promise<SlugAvailabilityResponse>;
143
+ }
144
+
145
+ export interface DatabaseExportResponse {
146
+ success: boolean;
147
+ download_url: string;
148
+ expires_in: number;
149
+ }
150
+
151
+ export interface DeleteProjectResponse {
152
+ success: boolean;
153
+ project_id: string;
154
+ deleted_at: string;
155
+ resources: Array<{ resource: string; success: boolean; error?: string }>;
156
+ warnings?: string;
157
+ }
158
+
159
+ export interface ManagedProject {
160
+ id: string;
161
+ org_id: string;
162
+ name: string;
163
+ slug: string;
164
+ status: "active" | "error" | "deleted";
165
+ created_at: string;
166
+ updated_at: string;
167
+ }
168
+
169
+ /**
170
+ * Export a managed project's D1 database.
171
+ */
172
+ export async function exportManagedDatabase(projectId: string): Promise<DatabaseExportResponse> {
173
+ const { authFetch } = await import("./auth/index.ts");
174
+
175
+ const response = await authFetch(
176
+ `${getControlApiUrl()}/v1/projects/${projectId}/database/export`,
177
+ );
178
+
179
+ if (response.status === 504) {
180
+ throw new Error("Database export timed out. The database may be too large.");
181
+ }
182
+
183
+ if (!response.ok) {
184
+ const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
185
+ message?: string;
186
+ };
187
+ throw new Error(err.message || `Failed to export database: ${response.status}`);
188
+ }
189
+
190
+ return response.json() as Promise<DatabaseExportResponse>;
191
+ }
192
+
193
+ /**
194
+ * Delete a managed project and all its resources.
195
+ */
196
+ export async function deleteManagedProject(projectId: string): Promise<DeleteProjectResponse> {
197
+ const { authFetch } = await import("./auth/index.ts");
198
+
199
+ const response = await authFetch(`${getControlApiUrl()}/v1/projects/${projectId}`, {
200
+ method: "DELETE",
201
+ });
202
+
203
+ // 404 means project doesn't exist - treat as already deleted
204
+ if (response.status === 404) {
205
+ return {
206
+ success: true,
207
+ project_id: projectId,
208
+ deleted_at: new Date().toISOString(),
209
+ resources: [],
210
+ warnings: "Project was already deleted or not found",
211
+ };
212
+ }
213
+
214
+ if (!response.ok) {
215
+ const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
216
+ message?: string;
217
+ };
218
+ throw new Error(err.message || `Failed to delete project: ${response.status}`);
219
+ }
220
+
221
+ return response.json() as Promise<DeleteProjectResponse>;
222
+ }
223
+
224
+ /**
225
+ * List all managed projects from the control plane.
226
+ */
227
+ export async function listManagedProjects(): Promise<ManagedProject[]> {
228
+ const { authFetch } = await import("./auth/index.ts");
229
+
230
+ const response = await authFetch(`${getControlApiUrl()}/v1/projects`);
231
+
232
+ if (!response.ok) {
233
+ const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
234
+ message?: string;
235
+ };
236
+ throw new Error(err.message || `Failed to list managed projects: ${response.status}`);
237
+ }
238
+
239
+ const data = (await response.json()) as { projects: ManagedProject[] };
240
+ return data.projects;
241
+ }
242
+
243
+ /**
244
+ * Find a managed project by slug.
245
+ */
246
+ export async function findProjectBySlug(slug: string): Promise<ManagedProject | null> {
247
+ const { authFetch } = await import("./auth/index.ts");
248
+
249
+ const response = await authFetch(
250
+ `${getControlApiUrl()}/v1/projects/by-slug/${encodeURIComponent(slug)}`,
251
+ );
252
+
253
+ if (response.status === 404) {
254
+ return null;
255
+ }
256
+
257
+ if (!response.ok) {
258
+ const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
259
+ message?: string;
260
+ };
261
+ throw new Error(err.message || `Failed to find project: ${response.status}`);
262
+ }
263
+
264
+ const data = (await response.json()) as { project: ManagedProject };
265
+ return data.project;
266
+ }
267
+
268
+ export interface ProjectResource {
269
+ id: string;
270
+ resource_type: string;
271
+ resource_name: string;
272
+ provider_id: string;
273
+ status: string;
274
+ created_at: string;
275
+ }
276
+
277
+ /**
278
+ * Fetch all resources for a managed project.
279
+ * Uses GET /v1/projects/:id/resources endpoint.
280
+ */
281
+ export async function fetchProjectResources(projectId: string): Promise<ProjectResource[]> {
282
+ const { authFetch } = await import("./auth/index.ts");
283
+
284
+ const response = await authFetch(`${getControlApiUrl()}/v1/projects/${projectId}/resources`);
285
+
286
+ if (!response.ok) {
287
+ if (response.status === 404) {
288
+ return [];
289
+ }
290
+ throw new Error(`Failed to fetch resources: ${response.status}`);
291
+ }
292
+
293
+ const data = (await response.json()) as { resources: ProjectResource[] };
294
+ return data.resources;
295
+ }
package/src/lib/debug.ts CHANGED
@@ -63,8 +63,10 @@ export async function time(label: string, fn: () => Promise<void>): Promise<numb
63
63
  timerStart(label);
64
64
  try {
65
65
  await fn();
66
- } finally {
67
66
  return timerEnd(label);
67
+ } catch (error) {
68
+ timerEnd(label); // Clean up timer even on failure
69
+ throw error;
68
70
  }
69
71
  }
70
72
 
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Deploy mode selection and validation
3
+ */
4
+
5
+ import { $ } from "bun";
6
+ import { isLoggedIn } from "./auth/index.ts";
7
+ import type { DeployMode } from "./registry.ts";
8
+ import { Events, track } from "./telemetry.ts";
9
+
10
+ export interface ModeFlags {
11
+ managed?: boolean;
12
+ byo?: boolean;
13
+ }
14
+
15
+ /**
16
+ * Check if wrangler CLI is available.
17
+ */
18
+ export async function isWranglerAvailable(): Promise<boolean> {
19
+ try {
20
+ const result = await $`which wrangler`.nothrow().quiet();
21
+ return result.exitCode === 0;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Determine deploy mode based on login status and flags.
29
+ *
30
+ * Omakase behavior:
31
+ * - Logged in => managed
32
+ * - Logged out => BYO
33
+ *
34
+ * Explicit flags always override.
35
+ */
36
+ export async function resolveDeployMode(flags: ModeFlags = {}): Promise<DeployMode> {
37
+ // Validate mutual exclusion
38
+ if (flags.managed && flags.byo) {
39
+ throw new Error("Cannot use both --managed and --byo flags. Choose one.");
40
+ }
41
+
42
+ // Explicit flag takes precedence
43
+ if (flags.managed) {
44
+ track(Events.DEPLOY_MODE_SELECTED, { mode: "managed", explicit: true });
45
+ return "managed";
46
+ }
47
+ if (flags.byo) {
48
+ track(Events.DEPLOY_MODE_SELECTED, { mode: "byo", explicit: true });
49
+ return "byo";
50
+ }
51
+
52
+ // Omakase default based on login status
53
+ const loggedIn = await isLoggedIn();
54
+ const mode = loggedIn ? "managed" : "byo";
55
+
56
+ track(Events.DEPLOY_MODE_SELECTED, { mode, explicit: false });
57
+
58
+ return mode;
59
+ }
60
+
61
+ /**
62
+ * Validate that the chosen mode is available.
63
+ *
64
+ * @returns Error message if unavailable, null if OK
65
+ */
66
+ export async function validateModeAvailability(mode: DeployMode): Promise<string | null> {
67
+ if (mode === "managed") {
68
+ const loggedIn = await isLoggedIn();
69
+ if (!loggedIn) {
70
+ return "Not logged in. Run: jack login or use --byo";
71
+ }
72
+ const hasWrangler = await isWranglerAvailable();
73
+ if (!hasWrangler) {
74
+ return "wrangler not found. Install wrangler or use --byo";
75
+ }
76
+ }
77
+
78
+ if (mode === "byo") {
79
+ const hasWrangler = await isWranglerAvailable();
80
+ if (!hasWrangler) {
81
+ return "wrangler not found. Install wrangler or run: jack login";
82
+ }
83
+ }
84
+
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Get a human-readable label for the deploy mode.
90
+ */
91
+ export function getDeployModeLabel(mode: DeployMode): string {
92
+ return mode === "managed" ? "jack cloud" : "wrangler (BYO)";
93
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Upload deployment artifacts to control plane via multipart form-data
3
+ */
4
+
5
+ import { readFile } from "node:fs/promises";
6
+ import type { AssetManifest } from "./asset-hash.ts";
7
+ import { authFetch } from "./auth/index.ts";
8
+ import { getControlApiUrl } from "./control-plane.ts";
9
+
10
+ export interface DeployUploadOptions {
11
+ projectId: string;
12
+ bundleZipPath: string;
13
+ sourceZipPath: string;
14
+ manifestPath: string;
15
+ schemaPath?: string;
16
+ secretsPath?: string;
17
+ assetsZipPath?: string;
18
+ assetManifest?: AssetManifest;
19
+ }
20
+
21
+ export interface DeployUploadResult {
22
+ id: string;
23
+ project_id: string;
24
+ status: "queued" | "building" | "live" | "failed";
25
+ source: string;
26
+ created_at: string;
27
+ }
28
+
29
+ /**
30
+ * Upload deployment artifacts via multipart/form-data
31
+ */
32
+ export async function uploadDeployment(options: DeployUploadOptions): Promise<DeployUploadResult> {
33
+ const formData = new FormData();
34
+
35
+ // Read files and add to form data
36
+ const manifestContent = await readFile(options.manifestPath);
37
+ formData.append(
38
+ "manifest",
39
+ new Blob([manifestContent], { type: "application/json" }),
40
+ "manifest.json",
41
+ );
42
+
43
+ const bundleContent = await readFile(options.bundleZipPath);
44
+ formData.append("bundle", new Blob([bundleContent], { type: "application/zip" }), "bundle.zip");
45
+
46
+ const sourceContent = await readFile(options.sourceZipPath);
47
+ formData.append("source", new Blob([sourceContent], { type: "application/zip" }), "source.zip");
48
+
49
+ // Optional files
50
+ if (options.schemaPath) {
51
+ const schemaContent = await readFile(options.schemaPath);
52
+ formData.append("schema", new Blob([schemaContent], { type: "text/sql" }), "schema.sql");
53
+ }
54
+
55
+ if (options.secretsPath) {
56
+ const secretsContent = await readFile(options.secretsPath);
57
+ formData.append(
58
+ "secrets",
59
+ new Blob([secretsContent], { type: "application/json" }),
60
+ "secrets.json",
61
+ );
62
+ }
63
+
64
+ if (options.assetsZipPath) {
65
+ const assetsContent = await readFile(options.assetsZipPath);
66
+ formData.append("assets", new Blob([assetsContent], { type: "application/zip" }), "assets.zip");
67
+ }
68
+
69
+ if (options.assetManifest) {
70
+ formData.append(
71
+ "asset-manifest",
72
+ new Blob([JSON.stringify(options.assetManifest)], { type: "application/json" }),
73
+ "asset-manifest.json",
74
+ );
75
+ }
76
+
77
+ // POST to control plane
78
+ const url = `${getControlApiUrl()}/v1/projects/${options.projectId}/deployments/upload`;
79
+ const response = await authFetch(url, {
80
+ method: "POST",
81
+ body: formData,
82
+ });
83
+
84
+ if (!response.ok) {
85
+ const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
86
+ message?: string;
87
+ };
88
+ throw new Error(err.message || `Upload failed: ${response.status}`);
89
+ }
90
+
91
+ return response.json() as Promise<DeployUploadResult>;
92
+ }
package/src/lib/errors.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export enum JackErrorCode {
2
2
  AUTH_FAILED = "AUTH_FAILED",
3
3
  WRANGLER_AUTH_EXPIRED = "WRANGLER_AUTH_EXPIRED",
4
+ JACK_AUTH_REQUIRED = "JACK_AUTH_REQUIRED",
5
+ JACK_AUTH_EXPIRED = "JACK_AUTH_EXPIRED",
4
6
  PROJECT_NOT_FOUND = "PROJECT_NOT_FOUND",
5
7
  TEMPLATE_NOT_FOUND = "TEMPLATE_NOT_FOUND",
6
8
  BUILD_FAILED = "BUILD_FAILED",
package/src/lib/github.ts CHANGED
@@ -2,7 +2,9 @@ import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { $ } from "bun";
5
- import type { Template } from "../templates/types";
5
+ import type { AgentContext, Capability, Template, TemplateHooks } from "../templates/types";
6
+ import { parseJsonc } from "./jsonc.ts";
7
+ import type { ServiceTypeKey } from "./services/index.ts";
6
8
 
7
9
  /**
8
10
  * Parse GitHub input: "user/repo" or "https://github.com/user/repo"
@@ -110,6 +112,34 @@ export async function fetchFromGitHub(input: string): Promise<Template> {
110
112
  console.warn(" (no wrangler.toml or worker entry point found)\n");
111
113
  }
112
114
 
115
+ // Read .jack.json metadata if it exists
116
+ const jackJsonContent = files[".jack.json"];
117
+ if (jackJsonContent) {
118
+ try {
119
+ const metadata = parseJsonc(jackJsonContent) as {
120
+ description?: string;
121
+ secrets?: string[];
122
+ capabilities?: Capability[];
123
+ requires?: ServiceTypeKey[];
124
+ hooks?: TemplateHooks;
125
+ agentContext?: AgentContext;
126
+ };
127
+ // Remove .jack.json from files (not needed in project)
128
+ const { ".jack.json": _, ...filesWithoutJackJson } = files;
129
+ return {
130
+ description: metadata.description || `GitHub: ${owner}/${repo}`,
131
+ secrets: metadata.secrets,
132
+ capabilities: metadata.capabilities,
133
+ requires: metadata.requires,
134
+ hooks: metadata.hooks,
135
+ agentContext: metadata.agentContext,
136
+ files: filesWithoutJackJson,
137
+ };
138
+ } catch {
139
+ // Invalid JSON, fall through to default
140
+ }
141
+ }
142
+
113
143
  return {
114
144
  description: `GitHub: ${owner}/${repo}`,
115
145
  files,
package/src/lib/hooks.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { HookAction } from "../templates/types";
4
4
  import { getSavedSecrets } from "./secrets";
5
+ import { restoreTty } from "./tty";
5
6
 
6
7
  export interface HookContext {
7
8
  domain?: string; // deployed domain (e.g., "my-app.username.workers.dev")
@@ -71,10 +72,7 @@ export async function promptSelect(options: string[]): Promise<number> {
71
72
 
72
73
  const cleanup = () => {
73
74
  process.stdin.removeListener("data", onData);
74
- if (process.stdin.isTTY) {
75
- process.stdin.setRawMode(false);
76
- }
77
- process.stdin.pause();
75
+ restoreTty();
78
76
  };
79
77
 
80
78
  process.stdin.on("data", onData);
@@ -98,19 +96,13 @@ async function waitForEnter(message?: string): Promise<void> {
98
96
  // Enter, Space, or any key to continue
99
97
  if (char === "\r" || char === "\n" || char === " ") {
100
98
  process.stdin.removeListener("data", onData);
101
- if (process.stdin.isTTY) {
102
- process.stdin.setRawMode(false);
103
- }
104
- process.stdin.pause();
99
+ restoreTty();
105
100
  resolve();
106
101
  }
107
102
  // Esc to skip
108
103
  if (char === "\x1b") {
109
104
  process.stdin.removeListener("data", onData);
110
- if (process.stdin.isTTY) {
111
- process.stdin.setRawMode(false);
112
- }
113
- process.stdin.pause();
105
+ restoreTty();
114
106
  resolve();
115
107
  }
116
108
  };
@@ -0,0 +1,88 @@
1
+ import { BUILTIN_TEMPLATES, resolveTemplateWithOrigin } from "../templates/index.ts";
2
+
3
+ export interface IntentMatchResult {
4
+ template: string;
5
+ matchedKeywords: string[];
6
+ }
7
+
8
+ /**
9
+ * Match an intent phrase against template keywords
10
+ * Uses case-insensitive partial matching
11
+ */
12
+ export function matchTemplateByIntent(
13
+ phrase: string,
14
+ templates: Array<{ name: string; keywords: string[] }>,
15
+ ): IntentMatchResult[] {
16
+ const normalizedPhrase = phrase.toLowerCase();
17
+ const matches: IntentMatchResult[] = [];
18
+
19
+ for (const template of templates) {
20
+ const matchedKeywords: string[] = [];
21
+
22
+ for (const keyword of template.keywords) {
23
+ // Case-insensitive partial match - keyword appears anywhere in phrase
24
+ if (normalizedPhrase.includes(keyword.toLowerCase())) {
25
+ matchedKeywords.push(keyword);
26
+ }
27
+ }
28
+
29
+ if (matchedKeywords.length > 0) {
30
+ matches.push({
31
+ template: template.name,
32
+ matchedKeywords,
33
+ });
34
+ }
35
+ }
36
+
37
+ return matches;
38
+ }
39
+
40
+ /**
41
+ * Detect if the first arg is an intent phrase vs a project name
42
+ * Heuristics:
43
+ * - Contains spaces -> intent phrase
44
+ * - Contains special characters (except hyphen/underscore) -> intent phrase
45
+ * - Matches common intent indicator words -> intent phrase
46
+ * - Otherwise -> project name
47
+ */
48
+ export function isIntentPhrase(arg: string): boolean {
49
+ // Contains spaces -> definitely intent
50
+ if (arg.includes(" ")) return true;
51
+
52
+ // Contains special chars (except hyphen/underscore) -> intent
53
+ if (/[^a-zA-Z0-9_-]/.test(arg)) return true;
54
+
55
+ // Common intent indicator words (single words that suggest intent)
56
+ const intentWords = ["api", "webhook", "miniapp", "dashboard", "frontend", "backend", "endpoint"];
57
+ const lower = arg.toLowerCase();
58
+
59
+ // Exact match or compound word containing the intent word
60
+ for (const word of intentWords) {
61
+ if (lower === word) return true;
62
+ }
63
+
64
+ return false;
65
+ }
66
+
67
+ /**
68
+ * Load all templates and extract their intent keywords
69
+ */
70
+ export async function loadTemplateKeywords(): Promise<Array<{ name: string; keywords: string[] }>> {
71
+ const templates: Array<{ name: string; keywords: string[] }> = [];
72
+
73
+ for (const name of BUILTIN_TEMPLATES) {
74
+ try {
75
+ const { template } = await resolveTemplateWithOrigin(name);
76
+ if (template.intent?.keywords && template.intent.keywords.length > 0) {
77
+ templates.push({
78
+ name,
79
+ keywords: template.intent.keywords,
80
+ });
81
+ }
82
+ } catch {
83
+ // Skip templates that fail to load
84
+ }
85
+ }
86
+
87
+ return templates;
88
+ }