@supatype/cli 0.1.2 → 0.1.4

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 (71) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +111 -108
  3. package/.turbo/turbo-typecheck.log +1 -1
  4. package/dist/commands/admin.d.ts +12 -0
  5. package/dist/commands/admin.d.ts.map +1 -1
  6. package/dist/commands/admin.js +97 -23
  7. package/dist/commands/admin.js.map +1 -1
  8. package/dist/commands/init.d.ts.map +1 -1
  9. package/dist/commands/init.js +287 -182
  10. package/dist/commands/init.js.map +1 -1
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +12 -1
  13. package/dist/config.js.map +1 -1
  14. package/dist/dev-compose.js +3 -3
  15. package/dist/dev-compose.js.map +1 -1
  16. package/dist/dev-log-bus.d.ts +1 -1
  17. package/dist/dev-log-bus.d.ts.map +1 -1
  18. package/dist/dev-log-bus.js +5 -3
  19. package/dist/dev-log-bus.js.map +1 -1
  20. package/dist/dev-ready-panel.d.ts +2 -0
  21. package/dist/dev-ready-panel.d.ts.map +1 -1
  22. package/dist/dev-ready-panel.js +18 -0
  23. package/dist/dev-ready-panel.js.map +1 -1
  24. package/dist/init-project-detect.d.ts +14 -0
  25. package/dist/init-project-detect.d.ts.map +1 -0
  26. package/dist/init-project-detect.js +89 -0
  27. package/dist/init-project-detect.js.map +1 -0
  28. package/dist/type-extractor.js +44 -0
  29. package/dist/type-extractor.js.map +1 -1
  30. package/dist/ui/dev/DevDashboard.d.ts.map +1 -1
  31. package/dist/ui/dev/DevDashboard.js +11 -4
  32. package/dist/ui/dev/DevDashboard.js.map +1 -1
  33. package/dist/ui/dev/DevReadyPanelView.d.ts +2 -1
  34. package/dist/ui/dev/DevReadyPanelView.d.ts.map +1 -1
  35. package/dist/ui/dev/DevReadyPanelView.js +21 -2
  36. package/dist/ui/dev/DevReadyPanelView.js.map +1 -1
  37. package/dist/ui/flows/FlowApp.d.ts.map +1 -1
  38. package/dist/ui/flows/FlowApp.js +3 -1
  39. package/dist/ui/flows/FlowApp.js.map +1 -1
  40. package/dist/ui/flows/prompt-fields.d.ts.map +1 -1
  41. package/dist/ui/flows/prompt-fields.js +9 -5
  42. package/dist/ui/flows/prompt-fields.js.map +1 -1
  43. package/dist/ui/runtime/run-clack-flow.d.ts.map +1 -1
  44. package/dist/ui/runtime/run-clack-flow.js +15 -3
  45. package/dist/ui/runtime/run-clack-flow.js.map +1 -1
  46. package/dist/ui/runtime/stdin-after-ink.d.ts +3 -0
  47. package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -0
  48. package/dist/ui/runtime/stdin-after-ink.js +16 -0
  49. package/dist/ui/runtime/stdin-after-ink.js.map +1 -0
  50. package/package.json +1 -1
  51. package/src/commands/admin.ts +135 -26
  52. package/src/commands/init.ts +360 -213
  53. package/src/config.ts +13 -1
  54. package/src/dev-compose.ts +4 -4
  55. package/src/dev-log-bus.ts +5 -3
  56. package/src/dev-ready-panel.ts +17 -0
  57. package/src/init-project-detect.ts +99 -0
  58. package/src/type-extractor.ts +57 -0
  59. package/src/ui/dev/DevDashboard.tsx +14 -4
  60. package/src/ui/dev/DevReadyPanelView.tsx +76 -9
  61. package/src/ui/flows/FlowApp.tsx +3 -1
  62. package/src/ui/flows/prompt-fields.tsx +13 -7
  63. package/src/ui/runtime/run-clack-flow.tsx +16 -3
  64. package/src/ui/runtime/stdin-after-ink.ts +18 -0
  65. package/tests/admin-ensure.test.ts +80 -0
  66. package/tests/config.test.ts +19 -0
  67. package/tests/dev-ready-panel.test.ts +18 -1
  68. package/tests/init-project-detect.test.ts +53 -0
  69. package/tests/stdin-after-ink.test.ts +8 -0
  70. package/tests/type-extractor.test.ts +101 -0
  71. package/tsconfig.tsbuildinfo +1 -1
