@agentuity/cli 1.0.32 → 1.0.33

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 (257) hide show
  1. package/dist/catalyst.d.ts +3 -1
  2. package/dist/catalyst.d.ts.map +1 -1
  3. package/dist/catalyst.js +5 -1
  4. package/dist/catalyst.js.map +1 -1
  5. package/dist/cmd/cloud/db/create.d.ts.map +1 -1
  6. package/dist/cmd/cloud/db/create.js +8 -10
  7. package/dist/cmd/cloud/db/create.js.map +1 -1
  8. package/dist/cmd/cloud/db/delete.d.ts.map +1 -1
  9. package/dist/cmd/cloud/db/delete.js +12 -14
  10. package/dist/cmd/cloud/db/delete.js.map +1 -1
  11. package/dist/cmd/cloud/db/get.d.ts.map +1 -1
  12. package/dist/cmd/cloud/db/get.js +7 -7
  13. package/dist/cmd/cloud/db/get.js.map +1 -1
  14. package/dist/cmd/cloud/db/list.d.ts.map +1 -1
  15. package/dist/cmd/cloud/db/list.js +5 -5
  16. package/dist/cmd/cloud/db/list.js.map +1 -1
  17. package/dist/cmd/cloud/db/logs.d.ts.map +1 -1
  18. package/dist/cmd/cloud/db/logs.js +7 -7
  19. package/dist/cmd/cloud/db/logs.js.map +1 -1
  20. package/dist/cmd/cloud/db/sql.js +5 -5
  21. package/dist/cmd/cloud/db/sql.js.map +1 -1
  22. package/dist/cmd/cloud/db/stats.d.ts.map +1 -1
  23. package/dist/cmd/cloud/db/stats.js +5 -5
  24. package/dist/cmd/cloud/db/stats.js.map +1 -1
  25. package/dist/cmd/cloud/deploy.d.ts.map +1 -1
  26. package/dist/cmd/cloud/deploy.js +1 -1
  27. package/dist/cmd/cloud/deploy.js.map +1 -1
  28. package/dist/cmd/cloud/email/stats.d.ts.map +1 -1
  29. package/dist/cmd/cloud/email/stats.js +5 -5
  30. package/dist/cmd/cloud/email/stats.js.map +1 -1
  31. package/dist/cmd/cloud/email/util.d.ts +2 -2
  32. package/dist/cmd/cloud/email/util.d.ts.map +1 -1
  33. package/dist/cmd/cloud/email/util.js +2 -2
  34. package/dist/cmd/cloud/email/util.js.map +1 -1
  35. package/dist/cmd/cloud/keyvalue/util.d.ts.map +1 -1
  36. package/dist/cmd/cloud/keyvalue/util.js +1 -1
  37. package/dist/cmd/cloud/keyvalue/util.js.map +1 -1
  38. package/dist/cmd/cloud/machine/delete.d.ts.map +1 -1
  39. package/dist/cmd/cloud/machine/delete.js +6 -6
  40. package/dist/cmd/cloud/machine/delete.js.map +1 -1
  41. package/dist/cmd/cloud/machine/deployments.d.ts.map +1 -1
  42. package/dist/cmd/cloud/machine/deployments.js +5 -5
  43. package/dist/cmd/cloud/machine/deployments.js.map +1 -1
  44. package/dist/cmd/cloud/machine/get.d.ts.map +1 -1
  45. package/dist/cmd/cloud/machine/get.js +5 -5
  46. package/dist/cmd/cloud/machine/get.js.map +1 -1
  47. package/dist/cmd/cloud/machine/list.d.ts.map +1 -1
  48. package/dist/cmd/cloud/machine/list.js +5 -5
  49. package/dist/cmd/cloud/machine/list.js.map +1 -1
  50. package/dist/cmd/cloud/queue/util.d.ts +1 -1
  51. package/dist/cmd/cloud/queue/util.d.ts.map +1 -1
  52. package/dist/cmd/cloud/queue/util.js +1 -1
  53. package/dist/cmd/cloud/queue/util.js.map +1 -1
  54. package/dist/cmd/cloud/redis/get.js +5 -5
  55. package/dist/cmd/cloud/redis/get.js.map +1 -1
  56. package/dist/cmd/cloud/sandbox/execution/get.d.ts.map +1 -1
  57. package/dist/cmd/cloud/sandbox/execution/get.js +4 -4
  58. package/dist/cmd/cloud/sandbox/execution/get.js.map +1 -1
  59. package/dist/cmd/cloud/sandbox/execution/list.d.ts.map +1 -1
  60. package/dist/cmd/cloud/sandbox/execution/list.js +5 -5
  61. package/dist/cmd/cloud/sandbox/execution/list.js.map +1 -1
  62. package/dist/cmd/cloud/sandbox/runtime/list.d.ts.map +1 -1
  63. package/dist/cmd/cloud/sandbox/runtime/list.js +4 -4
  64. package/dist/cmd/cloud/sandbox/runtime/list.js.map +1 -1
  65. package/dist/cmd/cloud/sandbox/snapshot/build.js +1 -1
  66. package/dist/cmd/cloud/sandbox/snapshot/build.js.map +1 -1
  67. package/dist/cmd/cloud/sandbox/snapshot/create.d.ts.map +1 -1
  68. package/dist/cmd/cloud/sandbox/snapshot/create.js +5 -5
  69. package/dist/cmd/cloud/sandbox/snapshot/create.js.map +1 -1
  70. package/dist/cmd/cloud/sandbox/snapshot/delete.d.ts.map +1 -1
  71. package/dist/cmd/cloud/sandbox/snapshot/delete.js +4 -4
  72. package/dist/cmd/cloud/sandbox/snapshot/delete.js.map +1 -1
  73. package/dist/cmd/cloud/sandbox/snapshot/get.d.ts.map +1 -1
  74. package/dist/cmd/cloud/sandbox/snapshot/get.js +4 -4
  75. package/dist/cmd/cloud/sandbox/snapshot/get.js.map +1 -1
  76. package/dist/cmd/cloud/sandbox/snapshot/list.d.ts.map +1 -1
  77. package/dist/cmd/cloud/sandbox/snapshot/list.js +4 -4
  78. package/dist/cmd/cloud/sandbox/snapshot/list.js.map +1 -1
  79. package/dist/cmd/cloud/sandbox/snapshot/tag.d.ts.map +1 -1
  80. package/dist/cmd/cloud/sandbox/snapshot/tag.js +4 -4
  81. package/dist/cmd/cloud/sandbox/snapshot/tag.js.map +1 -1
  82. package/dist/cmd/cloud/sandbox/stats.d.ts.map +1 -1
  83. package/dist/cmd/cloud/sandbox/stats.js +5 -5
  84. package/dist/cmd/cloud/sandbox/stats.js.map +1 -1
  85. package/dist/cmd/cloud/sandbox/util.d.ts +3 -3
  86. package/dist/cmd/cloud/sandbox/util.d.ts.map +1 -1
  87. package/dist/cmd/cloud/sandbox/util.js +5 -5
  88. package/dist/cmd/cloud/sandbox/util.js.map +1 -1
  89. package/dist/cmd/cloud/schedule/stats.d.ts.map +1 -1
  90. package/dist/cmd/cloud/schedule/stats.js +5 -5
  91. package/dist/cmd/cloud/schedule/stats.js.map +1 -1
  92. package/dist/cmd/cloud/schedule/util.d.ts +1 -1
  93. package/dist/cmd/cloud/schedule/util.d.ts.map +1 -1
  94. package/dist/cmd/cloud/schedule/util.js +1 -1
  95. package/dist/cmd/cloud/schedule/util.js.map +1 -1
  96. package/dist/cmd/cloud/services/stats.d.ts.map +1 -1
  97. package/dist/cmd/cloud/services/stats.js +5 -5
  98. package/dist/cmd/cloud/services/stats.js.map +1 -1
  99. package/dist/cmd/cloud/session/get.d.ts.map +1 -1
  100. package/dist/cmd/cloud/session/get.js +5 -5
  101. package/dist/cmd/cloud/session/get.js.map +1 -1
  102. package/dist/cmd/cloud/session/list.d.ts.map +1 -1
  103. package/dist/cmd/cloud/session/list.js +5 -5
  104. package/dist/cmd/cloud/session/list.js.map +1 -1
  105. package/dist/cmd/cloud/storage/config.d.ts.map +1 -1
  106. package/dist/cmd/cloud/storage/config.js +7 -7
  107. package/dist/cmd/cloud/storage/config.js.map +1 -1
  108. package/dist/cmd/cloud/storage/create.d.ts.map +1 -1
  109. package/dist/cmd/cloud/storage/create.js +8 -10
  110. package/dist/cmd/cloud/storage/create.js.map +1 -1
  111. package/dist/cmd/cloud/storage/delete.d.ts.map +1 -1
  112. package/dist/cmd/cloud/storage/delete.js +12 -14
  113. package/dist/cmd/cloud/storage/delete.js.map +1 -1
  114. package/dist/cmd/cloud/storage/download.d.ts.map +1 -1
  115. package/dist/cmd/cloud/storage/download.js +6 -6
  116. package/dist/cmd/cloud/storage/download.js.map +1 -1
  117. package/dist/cmd/cloud/storage/get.d.ts.map +1 -1
  118. package/dist/cmd/cloud/storage/get.js +6 -6
  119. package/dist/cmd/cloud/storage/get.js.map +1 -1
  120. package/dist/cmd/cloud/storage/list.d.ts.map +1 -1
  121. package/dist/cmd/cloud/storage/list.js +6 -6
  122. package/dist/cmd/cloud/storage/list.js.map +1 -1
  123. package/dist/cmd/cloud/storage/upload.d.ts.map +1 -1
  124. package/dist/cmd/cloud/storage/upload.js +7 -7
  125. package/dist/cmd/cloud/storage/upload.js.map +1 -1
  126. package/dist/cmd/cloud/stream/create.js +1 -1
  127. package/dist/cmd/cloud/stream/create.js.map +1 -1
  128. package/dist/cmd/cloud/stream/stats.d.ts.map +1 -1
  129. package/dist/cmd/cloud/stream/stats.js +5 -5
  130. package/dist/cmd/cloud/stream/stats.js.map +1 -1
  131. package/dist/cmd/cloud/task/stats.d.ts.map +1 -1
  132. package/dist/cmd/cloud/task/stats.js +5 -5
  133. package/dist/cmd/cloud/task/stats.js.map +1 -1
  134. package/dist/cmd/cloud/task/util.d.ts +1 -1
  135. package/dist/cmd/cloud/task/util.d.ts.map +1 -1
  136. package/dist/cmd/cloud/task/util.js +2 -2
  137. package/dist/cmd/cloud/task/util.js.map +1 -1
  138. package/dist/cmd/cloud/thread/delete.d.ts.map +1 -1
  139. package/dist/cmd/cloud/thread/delete.js +5 -5
  140. package/dist/cmd/cloud/thread/delete.js.map +1 -1
  141. package/dist/cmd/cloud/thread/get.d.ts.map +1 -1
  142. package/dist/cmd/cloud/thread/get.js +5 -5
  143. package/dist/cmd/cloud/thread/get.js.map +1 -1
  144. package/dist/cmd/cloud/thread/list.d.ts.map +1 -1
  145. package/dist/cmd/cloud/thread/list.js +5 -5
  146. package/dist/cmd/cloud/thread/list.js.map +1 -1
  147. package/dist/cmd/cloud/vector/util.d.ts.map +1 -1
  148. package/dist/cmd/cloud/vector/util.js +1 -1
  149. package/dist/cmd/cloud/vector/util.js.map +1 -1
  150. package/dist/cmd/cloud/webhook/util.d.ts +1 -1
  151. package/dist/cmd/cloud/webhook/util.d.ts.map +1 -1
  152. package/dist/cmd/cloud/webhook/util.js +1 -1
  153. package/dist/cmd/cloud/webhook/util.js.map +1 -1
  154. package/dist/cmd/git/api.d.ts +38 -0
  155. package/dist/cmd/git/api.d.ts.map +1 -1
  156. package/dist/cmd/git/api.js +55 -0
  157. package/dist/cmd/git/api.js.map +1 -1
  158. package/dist/cmd/project/add/database.js +8 -8
  159. package/dist/cmd/project/add/database.js.map +1 -1
  160. package/dist/cmd/project/add/storage.js +8 -8
  161. package/dist/cmd/project/add/storage.js.map +1 -1
  162. package/dist/cmd/project/auth/init.d.ts.map +1 -1
  163. package/dist/cmd/project/auth/init.js +7 -6
  164. package/dist/cmd/project/auth/init.js.map +1 -1
  165. package/dist/cmd/project/auth/shared.d.ts +2 -3
  166. package/dist/cmd/project/auth/shared.d.ts.map +1 -1
  167. package/dist/cmd/project/auth/shared.js +7 -7
  168. package/dist/cmd/project/auth/shared.js.map +1 -1
  169. package/dist/cmd/project/download.d.ts +12 -1
  170. package/dist/cmd/project/download.d.ts.map +1 -1
  171. package/dist/cmd/project/download.js +37 -14
  172. package/dist/cmd/project/download.js.map +1 -1
  173. package/dist/cmd/project/import.d.ts.map +1 -1
  174. package/dist/cmd/project/import.js +67 -9
  175. package/dist/cmd/project/import.js.map +1 -1
  176. package/dist/cmd/project/remote-import.d.ts +41 -0
  177. package/dist/cmd/project/remote-import.d.ts.map +1 -0
  178. package/dist/cmd/project/remote-import.js +1074 -0
  179. package/dist/cmd/project/remote-import.js.map +1 -0
  180. package/dist/cmd/project/template-flow.d.ts +1 -1
  181. package/dist/cmd/project/template-flow.d.ts.map +1 -1
  182. package/dist/cmd/project/template-flow.js +27 -11
  183. package/dist/cmd/project/template-flow.js.map +1 -1
  184. package/dist/config.d.ts +29 -5
  185. package/dist/config.d.ts.map +1 -1
  186. package/dist/config.js +13 -14
  187. package/dist/config.js.map +1 -1
  188. package/dist/schema-parser.d.ts.map +1 -1
  189. package/dist/schema-parser.js +47 -5
  190. package/dist/schema-parser.js.map +1 -1
  191. package/dist/types.d.ts +28 -3
  192. package/dist/types.d.ts.map +1 -1
  193. package/dist/types.js +42 -0
  194. package/dist/types.js.map +1 -1
  195. package/package.json +6 -6
  196. package/src/catalyst.ts +9 -1
  197. package/src/cmd/cloud/db/create.ts +8 -9
  198. package/src/cmd/cloud/db/delete.ts +18 -13
  199. package/src/cmd/cloud/db/get.ts +13 -7
  200. package/src/cmd/cloud/db/list.ts +11 -5
  201. package/src/cmd/cloud/db/logs.ts +13 -7
  202. package/src/cmd/cloud/db/sql.ts +5 -5
  203. package/src/cmd/cloud/db/stats.ts +11 -5
  204. package/src/cmd/cloud/deploy.ts +7 -1
  205. package/src/cmd/cloud/email/stats.ts +11 -5
  206. package/src/cmd/cloud/email/util.ts +4 -4
  207. package/src/cmd/cloud/keyvalue/util.ts +2 -2
  208. package/src/cmd/cloud/machine/delete.ts +12 -6
  209. package/src/cmd/cloud/machine/deployments.ts +11 -5
  210. package/src/cmd/cloud/machine/get.ts +11 -5
  211. package/src/cmd/cloud/machine/list.ts +11 -5
  212. package/src/cmd/cloud/queue/util.ts +2 -2
  213. package/src/cmd/cloud/redis/get.ts +5 -5
  214. package/src/cmd/cloud/sandbox/execution/get.ts +10 -4
  215. package/src/cmd/cloud/sandbox/execution/list.ts +6 -5
  216. package/src/cmd/cloud/sandbox/runtime/list.ts +10 -4
  217. package/src/cmd/cloud/sandbox/snapshot/build.ts +1 -1
  218. package/src/cmd/cloud/sandbox/snapshot/create.ts +12 -5
  219. package/src/cmd/cloud/sandbox/snapshot/delete.ts +10 -4
  220. package/src/cmd/cloud/sandbox/snapshot/get.ts +12 -6
  221. package/src/cmd/cloud/sandbox/snapshot/list.ts +10 -4
  222. package/src/cmd/cloud/sandbox/snapshot/tag.ts +10 -4
  223. package/src/cmd/cloud/sandbox/stats.ts +11 -5
  224. package/src/cmd/cloud/sandbox/util.ts +14 -7
  225. package/src/cmd/cloud/schedule/stats.ts +11 -5
  226. package/src/cmd/cloud/schedule/util.ts +3 -3
  227. package/src/cmd/cloud/services/stats.ts +13 -7
  228. package/src/cmd/cloud/session/get.ts +14 -8
  229. package/src/cmd/cloud/session/list.ts +11 -5
  230. package/src/cmd/cloud/storage/config.ts +24 -12
  231. package/src/cmd/cloud/storage/create.ts +8 -9
  232. package/src/cmd/cloud/storage/delete.ts +18 -13
  233. package/src/cmd/cloud/storage/download.ts +12 -6
  234. package/src/cmd/cloud/storage/get.ts +12 -6
  235. package/src/cmd/cloud/storage/list.ts +12 -6
  236. package/src/cmd/cloud/storage/upload.ts +13 -7
  237. package/src/cmd/cloud/stream/create.ts +1 -1
  238. package/src/cmd/cloud/stream/stats.ts +11 -5
  239. package/src/cmd/cloud/task/stats.ts +11 -5
  240. package/src/cmd/cloud/task/util.ts +4 -4
  241. package/src/cmd/cloud/thread/delete.ts +11 -5
  242. package/src/cmd/cloud/thread/get.ts +11 -5
  243. package/src/cmd/cloud/thread/list.ts +11 -5
  244. package/src/cmd/cloud/vector/util.ts +2 -2
  245. package/src/cmd/cloud/webhook/util.ts +2 -2
  246. package/src/cmd/git/api.ts +127 -0
  247. package/src/cmd/project/add/database.ts +9 -9
  248. package/src/cmd/project/add/storage.ts +9 -9
  249. package/src/cmd/project/auth/init.ts +11 -10
  250. package/src/cmd/project/auth/shared.ts +15 -10
  251. package/src/cmd/project/download.ts +52 -16
  252. package/src/cmd/project/import.ts +71 -9
  253. package/src/cmd/project/remote-import.ts +1347 -0
  254. package/src/cmd/project/template-flow.ts +38 -22
  255. package/src/config.ts +23 -18
  256. package/src/schema-parser.ts +48 -5
  257. package/src/types.ts +45 -0
