@getjack/jack 0.1.5 → 0.1.7

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.
@@ -49,6 +49,33 @@ export interface SlugAvailabilityResponse {
49
49
  error?: string;
50
50
  }
51
51
 
52
+ export interface UsernameAvailabilityResponse {
53
+ available: boolean;
54
+ username: string;
55
+ error?: string;
56
+ }
57
+
58
+ export interface SetUsernameResponse {
59
+ success: boolean;
60
+ username: string;
61
+ }
62
+
63
+ export interface UserProfile {
64
+ id: string;
65
+ email: string;
66
+ first_name: string | null;
67
+ last_name: string | null;
68
+ username: string | null;
69
+ created_at: string;
70
+ updated_at: string;
71
+ }
72
+
73
+ export interface PublishProjectResponse {
74
+ success: boolean;
75
+ published_as: string;
76
+ fork_command: string;
77
+ }
78
+
52
79
  export interface CreateDeploymentRequest {
53
80
  source: string;
54
81
  }
@@ -166,6 +193,7 @@ export interface ManagedProject {
166
193
  created_at: string;
167
194
  updated_at: string;
168
195
  tags?: string; // JSON string array from DB, e.g., '["backend", "api"]'
196
+ owner_username?: string | null;
169
197
  }
170
198
 
171
199
  /**
@@ -341,3 +369,125 @@ export async function fetchProjectTags(projectId: string): Promise<string[]> {
341
369
  return [];
342
370
  }
343
371
  }
372
+
373
+ /**
374
+ * Check if a username is available on jack cloud.
375
+ * Does not require authentication.
376
+ */
377
+ export async function checkUsernameAvailable(
378
+ username: string,
379
+ ): Promise<UsernameAvailabilityResponse> {
380
+ const response = await fetch(
381
+ `${getControlApiUrl()}/v1/usernames/${encodeURIComponent(username)}/available`,
382
+ );
383
+
384
+ if (!response.ok) {
385
+ throw new Error(`Failed to check username availability: ${response.status}`);
386
+ }
387
+
388
+ return response.json() as Promise<UsernameAvailabilityResponse>;
389
+ }
390
+
391
+ /**
392
+ * Set the current user's username.
393
+ * Can only be called once per user.
394
+ */
395
+ export async function setUsername(username: string): Promise<SetUsernameResponse> {
396
+ const { authFetch } = await import("./auth/index.ts");
397
+
398
+ const response = await authFetch(`${getControlApiUrl()}/v1/me/username`, {
399
+ method: "PUT",
400
+ headers: { "Content-Type": "application/json" },
401
+ body: JSON.stringify({ username }),
402
+ });
403
+
404
+ if (response.status === 409) {
405
+ const err = (await response.json().catch(() => ({ message: "Username taken" }))) as {
406
+ message?: string;
407
+ };
408
+ throw new Error(err.message || "Username is already taken");
409
+ }
410
+
411
+ if (!response.ok) {
412
+ const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
413
+ message?: string;
414
+ };
415
+ throw new Error(err.message || `Failed to set username: ${response.status}`);
416
+ }
417
+
418
+ return response.json() as Promise<SetUsernameResponse>;
419
+ }
420
+
421
+ /**
422
+ * Get the current user's profile including username.
423
+ */
424
+ export async function getCurrentUserProfile(): Promise<UserProfile | null> {
425
+ const { authFetch } = await import("./auth/index.ts");
426
+
427
+ try {
428
+ const response = await authFetch(`${getControlApiUrl()}/v1/me`);
429
+
430
+ if (!response.ok) {
431
+ return null;
432
+ }
433
+
434
+ const data = (await response.json()) as { user: UserProfile };
435
+ return data.user;
436
+ } catch {
437
+ return null;
438
+ }
439
+ }
440
+
441
+ export interface SourceSnapshotResponse {
442
+ success: boolean;
443
+ source_key: string;
444
+ }
445
+
446
+ /**
447
+ * Upload a source snapshot for a project.
448
+ * Used to enable project forking.
449
+ */
450
+ export async function uploadSourceSnapshot(
451
+ projectId: string,
452
+ sourceZipPath: string,
453
+ ): Promise<SourceSnapshotResponse> {
454
+ const { authFetch } = await import("./auth/index.ts");
455
+
456
+ const formData = new FormData();
457
+ const sourceFile = Bun.file(sourceZipPath);
458
+ formData.append("source", sourceFile);
459
+
460
+ const response = await authFetch(`${getControlApiUrl()}/v1/projects/${projectId}/source`, {
461
+ method: "POST",
462
+ body: formData,
463
+ });
464
+
465
+ if (!response.ok) {
466
+ const error = (await response.json().catch(() => ({ message: "Upload failed" }))) as {
467
+ message?: string;
468
+ };
469
+ throw new Error(error.message || `Source upload failed: ${response.status}`);
470
+ }
471
+
472
+ return response.json() as Promise<SourceSnapshotResponse>;
473
+ }
474
+
475
+ /**
476
+ * Publish a project to make it forkable by others.
477
+ */
478
+ export async function publishProject(projectId: string): Promise<PublishProjectResponse> {
479
+ const { authFetch } = await import("./auth/index.ts");
480
+
481
+ const response = await authFetch(`${getControlApiUrl()}/v1/projects/${projectId}/publish`, {
482
+ method: "POST",
483
+ });
484
+
485
+ if (!response.ok) {
486
+ const err = (await response.json().catch(() => ({ message: "Unknown error" }))) as {
487
+ message?: string;
488
+ };
489
+ throw new Error(err.message || `Failed to publish project: ${response.status}`);
490
+ }
491
+
492
+ return response.json() as Promise<PublishProjectResponse>;
493
+ }
@@ -6,7 +6,8 @@
6
6
 
