@supatype/cli 0.1.9 → 0.1.11

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 (62) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +139 -136
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/cli-install-method.d.ts +8 -0
  5. package/dist/cli-install-method.d.ts.map +1 -1
  6. package/dist/cli-install-method.js +10 -0
  7. package/dist/cli-install-method.js.map +1 -1
  8. package/dist/cli-version-embedded.d.ts.map +1 -1
  9. package/dist/cli-version-embedded.js +1 -1
  10. package/dist/cli-version-embedded.js.map +1 -1
  11. package/dist/commands/add.js +3 -2
  12. package/dist/commands/add.js.map +1 -1
  13. package/dist/commands/db.d.ts.map +1 -1
  14. package/dist/commands/db.js +3 -2
  15. package/dist/commands/db.js.map +1 -1
  16. package/dist/commands/functions.d.ts.map +1 -1
  17. package/dist/commands/functions.js +3 -2
  18. package/dist/commands/functions.js.map +1 -1
  19. package/dist/commands/generate.d.ts.map +1 -1
  20. package/dist/commands/generate.js +15 -18
  21. package/dist/commands/generate.js.map +1 -1
  22. package/dist/commands/init.d.ts.map +1 -1
  23. package/dist/commands/init.js +16 -5
  24. package/dist/commands/init.js.map +1 -1
  25. package/dist/commands/internal.d.ts.map +1 -1
  26. package/dist/commands/internal.js +37 -0
  27. package/dist/commands/internal.js.map +1 -1
  28. package/dist/commands/push.d.ts.map +1 -1
  29. package/dist/commands/push.js +15 -10
  30. package/dist/commands/push.js.map +1 -1
  31. package/dist/config.d.ts +22 -0
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/config.js +34 -15
  34. package/dist/config.js.map +1 -1
  35. package/dist/resolve-api-url.d.ts.map +1 -1
  36. package/dist/resolve-api-url.js +3 -2
  37. package/dist/resolve-api-url.js.map +1 -1
  38. package/dist/tsx-runner.d.ts +12 -0
  39. package/dist/tsx-runner.d.ts.map +1 -1
  40. package/dist/tsx-runner.js +35 -1
  41. package/dist/tsx-runner.js.map +1 -1
  42. package/dist/type-generation.d.ts +24 -0
  43. package/dist/type-generation.d.ts.map +1 -0
  44. package/dist/type-generation.js +44 -0
  45. package/dist/type-generation.js.map +1 -0
  46. package/package.json +1 -1
  47. package/src/cli-install-method.ts +11 -0
  48. package/src/cli-version-embedded.ts +1 -1
  49. package/src/commands/add.ts +3 -2
  50. package/src/commands/db.ts +3 -2
  51. package/src/commands/functions.ts +3 -2
  52. package/src/commands/generate.ts +13 -21
  53. package/src/commands/init.ts +16 -5
  54. package/src/commands/internal.ts +37 -0
  55. package/src/commands/push.ts +14 -8
  56. package/src/config.ts +36 -15
  57. package/src/resolve-api-url.ts +3 -2
  58. package/src/tsx-runner.ts +40 -1
  59. package/src/type-generation.ts +60 -0
  60. package/tests/app-command.test.ts +12 -2
  61. package/tests/config-load-failure.test.ts +44 -0
  62. package/tsconfig.tsbuildinfo +1 -1
package/src/config.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"
2
2
  import { resolve } from "node:path"
3
3
  import { tmpdir } from "node:os"
4
4
  import { join } from "node:path"
5
- import { evalTsSnippet } from "./tsx-runner.js"
5
+ import { importModuleAsJson } from "./tsx-runner.js"
6
6
  import { readEnvFile } from "./env-file.js"