@@ -0,0 +1,1347 @@
1
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { type Logger, parseEnvExample, StructuredError } from '@agentuity/core';
5
+ import {
6
+ createQueue,
7
+ createResources,
8
+ listOrganizations,
9
+ listQueues,
10
+ listResources,
11
+ projectCreate,
12
+ validateDatabaseName,
13
+ } from '@agentuity/server';
14
+ import type { APIClient } from '../../api';
15
+ import { isTTY } from '../../auth';
16
+ import {
17
+ createProjectConfig,
18
+ getCatalystAPIClient,
19
+ getGlobalCatalystAPIClient,
20
+ } from '../../config';
21
+ import { addResourceEnvVars } from '../../env-util';
22
+ import { getDefaultBranch, isGitAvailable } from '../../git-helper';
23
+ import { fetchRegionsWithCache } from '../../regions';
24
+ import * as tui from '../../tui';
25
+ import { createPrompt } from '../../tui';
26
+ import type { AuthData, Config } from '../../types';
27
+ import {
28
+ checkGithubRepo,
29
+ createGithubRepo,
30
+ getGithubBotIdentity,
31
+ getGithubToken,
32
+ linkProjectToRepo,
33
+ } from '../git/api';
34
+ import { initGitRepo } from './download';
35
+
36
+ // ─── Structured Errors ───
37
+
38
+ const RemoteImportInvalidURLError = StructuredError('RemoteImportInvalidURLError');
39
+ const RemoteImportUnsupportedHostError = StructuredError('RemoteImportUnsupportedHostError');
40
+ const RemoteImportDownloadError = StructuredError('RemoteImportDownloadError');
41
+ const RemoteImportExtractError = StructuredError('RemoteImportExtractError');
42
+ const RemoteImportNoOrganizationError = StructuredError(
43
+ 'RemoteImportNoOrganizationError',
44
+ 'No organizations found for your account'
45
+ );
46
+ const RemoteImportNoRegionError = StructuredError(
47
+ 'RemoteImportNoRegionError',
48
+ 'No cloud regions available'
49
+ );
50
+ const RemoteImportInvalidRepoError = StructuredError('RemoteImportInvalidRepoError');
51
+ const RemoteImportGitError = StructuredError('RemoteImportGitError');
52
+ const RemoteImportDeployError = StructuredError('RemoteImportDeployError');
53
+ const RemoteImportDirectoryNotFoundError = StructuredError('RemoteImportDirectoryNotFoundError');
54
+ const RemoteImportConfigError = StructuredError('RemoteImportConfigError');
55
+
56
+ export interface RemoteImportOptions {
57
+ url: string;
58
+ deploy: boolean;
59
+ projectId?: string;
60
+ repo?: string;
61
+ name?: string;
62
+ env?: string[];
63
+ org?: string;
64
+ region?: string;
65
+ apiClient: APIClient;
66
+ auth: AuthData;
67
+ config: Config;
68
+ logger: Logger;
69
+ }
70
+
71
+ interface ParsedGitHubUrl {
72
+ owner: string;
73
+ repo: string;
74
+ branch: string;
75
+ directory?: string;
76
+ }
77
+
78
+ /**
79
+ * Sanitize a string by removing any embedded GitHub tokens from URLs.
80
+ * Prevents token leakage in error messages and logs.
81
+ */
82
+ function sanitizeTokens(msg: string): string {
83
+ return msg.replace(/x-access-token:[^@]+@/g, 'x-access-token:***@');
84
+ }
85
+
86
+ /**
87
+ * Build GitHub API request headers, using the Agentuity-managed GitHub token
88
+ * when available, falling back to the GITHUB_TOKEN env var.
89
+ */
90
+ async function githubHeaders(apiClient: APIClient): Promise<Record<string, string>> {
91
+ const headers: Record<string, string> = {
92
+ Accept: 'application/vnd.github+json',
93
+ 'User-Agent': 'Agentuity-CLI',
94
+ };
95
+ try {
96
+ const { token } = await getGithubToken(apiClient);
97
+ headers.Authorization = `Bearer ${token}`;
98
+ } catch {
99
+ // Fallback to GITHUB_TOKEN env var
100
+ const githubToken = process.env.GITHUB_TOKEN;
101
+ if (githubToken) {
102
+ headers.Authorization = `Bearer ${githubToken}`;
103
+ }
104
+ }
105
+ return headers;
106
+ }
107
+
108
+ /**
109
+ * Fetch the default branch of a GitHub repository via the API.
110
+ * Falls back to 'main' on any error.
111
+ */
112
+ async function fetchDefaultBranch(
113
+ owner: string,
114
+ repo: string,
115
+ apiClient: APIClient
116
+ ): Promise<string> {
117
+ try {
118
+ const headers = await githubHeaders(apiClient);
119
+ const resp = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
120
+ headers,
121
+ });
122
+ if (!resp.ok) return 'main';
123
+ const data = (await resp.json()) as { default_branch?: string };
124
+ return data.default_branch ?? 'main';
125
+ } catch {
126
+ return 'main';
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Parse a GitHub URL into its components.
132
+ *
133
+ * Supported formats:
134
+ * https://github.com/owner/repo
135
+ * https://github.com/owner/repo/tree/branch
136
+ * https://github.com/owner/repo/tree/branch/path/to/dir
137
+ *
138
+ * When the URL does not include a branch (no `/tree/…` segment), the GitHub
139
+ * API is queried to discover the repository's default branch.
140
+ */
141
+ export async function parseGitHubUrl(url: string, apiClient: APIClient): Promise<ParsedGitHubUrl> {
142
+ let parsed: URL;
143
+ try {
144
+ parsed = new URL(url);
145
+ } catch {
146
+ throw new RemoteImportInvalidURLError({ message: `Invalid URL: ${url}` });
147
+ }
148
+
149
+ if (parsed.hostname !== 'github.com') {
150
+ throw new RemoteImportUnsupportedHostError({
151
+ message: `Only GitHub URLs are supported. Got: ${parsed.hostname}`,
152
+ });
153
+ }
154
+
155
+ // pathname is like /owner/repo or /owner/repo/tree/branch/path
156
+ const parts = parsed.pathname.replace(/^\//, '').replace(/\/$/, '').split('/');
157
+
158
+ if (parts.length < 2) {
159
+ throw new RemoteImportInvalidURLError({
160
+ message: `Invalid GitHub URL: expected at least owner/repo in path. Got: ${parsed.pathname}`,
161
+ });
162
+ }
163
+
164
+ const owner = parts[0]!;
165
+ // Strip .git suffix from repo name if present
166
+ const repo = parts[1]!.replace(/\.git$/, '');
167
+
168
+ let branch: string;
169
+ let directory: string | undefined;
170
+
171
+ // /owner/repo/tree/branch[/path/to/dir]
172
+ if (parts.length >= 4 && parts[2] === 'tree') {
173
+ branch = parts[3]!;
174
+ if (parts.length > 4) {
175
+ directory = parts.slice(4).join('/');
176
+ }
177
+ } else {
178
+ // No branch in URL — ask GitHub for the repo's default branch
179
+ branch = await fetchDefaultBranch(owner, repo, apiClient);
180
+ }
181
+
182
+ return { owner, repo, branch, directory };
183
+ }
184
+
185
+ /**
186
+ * Download and extract a GitHub repository zipball to a temp directory.
187
+ * Returns the path to the extracted content root.
188
+ */
189
+ async function downloadAndExtract(
190
+ parsed: ParsedGitHubUrl,
191
+ apiClient: APIClient,
192
+ logger: Logger
193
+ ): Promise<{ extractDir: string; tempDir: string }> {
194
+ const { owner, repo, branch } = parsed;
195
+ const zipUrl = `https://api.github.com/repos/${owner}/${repo}/zipball/${branch}`;
196
+
197
+ logger.debug('[remote-import] Downloading zipball from: %s', zipUrl);
198
+
199
+ const tempDir = mkdtempSync(join(tmpdir(), 'agentuity-remote-'));
200
+
201
+ try {
202
+ const zipPath = join(tempDir, 'download.zip');
203
+
204
+ // Download the zipball
205
+ const headers = await githubHeaders(apiClient);
206
+ await tui.spinner({
207
+ message: `Downloading ${owner}/${repo}...`,
208
+ clearOnSuccess: true,
209
+ callback: async () => {
210
+ const resp = await fetch(zipUrl, {
211
+ headers,
212
+ redirect: 'follow',
213
+ });
214
+
215
+ if (!resp.ok) {
216
+ throw new RemoteImportDownloadError({
217
+ message: `Failed to download from GitHub: ${resp.status} ${resp.statusText}`,
218
+ });
219
+ }
220
+
221
+ const buffer = Buffer.from(await resp.arrayBuffer());
222
+ await Bun.write(zipPath, buffer);
223
+ logger.debug('[remote-import] Downloaded %d bytes to %s', buffer.length, zipPath);
224
+
225
+ return resp;
226
+ },
227
+ });
228
+
229
+ // Extract the zip
230
+ const extractDir = join(tempDir, 'extracted');
231
+ mkdirSync(extractDir, { recursive: true });
232
+
233
+ await tui.spinner({
234
+ message: 'Extracting template...',
235
+ clearOnSuccess: true,
236
+ callback: async () => {
237
+ // Use Bun's built-in unzip via subprocess
238
+ const proc = Bun.spawnSync(['unzip', '-q', '-o', zipPath, '-d', extractDir], {
239
+ stdout: 'pipe',
240
+ stderr: 'pipe',
241
+ });
242
+
243
+ if (proc.exitCode !== 0) {
244
+ const stderr = proc.stderr.toString();
245
+ throw new RemoteImportExtractError({
246
+ message: `Failed to extract zip: ${stderr}`,
247
+ });
248
+ }
249
+
250
+ logger.debug('[remote-import] Extracted to %s', extractDir);
251
+ },
252
+ });
253
+
254
+ // GitHub zipball creates a top-level directory like "owner-repo-sha/"
255
+ // We need to find it and return its path
256
+ const entries = readdirSync(extractDir);
257
+ if (entries.length === 1 && entries[0]) {
258
+ const innerDir = join(extractDir, entries[0]);
259
+ return { extractDir: innerDir, tempDir };
260
+ }
261
+
262
+ return { extractDir, tempDir };
263
+ } catch (err) {
264
+ // Clean up temp directory on failure to prevent leaks
265
+ try {
266
+ rmSync(tempDir, { recursive: true, force: true });
267
+ } catch {
268
+ // Ignore cleanup errors
269
+ }
270
+ throw err;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Look for agentuity.yaml in the extracted content and parse it.
276
+ * Returns the parsed content or null if not found.
277
+ */
278
+ async function findAgentuityYaml(
279
+ dir: string,
280
+ logger: Logger
281
+ ): Promise<Record<string, unknown> | null> {
282
+ const yamlPath = join(dir, 'agentuity.yaml');
283
+ const file = Bun.file(yamlPath);
284
+
285
+ if (!(await file.exists())) {
286
+ logger.debug('[remote-import] No agentuity.yaml found at %s', yamlPath);
287
+ return null;
288
+ }
289
+
290
+ try {
291
+ const { YAML } = await import('bun');
292
+ const content = await file.text();
293
+ const parsed = YAML.parse(content) as Record<string, unknown>;
294
+ logger.debug('[remote-import] Parsed agentuity.yaml: %o', parsed);
295
+ return parsed;
296
+ } catch (err) {
297
+ logger.debug('[remote-import] Failed to parse agentuity.yaml: %o', err);
298
+ return null;
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Create a project via the API in non-interactive mode using the provided name.
304
+ */
305
+ async function createProjectNonInteractive(
306
+ apiClient: APIClient,
307
+ config: Config,
308
+ logger: Logger,
309
+ name: string,
310
+ region?: string,
311
+ orgOverride?: string
312
+ ): Promise<{ id: string; sdkKey: string; orgId: string; region: string }> {
313
+ // Fetch orgs — use the first one in non-interactive mode
314
+ const orgs = await listOrganizations(apiClient);
315
+ if (orgs.length === 0) {
316
+ throw new RemoteImportNoOrganizationError();
317
+ }
318
+
319
+ const firstOrg = orgs[0];
320
+ if (!firstOrg) {
321
+ throw new RemoteImportNoOrganizationError();
322
+ }
323
+
324
+ const orgId = orgOverride ?? config.preferences?.orgId ?? firstOrg.id;
325
+
326
+ // Determine region
327
+ let selectedRegion = region;
328
+ if (!selectedRegion) {
329
+ selectedRegion = process.env.AGENTUITY_REGION ?? config.preferences?.region;
330
+ }
331
+ if (!selectedRegion) {
332
+ const regions = await fetchRegionsWithCache(config.name, apiClient, logger);
333
+ const firstRegion = regions[0];
334
+ if (!firstRegion) {
335
+ throw new RemoteImportNoRegionError();
336
+ }
337
+ selectedRegion = firstRegion.region;
338
+ }
339
+
340
+ const newProject = await tui.spinner({
341
+ message: 'Creating project...',
342
+ clearOnSuccess: true,
343
+ callback: async () => {
344
+ return projectCreate(apiClient, {
345
+ name,
346
+ orgId,
347
+ cloudRegion: selectedRegion,
348
+ });
349
+ },
350
+ });
351
+
352
+ return {
353
+ id: newProject.id,
354
+ sdkKey: newProject.sdkKey,
355
+ orgId,
356
+ region: selectedRegion,
357
+ };
358
+ }
359
+
360
+ /**
361
+ * Create a project interactively — select org, region, name via TUI prompts.
362
+ */
363
+ async function createProjectInteractive(
364
+ apiClient: APIClient,
365
+ config: Config,
366
+ logger: Logger,
367
+ defaultName?: string
368
+ ): Promise<{ id: string; sdkKey: string; orgId: string; region: string }> {
369
+ // Fetch orgs
370
+ const orgs = await tui.spinner({
371
+ message: 'Fetching organizations...',
372
+ clearOnSuccess: true,
373
+ callback: () => listOrganizations(apiClient),
374
+ });
375
+
376
+ if (orgs.length === 0) {
377
+ throw new RemoteImportNoOrganizationError();
378
+ }
379
+
380
+ // Select org
381
+ const orgId = await tui.selectOrganization(orgs, config.preferences?.orgId);
382
+
383
+ // Fetch and select region
384
+ const regions = await tui.spinner({
385
+ message: 'Fetching regions...',
386
+ clearOnSuccess: true,
387
+ callback: () => fetchRegionsWithCache(config.name, apiClient, logger),
388
+ });
389
+
390
+ let selectedRegion: string;
391
+ if (regions.length === 1 && regions[0]) {
392
+ selectedRegion = regions[0].region;
393
+ } else {
394
+ const prompt = tui.createPrompt();
395
+ const options = regions.map((r) => ({
396
+ value: r.region,
397
+ label: `${r.description} (${r.region})`,
398
+ }));
399
+ const firstOption = options[0];
400
+ selectedRegion = await prompt.select({
401
+ message: 'Select a region:',
402
+ options,
403
+ initial: firstOption?.value ?? '',
404
+ });
405
+ }
406
+
407
+ // Get project name
408
+ const prompt = tui.createPrompt();
409
+ const projectName = await prompt.text({
410
+ message: 'Project name:',
411
+ initial: defaultName,
412
+ validate: (value: string) => {
413
+ if (!value || value.trim().length === 0) {
414
+ return 'Project name is required';
415
+ }
416
+ return true;
417
+ },
418
+ });
419
+
420
+ // Create the project
421
+ const newProject = await tui.spinner({
422
+ message: 'Registering project...',
423
+ clearOnSuccess: true,
424
+ callback: async () => {
425
+ return projectCreate(apiClient, {
426
+ name: projectName,
427
+ orgId,
428
+ cloudRegion: selectedRegion,
429
+ });
430
+ },
431
+ });
432
+
433
+ return {
434
+ id: newProject.id,
435
+ sdkKey: newProject.sdkKey,
436
+ orgId,
437
+ region: selectedRegion,
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Parse a repo URL or "owner/name" string into owner and name components.
443
+ * Supports:
444
+ * - https://github.com/owner/repo
445
+ * - https://github.com/owner/repo.git
446
+ * - owner/repo
447
+ */
448
+ function parseRepoTarget(repo: string): { owner: string; name: string } {
449
+ // Try as a URL first
450
+ try {
451
+ const parsed = new URL(repo);
452
+ if (parsed.hostname === 'github.com') {
453
+ const parts = parsed.pathname.replace(/^\//, '').replace(/\/$/, '').split('/');
454
+ if (parts.length >= 2 && parts[0] && parts[1]) {
455
+ return { owner: parts[0], name: parts[1].replace(/\.git$/, '') };
456
+ }
457
+ }
458
+ } catch {
459
+ // Not a URL — try "owner/name" format
460
+ }
461
+
462
+ const parts = repo.split('/');
463
+ if (parts.length === 2 && parts[0] && parts[1]) {
464
+ return { owner: parts[0], name: parts[1].replace(/\.git$/, '') };
465
+ }
466
+
467
+ throw new RemoteImportInvalidRepoError({
468
+ message: `Invalid repo target: "${repo}". Expected a GitHub URL (https://github.com/owner/repo) or owner/repo format.`,
469
+ });
470
+ }
471
+
472
+ /**
473
+ * Create a GitHub repo via the API. Preflight check already verified it doesn't exist.
474
+ * Returns the HTTPS URL of the repo.
475
+ */
476
+ async function createGithubRepoForImport(
477
+ apiClient: APIClient,
478
+ repo: string,
479
+ _logger: Logger
480
+ ): Promise<string> {
481
+ const { owner, name } = parseRepoTarget(repo);
482
+
483
+ const createResult = await tui.spinner({
484
+ message: `Creating repository ${owner}/${name}...`,
485
+ clearOnSuccess: true,
486
+ callback: () =>
487
+ createGithubRepo(apiClient, {
488
+ name,
489
+ owner,
490
+ private: true,
491
+ }),
492
+ });
493
+
494
+ tui.success(`Created repository ${createResult.fullName}`);
495
+ return createResult.url;
496
+ }
497
+
498
+ /**
499
+ * Push the working directory to a remote git repository.
500
+ */
501
+ async function pushToRepo(
502
+ dest: string,
503
+ repoUrl: string,
504
+ apiClient: APIClient,
505
+ logger: Logger
506
+ ): Promise<void> {
507
+ const gitAvailable = await isGitAvailable();
508
+ if (!gitAvailable) {
509
+ tui.warning('Git is not available — skipping git push.');
510
+ return;
511
+ }
512
+
513
+ const defaultBranch = (await getDefaultBranch()) || 'main';
514
+
515
+ // Get GitHub token from Agentuity API (uses stored OAuth token)
516
+ let remoteUrl = repoUrl;
517
+ try {
518
+ const { token } = await getGithubToken(apiClient);
519
+ const parsed = new URL(repoUrl);
520
+ if (parsed.hostname === 'github.com') {
521
+ remoteUrl = `https://x-access-token:${token}@github.com${parsed.pathname}`;
522
+ if (!remoteUrl.endsWith('.git')) {
523
+ remoteUrl += '.git';
524
+ }
525
+ }
526
+ } catch (err) {
527
+ logger.debug(
528
+ '[remote-import] Could not get GitHub token from API, trying without auth: %o',
529
+ err
530
+ );
531
+ // Fall through — push will likely fail for private repos but may work for public
532
+ }
533
+
534
+ await tui.spinner({
535
+ message: 'Pushing to remote repository...',
536
+ clearOnSuccess: true,
537
+ callback: async () => {
538
+ // Add remote origin
539
+ const addRemote = Bun.spawnSync(['git', 'remote', 'add', 'origin', remoteUrl], {
540
+ cwd: dest,
541
+ stdout: 'pipe',
542
+ stderr: 'pipe',
543
+ });
544
+
545
+ if (addRemote.exitCode !== 0) {
546
+ // Remote might already exist, try set-url instead
547
+ const setUrl = Bun.spawnSync(['git', 'remote', 'set-url', 'origin', remoteUrl], {
548
+ cwd: dest,
549
+ stdout: 'pipe',
550
+ stderr: 'pipe',
551
+ });
552
+ if (setUrl.exitCode !== 0) {
553
+ throw new RemoteImportGitError({
554
+ message: `Failed to set git remote: ${sanitizeTokens(setUrl.stderr.toString())}`,
555
+ });
556
+ }
557
+ }
558
+
559
+ // Push to remote
560
+ const push = Bun.spawnSync(['git', 'push', '-u', 'origin', defaultBranch], {
561
+ cwd: dest,
562
+ stdout: 'pipe',
563
+ stderr: 'pipe',
564
+ });
565
+
566
+ if (push.exitCode !== 0) {
567
+ throw new RemoteImportGitError({
568
+ message: `Failed to push to remote: ${sanitizeTokens(push.stderr.toString())}`,
569
+ });
570
+ }
571
+
572
+ logger.debug('[remote-import] Pushed to %s on branch %s', repoUrl, defaultBranch);
573
+ },
574
+ });
575
+
576
+ tui.success(`Pushed to ${repoUrl}`);
577
+ }
578
+
579
+ /**
580
+ * Run the deploy command as a subprocess.
581
+ *
582
+ * Uses `bunx agentuity deploy …` to match the fork-wrapper pattern used
583
+ * elsewhere in the CLI (see deploy-fork.ts). This avoids relying on
584
+ * `agentuity` being independently available on PATH.
585
+ */
586
+ async function runDeploy(dest: string, logger: Logger): Promise<void> {
587
+ tui.info('Deploying project...');
588
+
589
+ const args = ['bunx', 'agentuity', 'deploy', '--trigger', 'cli', '--event', 'manual'];
590
+
591
+ logger.debug('[remote-import] Running deploy: %s', args.join(' '));
592
+
593
+ const proc = Bun.spawn(args, {
594
+ cwd: dest,
595
+ stdout: 'inherit',
596
+ stderr: 'inherit',
597
+ env: {
598
+ ...process.env,
599
+ },
600
+ });
601
+
602
+ const exitCode = await proc.exited;
603
+ if (exitCode !== 0) {
604
+ throw new RemoteImportDeployError({
605
+ message: `Deploy failed with exit code ${exitCode}`,
606
+ });
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Run the remote import flow: download from GitHub, set up project, optionally push and deploy.
612
+ */
613
+ export async function runRemoteImport(options: RemoteImportOptions): Promise<void> {
614
+ const {
615
+ url,
616
+ deploy,
617
+ projectId,
618
+ repo,
619
+ name,
620
+ env,
621
+ org,
622
+ region: optRegion,
623
+ apiClient,
624
+ auth,
625
+ config,
626
+ logger,
627
+ } = options;
628
+
629
+ // Safety check: refuse to run inside an existing git repo
630
+ try {
631
+ const result = Bun.spawnSync(['git', 'rev-parse', '--is-inside-work-tree'], {
632
+ cwd: process.cwd(),
633
+ stdout: 'pipe',
634
+ stderr: 'pipe',
635
+ });
636
+ if (result.exitCode === 0 && result.stdout.toString().trim() === 'true') {
637
+ tui.fatal(
638
+ 'Cannot run remote import inside an existing git repository. Please run from an empty directory.'
639
+ );
640
+ }
641
+ } catch {
642
+ // git not found or command failed — not inside a repo, which is fine
643
+ }
644
+
645
+ // 1. Parse GitHub URL (async — may query GitHub API for default branch)
646
+ const parsed = await parseGitHubUrl(url, apiClient);
647
+ logger.debug(
648
+ '[remote-import] Parsed URL: owner=%s repo=%s branch=%s dir=%s',
649
+ parsed.owner,
650
+ parsed.repo,
651
+ parsed.branch,
652
+ parsed.directory ?? '(root)'
653
+ );
654
+
655
+ // ── Preflight checks (all before any mutations) ──
656
+
657
+ // Check: target directory doesn't already exist
658
+ const projectDirName = name ?? parsed.repo;
659
+ const dest = join(process.cwd(), projectDirName);
660
+ if (existsSync(dest)) {
661
+ tui.fatal(
662
+ `Directory "${projectDirName}" already exists. Choose a different name with --name.`
663
+ );
664
+ }
665
+
666
+ // Check: target GitHub repo doesn't already exist
667
+ if (repo) {
668
+ const { owner: repoOwner, name: repoName } = parseRepoTarget(repo);
669
+ const checkResult = await tui.spinner({
670
+ message: `Checking repository ${repoOwner}/${repoName}...`,
671
+ clearOnSuccess: true,
672
+ callback: () => checkGithubRepo(apiClient, { owner: repoOwner, name: repoName }),
673
+ });
674
+ if (checkResult.exists) {
675
+ tui.fatal(
676
+ `Repository ${repoOwner}/${repoName} already exists. Use a different name or delete the existing repo first.`
677
+ );
678
+ }
679
+ }
680
+
681
+ // ── All checks passed — start doing work ──
682
+
683
+ // 2. Download and extract template source
684
+ let tempDir: string | undefined;
685
+ let sourceDir: string;
686
+
687
+ try {
688
+ const result = await downloadAndExtract(parsed, apiClient, logger);
689
+ tempDir = result.tempDir;
690
+ sourceDir = result.extractDir;
691
+
692
+ // If a specific directory was specified in the URL, navigate into it
693
+ if (parsed.directory) {
694
+ const subDir = join(sourceDir, parsed.directory);
695
+ if (!existsSync(subDir)) {
696
+ throw new RemoteImportDirectoryNotFoundError({
697
+ message: `Directory "${parsed.directory}" not found in the repository.`,
698
+ });
699
+ }
700
+ sourceDir = subDir;
701
+ }
702
+
703
+ // 3. Find and parse agentuity.yaml (informational, for future use)
704
+ const yamlConfig = await findAgentuityYaml(sourceDir, logger);
705
+ if (yamlConfig) {
706
+ tui.info('Found agentuity.yaml in template.');
707
+ }
708
+
709
+ // 4. Project setup
710
+ let projectInfo: {
711
+ id: string;
712
+ sdkKey: string;
713
+ orgId: string;
714
+ region: string;
715
+ };
716
+
717
+ if (projectId) {
718
+ // --project-id was provided: skip creation, just write config
719
+ const sdkKey = process.env.AGENTUITY_SDK_KEY;
720
+ if (!sdkKey) {
721
+ throw new RemoteImportConfigError({
722
+ message:
723
+ 'AGENTUITY_SDK_KEY environment variable is required when using --project-id',
724
+ });
725
+ }
726
+ const orgId = org ?? config.preferences?.orgId;
727
+ if (!orgId) {
728
+ throw new RemoteImportConfigError({
729
+ message:
730
+ 'Organization ID not found. Use --org flag, set orgId in config preferences, or use interactive mode.',
731
+ });
732
+ }
733
+ const region = process.env.AGENTUITY_REGION ?? config.preferences?.region ?? 'usc';
734
+
735
+ projectInfo = { id: projectId, sdkKey, orgId, region };
736
+ tui.info(`Using pre-created project: ${projectId}`);
737
+ } else if (name) {
738
+ // --name provided: create non-interactively (headless-friendly)
739
+ projectInfo = await createProjectNonInteractive(
740
+ apiClient,
741
+ config,
742
+ logger,
743
+ name,
744
+ optRegion,
745
+ org
746
+ );
747
+ } else if (isTTY()) {
748
+ // Interactive mode: prompt for org/region/name
749
+ projectInfo = await createProjectInteractive(apiClient, config, logger, parsed.repo);
750
+ } else {
751
+ // Non-interactive without --name: use repo name
752
+ projectInfo = await createProjectNonInteractive(
753
+ apiClient,
754
+ config,
755
+ logger,
756
+ parsed.repo,
757
+ optRegion,
758
+ org
759
+ );
760
+ }
761
+
762
+ // Parse .env.example to detect required env vars and resources
763
+ // Platform-managed vars that should not appear in requirements
764
+ const platformManagedVars = new Set([
765
+ 'AGENTUITY_SDK_KEY',
766
+ 'AGENTUITY_URL',
767
+ 'AGENTUITY_TRANSPORT_URL',
768
+ 'AGENTUITY_BEARER_TOKEN',
769
+ 'NODE_ENV',
770
+ ]);
771
+
772
+ type TemplateConfig = {
773
+ source?: string;
774
+ requirements?: {
775
+ resources?: Array<{
776
+ type: 'database' | 'queue';
777
+ envVar: string;
778
+ description?: string;
779
+ defaultName?: string;
780
+ queueType?: 'worker' | 'pubsub';
781
+ }>;
782
+ env?: Array<{ key: string; required: boolean; description?: string }>;
783
+ };
784
+ };
785
+
786
+ let template: TemplateConfig | undefined;
787
+ const envExamplePath = join(sourceDir, '.env.example');
788
+ if (await Bun.file(envExamplePath).exists()) {
789
+ try {
790
+ const envContent = await Bun.file(envExamplePath).text();
791
+ const envFields = parseEnvExample(envContent).filter(
792
+ (f) => !platformManagedVars.has(f.key)
793
+ );
794
+
795
+ const resources: NonNullable<NonNullable<TemplateConfig['requirements']>['resources']> =
796
+ envFields
797
+ .filter((f) => f.resource)
798
+ .map((f) => ({
799
+ type: f.resource!,
800
+ envVar: f.key,
801
+ description: f.comment,
802
+ }));
803
+
804
+ const envVars = envFields
805
+ .filter((f) => !f.resource)
806
+ .map((f) => ({
807
+ key: f.key,
808
+ required: f.required ?? false,
809
+ description: f.comment,
810
+ }));
811
+
812
+ // Merge curated metadata from the source template's existing agentuity.json
813
+ const existingConfigPath = join(sourceDir, 'agentuity.json');
814
+ if (await Bun.file(existingConfigPath).exists()) {
815
+ try {
816
+ const existingConfig = JSON.parse(await Bun.file(existingConfigPath).text());
817
+ const existingResources = existingConfig?.template?.requirements?.resources;
818
+ if (Array.isArray(existingResources)) {
819
+ // Merge extra fields (like defaultName) from curated config
820
+ for (const resource of resources) {
821
+ const curated = existingResources.find(
822
+ (r: { envVar?: string }) => r.envVar === resource.envVar
823
+ );
824
+ if (curated?.defaultName) {
825
+ resource.defaultName = curated.defaultName;
826
+ }
827
+ if (curated?.queueType) {
828
+ resource.queueType = curated.queueType;
829
+ }
830
+ }
831
+ // Preserve curated resources NOT detected by parser
832
+ for (const curated of existingResources) {
833
+ if (!resources.some((r) => r.envVar === curated.envVar)) {
834
+ resources.push(curated);
835
+ }
836
+ }
837
+ }
838
+
839
+ // Merge curated env vars not detected by parser
840
+ const existingEnv = existingConfig?.template?.requirements?.env;
841
+ if (Array.isArray(existingEnv)) {
842
+ for (const curated of existingEnv) {
843
+ if (!envVars.some((e) => e.key === curated.key)) {
844
+ envVars.push(curated);
845
+ }
846
+ }
847
+ }
848
+ } catch {
849
+ // Ignore parse errors — source config may be malformed
850
+ }
851
+ }
852
+
853
+ if (resources.length > 0 || envVars.length > 0) {
854
+ template = {
855
+ source: `github.com/${parsed.owner}/${parsed.repo}`,
856
+ requirements: {
857
+ resources: resources.length > 0 ? resources : undefined,
858
+ env: envVars.length > 0 ? envVars : undefined,
859
+ },
860
+ };
861
+
862
+ for (const r of resources) {
863
+ tui.info(
864
+ `Requires ${r.type}: ${r.envVar}${r.description ? ` (${r.description})` : ''}`
865
+ );
866
+ }
867
+ const requiredEnv = envVars.filter((f) => f.required);
868
+ for (const f of requiredEnv) {
869
+ tui.info(
870
+ `Required env var: ${f.key}${f.description ? ` (${f.description})` : ''}`
871
+ );
872
+ }
873
+ }
874
+ } catch (err) {
875
+ logger.debug('[remote-import] Could not parse .env.example: %o', err);
876
+ }
877
+ }
878
+
879
+ // If no .env.example but we know the source, still track it
880
+ if (!template && parsed.owner && parsed.repo) {
881
+ template = { source: `github.com/${parsed.owner}/${parsed.repo}` };
882
+ }
883
+
884
+ // ─── Resource Provisioning ───
885
+
886
+ // Parse --env flags into a map
887
+ const envOverrides = new Map<string, string>();
888
+ for (const e of env ?? []) {
889
+ const colonIdx = e.indexOf(':');
890
+ if (colonIdx > 0) {
891
+ envOverrides.set(e.slice(0, colonIdx), e.slice(colonIdx + 1));
892
+ }
893
+ }
894
+
895
+ const resourceEnvVars: Record<string, string> = {};
896
+ const interactive = isTTY();
897
+ const templateResources = template?.requirements?.resources ?? [];
898
+ const templateEnvVars = template?.requirements?.env ?? [];
899
+
900
+ // Check if we can provision (need auth + org + region)
901
+ const orgId = projectInfo.orgId;
902
+ const region = projectInfo.region;
903
+ const canProvision = !!orgId && !!region;
904
+
905
+ if (canProvision && (templateResources.length > 0 || templateEnvVars.length > 0)) {
906
+ // ── Preflight validation: check all required items before creating anything ──
907
+ if (!interactive) {
908
+ const missing: string[] = [];
909
+
910
+ for (const r of templateResources) {
911
+ if (!envOverrides.has(r.envVar)) {
912
+ missing.push(`--env ${r.envVar}:<${r.type}-name>`);
913
+ }
914
+ }
915
+
916
+ for (const e of templateEnvVars) {
917
+ if (e.required && !envOverrides.has(e.key)) {
918
+ missing.push(`--env ${e.key}:<value>`);
919
+ }
920
+ }
921
+
922
+ if (missing.length > 0) {
923
+ for (const m of missing) {
924
+ tui.error(`Missing: ${m}`);
925
+ }
926
+ tui.fatal('Provide all required --env flags for non-interactive mode.');
927
+ }
928
+
929
+ // Validate database names upfront
930
+ for (const r of templateResources.filter((res) => res.type === 'database')) {
931
+ const name = envOverrides.get(r.envVar)!;
932
+ const validation = validateDatabaseName(name);
933
+ if (!validation.valid) {
934
+ tui.fatal(`Invalid database name "${name}" for ${r.envVar}: ${validation.error}`);
935
+ }
936
+ }
937
+ }
938
+
939
+ const catalystClient = getCatalystAPIClient(logger, auth, region, undefined, config);
940
+
941
+ // ── Database Resources ──
942
+ for (const r of templateResources.filter((resource) => resource.type === 'database')) {
943
+ const overrideName = envOverrides.get(r.envVar);
944
+
945
+ if (overrideName) {
946
+ // Non-interactive path: create DB with the given name
947
+ try {
948
+ const validation = validateDatabaseName(overrideName);
949
+ if (!validation.valid) {
950
+ throw new RemoteImportConfigError({
951
+ message: `Invalid database name "${overrideName}": ${validation.error}`,
952
+ });
953
+ }
954
+ const created = await tui.spinner({
955
+ message: `Creating database "${overrideName}"`,
956
+ clearOnSuccess: true,
957
+ callback: () =>
958
+ createResources(catalystClient, orgId, region, [
959
+ { type: 'db', name: overrideName, description: r.description },
960
+ ]),
961
+ });
962
+ if (created[0]?.env) {
963
+ // Map using the template-defined envVar name
964
+ const connStr =
965
+ created[0].env.DATABASE_URL ?? Object.values(created[0].env)[0];
966
+ if (connStr) resourceEnvVars[r.envVar] = connStr;
967
+ }
968
+ tui.success(`Created database: ${overrideName}`);
969
+ } catch (err: unknown) {
970
+ const msg = err instanceof Error ? err.message : String(err);
971
+ if (!interactive) {
972
+ throw new RemoteImportConfigError({
973
+ message: `Failed to create database "${overrideName}": ${msg}`,
974
+ });
975
+ }
976
+ tui.error(`Failed to create database "${overrideName}": ${msg}`);
977
+ // Fall through to interactive prompt below
978
+ }
979
+ }
980
+
981
+ // Interactive fallback (no --env or --env failed)
982
+ if (!resourceEnvVars[r.envVar] && interactive) {
983
+ const prompt = createPrompt();
984
+ let existingDbs: Awaited<ReturnType<typeof listResources>> | undefined;
985
+ try {
986
+ existingDbs = await tui.spinner({
987
+ message: 'Fetching existing databases',
988
+ clearOnSuccess: true,
989
+ callback: () => listResources(catalystClient, orgId, region),
990
+ });
991
+ } catch {
992
+ // Ignore — just won't show existing options
993
+ }
994
+
995
+ let dbCreated = false;
996
+ while (!dbCreated) {
997
+ const action = await prompt.select({
998
+ message: `${r.description || r.envVar} requires a database`,
999
+ options: [
1000
+ { value: 'skip', label: 'Skip — set up later' },
1001
+ { value: 'create', label: 'Create a new database' },
1002
+ ...(existingDbs?.db ?? []).map((db) => ({
1003
+ value: `existing:${db.name}`,
1004
+ label: `Use existing: ${tui.tuiColors.primary(db.name)}`,
1005
+ })),
1006
+ ],
1007
+ });
1008
+
1009
+ if (action === 'skip') break;
1010
+
1011
+ if (action === 'create') {
1012
+ const dbName = await prompt.text({
1013
+ message: 'Database name',
1014
+ hint: 'Lowercase letters, digits, underscores only',
1015
+ initial: r.defaultName,
1016
+ validate: (value: string) => {
1017
+ const trimmed = value.trim();
1018
+ if (trimmed === '') return 'Name is required';
1019
+ const result = validateDatabaseName(trimmed);
1020
+ return result.valid ? true : result.error!;
1021
+ },
1022
+ });
1023
+ try {
1024
+ const created = await tui.spinner({
1025
+ message: `Creating database "${dbName}"`,
1026
+ clearOnSuccess: true,
1027
+ callback: () =>
1028
+ createResources(catalystClient, orgId, region, [
1029
+ { type: 'db', name: dbName.trim(), description: r.description },
1030
+ ]),
1031
+ });
1032
+ if (created[0]?.env) {
1033
+ const connStr =
1034
+ created[0].env.DATABASE_URL ?? Object.values(created[0].env)[0];
1035
+ if (connStr) resourceEnvVars[r.envVar] = connStr;
1036
+ }
1037
+ tui.success(`Created database: ${dbName}`);
1038
+ dbCreated = true;
1039
+ } catch (err: unknown) {
1040
+ tui.error(
1041
+ `Failed to create database: ${err instanceof Error ? err.message : String(err)}`
1042
+ );
1043
+ // Loop back to prompt
1044
+ }
1045
+ } else if (action.startsWith('existing:')) {
1046
+ const selectedName = action.slice('existing:'.length);
1047
+ const selectedDb = existingDbs?.db.find((d) => d.name === selectedName);
1048
+ if (selectedDb?.env) {
1049
+ const connStr =
1050
+ selectedDb.env.DATABASE_URL ?? Object.values(selectedDb.env)[0];
1051
+ if (connStr) resourceEnvVars[r.envVar] = connStr;
1052
+ }
1053
+ dbCreated = true;
1054
+ }
1055
+ }
1056
+ }
1057
+
1058
+ if (!resourceEnvVars[r.envVar] && !interactive) {
1059
+ throw new RemoteImportConfigError({
1060
+ message: `Missing required database for ${r.envVar}. Pass --env ${r.envVar}:<db-name> to provision.`,
1061
+ });
1062
+ }
1063
+ }
1064
+
1065
+ // ── Queue Resources ──
1066
+ const queueClient = await getGlobalCatalystAPIClient(
1067
+ logger,
1068
+ auth,
1069
+ config?.name,
1070
+ undefined,
1071
+ config
1072
+ );
1073
+ const queueOrgOpts = orgId ? { orgId } : undefined;
1074
+
1075
+ for (const r of templateResources.filter((resource) => resource.type === 'queue')) {
1076
+ if (!r.queueType) {
1077
+ logger.debug(
1078
+ '[remote-import] Queue resource %s missing queueType, skipping',
1079
+ r.envVar
1080
+ );
1081
+ continue;
1082
+ }
1083
+
1084
+ const overrideName = envOverrides.get(r.envVar);
1085
+
1086
+ if (overrideName) {
1087
+ // Non-interactive path: create queue with given name
1088
+ try {
1089
+ const queue = await tui.spinner({
1090
+ message: `Creating ${r.queueType} queue "${overrideName}"`,
1091
+ clearOnSuccess: true,
1092
+ callback: () =>
1093
+ createQueue(
1094
+ queueClient,
1095
+ {
1096
+ name: overrideName,
1097
+ queue_type: r.queueType!,
1098
+ description: r.description,
1099
+ },
1100
+ queueOrgOpts
1101
+ ),
1102
+ });
1103
+ resourceEnvVars[r.envVar] = queue.name;
1104
+ tui.success(`Created queue: ${queue.name}`);
1105
+ } catch (err: unknown) {
1106
+ const msg = err instanceof Error ? err.message : String(err);
1107
+ if (!interactive) {
1108
+ throw new RemoteImportConfigError({
1109
+ message: `Failed to create queue "${overrideName}": ${msg}`,
1110
+ });
1111
+ }
1112
+ tui.error(`Failed to create queue "${overrideName}": ${msg}`);
1113
+ }
1114
+ }
1115
+
1116
+ // Interactive fallback
1117
+ if (!resourceEnvVars[r.envVar] && interactive) {
1118
+ const prompt = createPrompt();
1119
+ let existingQueues: Awaited<ReturnType<typeof listQueues>> | undefined;
1120
+ try {
1121
+ existingQueues = await tui.spinner({
1122
+ message: 'Fetching existing queues',
1123
+ clearOnSuccess: true,
1124
+ callback: () => listQueues(queueClient, {}, queueOrgOpts),
1125
+ });
1126
+ } catch {
1127
+ // Ignore
1128
+ }
1129
+
1130
+ let queueCreated = false;
1131
+ while (!queueCreated) {
1132
+ const action = await prompt.select({
1133
+ message: `${r.description || r.envVar} requires a ${r.queueType} queue`,
1134
+ options: [
1135
+ { value: 'skip', label: 'Skip — set up later' },
1136
+ { value: 'create', label: `Create a new ${r.queueType} queue` },
1137
+ ...(existingQueues?.queues ?? []).map((q) => ({
1138
+ value: `existing:${q.name}`,
1139
+ label: `Use existing: ${tui.tuiColors.primary(q.name)}`,
1140
+ })),
1141
+ ],
1142
+ });
1143
+
1144
+ if (action === 'skip') break;
1145
+
1146
+ if (action === 'create') {
1147
+ const queueName = await prompt.text({
1148
+ message: 'Queue name',
1149
+ hint: 'Optional — auto-generated if empty',
1150
+ initial: r.defaultName,
1151
+ });
1152
+ try {
1153
+ const queue = await tui.spinner({
1154
+ message: `Creating ${r.queueType} queue "${queueName || '(auto)'}"`,
1155
+ clearOnSuccess: true,
1156
+ callback: () =>
1157
+ createQueue(
1158
+ queueClient,
1159
+ {
1160
+ name: queueName.trim() || undefined,
1161
+ queue_type: r.queueType!,
1162
+ description: r.description,
1163
+ },
1164
+ queueOrgOpts
1165
+ ),
1166
+ });
1167
+ resourceEnvVars[r.envVar] = queue.name;
1168
+ tui.success(`Created queue: ${queue.name}`);
1169
+ queueCreated = true;
1170
+ } catch (err: unknown) {
1171
+ tui.error(
1172
+ `Failed to create queue: ${err instanceof Error ? err.message : String(err)}`
1173
+ );
1174
+ }
1175
+ } else if (action.startsWith('existing:')) {
1176
+ const selectedName = action.slice('existing:'.length);
1177
+ resourceEnvVars[r.envVar] = selectedName;
1178
+ queueCreated = true;
1179
+ }
1180
+ }
1181
+ }
1182
+
1183
+ if (!resourceEnvVars[r.envVar] && !interactive) {
1184
+ throw new RemoteImportConfigError({
1185
+ message: `Missing required queue for ${r.envVar}. Pass --env ${r.envVar}:<queue-name> to provision.`,
1186
+ });
1187
+ }
1188
+ }
1189
+
1190
+ // ── Plain Env Vars ──
1191
+ for (const e of templateEnvVars) {
1192
+ if (envOverrides.has(e.key)) {
1193
+ resourceEnvVars[e.key] = envOverrides.get(e.key)!;
1194
+ } else if (e.required) {
1195
+ if (interactive) {
1196
+ const prompt = createPrompt();
1197
+ const val = await prompt.text({
1198
+ message: `Enter value for ${e.key}${e.description ? ` (${e.description})` : ''}`,
1199
+ });
1200
+ if (val.trim()) {
1201
+ resourceEnvVars[e.key] = val.trim();
1202
+ }
1203
+ } else {
1204
+ throw new RemoteImportConfigError({
1205
+ message: `Missing required env var ${e.key}. Pass --env ${e.key}:<value> to set it.`,
1206
+ });
1207
+ }
1208
+ }
1209
+ }
1210
+
1211
+ // Write all collected env vars to .env
1212
+ if (Object.keys(resourceEnvVars).length > 0) {
1213
+ await addResourceEnvVars(sourceDir, resourceEnvVars);
1214
+ tui.success(
1215
+ `Configured ${Object.keys(resourceEnvVars).length} environment variable(s)`
1216
+ );
1217
+ }
1218
+ }
1219
+
1220
+ // Write agentuity.json and .env to sourceDir so git commit includes them
1221
+ await createProjectConfig(sourceDir, {
1222
+ projectId: projectInfo.id,
1223
+ orgId: projectInfo.orgId,
1224
+ sdkKey: projectInfo.sdkKey,
1225
+ region: projectInfo.region,
1226
+ template,
1227
+ });
1228
+ tui.success('Created agentuity.json');
1229
+
1230
+ // Ensure .env is gitignored before any git operations (prevents secret leak)
1231
+ const gitignorePath = join(sourceDir, '.gitignore');
1232
+ const gitignoreFile = Bun.file(gitignorePath);
1233
+ if (await gitignoreFile.exists()) {
1234
+ const gitignoreContent = await gitignoreFile.text();
1235
+ const lines = gitignoreContent.split('\n').map((l) => l.trim());
1236
+ if (!lines.includes('.env')) {
1237
+ await Bun.write(gitignorePath, `${gitignoreContent.trimEnd()}\n.env\n`);
1238
+ }
1239
+ } else {
1240
+ await Bun.write(gitignorePath, '.env\n.env.*\n');
1241
+ }
1242
+
1243
+ // Update package.json name to match the project name
1244
+ const pkgJsonPath = join(sourceDir, 'package.json');
1245
+ if (await Bun.file(pkgJsonPath).exists()) {
1246
+ try {
1247
+ const pkgRaw = await Bun.file(pkgJsonPath).text();
1248
+ const pkg = JSON.parse(pkgRaw);
1249
+ pkg.name = projectDirName;
1250
+ await Bun.write(pkgJsonPath, JSON.stringify(pkg, null, 2) + '\n');
1251
+ logger.debug('[remote-import] Updated package.json name to %s', projectDirName);
1252
+ } catch (err) {
1253
+ logger.debug('[remote-import] Could not update package.json name: %o', err);
1254
+ }
1255
+ }
1256
+
1257
+ // Fetch GitHub App bot identity for commit authorship
1258
+ let botAuthor: { name: string; email: string } | undefined;
1259
+ try {
1260
+ botAuthor = await getGithubBotIdentity(apiClient);
1261
+ } catch {
1262
+ logger.debug('[remote-import] Could not fetch bot identity, using fallback');
1263
+ }
1264
+
1265
+ // 5. Git init + push (if --repo flag provided) — in sourceDir, not CWD
1266
+ if (repo) {
1267
+ // Create the repo (we already verified it doesn't exist in preflight)
1268
+ const repoUrl = await createGithubRepoForImport(apiClient, repo, logger);
1269
+
1270
+ // Initialize git repo in sourceDir (handles init + first commit)
1271
+ await initGitRepo(sourceDir, {
1272
+ projectName: projectDirName,
1273
+ source: `github.com/${parsed.owner}/${parsed.repo}`,
1274
+ author: botAuthor,
1275
+ });
1276
+
1277
+ // Push to remote from sourceDir
1278
+ await pushToRepo(sourceDir, repoUrl, apiClient, logger);
1279
+ tui.success(`GitHub repo: ${repoUrl}`);
1280
+
1281
+ // Link the repo to the Agentuity project (auto-deploy + preview disabled until first deploy completes)
1282
+ try {
1283
+ const { owner: linkOwner, name: linkName } = parseRepoTarget(repo);
1284
+ const pushedBranch = (await getDefaultBranch()) || 'main';
1285
+ await linkProjectToRepo(apiClient, {
1286
+ projectId: projectInfo.id,
1287
+ repoFullName: `${linkOwner}/${linkName}`,
1288
+ branch: pushedBranch,
1289
+ autoDeploy: false,
1290
+ previewDeploy: false,
1291
+ directory: parsed.directory,
1292
+ });
1293
+ tui.success('Linked repo to project');
1294
+ } catch (err) {
1295
+ logger.debug('[remote-import] Failed to link repo to project: %o', err);
1296
+ tui.warning(
1297
+ 'Could not link repo to project — you can link manually with `agentuity link`'
1298
+ );
1299
+ }
1300
+ }
1301
+
1302
+ // 6. Copy extracted content into project folder (already validated in preflight)
1303
+ await tui.spinner({
1304
+ message: 'Copying project files...',
1305
+ clearOnSuccess: true,
1306
+ callback: async () => {
1307
+ mkdirSync(dest, { recursive: true });
1308
+ const entries = readdirSync(sourceDir);
1309
+ for (const entry of entries) {
1310
+ cpSync(join(sourceDir, entry), join(dest, entry), {
1311
+ recursive: true,
1312
+ });
1313
+ }
1314
+ },
1315
+ });
1316
+
1317
+ // Reset git remote to clean URL (pushToRepo may have embedded a token)
1318
+ if (repo) {
1319
+ const { owner, name: repoName } = parseRepoTarget(repo);
1320
+ const cleanUrl = `https://github.com/${owner}/${repoName}.git`;
1321
+ Bun.spawnSync(['git', 'remote', 'set-url', 'origin', cleanUrl], {
1322
+ cwd: dest,
1323
+ stdout: 'pipe',
1324
+ stderr: 'pipe',
1325
+ });
1326
+ }
1327
+
1328
+ tui.success(`Project created in ./${projectDirName}`);
1329
+
1330
+ // 7. Deploy (if --deploy flag)
1331
+ if (deploy) {
1332
+ await runDeploy(dest, logger);
1333
+ }
1334
+
1335
+ tui.success('Remote import completed successfully!');
1336
+ } finally {
1337
+ // Clean up temp directory
1338
+ if (tempDir) {
1339
+ try {
1340
+ rmSync(tempDir, { recursive: true, force: true });
1341
+ logger.debug('[remote-import] Cleaned up temp dir: %s', tempDir);
1342
+ } catch {
1343
+ // Ignore cleanup errors
1344
+ }
1345
+ }
1346
+ }
1347
+ }