7
7
  import { validateBindings } from "./binding-validator.ts";
8
8
  import { buildProject, parseWranglerConfig } from "./build-helper.ts";
9
- import { createManagedProject, syncProjectTags } from "./control-plane.ts";
9
+ import { createManagedProject, syncProjectTags, uploadSourceSnapshot } from "./control-plane.ts";
10
+ import { debug } from "./debug.ts";
10
11
  import { uploadDeployment } from "./deploy-upload.ts";
11
12
  import { JackError, JackErrorCode } from "./errors.ts";
12
13
  import type { OperationReporter } from "./project-operations.ts";
@@ -48,7 +49,7 @@ export async function createManagedProjectRemote(
48
49
  usePrebuilt: options?.usePrebuilt ?? true,
49
50
  });
50
51
 
51
- const runjackUrl = `https://${result.project.slug}.runjack.xyz`;
52
+ const runjackUrl = result.url || `https://${result.project.slug}.runjack.xyz`;
52
53
 
53
54
  reporter?.stop();
54
55
  reporter?.success("Created managed project");
@@ -148,6 +149,13 @@ export async function deployCodeToManagedProject(
148
149
  })
149
150
  .catch(() => {});
150
151
 
152
+ // Upload source snapshot for forking (non-fatal, but must await before cleanup)
153
+ try {
154
+ await uploadSourceSnapshot(projectId, pkg.sourceZipPath);
155
+ } catch (err) {
156
+ debug("Source snapshot upload failed:", err instanceof Error ? err.message : String(err));
157
+ }
158
+
151
159
  return {
152
160
  deploymentId: result.id,
153
161
  status: result.status,
@@ -0,0 +1,412 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { join, relative } from "node:path";
4
+ import { Glob } from "bun";
5
+ import { DEFAULT_EXCLUDES } from "./storage/file-filter.ts";
6
+
7
+ export type ProjectType = "vite" | "sveltekit" | "hono" | "unknown";
8
+
9
+ export interface DetectionResult {
10
+ type: ProjectType;
11
+ configFile?: string;
12
+ entryPoint?: string;
13
+ error?: string;
14
+ unsupportedFramework?:
15
+ | "nextjs"
16
+ | "astro"
17
+ | "nuxt"
18
+ | "remix"
19
+ | "react-router"
20
+ | "tanstack-start"
21
+ | "tauri";
22
+ detectedDeps?: string[];
23
+ configFiles?: string[];
24
+ }
25
+
26
+ export interface ValidationResult {
27
+ valid: boolean;
28
+ error?: string;
29
+ fileCount?: number;
30
+ totalSizeKb?: number;
31
+ }
32
+
33
+ interface PackageJson {
34
+ name?: string;
35
+ dependencies?: Record<string, string>;
36
+ devDependencies?: Record<string, string>;
37
+ }
38
+
39
+ const CONFIG_EXTENSIONS = [".ts", ".js", ".mjs"];
40
+ const HONO_ENTRY_CANDIDATES = [
41
+ "src/index.ts",
42
+ "src/index.js",
43
+ "index.ts",
44
+ "index.js",
45
+ "src/server.ts",
46
+ "src/server.js",
47
+ ];
48
+
49
+ const MAX_FILES = 1000;
50
+ const MAX_SIZE_KB = 50 * 1024; // 50MB in KB
51
+
52
+ function hasDep(pkg: PackageJson, name: string): boolean {
53
+ return !!(pkg.dependencies?.[name] || pkg.devDependencies?.[name]);
54
+ }
55
+
56
+ function hasConfigFile(projectPath: string, baseName: string): string | null {
57
+ for (const ext of CONFIG_EXTENSIONS) {
58
+ const configPath = join(projectPath, `${baseName}${ext}`);
59
+ if (existsSync(configPath)) {
60
+ return `${baseName}${ext}`;
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function readPackageJson(projectPath: string): PackageJson | null {
67
+ const packageJsonPath = join(projectPath, "package.json");
68
+ if (!existsSync(packageJsonPath)) {
69
+ return null;
70
+ }
71
+ try {
72
+ return JSON.parse(readFileSync(packageJsonPath, "utf-8"));
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ // Error messages with helpful setup instructions for each framework
79
+ const FRAMEWORK_SETUP_MESSAGES: Record<string, string> = {
80
+ nextjs: `Next.js project detected!
81
+
82
+ Next.js support requires OpenNext. For now, set up manually:
83
+ 1. bun add @opennextjs/cloudflare
84
+ 2. Create open-next.config.ts
85
+ 3. Create wrangler.jsonc manually
86
+
87
+ Docs: https://opennext.js.org/cloudflare`,
88
+
89
+ astro: `Astro project detected!
90
+
91
+ Auto-deploy coming soon. For now, set up manually:
92
+ 1. bun add @astrojs/cloudflare
93
+ 2. Configure adapter in astro.config.mjs
94
+ 3. Create wrangler.jsonc with main: "dist/_worker.js"
95
+
96
+ Docs: https://docs.astro.build/en/guides/deploy/cloudflare/
97
+ Want this supported? Run: jack feedback`,
98
+
99
+ nuxt: `Nuxt project detected!
100
+
101
+ Auto-deploy coming soon. For now, set up manually:
102
+ 1. Set nitro.preset to 'cloudflare' in nuxt.config.ts
103
+ 2. Run: NITRO_PRESET=cloudflare bunx nuxt build
104
+ 3. Create wrangler.jsonc with main: ".output/server/index.mjs"
105
+
106
+ Docs: https://nuxt.com/deploy/cloudflare
107
+ Want this supported? Run: jack feedback`,
108
+
109
+ remix: `Remix project detected!
110
+
111
+ Remix has been superseded by React Router v7. Consider migrating:
112
+ https://reactrouter.com/upgrading/remix
113
+
114
+ Or set up Remix for Cloudflare manually:
115
+ 1. Use @remix-run/cloudflare adapter
116
+ 2. Create wrangler.jsonc manually`,
117
+
118
+ "react-router": `React Router v7 project detected!
119
+
120
+ Auto-deploy coming soon. For now, set up manually:
121
+ 1. bun add @react-router/cloudflare @cloudflare/vite-plugin
122
+ 2. Configure cloudflare() plugin in vite.config.ts
123
+ 3. Create wrangler.jsonc with main: "build/server/index.js"
124
+
125
+ Docs: https://reactrouter.com/deploying/cloudflare
126
+ Want this supported? Run: jack feedback`,
127
+
128
+ "tanstack-start": `TanStack Start project detected!
129
+
130
+ Auto-deploy coming soon. For now, set up manually:
131
+ 1. bun add -d @cloudflare/vite-plugin
132
+ 2. Configure cloudflare() plugin in vite.config.ts
133
+ 3. Set target: 'cloudflare-module' in tanstackStart() config
134
+ 4. Create wrangler.jsonc with main: ".output/server/index.mjs"
135
+
136
+ Docs: https://tanstack.com/start/latest/docs/framework/react/hosting#cloudflare
137
+ Want this supported? Run: jack feedback`,
138
+
139
+ tauri: `Tauri desktop app detected!
140
+
141
+ Tauri apps are native desktop applications and cannot be deployed to Cloudflare Workers.
142
+ jack is for web apps that run in the browser or on the edge.
143
+
144
+ If you have a web frontend in this project, consider deploying it separately.`,
145
+ };
146
+
147
+ type UnsupportedFramework =
148
+ | "nextjs"
149
+ | "astro"
150
+ | "nuxt"
151
+ | "remix"
152
+ | "react-router"
153
+ | "tanstack-start"
154
+ | "tauri";
155
+
156
+ function detectUnsupportedFramework(
157
+ projectPath: string,
158
+ pkg: PackageJson | null,
159
+ ): UnsupportedFramework | null {
160
+ // Next.js - check config file
161
+ if (hasConfigFile(projectPath, "next.config")) {
162
+ return "nextjs";
163
+ }
164
+
165
+ // Astro - check config file AND dependency (to avoid false positives)
166
+ if (hasConfigFile(projectPath, "astro.config") && pkg && hasDep(pkg, "astro")) {
167
+ return "astro";
168
+ }
169
+
170
+ // React Router v7 - check for @react-router/dev (BEFORE checking Nuxt to avoid conflicts)
171
+ if (pkg && hasDep(pkg, "@react-router/dev")) {
172
+ return "react-router";
173
+ }
174
+
175
+ // TanStack Start - check for @tanstack/react-start or @tanstack/start
176
+ if (pkg && (hasDep(pkg, "@tanstack/react-start") || hasDep(pkg, "@tanstack/start"))) {
177
+ return "tanstack-start";
178
+ }
179
+
180
+ // Nuxt - check config file AND dependency
181
+ if (hasConfigFile(projectPath, "nuxt.config") && pkg && hasDep(pkg, "nuxt")) {
182
+ return "nuxt";
183
+ }
184
+
185
+ // Legacy Remix (not React Router v7)
186
+ if (pkg && (hasDep(pkg, "@remix-run/node") || hasDep(pkg, "@remix-run/react"))) {
187
+ return "remix";
188
+ }
189
+
190
+ // Tauri - desktop app framework (check for src-tauri dir or tauri deps)
191
+ if (
192
+ existsSync(join(projectPath, "src-tauri")) ||
193
+ (pkg && (hasDep(pkg, "@tauri-apps/cli") || hasDep(pkg, "@tauri-apps/api")))
194
+ ) {
195
+ return "tauri";
196
+ }
197
+
198
+ return null;
199
+ }
200
+
201
+ function findHonoEntry(projectPath: string): string | null {
202
+ for (const candidate of HONO_ENTRY_CANDIDATES) {
203
+ if (existsSync(join(projectPath, candidate))) {
204
+ return candidate;
205
+ }
206
+ }
207
+ return null;
208
+ }
209
+
210
+ export function detectProjectType(projectPath: string): DetectionResult {
211
+ const pkg = readPackageJson(projectPath);
212
+ const detectedDeps: string[] = [];
213
+ const configFiles: string[] = [];
214
+
215
+ // Check for unsupported/coming-soon frameworks first (before reading package.json)
216
+ // This provides better error messages for frameworks we recognize but don't auto-deploy yet
217
+ const unsupported = detectUnsupportedFramework(projectPath, pkg);
218
+ if (unsupported) {
219
+ // Collect detected config files for reporting
220
+ const configFileMap: Record<string, string> = {
221
+ nextjs: "next.config",
222
+ astro: "astro.config",
223
+ nuxt: "nuxt.config",
224
+ };
225
+
226
+ if (configFileMap[unsupported]) {
227
+ const configFile = hasConfigFile(projectPath, configFileMap[unsupported]);
228
+ if (configFile) {
229
+ configFiles.push(configFile);
230
+ }
231
+ }
232
+
233
+ // Collect detected dependencies
234
+ if (pkg) {
235
+ if (unsupported === "astro" && hasDep(pkg, "astro")) detectedDeps.push("astro");
236
+ if (unsupported === "nuxt" && hasDep(pkg, "nuxt")) detectedDeps.push("nuxt");
237
+ if (unsupported === "react-router" && hasDep(pkg, "@react-router/dev"))
238
+ detectedDeps.push("@react-router/dev");
239
+ if (
240
+ unsupported === "tanstack-start" &&
241
+ (hasDep(pkg, "@tanstack/react-start") || hasDep(pkg, "@tanstack/start"))
242
+ ) {
243
+ detectedDeps.push(
244
+ hasDep(pkg, "@tanstack/react-start") ? "@tanstack/react-start" : "@tanstack/start",
245
+ );
246
+ }
247
+ if (unsupported === "remix") {
248
+ if (hasDep(pkg, "@remix-run/node")) detectedDeps.push("@remix-run/node");
249
+ if (hasDep(pkg, "@remix-run/react")) detectedDeps.push("@remix-run/react");
250
+ }
251
+ }
252
+
253
+ return {
254
+ type: "unknown",
255
+ unsupportedFramework: unsupported,
256
+ error: FRAMEWORK_SETUP_MESSAGES[unsupported],
257
+ detectedDeps,
258
+ configFiles,
259
+ };
260
+ }
261
+
262
+ if (!pkg) {
263
+ return {
264
+ type: "unknown",
265
+ error: "No package.json found",
266
+ };
267
+ }
268
+
269
+ // SvelteKit detection
270
+ const svelteConfig = hasConfigFile(projectPath, "svelte.config");
271
+ if (svelteConfig && hasDep(pkg, "@sveltejs/kit")) {
272
+ configFiles.push(svelteConfig);
273
+ detectedDeps.push("@sveltejs/kit");
274
+
275
+ if (!hasDep(pkg, "@sveltejs/adapter-cloudflare")) {
276
+ return {
277
+ type: "sveltekit",
278
+ configFile: svelteConfig,
279
+ error:
280
+ "Missing @sveltejs/adapter-cloudflare dependency. Install it: bun add -D @sveltejs/adapter-cloudflare",
281
+ detectedDeps,
282
+ configFiles,
283
+ };
284
+ }
285
+
286
+ detectedDeps.push("@sveltejs/adapter-cloudflare");
287
+ return {
288
+ type: "sveltekit",
289
+ configFile: svelteConfig,
290
+ detectedDeps,
291
+ configFiles,
292
+ };
293
+ }
294
+
295
+ // Vite detection
296
+ const viteConfig = hasConfigFile(projectPath, "vite.config");
297
+ if (viteConfig && hasDep(pkg, "vite")) {
298
+ configFiles.push(viteConfig);
299
+ detectedDeps.push("vite");
300
+ return {
301
+ type: "vite",
302
+ configFile: viteConfig,
303
+ detectedDeps,
304
+ configFiles,
305
+ };
306
+ }
307
+
308
+ // Hono detection
309
+ if (hasDep(pkg, "hono")) {
310
+ detectedDeps.push("hono");
311
+ const entryPoint = findHonoEntry(projectPath);
312
+ if (entryPoint) {
313
+ return {
314
+ type: "hono",
315
+ entryPoint,
316
+ detectedDeps,
317
+ configFiles,
318
+ };
319
+ }
320
+ return {
321
+ type: "hono",
322
+ error:
323
+ "Hono detected but no entry file found. Expected: src/index.ts, index.ts, src/server.ts, or similar.",
324
+ detectedDeps,
325
+ configFiles,
326
+ };
327
+ }
328
+
329
+ return {
330
+ type: "unknown",
331
+ error: "Could not detect project type. Supported: Vite, SvelteKit, Hono.",
332
+ detectedDeps,
333
+ configFiles,
334
+ };
335
+ }
336
+
337
+ function shouldExclude(relativePath: string): boolean {
338
+ for (const pattern of DEFAULT_EXCLUDES) {
339
+ const glob = new Glob(pattern);
340
+ if (glob.match(relativePath)) {
341
+ return true;
342
+ }
343
+ }
344
+ return false;
345
+ }
346
+
347
+ export async function validateProject(projectPath: string): Promise<ValidationResult> {
348
+ if (!existsSync(join(projectPath, "package.json"))) {
349
+ return {
350
+ valid: false,
351
+ error: "No package.json found in project directory",
352
+ };
353
+ }
354
+
355
+ let fileCount = 0;
356
+ let totalSizeBytes = 0;
357
+
358
+ try {
359
+ const entries = await readdir(projectPath, {
360
+ recursive: true,
361
+ withFileTypes: true,
362
+ });
363
+
364
+ for (const entry of entries) {
365
+ if (!entry.isFile()) {
366
+ continue;
367
+ }
368
+
369
+ const parentDir = entry.parentPath ?? projectPath;
370
+ const absolutePath = join(parentDir, entry.name);
371
+ const relativePath = relative(projectPath, absolutePath);
372
+
373
+ if (shouldExclude(relativePath)) {
374
+ continue;
375
+ }
376
+
377
+ fileCount++;
378
+ if (fileCount > MAX_FILES) {
379
+ return {
380
+ valid: false,
381
+ error: `Project has more than ${MAX_FILES} files (excluding node_modules, .git, etc.)`,
382
+ fileCount,
383
+ };
384
+ }
385
+
386
+ const stats = await stat(absolutePath);
387
+ totalSizeBytes += stats.size;
388
+ }
389
+
390
+ const totalSizeKb = Math.round(totalSizeBytes / 1024);
391
+
392
+ if (totalSizeKb > MAX_SIZE_KB) {
393
+ return {
394
+ valid: false,
395
+ error: `Project size exceeds ${MAX_SIZE_KB / 1024}MB limit (${Math.round(totalSizeKb / 1024)}MB)`,
396
+ fileCount,
397
+ totalSizeKb,
398
+ };
399
+ }
400
+
401
+ return {
402
+ valid: true,
403
+ fileCount,
404
+ totalSizeKb,
405
+ };
406
+ } catch (err) {
407
+ return {
408
+ valid: false,
409
+ error: `Failed to scan project: ${err instanceof Error ? err.message : String(err)}`,
410
+ };
411
+ }
412
+ }
@@ -336,15 +336,14 @@ describe("project-link", () => {
336
336
  expect(template).toEqual({ type: "builtin", name: "miniapp" });
337
337
  });
338
338
 
339
- it("creates template.json for github template", async () => {
339
+ it("creates template.json for published template", async () => {
340
340
  await writeTemplateMetadata(testDir, {
341
- type: "github",
342
- name: "user/repo",
343
- ref: "main",
341
+ type: "published",
342
+ name: "alice/my-api",
344
343
  });
345
344
 
346
345
  const template = await readTemplateMetadata(testDir);
347
- expect(template).toEqual({ type: "github", name: "user/repo", ref: "main" });
346
+ expect(template).toEqual({ type: "published", name: "alice/my-api" });
348
347
  });
349
348
 
350
349
  it("creates .jack directory if not exists", async () => {
@@ -29,15 +29,15 @@ export interface LocalProjectLink {
29
29
  deploy_mode: DeployMode;
30
30
  linked_at: string;
31
31
  tags?: string[];
32
+ owner_username?: string;
32
33
  }
33
34
 
34
35
  /**
35
36
  * Template metadata stored in .jack/template.json
36
37
  */
37
38
  export interface TemplateMetadata {
38
- type: "builtin" | "github";
39
- name: string; // "miniapp", "api", or "user/repo" for github
40
- ref?: string; // git ref for github templates
39
+ type: "builtin" | "user" | "published";
40
+ name: string; // "miniapp", "api", or "username/slug" for published
41
41
  }
42
42
 
43
43
  const JACK_DIR = ".jack";
@@ -94,6 +94,7 @@ export async function linkProject(
94
94
  projectDir: string,
95
95
  projectId: string,
96
96
  deployMode: DeployMode,
97
+ ownerUsername?: string,
97
98
  ): Promise<void> {
98
99
  const jackDir = getJackDir(projectDir);
99
100
  const linkPath = getProjectLinkPath(projectDir);
@@ -108,6 +109,7 @@ export async function linkProject(
108
109
  project_id: projectId,
109
110
  deploy_mode: deployMode,
110
111
  linked_at: new Date().toISOString(),
112
+ owner_username: ownerUsername,
111
113
  };
112
114
 
113
115
  await writeFile(linkPath, JSON.stringify(link, null, 2));
@@ -307,7 +307,11 @@ export function formatProjectLine(item: ProjectListItem, options: FormatLineOpti
307
307
  let url = "";
308
308
  if (showUrl && item.url) {
309
309
  url = item.url.replace("https://", "");
310
- } else if (showUrl && (item.status === "error" || item.status === "auth-expired") && item.errorMessage) {
310
+ } else if (
311
+ showUrl &&
312
+ (item.status === "error" || item.status === "auth-expired") &&
313
+ item.errorMessage
314
+ ) {
311
315
  url = `${colors.dim}${item.errorMessage}${colors.reset}`;
312
316
  }
313
317