7
7
  import {
8
8
  mergeProjectConfig,
@@ -157,6 +157,37 @@ function configLoadEnv(cwd: string): NodeJS.ProcessEnv {
157
157
  return { ...readEnvFile(cwd), ...process.env }
158
158
  }
159
159
 
160
+ /**
161
+ * A config file exists but could not be evaluated.
162
+ *
163
+ * Distinct from absence on purpose. Six call sites catch config loading so the CLI still works
164
+ * outside a project, and while they treated both cases the same, a config that failed to load
165
+ * looked exactly like no config at all. That is how a standalone binary that could not read any
166
+ * config shipped in v0.1.9 and still appeared to function: `status` printed a stack of stopped
167
+ * services, and `db check` fell back to DATABASE_URL from .env and reported a connection error.
168
+ * Those call sites should swallow absence and re-throw this.
169
+ */
170
+ export class ConfigLoadError extends Error {
171
+ readonly configPath: string
172
+
173
+ constructor(configPath: string, detail: string) {
174
+ super(`Failed to load ${configPath}:\n${detail}`)
175
+ this.name = "ConfigLoadError"
176
+ this.configPath = configPath
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Re-throw a config that exists but could not be read; swallow anything else.
182
+ *
183
+ * For the call sites that catch config loading so the CLI still works outside a project. They
184
+ * want to ignore absence, not breakage, and treating the two alike is what let a standalone
185
+ * binary that could read no config at all still look like it was working.
186
+ */
187
+ export function rethrowIfConfigBroken(err: unknown): void {
188
+ if (err instanceof ConfigLoadError) throw err
189
+ }
190
+
160
191
  function loadFirstTsConfig(
161
192
  cwd: string,
162
193
  candidates: string[],
@@ -166,24 +197,19 @@ function loadFirstTsConfig(
166
197
  if (!existsSync(configPath)) continue
167
198
 
168
199
  const urlPath = "file:///" + configPath.replace(/\\/g, "/")
169
- const snippet = `
170
- const mod = await import(${JSON.stringify(urlPath)})
171
- const config = mod.default ?? mod
172
- process.stdout.write(JSON.stringify(config))
173
- `
174
- const result = evalTsSnippet(snippet, { cwd, env: configLoadEnv(cwd) })
200
+ const result = importModuleAsJson(urlPath, { cwd, env: configLoadEnv(cwd) })
175
201
  if (result.exitCode === 0) {
176
202
  return JSON.parse(result.stdout) as Record<string, unknown>
177
203
  }
178
204
 
179
205
  const failure = result.stderr || result.stdout
180
206
  if (!shouldStripCliImportOnLoadFailure(failure)) {
181
- throw new Error(`Failed to load ${candidate}:\n${failure}`)
207
+ throw new ConfigLoadError(candidate, failure)
182
208
  }
183
209
 
184
210
  const fallback = loadTsConfigWithoutCliImport(configPath, cwd)
185
211
  if (fallback !== null) return fallback
186
- throw new Error(`Failed to load ${candidate}:\n${failure}`)
212
+ throw new ConfigLoadError(candidate, failure)
187
213
  }
188
214
  return null
189
215
  }
@@ -217,12 +243,7 @@ function loadTsConfigWithoutCliImport(
217
243
  writeFileSync(tmpPath, wrapper, "utf8")
218
244
  try {
219
245
  const urlPath = "file:///" + tmpPath.replace(/\\/g, "/")
220
- const snippet = `
221
- const mod = await import(${JSON.stringify(urlPath)})
222
- const config = mod.default ?? mod
223
- process.stdout.write(JSON.stringify(config))
224
- `
225
- const result = evalTsSnippet(snippet, { cwd, env: configLoadEnv(cwd) })
246
+ const result = importModuleAsJson(urlPath, { cwd, env: configLoadEnv(cwd) })
226
247
  if (result.exitCode !== 0) return null
227
248
  return JSON.parse(result.stdout) as Record<string, unknown>
228
249
  } finally {
@@ -2,7 +2,7 @@
2
2
  * Resolve the project API base URL (Kong gateway or direct server) for CLI HTTP calls.
3
3
  */
4
4
 
5
- import { loadConfig } from "./config.js"
5
+ import { loadConfig, rethrowIfConfigBroken } from "./config.js"
6
6
  import { readEnvValue } from "./env-file.js"
7
7
  import { serverBaseUrl } from "./project-config.js"
8
8
 
@@ -34,7 +34,8 @@ export function resolveProjectApiUrl(cwd: string): string {
34
34
  if (fromConfig) {
35
35
  return fromConfig.replace(/\/+$/, "")
36
36
  }
37
- } catch {
37
+ } catch (err) {
38
+ rethrowIfConfigBroken(err)
38
39
  // No supatype.config: fall through to PORT default.
39
40
  }
40
41
 
package/src/tsx-runner.ts CHANGED
@@ -10,6 +10,7 @@ import { createRequire } from "node:module"
10
10
  import { fileURLToPath } from "node:url"
11
11
  import { writeFileSync, unlinkSync } from "node:fs"
12
12
  import { tmpdir } from "node:os"
13
+ import { isCompiledBinary } from "./cli-install-method.js"
13
14
 
14
15
  const _require = createRequire(import.meta.url)
15
16
 
@@ -51,7 +52,9 @@ export function runTsFile(
51
52
  filePath: string,
52
53
  opts: SpawnSyncOptions = {},
53
54
  ): RunResult {
54
- const result = spawnSync(process.execPath, [TSX_BIN, filePath], {
55
+ // Same split as importModuleAsJson: the binary interprets TypeScript itself, Node uses tsx.
56
+ const argv = isCompiledBinary() ? ["_run-ts", filePath] : [TSX_BIN, filePath]
57
+ const result = spawnSync(process.execPath, argv, {
55
58
  encoding: "utf8",
56
59
  maxBuffer: 50 * 1024 * 1024,
57
60
  ...opts,
@@ -87,3 +90,39 @@ export function evalTsSnippet(
87
90
  }
88
91
  }
89
92
  }
93
+
94
+ /**
95
+ * Import a module and return its default export as JSON text.
96
+ *
97
+ * Two interpreters, one contract. Under Node this writes a snippet and runs it through tsx, as
98
+ * it always has. As a compiled binary it re-executes itself, because there is no node_modules to
99
+ * resolve tsx from, no writable directory beside the executable, and on a `curl | sh` machine no
100
+ * Node either: the previous behaviour failed with
101
+ * `ENOENT: open '/$bunfs/root/supatype-eval-....mts'` and broke init, update, dev and push.
102
+ *
103
+ * Callers get the same stdout, stderr and exit code either way.
104
+ */
105
+ export function importModuleAsJson(
106
+ target: string,
107
+ opts: SpawnSyncOptions = {},
108
+ ): RunResult {
109
+ if (isCompiledBinary()) {
110
+ const result = spawnSync(process.execPath, ["_print-module", target], {
111
+ encoding: "utf8",
112
+ maxBuffer: 50 * 1024 * 1024,
113
+ ...opts,
114
+ })
115
+ return {
116
+ stdout: String(result.stdout ?? ""),
117
+ stderr: String(result.stderr ?? ""),
118
+ exitCode: result.status ?? 1,
119
+ }
120
+ }
121
+
122
+ const snippet = `
123
+ const mod = await import(${JSON.stringify(target)})
124
+ const config = mod.default ?? mod
125
+ process.stdout.write(JSON.stringify(config))
126
+ `
127
+ return evalTsSnippet(snippet, opts)
128
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Write a project's generated types to disk.
3
+ *
4
+ * `push` used to send `types_path` and `client_path` to the engine and read only `message`,
5
+ * expecting the engine to write the files. It does not write them, so with `output.types`
6
+ * configured the generated TypeScript was printed to the terminal and nothing reached disk:
7
+ * `supatype push` claims to generate types and produced none. `supatype generate` had it right
8
+ * all along, and this is that logic, shared, so the two cannot disagree again.
9
+ *
10
+ * Each path is optional and an absent one is skipped, because the two callers differ on purpose:
11
+ * `generate` falls back to defaults and always writes, while `push` writes only what the project
12
+ * asked for and must not start creating files in projects that never configured any.
13
+ */
14
+
15
+ import { mkdirSync, writeFileSync } from "node:fs"
16
+ import { dirname, resolve } from "node:path"
17
+ import { generateClientAugmentation } from "./augmentation-generator.js"
18
+ import { ensureEngine, engineRequest } from "./engine-client.js"
19
+
20
+ export interface GenerateTypesRequest {
21
+ cwd: string
22
+ ast: unknown
23
+ /** Relative path for the database types, from `output.types`. */
24
+ typesPath?: string | undefined
25
+ /** Relative path for the client augmentation, from `output.client`. */
26
+ clientPath?: string | undefined
27
+ }
28
+
29
+ /** Writes what was asked for and returns one message per file, for the caller to report. */
30
+ export async function writeGeneratedTypes(req: GenerateTypesRequest): Promise<string[]> {
31
+ const written: string[] = []
32
+
33
+ if (req.typesPath !== undefined && req.typesPath !== "") {
34
+ await ensureEngine()
35
+ const result = await engineRequest<{ code?: string; message?: string }>(
36
+ "/generate",
37
+ { ast: req.ast, lang: "typescript" },
38
+ )
39
+ // `code` is the field the engine fills; `message` is the older shape. Reading only `message`
40
+ // and printing it is how the generated file ended up in the terminal.
41
+ const code = result.code ?? result.message
42
+ if (code === undefined) {
43
+ throw new Error("Engine returned no output for type generation.")
44
+ }
45
+ const outPath = resolve(req.cwd, req.typesPath)
46
+ mkdirSync(dirname(outPath), { recursive: true })
47
+ writeFileSync(outPath, code, "utf8")
48
+ written.push(`Types written to ${req.typesPath}`)
49
+ }
50
+
51
+ // Generated locally from the AST, so it needs no engine round trip.
52
+ if (req.clientPath !== undefined && req.clientPath !== "") {
53
+ const outPath = resolve(req.cwd, req.clientPath)
54
+ mkdirSync(dirname(outPath), { recursive: true })
55
+ writeFileSync(outPath, generateClientAugmentation(req.ast), "utf8")
56
+ written.push(`Client augmentation written to ${req.clientPath}`)
57
+ }
58
+
59
+ return written
60
+ }
@@ -12,10 +12,20 @@ const CLI_BIN = resolve(__dirname, "../bin/supatype.js")
12
12
 
13
13
  function runCli(cwd: string, args: string[]): { stdout: string; stderr: string; exitCode: number } {
14
14
  const result = spawnSync(process.execPath, [CLI_BIN, ...args], {
15
- encoding: "utf8",
16
15
  cwd,
17
- timeout: 10_000,
16
+ encoding: "utf8",
17
+ // 60s, not 10s: this spawns the built CLI, which imports Ink, React and commander before it
18
+ // does anything. That is about 1.2s idle, and CI runs `turbo run test` across every package at
19
+ // once, where it exceeded 10s and the subprocess was killed. The assertion then compared
20
+ // against empty output and pointed at the CLI rather than at contention.
21
+ timeout: 60_000,
18
22
  })
23
+ if (result.signal) {
24
+ throw new Error(
25
+ `CLI subprocess killed by ${result.signal} after the spawn timeout. `
26
+ + `Args: ${args.join(" ")}. This is usually machine contention, not a CLI fault.`,
27
+ )
28
+ }
19
29
  return {
20
30
  stdout: String(result.stdout ?? ""),
21
31
  stderr: String(result.stderr ?? ""),
@@ -0,0 +1,44 @@
1
+ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { tmpdir } from "node:os"
4
+ import { spawnSync } from "node:child_process"
5
+ import { describe, expect, it } from "vitest"
6
+
7
+ const CLI = join(import.meta.dirname, "../bin/supatype.js")
8
+
9
+ function runIn(dir: string, args: string[]): { out: string; code: number } {
10
+ const r = spawnSync(process.execPath, [CLI, ...args], {
11
+ cwd: dir,
12
+ encoding: "utf8",
13
+ timeout: 60_000,
14
+ })
15
+ if (r.signal) throw new Error(`CLI killed by ${r.signal}, usually machine contention`)
16
+ return { out: `${r.stdout ?? ""}${r.stderr ?? ""}`, code: r.status ?? 1 }
17
+ }
18
+
19
+ describe("a config that cannot be read", () => {
20
+ // A broken config used to be indistinguishable from no config: the call sites that catch
21
+ // loadConfig so the CLI works outside a project swallowed both. That is why a standalone
22
+ // binary which could read no config at all still looked like it was working, for a whole
23
+ // release.
24
+ it("is reported, rather than treated as absent", () => {
25
+ const dir = mkdtempSync(join(tmpdir(), "supatype-broken-config-"))
26
+ writeFileSync(
27
+ join(dir, "supatype.config.ts"),
28
+ 'throw new Error("deliberately broken")\n',
29
+ "utf8",
30
+ )
31
+ const { out, code } = runIn(dir, ["db", "check"])
32
+ expect(out).toContain("Failed to load supatype.config.ts")
33
+ expect(out).not.toContain("No connection. Pass --connection")
34
+ expect(code).not.toBe(0)
35
+ })
36
+
37
+ it("does not stop the CLI working with no config at all", () => {
38
+ const dir = mkdtempSync(join(tmpdir(), "supatype-no-config-"))
39
+ mkdirSync(join(dir, "empty"), { recursive: true })
40
+ const { out } = runIn(join(dir, "empty"), ["db", "check"])
41
+ expect(out).toContain("No connection. Pass --connection")
42
+ expect(out).not.toContain("Failed to load")
43
+ })
44
+ })