@supatype/cli 0.1.9 → 0.1.10

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 (44) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +131 -128
  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/internal.d.ts.map +1 -1
  20. package/dist/commands/internal.js +37 -0
  21. package/dist/commands/internal.js.map +1 -1
  22. package/dist/config.d.ts +22 -0
  23. package/dist/config.d.ts.map +1 -1
  24. package/dist/config.js +34 -15
  25. package/dist/config.js.map +1 -1
  26. package/dist/resolve-api-url.d.ts.map +1 -1
  27. package/dist/resolve-api-url.js +3 -2
  28. package/dist/resolve-api-url.js.map +1 -1
  29. package/dist/tsx-runner.d.ts +12 -0
  30. package/dist/tsx-runner.d.ts.map +1 -1
  31. package/dist/tsx-runner.js +35 -1
  32. package/dist/tsx-runner.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/cli-install-method.ts +11 -0
  35. package/src/cli-version-embedded.ts +1 -1
  36. package/src/commands/add.ts +3 -2
  37. package/src/commands/db.ts +3 -2
  38. package/src/commands/functions.ts +3 -2
  39. package/src/commands/internal.ts +37 -0
  40. package/src/config.ts +36 -15
  41. package/src/resolve-api-url.ts +3 -2
  42. package/src/tsx-runner.ts +40 -1
  43. package/tests/config-load-failure.test.ts +44 -0
  44. package/tsconfig.tsbuildinfo +1 -1
@@ -3,6 +3,8 @@
3
3
  */
4
4
 
5
5
  import type { Command } from "commander"
6
+ import { resolve } from "node:path"
7
+ import { pathToFileURL } from "node:url"
6
8
  import { downloadAll } from "../binary-cache.js"
7
9
  import { error, info } from "../ui/messages.js"
8
10
 
@@ -20,4 +22,39 @@ export function registerInternalCommands(program: Command): void {
20
22
  process.exit(1)
21
23
  }
22
24
  })
25
+
26
+ // Interpreter for the standalone binary. It carries the Bun runtime, so it can import
27
+ // TypeScript directly; what it cannot do is spawn `node` with `tsx`, because a machine that
28
+ // installed with `curl | sh` has neither, and neither is embedded. So the binary re-executes
29
+ // itself for these two jobs rather than reaching for a toolchain that is not there.
30
+ //
31
+ // Hidden and prefixed, like _postinstall: they are an implementation detail of config loading,
32
+ // not commands anyone should run.
33
+ program
34
+ .command("_print-module <target>", { hidden: true })
35
+ .description("Import a module and print its default export as JSON")
36
+ .action(async (target: string) => {
37
+ try {
38
+ const href = target.startsWith("file:") ? target : pathToFileURL(resolve(target)).href
39
+ const mod = (await import(href)) as { default?: unknown }
40
+ const value = mod.default ?? mod
41
+ process.stdout.write(JSON.stringify(value))
42
+ } catch (err) {
43
+ // stderr, because the caller parses stdout as JSON and a message there would corrupt it.
44
+ process.stderr.write(err instanceof Error ? (err.stack ?? err.message) : String(err))
45
+ process.exit(1)
46
+ }
47
+ })
48
+
49
+ program
50
+ .command("_run-ts <file>", { hidden: true })
51
+ .description("Execute a TypeScript file for its side effects")
52
+ .action(async (file: string) => {
53
+ try {
54
+ await import(pathToFileURL(resolve(file)).href)
55
+ } catch (err) {
56
+ process.stderr.write(err instanceof Error ? (err.stack ?? err.message) : String(err))
57
+ process.exit(1)
58
+ }
59
+ })
23
60
  }
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,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
+ })