@getjack/jack 0.1.33 → 0.1.35

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 (94) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/commands/down.ts +39 -7
  4. package/src/commands/link.ts +2 -4
  5. package/src/commands/logs.ts +2 -4
  6. package/src/commands/mcp.ts +12 -10
  7. package/src/commands/secrets.ts +3 -1
  8. package/src/commands/services.ts +4 -2
  9. package/src/commands/sync.ts +5 -6
  10. package/src/lib/auth/client.ts +5 -2
  11. package/src/lib/binding-validator.ts +39 -3
  12. package/src/lib/build-helper.ts +18 -19
  13. package/src/lib/control-plane.ts +1 -0
  14. package/src/lib/crypto.ts +84 -0
  15. package/src/lib/deploy-upload.ts +7 -3
  16. package/src/lib/do-config.ts +110 -0
  17. package/src/lib/do-export-validator.ts +26 -0
  18. package/src/lib/hooks.ts +1 -2
  19. package/src/lib/jsonc-edit.ts +292 -0
  20. package/src/lib/managed-deploy.ts +36 -1
  21. package/src/lib/project-link.ts +37 -0
  22. package/src/lib/project-operations.ts +37 -46
  23. package/src/lib/prompts.ts +2 -2
  24. package/src/lib/resources.ts +4 -5
  25. package/src/lib/schema.ts +8 -12
  26. package/src/lib/services/db-create.ts +2 -2
  27. package/src/lib/services/db-execute.ts +9 -6
  28. package/src/lib/services/db-list.ts +6 -4
  29. package/src/lib/services/endpoint-test.ts +275 -0
  30. package/src/lib/services/project-delete.ts +190 -0
  31. package/src/lib/services/project-environment.ts +457 -0
  32. package/src/lib/services/storage-config.ts +7 -309
  33. package/src/lib/services/storage-create.ts +2 -1
  34. package/src/lib/services/storage-delete.ts +3 -2
  35. package/src/lib/services/storage-info.ts +2 -1
  36. package/src/lib/services/storage-list.ts +6 -3
  37. package/src/lib/services/vectorize-config.ts +7 -264
  38. package/src/lib/services/vectorize-create.ts +2 -1
  39. package/src/lib/services/vectorize-delete.ts +6 -4
  40. package/src/lib/services/vectorize-list.ts +6 -3
  41. package/src/lib/storage/index.ts +21 -23
  42. package/src/lib/telemetry.ts +1 -0
  43. package/src/lib/wrangler-config.ts +43 -312
  44. package/src/lib/zip-packager.ts +28 -0
  45. package/src/mcp/test-utils.ts +31 -0
  46. package/src/mcp/tools/index.ts +271 -0
  47. package/src/templates/index.ts +5 -0
  48. package/src/templates/types.ts +4 -0
  49. package/templates/AI-BINDINGS.md +34 -76
  50. package/templates/CLAUDE.md +22 -1
  51. package/templates/ai-chat/src/index.ts +7 -14
  52. package/templates/ai-chat/src/jack-ai.ts +0 -6
  53. package/templates/chat/.jack.json +45 -0
  54. package/templates/chat/bun.lock +1588 -0
  55. package/templates/chat/components.json +23 -0
  56. package/templates/chat/index.html +12 -0
  57. package/templates/chat/package.json +41 -0
  58. package/templates/chat/src/chat-agent.ts +61 -0
  59. package/templates/chat/src/client/app.tsx +189 -0
  60. package/templates/chat/src/client/chat.tsx +222 -0
  61. package/templates/chat/src/client/components/prompt-kit/chat-container.tsx +47 -0
  62. package/templates/chat/src/client/components/prompt-kit/loader.tsx +33 -0
  63. package/templates/chat/src/client/components/prompt-kit/markdown.tsx +84 -0
  64. package/templates/chat/src/client/components/prompt-kit/message.tsx +54 -0
  65. package/templates/chat/src/client/components/prompt-kit/prompt-suggestion.tsx +20 -0
  66. package/templates/chat/src/client/components/prompt-kit/reasoning.tsx +134 -0
  67. package/templates/chat/src/client/components/prompt-kit/scroll-button.tsx +28 -0
  68. package/templates/chat/src/client/components/ui/button.tsx +38 -0
  69. package/templates/chat/src/client/lib/utils.ts +6 -0
  70. package/templates/chat/src/client/main.tsx +11 -0
  71. package/templates/chat/src/client/styles.css +125 -0
  72. package/templates/chat/src/index.ts +25 -0
  73. package/templates/chat/src/jack-ai.ts +94 -0
  74. package/templates/chat/tsconfig.json +18 -0
  75. package/templates/chat/vite.config.ts +14 -0
  76. package/templates/chat/wrangler.jsonc +18 -0
  77. package/templates/cron/.jack.json +18 -28
  78. package/templates/cron/schema.sql +10 -20
  79. package/templates/cron/src/admin.ts +321 -0
  80. package/templates/cron/src/index.ts +151 -81
  81. package/templates/cron/src/monitor.ts +124 -0
  82. package/templates/nextjs-clerk/app/layout.tsx +2 -0
  83. package/templates/semantic-search/src/index.ts +5 -43
  84. package/templates/semantic-search/src/jack-ai.ts +0 -6
  85. package/templates/telegram-bot/.jack.json +56 -0
  86. package/templates/telegram-bot/bun.lock +41 -0
  87. package/templates/telegram-bot/package.json +16 -0
  88. package/templates/telegram-bot/src/index.ts +236 -0
  89. package/templates/telegram-bot/src/jack-ai.ts +100 -0
  90. package/templates/telegram-bot/tsconfig.json +11 -0
  91. package/templates/telegram-bot/wrangler.jsonc +8 -0
  92. package/templates/cron/src/jobs.ts +0 -139
  93. package/templates/cron/src/webhooks.ts +0 -95
  94. package/templates/semantic-search/src/jack-vectorize.ts +0 -169