package/src/config.ts CHANGED
@@ -158,7 +158,7 @@ process.stdout.write(JSON.stringify(config))
158
158
  }
159
159
 
160
160
  const failure = result.stderr || result.stdout
161
- if (!failure.includes("ERR_PACKAGE_PATH_NOT_EXPORTED")) {
161
+ if (!shouldStripCliImportOnLoadFailure(failure)) {
162
162
  throw new Error(`Failed to load ${candidate}:\n${failure}`)
163
163
  }
164
164
 
@@ -169,6 +169,18 @@ process.stdout.write(JSON.stringify(config))
169
169
  return null
170
170
  }
171
171
 
172
+ /** When @supatype/cli is not installed yet (e.g. during `supatype init`), strip its import. */
173
+ function shouldStripCliImportOnLoadFailure(failure: string): boolean {
174
+ if (failure.includes("ERR_PACKAGE_PATH_NOT_EXPORTED")) return true
175
+ if (!failure.includes("@supatype/cli")) return false
176
+ return (
177
+ failure.includes("ERR_MODULE_NOT_FOUND") ||
178
+ failure.includes("MODULE_NOT_FOUND") ||
179
+ failure.includes("Cannot find module '@supatype/cli'") ||
180
+ failure.includes('Cannot find package \'@supatype/cli\'')
181
+ )
182
+ }
183
+
172
184
  function loadTsConfigWithoutCliImport(
173
185
  configPath: string,
174
186
  cwd: string,
@@ -768,10 +768,6 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
768
768
  console.error("[supatype] Initial schema push failed:", (e as Error).message),
769
769
  )
770
770
 
771
- await ensureFirstAdminUserForProject(cwd, config, {
772
- compose: { project, composePath: paths.composePath },
773
- })
774
-
775
771
  if (localServerImage !== undefined) {
776
772
  console.log("[supatype] Recreating server with local image...")
777
773
  const recreateStatus = runDockerCompose(
@@ -792,6 +788,10 @@ export async function runDevCompose(cwd: string, config: SupatypeProjectConfig,
792
788
  console.log("[supatype] Waiting for storage API...")
793
789
  await waitStorageApiReady(kongPort, serviceRoleKey, 90)
794
790
 
791
+ await ensureFirstAdminUserForProject(cwd, config, {
792
+ compose: { project, composePath: paths.composePath },
793
+ })
794
+
795
795
  writeLocalEnvironment(cwd, {
796
796
  target: "local",
797
797
  apiUrl: `http://localhost:${kongPort}`,
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { DevReadyPanel } from "./dev-ready-panel.js"
6
- import { devReadyPanelRowCount } from "./dev-ready-panel.js"
6
+ import { devReadyPanelCompactRowCount, devReadyPanelRowCount } from "./dev-ready-panel.js"
7
7
 
8
8
  export type DevLogLevel = "log" | "warn" | "error"
9
9
 
@@ -79,9 +79,11 @@ export class DevLogBus {
79
79
  return this.readyPanel
80
80
  }
81
81
 
82
- readyPanelRowCount(): number {
82
+ readyPanelRowCount(compact = false): number {
83
83
  if (!this.readyPanel) return 0
84
- return devReadyPanelRowCount(this.readyPanel)
84
+ return compact
85
+ ? devReadyPanelCompactRowCount(this.readyPanel)
86
+ : devReadyPanelRowCount(this.readyPanel)
85
87
  }
86
88
 
87
89
  append(taskId: string, line: string, level: DevLogLevel = "log"): void {
@@ -34,6 +34,23 @@ export function devReadyPanelRowCount(panel: DevReadyPanel): number {
34
34
  return rows
35
35
  }
36
36
 
37
+ /** Shorter panel when the terminal is too small for every service row. */
38
+ export function devReadyPanelCompactRowCount(panel: DevReadyPanel): number {
39
+ let rows = 2 // border
40
+ rows += 1 // title
41
+ rows += 2 // gateway + studio summary
42
+ for (const hint of panel.hints ?? []) {
43
+ if (hint.trim()) rows += 1
44
+ }
45
+ if (panel.anonKey) {
46
+ rows += 1
47
+ rows += 1
48
+ if (panel.serviceRoleKey) rows += 1
49
+ }
50
+ rows += 1 // marginBottom
51
+ return rows
52
+ }
53
+
37
54
  function formatStreamBlock(panel: DevReadyPanel): string {
38
55
  const lines = [`[supatype] ${panel.title}`]
39
56
  for (const link of panel.links) {
@@ -0,0 +1,99 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs"
2
+ import { join } from "node:path"
3
+
4
+ const VITE_CONFIG_NAMES = [
5
+ "vite.config.ts",
6
+ "vite.config.js",
7
+ "vite.config.mjs",
8
+ "vite.config.cjs",
9
+ ] as const
10
+
11
+ const SUPATYPE_CONFIG_NAMES = [
12
+ "supatype.config.ts",
13
+ "supatype.config.js",
14
+ "supatype.config.mjs",
15
+ ] as const
16
+
17
+ export interface DetectedProjectSetup {
18
+ /** Directory has files other than `.git`. */
19
+ hasExistingFiles: boolean
20
+ hasSupatypeConfig: boolean
21
+ hasVite: boolean
22
+ hasViteConfig: boolean
23
+ viteDevUrl: string
24
+ staticDir: string
25
+ /** Human-readable bullets for the init wizard. */
26
+ summaryLines: string[]
27
+ }
28
+
29
+ function hasPackageVite(cwd: string): boolean {
30
+ const pkgPath = join(cwd, "package.json")
31
+ if (!existsSync(pkgPath)) return false
32
+ try {
33
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
34
+ dependencies?: Record<string, string>
35
+ devDependencies?: Record<string, string>
36
+ }
37
+ return Boolean(pkg.devDependencies?.["vite"] ?? pkg.dependencies?.["vite"])
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
43
+ function readVitePort(cwd: string): number {
44
+ for (const name of VITE_CONFIG_NAMES) {
45
+ const path = join(cwd, name)
46
+ if (!existsSync(path)) continue
47
+ const src = readFileSync(path, "utf8")
48
+ const portMatch = src.match(/port:\s*(\d+)/)
49
+ if (portMatch?.[1]) {
50
+ const port = Number.parseInt(portMatch[1], 10)
51
+ if (Number.isInteger(port) && port > 0) return port
52
+ }
53
+ }
54
+ return 5173
55
+ }
56
+
57
+ function detectStaticDir(cwd: string): string {
58
+ if (existsSync(join(cwd, "dist", "index.html"))) return "./dist"
59
+ if (existsSync(join(cwd, "public", "index.html"))) return "./public"
60
+ if (existsSync(join(cwd, "dist"))) return "./dist"
61
+ if (existsSync(join(cwd, "public"))) return "./public"
62
+ return "./public"
63
+ }
64
+
65
+ /** Inspect `cwd` before init prompts — Vite, static dir, existing Supatype config. */
66
+ export function detectProjectSetup(cwd: string): DetectedProjectSetup {
67
+ let entries: string[] = []
68
+ try {
69
+ entries = readdirSync(cwd).filter((entry) => entry !== ".git")
70
+ } catch {
71
+ entries = []
72
+ }
73
+
74
+ const hasExistingFiles = entries.length > 0
75
+ const hasViteConfig = VITE_CONFIG_NAMES.some((name) => existsSync(join(cwd, name)))
76
+ const hasVite = hasViteConfig || hasPackageVite(cwd)
77
+ const viteDevUrl = `http://127.0.0.1:${readVitePort(cwd)}`
78
+ const staticDir = detectStaticDir(cwd)
79
+ const hasSupatypeConfig = SUPATYPE_CONFIG_NAMES.some((name) => existsSync(join(cwd, name)))
80
+
81
+ const summaryLines: string[] = []
82
+ if (hasSupatypeConfig) summaryLines.push("supatype.config already present")
83
+ if (hasViteConfig) summaryLines.push(`Vite config found (${viteDevUrl})`)
84
+ else if (hasVite) summaryLines.push(`Vite dependency in package.json (${viteDevUrl})`)
85
+ if (existsSync(join(cwd, staticDir.replace(/^\.\//, "")))) {
86
+ summaryLines.push(`static assets in ${staticDir}`)
87
+ }
88
+ if (existsSync(join(cwd, "package.json"))) summaryLines.push("package.json present")
89
+
90
+ return {
91
+ hasExistingFiles,
92
+ hasSupatypeConfig,
93
+ hasVite,
94
+ hasViteConfig,
95
+ viteDevUrl,
96
+ staticDir,
97
+ summaryLines,
98
+ }
99
+ }
@@ -265,6 +265,58 @@ function getPropertyName(name: ts.PropertyName): string | null {
265
265
  return null
266
266
  }
267
267
 
268
+ /**
269
+ * Known @supatype/types intersection mixins and the field source text they contribute.
270
+ * Used when a mixin type can't be resolved from the local alias registry
271
+ * (it comes from the external @supatype/types package, not a local file).
272
+ */
273
+ const KNOWN_MIXIN_SOURCES: Record<string, string> = {
274
+ Timestamps: "{ created_at: ServerDefault<Date>; updated_at: ServerDefault<Date> }",
275
+ SoftDelete: "{ deleted_at: Optional<Date> }",
276
+ Publishable: "{ published_at: Optional<Date> }",
277
+ }
278
+
279
+ function synthesizeTypeLiteralMembers(source: string): ts.TypeElement[] {
280
+ const synth = ts.createSourceFile(
281
+ "__synth__.ts",
282
+ `type __T__ = ${source}`,
283
+ ts.ScriptTarget.Latest,
284
+ true,
285
+ ts.ScriptKind.TS,
286
+ )
287
+ const decl = synth.statements[0]
288
+ if (!decl || !ts.isTypeAliasDeclaration(decl) || !ts.isTypeLiteralNode(decl.type)) return []
289
+ return [...decl.type.members]
290
+ }
291
+
292
+ function mergeIntersectionParts(
293
+ parts: readonly ts.TypeNode[],
294
+ sourceFile: ts.SourceFile,
295
+ resolveCtx: ResolveContext,
296
+ depth: number,
297
+ ): ts.TypeLiteralNode | null {
298
+ const allMembers: ts.TypeElement[] = []
299
+ for (const part of parts) {
300
+ const resolved = unwrapModelFields(part, sourceFile, resolveCtx, depth + 1)
301
+ if (resolved) {
302
+ allMembers.push(...resolved.members)
303
+ continue
304
+ }
305
+ // Fall back to known @supatype/types intersection mixins (Timestamps, SoftDelete, Publishable)
306
+ if (ts.isTypeReferenceNode(part) && ts.isIdentifier(part.typeName)) {
307
+ const typeName = applyImportRename(part.typeName.text, sourceFile, resolveCtx.renameMap)
308
+ const mixinSource = KNOWN_MIXIN_SOURCES[typeName]
309
+ if (mixinSource) {
310
+ allMembers.push(...synthesizeTypeLiteralMembers(mixinSource))
311
+ continue
312
+ }
313
+ }
314
+ // Unresolvable parts are skipped — the model still extracts with whatever fields were found
315
+ }
316
+ if (allMembers.length === 0) return null
317
+ return ts.factory.createTypeLiteralNode(allMembers)
318
+ }
319
+
268
320
  function unwrapModelFields(
269
321
  typeNode: ts.TypeNode,
270
322
  sourceFile: ts.SourceFile,
@@ -274,6 +326,11 @@ function unwrapModelFields(
274
326
  if (depth > 16) return null
275
327
  if (ts.isTypeLiteralNode(typeNode)) return typeNode
276
328
 
329
+ // Handle intersection types: `{ …fields } & Timestamps`, `{ …fields } & SoftDelete`, etc.
330
+ if (ts.isIntersectionTypeNode(typeNode)) {
331
+ return mergeIntersectionParts(typeNode.types, sourceFile, resolveCtx, depth)
332
+ }
333
+
277
334
  if (needsChecker(typeNode)) {
278
335
  const resolved = resolveTypeNode(typeNode, sourceFile, resolveCtx)
279
336
  if (ts.isTypeLiteralNode(resolved)) return resolved
@@ -22,6 +22,9 @@ const TASK_COL_WIDTH = 22
22
22
  const MIN_WIDTH = 60
23
23
  const MIN_HEIGHT = 14
24
24
  const TAGLINE = "local development"
25
+ const CHROME_ABOVE_LOG = 2 // separator + keybindings
26
+ const CHROME_BELOW_LOG = 2 // footer separator + focused line
27
+ const MIN_LOG_ROWS = 3
25
28
 
26
29
  function truncate(text: string, width: number): string {
27
30
  if (width <= 0) return ""
@@ -97,10 +100,15 @@ export function DevDashboard({ bus }: DevDashboardProps): React.ReactElement {
97
100
  const focusedId = bus.getFocusedTaskId()
98
101
  const taskIds = bus.getTaskOrder()
99
102
  const readyPanel = bus.getReadyPanel()
100
- const readyRows = readyPanel ? bus.readyPanelRowCount() : 0
101
103
  const logoRows = logoRowCount() + 1 // figlet + tagline
102
- const chromeRows = logoRows + readyRows + 2 // separator + keybindings line
103
- const logHeight = Math.max(4, rows - chromeRows - (promptOpen ? 6 : 0) - 1)
104
+ const promptRows = promptOpen ? 6 : 0
105
+ const fullReadyRows = readyPanel ? bus.readyPanelRowCount(false) : 0
106
+ const availableForPanel =
107
+ rows - logoRows - CHROME_ABOVE_LOG - CHROME_BELOW_LOG - MIN_LOG_ROWS - promptRows
108
+ const useCompactPanel = readyPanel !== null && fullReadyRows > availableForPanel
109
+ const readyRows = readyPanel ? bus.readyPanelRowCount(useCompactPanel) : 0
110
+ const chromeRows = logoRows + readyRows + CHROME_ABOVE_LOG + CHROME_BELOW_LOG + promptRows
111
+ const logHeight = Math.max(0, rows - chromeRows)
104
112
  const logWidth = cols - TASK_COL_WIDTH - 1
105
113
 
106
114
  useInput((input, key) => {
@@ -148,7 +156,9 @@ export function DevDashboard({ bus }: DevDashboardProps): React.ReactElement {
148
156
  return (
149
157
  <Box flexDirection="column" width={cols} height={rows}>
150
158
  <LogoWordmark maxWidth={cols} tagline={TAGLINE} />
151
- {readyPanel ? <DevReadyPanelView panel={readyPanel} width={cols} /> : null}
159
+ {readyPanel ? (
160
+ <DevReadyPanelView panel={readyPanel} width={cols} compact={useCompactPanel} />
161
+ ) : null}
152
162
  <Text dimColor>{"─".repeat(cols)}</Text>
153
163
  <Text dimColor>
154
164
  {truncate(" ↑/k ↓/j task u/d scroll g/G top/bottom Ctrl+C quit", cols)}
@@ -21,12 +21,70 @@ function panelInnerWidth(outerWidth: number): number {
21
21
  return Math.max(20, outerWidth - 4)
22
22
  }
23
23
 
24
+ function ServiceLinkRow({
25
+ label,
26
+ url,
27
+ innerWidth,
28
+ labelWidth,
29
+ }: {
30
+ label: string
31
+ url: string
32
+ innerWidth: number
33
+ labelWidth: number
34
+ }): React.ReactElement {
35
+ return (
36
+ <Text wrap="truncate">
37
+ <Text dimColor>{truncate(label.padEnd(labelWidth), labelWidth)}</Text>
38
+ <Text color={theme.info}>{truncate(url, Math.max(8, innerWidth - labelWidth))}</Text>
39
+ </Text>
40
+ )
41
+ }
42
+
43
+ function CompactLinks({ panel, innerWidth }: { panel: DevReadyPanel; innerWidth: number }): React.ReactElement {
44
+ const gateway = panel.links.find((link) => link.label === "API")?.url ?? panel.links[0]?.url ?? ""
45
+ const studio =
46
+ panel.links.find((link) => link.label === "Studio")?.url ??
47
+ panel.links[panel.links.length - 1]?.url ??
48
+ ""
49
+ const otherPaths = panel.links
50
+ .filter((link) => link.label !== "API" && link.label !== "Studio")
51
+ .map((link) => {
52
+ const prefix = `${link.url.split("://")[0] ?? "http"}://`
53
+ const path = link.url.slice(link.url.indexOf("://") + 3)
54
+ const slash = path.indexOf("/")
55
+ return slash >= 0 ? path.slice(slash) : link.url
56
+ })
57
+ .join(", ")
58
+
59
+ return (
60
+ <>
61
+ <Text wrap="truncate">
62
+ <Text dimColor>{"Gateway".padEnd(14)}</Text>
63
+ <Text color={theme.info}>{truncate(gateway, innerWidth - 14)}</Text>
64
+ </Text>
65
+ {otherPaths ? (
66
+ <Text dimColor wrap="truncate">
67
+ {truncate(`Also: ${otherPaths}`, innerWidth)}
68
+ </Text>
69
+ ) : null}
70
+ {studio ? (
71
+ <Text wrap="truncate">
72
+ <Text dimColor>{"Studio".padEnd(14)}</Text>
73
+ <Text color={theme.info}>{truncate(studio, innerWidth - 14)}</Text>
74
+ </Text>
75
+ ) : null}
76
+ </>
77
+ )
78
+ }
79
+
24
80
  export function DevReadyPanelView({
25
81
  panel,
26
82
  width,
83
+ compact = false,
27
84
  }: {
28
85
  panel: DevReadyPanel
29
86
  width: number
87
+ compact?: boolean
30
88
  }): React.ReactElement {
31
89
  const innerWidth = panelInnerWidth(width)
32
90
  const labelWidth = 14
@@ -34,6 +92,8 @@ export function DevReadyPanelView({
34
92
  return (
35
93
  <Box
36
94
  flexDirection="column"
95
+ flexShrink={0}
96
+ width={width}
37
97
  marginBottom={1}
38
98
  borderStyle="round"
39
99
  borderColor={theme.brand}
@@ -42,23 +102,30 @@ export function DevReadyPanelView({
42
102
  <Text bold color={theme.brand}>
43
103
  {truncate(panel.title, innerWidth)}
44
104
  </Text>
45
- {panel.links.map((link) => (
46
- <Box key={`${link.label}-${link.url}`}>
47
- <Text dimColor>{truncate(link.label.padEnd(labelWidth), labelWidth)}</Text>
48
- <Text color={theme.info}>{truncate(link.url, innerWidth - labelWidth)}</Text>
49
- </Box>
50
- ))}
105
+ {compact ? (
106
+ <CompactLinks panel={panel} innerWidth={innerWidth} />
107
+ ) : (
108
+ panel.links.map((link) => (
109
+ <ServiceLinkRow
110
+ key={`${link.label}-${link.url}`}
111
+ label={link.label}
112
+ url={link.url}
113
+ innerWidth={innerWidth}
114
+ labelWidth={labelWidth}
115
+ />
116
+ ))
117
+ )}
51
118
  {panel.hints?.map((hint) => (
52
- <Text key={hint} dimColor>
119
+ <Text key={hint} dimColor wrap="truncate">
53
120
  {truncate(hint, innerWidth)}
54
121
  </Text>
55
122
  ))}
56
123
  {panel.anonKey ? (
57
124
  <>
58
125
  <Text dimColor>API keys (local dev)</Text>
59
- <Text dimColor>{`anon ${shortenKey(panel.anonKey, innerWidth - 5)}`}</Text>
126
+ <Text dimColor wrap="truncate">{`anon ${shortenKey(panel.anonKey, innerWidth - 5)}`}</Text>
60
127
  {panel.serviceRoleKey ? (
61
- <Text dimColor>{`svc ${shortenKey(panel.serviceRoleKey, innerWidth - 5)}`}</Text>
128
+ <Text dimColor wrap="truncate">{`svc ${shortenKey(panel.serviceRoleKey, innerWidth - 5)}`}</Text>
62
129
  ) : null}
63
130
  </>
64
131
  ) : null}
@@ -36,6 +36,8 @@ export function FlowApp({ bind }: FlowAppProps): React.ReactElement {
36
36
  },
37
37
  waitForPrompt<T>(spec: ActiveFlowPrompt): Promise<T> {
38
38
  return new Promise<T>((resolve) => {
39
+ setLogLines([])
40
+ setSpinner(null)
39
41
  setPrompt(spec)
40
42
  setPromptResolver(() => (value: unknown) => {
41
43
  setPrompt(null)
@@ -51,7 +53,7 @@ export function FlowApp({ bind }: FlowAppProps): React.ReactElement {
51
53
  return (
52
54
  <Box flexDirection="column" paddingX={1}>
53
55
  <FlowLogoHeader />
54
- <FlowLogPane lines={logLines} />
56
+ {prompt ? null : <FlowLogPane lines={logLines} />}
55
57
  {spinner ? <FlowSpinnerLine message={spinner} /> : null}
56
58
  {prompt && promptResolver ? (
57
59
  <PromptPanel>
@@ -46,10 +46,16 @@ export function ConfirmPrompt({ spec, onSubmit }: ConfirmPromptProps): React.Rea
46
46
  const [value, setValue] = useState(spec.initialValue)
47
47
 
48
48
  useInput((input, key) => {
49
+ if (input === "y" || input === "Y") {
50
+ onSubmit(true)
51
+ return
52
+ }
53
+ if (input === "n" || input === "N") {
54
+ onSubmit(false)
55
+ return
56
+ }
49
57
  if (key.leftArrow || input === "h") setValue(false)
50
58
  if (key.rightArrow || input === "l") setValue(true)
51
- if (input === "y" || input === "Y") setValue(true)
52
- if (input === "n" || input === "N") setValue(false)
53
59
  if (key.return) onSubmit(value)
54
60
  })
55
61
 
@@ -60,19 +66,19 @@ export function ConfirmPrompt({ spec, onSubmit }: ConfirmPromptProps): React.Rea
60
66
  </Text>
61
67
  <Text>
62
68
  {value ? (
63
- <Text color={theme.success} bold>
69
+ <Text color={theme.brand} bold>
64
70
  Yes
65
71
  </Text>
66
72
  ) : (
67
- <Text>Yes</Text>
73
+ <Text dimColor>Yes</Text>
68
74
  )}
69
- <Text> / </Text>
75
+ <Text dimColor> / </Text>
70
76
  {!value ? (
71
- <Text color={theme.success} bold>
77
+ <Text color={theme.brand} bold>
72
78
  No
73
79
  </Text>
74
80
  ) : (
75
- <Text>No</Text>
81
+ <Text dimColor>No</Text>
76
82
  )}
77
83
  <Text dimColor> — y/n, ←/→, enter</Text>
78
84
  </Text>
@@ -1,8 +1,9 @@
1
1
  import React, { useRef } from "react"
2
- import { render } from "ink"
2
+ import { render, type Instance } from "ink"
3
3
  import { isInteractive } from "../interactive.js"
4
4
  import { createClackApi, type ClackApi } from "../clack-api.js"
5
5
  import { FlowApp } from "../flows/FlowApp.js"
6
+ import { restoreStdinAfterInk } from "./stdin-after-ink.js"
6
7
 
7
8
  interface RunClackFlowRootProps {
8
9
  run: (api: ClackApi) => Promise<unknown>
@@ -37,16 +38,28 @@ export async function runClackFlow<T>(run: (api: ClackApi) => Promise<T>): Promi
37
38
  <RunClackFlowRoot
38
39
  run={run as (api: ClackApi) => Promise<unknown>}
39
40
  onComplete={(value) => {
40
- instance.unmount()
41
+ teardownInk(instance)
41
42
  resolve(value as T)
42
43
  }}
43
44
  onError={(err) => {
44
- instance.unmount()
45
+ teardownInk(instance)
45
46
  reject(err)
46
47
  }}
47
48
  />,
49
+ { patchConsole: false },
48
50
  )
49
51
  })
50
52
  }
51
53
 
54
+ function teardownInk(instance: Instance): void {
55
+ try {
56
+ instance.clear()
57
+ } catch {
58
+ // ignore — terminal may already be reset
59
+ }
60
+ instance.unmount()
61
+ instance.cleanup()
62
+ restoreStdinAfterInk()
63
+ }
64
+
52
65
  export { CLACK_CANCEL } from "./cancel.js"
@@ -0,0 +1,18 @@
1
+ /** Restore stdin after Ink unmount so one-shot commands (init, link) can exit cleanly. */
2
+ export function restoreStdinAfterInk(): void {
3
+ if (!process.stdin.isTTY) return
4
+
5
+ const stdin = process.stdin as NodeJS.ReadStream & {
6
+ isRaw?: boolean
7
+ setRawMode?(mode: boolean): void
8
+ }
9
+
10
+ try {
11
+ if (stdin.isRaw) stdin.setRawMode?.(false)
12
+ } catch {
13
+ // ignore — stdin may already be restored
14
+ }
15
+
16
+ stdin.resume()
17
+ process.stdout.write("\n")
18
+ }
@@ -3,7 +3,11 @@ import {
3
3
  ADMIN_EMAIL_ENV,
4
4
  ADMIN_PASSWORD_ENV,
5
5
  clearAdminSeedPassword,
6
+ composePostgresPassword,
7
+ GOTRUE_NIL_INSTANCE_ID,
8
+ gotrueJwtAud,
6
9
  hashPasswordForAuth,
10
+ resolveAuthConfirmedAtColumn,
7
11
  } from "../src/commands/admin.js"
8
12
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
9
13
  import { join } from "node:path"
@@ -38,6 +42,82 @@ describe("clearAdminSeedPassword", () => {
38
42
  })
39
43
  })
40
44
 
45
+ describe("composePostgresPassword", () => {
46
+ it("reads POSTGRES_PASSWORD from .env", () => {
47
+ const dir = join(tmpdir(), `supatype-admin-pgpass-${Date.now()}`)
48
+ mkdirSync(dir, { recursive: true })
49
+ writeFileSync(join(dir, ".env"), "POSTGRES_PASSWORD=secret\n", "utf8")
50
+ try {
51
+ expect(composePostgresPassword(dir)).toBe("secret")
52
+ } finally {
53
+ rmSync(dir, { recursive: true, force: true })
54
+ }
55
+ })
56
+
57
+ it("defaults to postgres when unset", () => {
58
+ const dir = join(tmpdir(), `supatype-admin-pgpass-default-${Date.now()}`)
59
+ mkdirSync(dir, { recursive: true })
60
+ try {
61
+ expect(composePostgresPassword(dir)).toBe("postgres")
62
+ } finally {
63
+ rmSync(dir, { recursive: true, force: true })
64
+ }
65
+ })
66
+ })
67
+
68
+ describe("gotrueJwtAud", () => {
69
+ it("reads GOTRUE_JWT_AUD from .env", () => {
70
+ const dir = join(tmpdir(), `supatype-admin-aud-${Date.now()}`)
71
+ mkdirSync(dir, { recursive: true })
72
+ writeFileSync(join(dir, ".env"), "GOTRUE_JWT_AUD=custom-aud\n", "utf8")
73
+ try {
74
+ expect(gotrueJwtAud(dir)).toBe("custom-aud")
75
+ } finally {
76
+ rmSync(dir, { recursive: true, force: true })
77
+ }
78
+ })
79
+
80
+ it("defaults to authenticated", () => {
81
+ const dir = join(tmpdir(), `supatype-admin-aud-default-${Date.now()}`)
82
+ mkdirSync(dir, { recursive: true })
83
+ try {
84
+ expect(gotrueJwtAud(dir)).toBe("authenticated")
85
+ } finally {
86
+ rmSync(dir, { recursive: true, force: true })
87
+ }
88
+ })
89
+ })
90
+
91
+ describe("GOTRUE_NIL_INSTANCE_ID", () => {
92
+ it("is the nil UUID GoTrue uses for lookup", () => {
93
+ expect(GOTRUE_NIL_INSTANCE_ID).toBe("00000000-0000-0000-0000-000000000000")
94
+ })
95
+ })
96
+
97
+ describe("resolveAuthConfirmedAtColumn", () => {
98
+ it("prefers email_confirmed_at when both columns exist", async () => {
99
+ const column = await resolveAuthConfirmedAtColumn(async () => ({
100
+ rows: [{ column_name: "email_confirmed_at" }],
101
+ rowCount: 1,
102
+ command: "SELECT",
103
+ oid: 0,
104
+ fields: [],
105
+ }))
106
+ expect(column).toBe("email_confirmed_at")
107
+ })
108
+
109
+ it("falls back to confirmed_at for postgres init schema", async () => {
110
+ const column = await resolveAuthConfirmedAtColumn(async () => ({
111
+ rows: [{ value: "confirmed_at" }],
112
+ rowCount: 1,
113
+ command: "SELECT",
114
+ oid: 0,
115
+ fields: [],
116
+ }))
117
+ expect(column).toBe("confirmed_at")
118
+ })
119
+ })
120
+
41
121
  describe("init admin seed in .env", () => {
42
122
  it("writes SUPATYPE_ADMIN_* when scaffold options include credentials", () => {
43
123
  const dir = join(tmpdir(), `supatype-init-admin-${Date.now()}`)
@@ -179,6 +179,25 @@ describe("loadConfig()", () => {
179
179
  expect(cfg.versions!.engine).toBe("0.4.2")
180
180
  expect(cfg.schema?.path).toBe("./a.ts")
181
181
  })
182
+
183
+ it("loads defineConfig import before @supatype/cli is installed (init scaffold)", () => {
184
+ writeFileSync(
185
+ join(tmpDir, "supatype.config.ts"),
186
+ `import { defineConfig } from "@supatype/cli"
187
+
188
+ export default defineConfig({
189
+ project: { name: "fresh" },
190
+ database: { provider: "docker" },
191
+ server: { mode: "dev" },
192
+ app: { mode: "none" },
193
+ schema: { path: "./schema/index.ts" },
194
+ })
195
+ `,
196
+ )
197
+ const cfg = loadConfig(tmpDir)
198
+ expect(cfg.project?.name).toBe("fresh")
199
+ expect(cfg.schema?.path).toBe("./schema/index.ts")
200
+ })
182
201
  })
183
202
 
184
203
  describe("resolveRuntimeProvider()", () => {