package/src/lib/hooks.ts CHANGED
@@ -533,8 +533,7 @@ const actionHandlers: {
533
533
  const { isCancel, text } = await import("@clack/prompts");
534
534
  const value = await text({ message: promptMsg });
535
535
 
536
- if (isCancel(value) || !value.trim()) {
537
- ui.warn(`Skipped ${action.key}`);
536
+ if (isCancel(value) || !value || !value.trim()) {
538
537
  return false;
539
538
  }
540
539
 
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Shared JSONC text-manipulation helpers.
3
+ *
4
+ * These operate on raw JSONC strings so that comments and formatting
5
+ * are preserved when adding/removing sections.
6
+ */
7
+
8
+ /**
9
+ * Find the matching closing bracket/brace for an opening one,
10
+ * respecting strings and comments.
11
+ */
12
+ export function findMatchingBracket(
13
+ content: string,
14
+ startIndex: number,
15
+ openChar: string,
16
+ closeChar: string,
17
+ ): number {
18
+ let depth = 0;
19
+ let inString = false;
20
+ let stringChar = "";
21
+ let escaped = false;
22
+ let inLineComment = false;
23
+ let inBlockComment = false;
24
+
25
+ for (let i = startIndex; i < content.length; i++) {
26
+ const char = content[i] ?? "";
27
+ const next = content[i + 1] ?? "";
28
+
29
+ if (inLineComment) {
30
+ if (char === "\n") inLineComment = false;
31
+ continue;
32
+ }
33
+
34
+ if (inBlockComment) {
35
+ if (char === "*" && next === "/") {
36
+ inBlockComment = false;
37
+ i++;
38
+ }
39
+ continue;
40
+ }
41
+
42
+ if (inString) {
43
+ if (escaped) {
44
+ escaped = false;
45
+ continue;
46
+ }
47
+ if (char === "\\") {
48
+ escaped = true;
49
+ continue;
50
+ }
51
+ if (char === stringChar) {
52
+ inString = false;
53
+ stringChar = "";
54
+ }
55
+ continue;
56
+ }
57
+
58
+ if (char === "/" && next === "/") {
59
+ inLineComment = true;
60
+ i++;
61
+ continue;
62
+ }
63
+ if (char === "/" && next === "*") {
64
+ inBlockComment = true;
65
+ i++;
66
+ continue;
67
+ }
68
+
69
+ if (char === '"' || char === "'") {
70
+ inString = true;
71
+ stringChar = char;
72
+ continue;
73
+ }
74
+
75
+ if (char === openChar) {
76
+ depth++;
77
+ } else if (char === closeChar) {
78
+ depth--;
79
+ if (depth === 0) return i;
80
+ }
81
+ }
82
+
83
+ return -1;
84
+ }
85
+
86
+ /**
87
+ * Check if content is only whitespace and comments.
88
+ */
89
+ export function isOnlyCommentsAndWhitespace(content: string): boolean {
90
+ let inLineComment = false;
91
+ let inBlockComment = false;
92
+
93
+ for (let i = 0; i < content.length; i++) {
94
+ const char = content[i] ?? "";
95
+ const next = content[i + 1] ?? "";
96
+
97
+ if (inLineComment) {
98
+ if (char === "\n") inLineComment = false;
99
+ continue;
100
+ }
101
+
102
+ if (inBlockComment) {
103
+ if (char === "*" && next === "/") {
104
+ inBlockComment = false;
105
+ i++;
106
+ }
107
+ continue;
108
+ }
109
+
110
+ if (char === "/" && next === "/") {
111
+ inLineComment = true;
112
+ i++;
113
+ continue;
114
+ }
115
+
116
+ if (char === "/" && next === "*") {
117
+ inBlockComment = true;
118
+ i++;
119
+ continue;
120
+ }
121
+
122
+ if (!/\s/.test(char)) return false;
123
+ }
124
+
125
+ return true;
126
+ }
127
+
128
+ /**
129
+ * Find the last closing brace of an object within an array range.
130
+ */
131
+ export function findLastObjectEndInArray(
132
+ content: string,
133
+ startIndex: number,
134
+ endIndex: number,
135
+ ): number {
136
+ let lastBraceIndex = -1;
137
+ let inString = false;
138
+ let stringChar = "";
139
+ let escaped = false;
140
+ let inLineComment = false;
141
+ let inBlockComment = false;
142
+
143
+ for (let i = startIndex; i < endIndex; i++) {
144
+ const char = content[i] ?? "";
145
+ const next = content[i + 1] ?? "";
146
+
147
+ if (inLineComment) {
148
+ if (char === "\n") inLineComment = false;
149
+ continue;
150
+ }
151
+
152
+ if (inBlockComment) {
153
+ if (char === "*" && next === "/") {
154
+ inBlockComment = false;
155
+ i++;
156
+ }
157
+ continue;
158
+ }
159
+
160
+ if (inString) {
161
+ if (escaped) {
162
+ escaped = false;
163
+ continue;
164
+ }
165
+ if (char === "\\") {
166
+ escaped = true;
167
+ continue;
168
+ }
169
+ if (char === stringChar) {
170
+ inString = false;
171
+ stringChar = "";
172
+ }
173
+ continue;
174
+ }
175
+
176
+ if (char === "/" && next === "/") {
177
+ inLineComment = true;
178
+ i++;
179
+ continue;
180
+ }
181
+
182
+ if (char === "/" && next === "*") {
183
+ inBlockComment = true;
184
+ i++;
185
+ continue;
186
+ }
187
+
188
+ if (char === '"' || char === "'") {
189
+ inString = true;
190
+ stringChar = char;
191
+ continue;
192
+ }
193
+
194
+ if (char === "}") lastBraceIndex = i;
195
+ }
196
+
197
+ return lastBraceIndex;
198
+ }
199
+
200
+ /**
201
+ * Find the start of a line comment (//) in a string, respecting strings.
202
+ */
203
+ export function findLineCommentStart(line: string): number {
204
+ let inString = false;
205
+ let stringChar = "";
206
+ let escaped = false;
207
+
208
+ for (let i = 0; i < line.length - 1; i++) {
209
+ const char = line[i] ?? "";
210
+ const next = line[i + 1] ?? "";
211
+
212
+ if (inString) {
213
+ if (escaped) {
214
+ escaped = false;
215
+ continue;
216
+ }
217
+ if (char === "\\") {
218
+ escaped = true;
219
+ continue;
220
+ }
221
+ if (char === stringChar) {
222
+ inString = false;
223
+ stringChar = "";
224
+ }
225
+ continue;
226
+ }
227
+
228
+ if (char === '"' || char === "'") {
229
+ inString = true;
230
+ stringChar = char;
231
+ continue;
232
+ }
233
+
234
+ if (char === "/" && next === "/") return i;
235
+ }
236
+
237
+ return -1;
238
+ }
239
+
240
+ /**
241
+ * Determine if we need to add a comma before new content.
242
+ * Looks at the last non-whitespace, non-comment character.
243
+ */
244
+ export function shouldAddCommaBefore(content: string): boolean {
245
+ let i = content.length - 1;
246
+
247
+ // First pass: find where any trailing line comment starts
248
+ for (let j = content.length - 1; j >= 0; j--) {
249
+ if (content[j] === "\n") {
250
+ const lineStart = content.lastIndexOf("\n", j - 1) + 1;
251
+ const line = content.slice(lineStart, j);
252
+ const commentIndex = findLineCommentStart(line);
253
+ if (commentIndex !== -1) {
254
+ i = lineStart + commentIndex - 1;
255
+ }
256
+ break;
257
+ }
258
+ }
259
+
260
+ // Skip whitespace
261
+ while (i >= 0 && /\s/.test(content[i] ?? "")) {
262
+ i--;
263
+ }
264
+
265
+ if (i < 0) return false;
266
+
267
+ const lastChar = content[i];
268
+ return lastChar !== "{" && lastChar !== "[" && lastChar !== ",";
269
+ }
270
+
271
+ /**
272
+ * Add a new top-level section (key + value) to a JSONC file before the
273
+ * final closing brace, preserving existing comments and formatting.
274
+ *
275
+ * @param content Raw JSONC file content
276
+ * @param sectionJson The `"key": value` string to insert (no trailing comma)
277
+ * @returns Updated file content
278
+ */
279
+ export function addSectionBeforeClosingBrace(content: string, sectionJson: string): string {
280
+ const lastBraceIndex = content.lastIndexOf("}");
281
+ if (lastBraceIndex === -1) {
282
+ throw new Error("Invalid JSON: no closing brace found");
283
+ }
284
+
285
+ const beforeBrace = content.slice(0, lastBraceIndex);
286
+ const needsComma = shouldAddCommaBefore(beforeBrace);
287
+ const trimmedBefore = beforeBrace.trimEnd();
288
+
289
+ const insertion = needsComma ? `,\n\t${sectionJson}` : `\n\t${sectionJson}`;
290
+
291
+ return `${trimmedBefore + insertion}\n${content.slice(lastBraceIndex)}`;
292
+ }
@@ -5,17 +5,21 @@
5
5
  */
6
6
 
7
7
  import { stat } from "node:fs/promises";
8
+ import { join } from "node:path";
8
9
  import { validateBindings } from "./binding-validator.ts";
9
10
  import { buildProject, parseWranglerConfig } from "./build-helper.ts";
10
11
  import { createManagedProject, syncProjectTags } from "./control-plane.ts";
11
12
  import { debug } from "./debug.ts";
12
13
  import { uploadDeployment } from "./deploy-upload.ts";
14
+ import { ensureMigrations, ensureNodejsCompat } from "./do-config.ts";
15
+ import { validateDoExports } from "./do-export-validator.ts";
13
16
  import { JackError, JackErrorCode } from "./errors.ts";
14
17
  import { formatSize } from "./format.ts";
15
18
  import { createFileCountProgress, createUploadProgress } from "./progress.ts";
16
19
  import type { OperationReporter } from "./project-operations.ts";
17
20
  import { getProjectTags } from "./tags.ts";
18
21
  import { Events, track, trackActivationIfFirst } from "./telemetry.ts";
22
+ import { findWranglerConfig } from "./wrangler-config.ts";
19
23
  import { packageForDeploy } from "./zip-packager.ts";
20
24
 
21
25
  export interface ManagedCreateResult {
@@ -101,12 +105,43 @@ export async function deployCodeToManagedProject(
101
105
  let pkg: Awaited<ReturnType<typeof packageForDeploy>> | null = null;
102
106
 
103
107
  try {
104
- const config = await parseWranglerConfig(projectPath);
108
+ let config = await parseWranglerConfig(projectPath);
105
109
 
106
110
  // Step 1: Build the project (must happen before validation, as build creates dist/)
107
111
  reporter?.start("Building project...");
108
112
  const buildOutput = await buildProject({ projectPath, reporter });
109
113
 
114
+ // Step 1.5: Auto-fix DO prerequisites (after build so we have output to validate)
115
+ if (config.durable_objects?.bindings?.length) {
116
+ const configPath = findWranglerConfig(projectPath) ?? join(projectPath, "wrangler.jsonc");
117
+ const fixes: string[] = [];
118
+
119
+ const addedCompat = await ensureNodejsCompat(configPath, config);
120
+ if (addedCompat) fixes.push("nodejs_compat");
121
+
122
+ const migratedClasses = await ensureMigrations(configPath, config);
123
+ if (migratedClasses.length > 0) fixes.push(`migrations for ${migratedClasses.join(", ")}`);
124
+
125
+ if (fixes.length > 0) {
126
+ config = await parseWranglerConfig(projectPath);
127
+ reporter?.success(`Auto-configured: ${fixes.join(", ")}`);
128
+ }
129
+
130
+ // Validate DO class exports
131
+ const missing = await validateDoExports(
132
+ buildOutput.outDir,
133
+ buildOutput.entrypoint,
134
+ config.durable_objects.bindings.map((b) => b.class_name),
135
+ );
136
+ if (missing.length > 0) {
137
+ throw new JackError(
138
+ JackErrorCode.VALIDATION_ERROR,
139
+ `Durable Object class${missing.length > 1 ? "es" : ""} not exported: ${missing.join(", ")}`,
140
+ missing.map((c) => `Add "export" before "class ${c}" in your source code`).join("\n"),
141
+ );
142
+ }
143
+ }
144
+
110
145
  // Step 2: Validate bindings are supported (after build, so assets dir exists)
111
146
  const validation = validateBindings(config, projectPath);
112
147
  if (!validation.valid) {
@@ -86,6 +86,43 @@ export function generateByoProjectId(): string {
86
86
  return `byo_${uuid}`;
87
87
  }
88
88
 
89
+ /**
90
+ * Build the managed project URL for a given slug.
91
+ *
92
+ * If the link has owner_username, uses it directly. Otherwise fetches it
93
+ * from the control plane and backfills the local link for future calls.
94
+ *
95
+ * @param slug - Project slug (e.g. "cozy-paws-relate")
96
+ * @param ownerUsername - Owner username if already known
97
+ * @param projectDir - Project directory (for backfilling the link). If omitted, no backfill.
98
+ */
99
+ export async function buildManagedUrl(
100
+ slug: string,
101
+ ownerUsername?: string | null,
102
+ projectDir?: string,
103
+ ): Promise<string> {
104
+ if (ownerUsername) {
105
+ return `https://${ownerUsername}-${slug}.runjack.xyz`;
106
+ }
107
+
108
+ // Fetch from control plane to resolve owner_username
109
+ try {
110
+ const { findProjectBySlug } = await import("./control-plane.ts");
111
+ const project = await findProjectBySlug(slug);
112
+ if (project?.owner_username) {
113
+ // Backfill local link so subsequent calls are fast
114
+ if (projectDir) {
115
+ updateProjectLink(projectDir, { owner_username: project.owner_username }).catch(() => {});
116
+ }
117
+ return `https://${project.owner_username}-${slug}.runjack.xyz`;
118
+ }
119
+ } catch {
120
+ // Control plane unavailable, fall through
121
+ }
122
+
123
+ return `https://${slug}.runjack.xyz`;
124
+ }
125
+
89
126
  /**
90
127
  * Link a local directory to a control plane project (managed)
91
128
  * or create a local-only link (BYO with provided/generated ID)
@@ -60,6 +60,7 @@ import { detectProjectType, validateProject } from "./project-detection.ts";
60
60
  import {
61
61
  type DeployMode,
62
62
  type TemplateMetadata as TemplateOrigin,
63
+ buildManagedUrl,
63
64
  generateByoProjectId,
64
65
  linkProject,
65
66
  readProjectLink,
@@ -71,6 +72,7 @@ import { applySchema, getD1Bindings, getD1DatabaseName, hasD1Config } from "./sc
71
72
  import { getSavedSecrets, saveSecrets } from "./secrets.ts";
72
73
  import { getProjectNameFromDir, getRemoteManifest } from "./storage/index.ts";
73
74
  import { Events, track, trackActivationIfFirst } from "./telemetry.ts";
75
+ import { findWranglerConfig, hasWranglerConfig } from "./wrangler-config.ts";
74
76
 
75
77
  // ============================================================================
76
78
  // Type Definitions
@@ -304,20 +306,6 @@ async function promptEnvVars(
304
306
  return result;
305
307
  }
306
308
 
307
- /**
308
- * Get the wrangler config file path for a project
309
- * Returns the first found: wrangler.jsonc, wrangler.toml, wrangler.json
310
- */
311
- function getWranglerConfigPath(projectPath: string): string | null {
312
- const configs = ["wrangler.jsonc", "wrangler.toml", "wrangler.json"];
313
- for (const config of configs) {
314
- if (existsSync(join(projectPath, config))) {
315
- return config;
316
- }
317
- }
318
- return null;
319
- }
320
-
321
309
  /**
322
310
  * Run wrangler deploy with explicit config to avoid parent directory conflicts
323
311
  */
@@ -325,7 +313,7 @@ async function runWranglerDeploy(
325
313
  projectPath: string,
326
314
  options: { dryRun?: boolean; outDir?: string } = {},
327
315
  ) {
328
- const configFile = getWranglerConfigPath(projectPath);
316
+ const configFile = findWranglerConfig(projectPath);
329
317
  const configArg = configFile ? ["--config", configFile] : [];
330
318
  const dryRunArgs = options.dryRun
331
319
  ? ["--dry-run", ...(options.outDir ? ["--outdir", options.outDir] : [])]
@@ -797,10 +785,18 @@ export async function createProject(
797
785
  ? resolve(targetDirOption, name)
798
786
  : join(getJackHome(), name);
799
787
  if (existsSync(effectiveTargetDir)) {
788
+ const existingLink = await readProjectLink(effectiveTargetDir);
789
+ if (existingLink) {
790
+ throw new JackError(
791
+ JackErrorCode.VALIDATION_ERROR,
792
+ `'${name}' already exists`,
793
+ `cd ${effectiveTargetDir} && jack ship to deploy it, or rm -rf ${effectiveTargetDir} to start fresh.`,
794
+ );
795
+ }
800
796
  throw new JackError(
801
797
  JackErrorCode.VALIDATION_ERROR,
802
- `Folder exists at ${effectiveTargetDir}/`,
803
- "Remove it first, or use 'jack ship' if it's a project.",
798
+ `Folder already exists at ${effectiveTargetDir}/`,
799
+ "Remove it or pick a different name.",
804
800
  );
805
801
  }
806
802
  }
@@ -873,10 +869,18 @@ export async function createProject(
873
869
 
874
870
  // Check directory doesn't exist (only needed for auto-generated names now)
875
871
  if (!nameWasProvided && existsSync(targetDir)) {
872
+ const existingLink = await readProjectLink(targetDir);
873
+ if (existingLink) {
874
+ throw new JackError(
875
+ JackErrorCode.VALIDATION_ERROR,
876
+ `'${projectName}' already exists`,
877
+ `cd ${targetDir} && jack ship to deploy it, or rm -rf ${targetDir} to start fresh.`,
878
+ );
879
+ }
876
880
  throw new JackError(
877
881
  JackErrorCode.VALIDATION_ERROR,
878
- `Folder exists at ${targetDir}/`,
879
- "Remove it first, or use 'jack ship' if it's a project.",
882
+ `Folder already exists at ${targetDir}/`,
883
+ "Remove it first or pick a different name.",
880
884
  );
881
885
  }
882
886
 
@@ -1045,8 +1049,8 @@ export async function createProject(
1045
1049
  if (!hookResult.success) {
1046
1050
  throw new JackError(
1047
1051
  JackErrorCode.VALIDATION_ERROR,
1048
- "Project setup incomplete",
1049
- "Missing required configuration",
1052
+ "Project setup cancelled",
1053
+ `Run the same command again when you're ready — jack new ${projectName} -t ${resolvedTemplate}`,
1050
1054
  );
1051
1055
  }
1052
1056
  }
@@ -1111,7 +1115,7 @@ export async function createProject(
1111
1115
  placeholder: "paste value or press Esc to skip",
1112
1116
  });
1113
1117
 
1114
- if (!isCancel(value) && value.trim()) {
1118
+ if (!isCancel(value) && value && value.trim()) {
1115
1119
  secretsToUse[optionalSecret.name] = value.trim();
1116
1120
  // Save to global secrets for reuse
1117
1121
  await saveSecrets([
@@ -1123,7 +1127,7 @@ export async function createProject(
1123
1127
  ]);
1124
1128
  reporter.success(`Saved ${optionalSecret.name}`);
1125
1129
  } else {
1126
- reporter.info(`Skipped ${optionalSecret.name}`);
1130
+ reporter.info(`Skipped ${optionalSecret.name} — add it later with: jack secrets add ${optionalSecret.name}`);
1127
1131
  }
1128
1132
 
1129
1133
  reporter.start("Creating project...");
@@ -1590,25 +1594,22 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1590
1594
  const interactive = interactiveOption ?? !isCi;
1591
1595
 
1592
1596
  // Check for wrangler config
1593
- const hasWranglerConfig =
1594
- existsSync(join(projectPath, "wrangler.toml")) ||
1595
- existsSync(join(projectPath, "wrangler.jsonc")) ||
1596
- existsSync(join(projectPath, "wrangler.json"));
1597
+ const hasConfig = hasWranglerConfig(projectPath);
1597
1598
 
1598
1599
  // Check for existing project link
1599
1600
  const hasProjectLink = existsSync(join(projectPath, ".jack", "project.json"));
1600
1601
 
1601
1602
  // Auto-detect flow: no wrangler config and no project link
1602
1603
  let autoDetectResult: AutoDetectResult | null = null;
1603
- if (!hasWranglerConfig && !hasProjectLink) {
1604
+ if (!hasConfig && !hasProjectLink) {
1604
1605
  autoDetectResult = await runAutoDetectFlow(projectPath, reporter, interactive, dryRun);
1605
- } else if (!hasWranglerConfig) {
1606
+ } else if (!hasConfig) {
1606
1607
  throw new JackError(
1607
1608
  JackErrorCode.PROJECT_NOT_FOUND,
1608
1609
  "No wrangler config found in current directory",
1609
1610
  "Run: jack new <project-name>",
1610
1611
  );
1611
- } else if (hasWranglerConfig && !hasProjectLink) {
1612
+ } else if (hasConfig && !hasProjectLink) {
1612
1613
  // Orphaned state: wrangler config exists but no project link
1613
1614
  // This happens when: linking failed during jack new, user has existing wrangler project,
1614
1615
  // or project was moved/copied without .jack directory
@@ -1798,9 +1799,7 @@ export async function deployProject(options: DeployOptions = {}): Promise<Deploy
1798
1799
  managedDeployResult = await deployToManagedProject(managedProjectId as string, projectPath, reporter, options.message);
1799
1800
 
1800
1801
  // Construct URL with username if available
1801
- workerUrl = link?.owner_username
1802
- ? `https://${link.owner_username}-${projectName}.runjack.xyz`
1803
- : `https://${projectName}.runjack.xyz`;
1802
+ workerUrl = await buildManagedUrl(projectName, link?.owner_username, projectPath);
1804
1803
  } else {
1805
1804
  // BYO mode: deploy via wrangler
1806
1805
 
@@ -1990,11 +1989,8 @@ export async function getProjectStatus(
1990
1989
  const link = await readProjectLink(resolvedPath);
1991
1990
 
1992
1991
  // Check if local project exists at the resolved path
1993
- const hasWranglerConfig =
1994
- existsSync(join(resolvedPath, "wrangler.jsonc")) ||
1995
- existsSync(join(resolvedPath, "wrangler.toml")) ||
1996
- existsSync(join(resolvedPath, "wrangler.json"));
1997
- const localExists = hasWranglerConfig;
1992
+ const hasConfig = hasWranglerConfig(resolvedPath);
1993
+ const localExists = hasConfig;
1998
1994
  const localPath = localExists ? resolvedPath : null;
1999
1995
 
2000
1996
  // If no link and no local project, return null
@@ -2014,9 +2010,7 @@ export async function getProjectStatus(
2014
2010
  // Determine URL based on mode
2015
2011
  let workerUrl: string | null = null;
2016
2012
  if (link?.deploy_mode === "managed") {
2017
- workerUrl = link.owner_username
2018
- ? `https://${link.owner_username}-${projectName}.runjack.xyz`
2019
- : `https://${projectName}.runjack.xyz`;
2013
+ workerUrl = await buildManagedUrl(projectName, link.owner_username, resolvedPath);
2020
2014
  }
2021
2015
 
2022
2016
  // Get database name on-demand
@@ -2124,12 +2118,9 @@ export async function scanStaleProjects(): Promise<StaleProjectScan> {
2124
2118
 
2125
2119
  for (const projectPath of paths) {
2126
2120
  // Check if path exists and has valid wrangler config
2127
- const hasWranglerConfig =
2128
- existsSync(join(projectPath, "wrangler.jsonc")) ||
2129
- existsSync(join(projectPath, "wrangler.toml")) ||
2130
- existsSync(join(projectPath, "wrangler.json"));
2121
+ const hasConfig = hasWranglerConfig(projectPath);
2131
2122
 
2132
- if (!hasWranglerConfig) {
2123
+ if (!hasConfig) {
2133
2124
  // Type 1: No wrangler config at path (dir deleted/moved)
2134
2125
  const name = projectPath.split("/").pop() || projectId;
2135
2126
  stale.push({
@@ -68,7 +68,7 @@ async function promptAdditionalSecrets(): Promise<
68
68
  message: "Enter secret name (or press enter to finish):",
69
69
  });
70
70
 
71
- if (isCancel(key) || !key.trim()) {
71
+ if (isCancel(key) || !key || !key.trim()) {
72
72
  break;
73
73
  }
74
74
 
@@ -76,7 +76,7 @@ async function promptAdditionalSecrets(): Promise<
76
76
  message: `Enter value for ${key}:`,
77
77
  });
78
78
 
79
- if (isCancel(value)) {
79
+ if (isCancel(value) || !value) {
80
80
  break;
81
81
  }
82
82
 
@@ -5,9 +5,8 @@
5
5
  * or parsed from wrangler.jsonc (BYO).
6
6
  */
7
7
 
8
- import { existsSync } from "node:fs";
9
- import { join } from "node:path";
10
8
  import { parseJsonc } from "./jsonc.ts";
9
+ import { findWranglerConfig } from "./wrangler-config.ts";
11
10
 
12
11
  // Resource types matching control plane schema
13
12
  export type ResourceType =
@@ -88,10 +87,10 @@ export function convertControlPlaneResources(resources: ControlPlaneResource[]):
88
87
  * Returns a unified resource view.
89
88
  */
90
89
  export async function parseWranglerResources(projectPath: string): Promise<ResolvedResources> {
91
- const wranglerPath = join(projectPath, "wrangler.jsonc");
90
+ const wranglerPath = findWranglerConfig(projectPath);
92
91
 
93
- if (!existsSync(wranglerPath)) {
94
- return {};
92
+ if (!wranglerPath) {
93
+ return {} as ResolvedResources;
95
94
  }
96
95
 
97
96
  try {