@supatype/cli 0.3.0 → 0.3.2
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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +161 -154
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/api-config-cache.d.ts +20 -0
- package/dist/api-config-cache.d.ts.map +1 -1
- package/dist/api-config-cache.js +27 -0
- package/dist/api-config-cache.js.map +1 -1
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +63 -18
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/client-generator.d.ts +3 -0
- package/dist/client-generator.d.ts.map +1 -0
- package/dist/client-generator.js +108 -0
- package/dist/client-generator.js.map +1 -0
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +21 -4
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +2 -2
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +7 -10
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/keys.d.ts +35 -0
- package/dist/commands/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +90 -6
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +23 -13
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +19 -1
- package/dist/commands/update.js.map +1 -1
- package/dist/db-port.d.ts +36 -0
- package/dist/db-port.d.ts.map +1 -0
- package/dist/db-port.js +90 -0
- package/dist/db-port.js.map +1 -0
- package/dist/dev-compose.d.ts +13 -0
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +205 -18
- package/dist/dev-compose.js.map +1 -1
- package/dist/functions-context-refresh.d.ts +4 -0
- package/dist/functions-context-refresh.d.ts.map +1 -0
- package/dist/functions-context-refresh.js +26 -0
- package/dist/functions-context-refresh.js.map +1 -0
- package/dist/functions-deno-types.d.ts +9 -0
- package/dist/functions-deno-types.d.ts.map +1 -1
- package/dist/functions-deno-types.js +21 -1
- package/dist/functions-deno-types.js.map +1 -1
- package/dist/gitignore.d.ts +3 -1
- package/dist/gitignore.d.ts.map +1 -1
- package/dist/gitignore.js +28 -6
- package/dist/gitignore.js.map +1 -1
- package/dist/model-cache.d.ts +27 -0
- package/dist/model-cache.d.ts.map +1 -1
- package/dist/model-cache.js +61 -0
- package/dist/model-cache.js.map +1 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +17 -8
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/self-host-compose.d.ts +10 -2
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +114 -9
- package/dist/self-host-compose.js.map +1 -1
- package/dist/strict.d.ts +29 -0
- package/dist/strict.d.ts.map +1 -0
- package/dist/strict.js +37 -0
- package/dist/strict.js.map +1 -0
- package/dist/studio-dev-server.d.ts +12 -2
- package/dist/studio-dev-server.d.ts.map +1 -1
- package/dist/studio-dev-server.js +12 -2
- package/dist/studio-dev-server.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +8 -2
- package/dist/type-extractor.js.map +1 -1
- package/dist/type-generation.d.ts +14 -0
- package/dist/type-generation.d.ts.map +1 -1
- package/dist/type-generation.js +41 -2
- package/dist/type-generation.js.map +1 -1
- package/package.json +2 -2
- package/src/api-config-cache.ts +30 -0
- package/src/augmentation-generator.ts +75 -18
- package/src/cli-version-embedded.ts +1 -1
- package/src/client-generator.ts +122 -0
- package/src/commands/admin.ts +23 -6
- package/src/commands/functions.ts +5 -2
- package/src/commands/init.ts +10 -10
- package/src/commands/keys.ts +112 -7
- package/src/commands/push.ts +25 -15
- package/src/commands/update.ts +20 -1
- package/src/db-port.ts +99 -0
- package/src/dev-compose.ts +244 -19
- package/src/functions-context-refresh.ts +25 -0
- package/src/functions-deno-types.ts +24 -1
- package/src/gitignore.ts +31 -10
- package/src/model-cache.ts +48 -0
- package/src/schema-ast-v2.ts +17 -8
- package/src/self-host-compose.ts +115 -9
- package/src/strict.ts +40 -0
- package/src/studio-dev-server.ts +12 -2
- package/src/type-extractor.ts +8 -2
- package/src/type-generation.ts +47 -2
- package/tests/ast-derived-outputs.test.ts +92 -0
- package/tests/augmentation-generator.test.ts +127 -1
- package/tests/client-generator.test.ts +103 -0
- package/tests/db-port.test.ts +128 -0
- package/tests/external-database-compose.test.ts +8 -1
- package/tests/functions-context-refresh.test.ts +86 -0
- package/tests/gitignore-secrets.test.ts +90 -0
- package/tests/keys-write.test.ts +148 -0
- package/tests/runtime-contract.test.ts +115 -2
- package/tests/strict.test.ts +78 -0
- package/tests/type-extractor.test.ts +34 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -65,12 +65,58 @@ export function generateClientAugmentation(ast: unknown): string {
|
|
|
65
65
|
return lines.join("\n")
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
function toSnakeCase(name: string): string {
|
|
69
|
+
return name.replace(/([A-Z])/g, "_$1").replace(/^_/, "").toLowerCase()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The column a declared field actually occupies, which is not always the name it was declared under.
|
|
74
|
+
*
|
|
75
|
+
* Only `belongsTo` puts a column on this table, and that column is the foreign key: `speaker_id`
|
|
76
|
+
* holding text, not `speaker` holding the target row. This used to group `relation` with the JSONB
|
|
77
|
+
* kinds, so the augmentation described a column that does not exist while the engine described the
|
|
78
|
+
* one that does, and the two generated files disagreed about the same field. `hasMany`, `hasOne` and
|
|
79
|
+
* `manyToMany` keep their key on the other table, so there is nothing here to describe.
|
|
80
|
+
*/
|
|
81
|
+
function resolveColumn(
|
|
82
|
+
name: string,
|
|
83
|
+
meta: Record<string, unknown>,
|
|
84
|
+
): { column: string; ts: string } | null {
|
|
85
|
+
if (meta["kind"] !== "relation") return { column: name, ts: toTsType(meta) }
|
|
86
|
+
if (meta["cardinality"] !== "belongsTo") return null
|
|
87
|
+
|
|
88
|
+
const annotations = meta["annotations"]
|
|
89
|
+
const declared =
|
|
90
|
+
typeof annotations === "object" && annotations !== null
|
|
91
|
+
? (annotations as { db?: { foreignKey?: unknown } }).db?.foreignKey
|
|
92
|
+
: undefined
|
|
93
|
+
const column =
|
|
94
|
+
typeof declared === "string" && declared.length > 0 ? declared : `${toSnakeCase(name)}_id`
|
|
95
|
+
|
|
96
|
+
// Nullable unless the relation asked for `NOT NULL`, which is the engine's rule. A fix that made
|
|
97
|
+
// every foreign key non-nullable would be wrong in the other direction just as often.
|
|
98
|
+
return { column, ts: meta["required"] === true ? "string" : "string | null" }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Declared fields as the columns they become, dropping the ones that are not columns here. */
|
|
102
|
+
function columnsOf(
|
|
103
|
+
fields: Record<string, Record<string, unknown>>,
|
|
104
|
+
): Array<{ column: string; ts: string; meta: Record<string, unknown> }> {
|
|
105
|
+
return Object.entries(fields)
|
|
106
|
+
.map(([name, meta]) => {
|
|
107
|
+
const resolved = resolveColumn(name, meta)
|
|
108
|
+
return resolved === null ? null : { ...resolved, meta }
|
|
109
|
+
})
|
|
110
|
+
.filter((entry): entry is { column: string; ts: string; meta: Record<string, unknown> } =>
|
|
111
|
+
entry !== null,
|
|
112
|
+
)
|
|
113
|
+
.sort((a, b) => a.column.localeCompare(b.column))
|
|
114
|
+
}
|
|
115
|
+
|
|
68
116
|
export function generateRowType(fields: Record<string, Record<string, unknown>>): string {
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
const body =
|
|
72
|
-
.map(([name, meta]) => ` ${quoteKey(name)}: ${toTsType(meta)}`)
|
|
73
|
-
.join("\n")
|
|
117
|
+
const columns = columnsOf(fields)
|
|
118
|
+
if (columns.length === 0) return "Record<string, unknown>"
|
|
119
|
+
const body = columns.map(({ column, ts }) => ` ${quoteKey(column)}: ${ts}`).join("\n")
|
|
74
120
|
return `{\n${body}\n}`
|
|
75
121
|
}
|
|
76
122
|
|
|
@@ -79,23 +125,21 @@ function insertColumnOptionalOnInsert(meta: Record<string, unknown>): boolean {
|
|
|
79
125
|
}
|
|
80
126
|
|
|
81
127
|
export function generateInsertType(fields: Record<string, Record<string, unknown>>): string {
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
84
|
-
const body =
|
|
85
|
-
.map((
|
|
128
|
+
const columns = columnsOf(fields)
|
|
129
|
+
if (columns.length === 0) return "Record<string, unknown>"
|
|
130
|
+
const body = columns
|
|
131
|
+
.map(({ column, ts, meta }) => {
|
|
86
132
|
const required = meta["required"] === true && !insertColumnOptionalOnInsert(meta)
|
|
87
|
-
return ` ${quoteKey(
|
|
133
|
+
return ` ${quoteKey(column)}${required ? "" : "?"}: ${ts}`
|
|
88
134
|
})
|
|
89
135
|
.join("\n")
|
|
90
136
|
return `{\n${body}\n}`
|
|
91
137
|
}
|
|
92
138
|
|
|
93
139
|
export function generateUpdateType(fields: Record<string, Record<string, unknown>>): string {
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
96
|
-
const body =
|
|
97
|
-
.map(([name, meta]) => ` ${quoteKey(name)}?: ${toTsType(meta)}`)
|
|
98
|
-
.join("\n")
|
|
140
|
+
const columns = columnsOf(fields)
|
|
141
|
+
if (columns.length === 0) return "Record<string, unknown>"
|
|
142
|
+
const body = columns.map(({ column, ts }) => ` ${quoteKey(column)}?: ${ts}`).join("\n")
|
|
99
143
|
return `{\n${body}\n}`
|
|
100
144
|
}
|
|
101
145
|
|
|
@@ -119,6 +163,11 @@ function toTsType(meta: Record<string, unknown>): string {
|
|
|
119
163
|
case "timestamp":
|
|
120
164
|
case "datetime":
|
|
121
165
|
case "money":
|
|
166
|
+
// NUMERIC, and the exact value is the point. A `number` here is the same defect as parsing
|
|
167
|
+
// the wire with `JSON.parse`: `12345678901234567890.1234` has no float representation, so a
|
|
168
|
+
// column declared to hold it would arrive rounded and typed as though it had not been.
|
|
169
|
+
// `@supatype/types` already says so: `Decimal<P, S>` carries `string`.
|
|
170
|
+
case "decimal":
|
|
122
171
|
case "xml":
|
|
123
172
|
case "interval":
|
|
124
173
|
return "string"
|
|
@@ -126,7 +175,6 @@ function toTsType(meta: Record<string, unknown>): string {
|
|
|
126
175
|
case "smallInt":
|
|
127
176
|
case "serial":
|
|
128
177
|
case "float":
|
|
129
|
-
case "decimal":
|
|
130
178
|
return "number"
|
|
131
179
|
case "bigSerial":
|
|
132
180
|
case "bigInt":
|
|
@@ -139,9 +187,14 @@ function toTsType(meta: Record<string, unknown>): string {
|
|
|
139
187
|
case "vector":
|
|
140
188
|
case "relation":
|
|
141
189
|
case "array":
|
|
190
|
+
return "Record<string, unknown>"
|
|
191
|
+
// What `storage.upload()` actually produces, plus the bucket it went to. Emitting
|
|
192
|
+
// `Record<string, unknown>` here is why two example screens cast on the good path. A `url`
|
|
193
|
+
// is deliberately absent: a stored one goes stale and hard-codes the storage host, so it is
|
|
194
|
+
// derived at read time instead.
|
|
142
195
|
case "image":
|
|
143
196
|
case "file":
|
|
144
|
-
return "
|
|
197
|
+
return "{ bucket: string; path: string }"
|
|
145
198
|
case "richText":
|
|
146
199
|
return "(import(\"@supatype/types/lexical\").SerializedEditorState | string)"
|
|
147
200
|
case "enum": {
|
|
@@ -155,7 +208,11 @@ function toTsType(meta: Record<string, unknown>): string {
|
|
|
155
208
|
return "unknown"
|
|
156
209
|
}
|
|
157
210
|
})()
|
|
158
|
-
|
|
211
|
+
// A localized column is JSONB holding a locale map, `{"en": ..., "fr": ...}`, not the bare
|
|
212
|
+
// value. `page.title` is exactly this and was typed `string`, so calling a string method on it
|
|
213
|
+
// compiled and then failed against real data.
|
|
214
|
+
const shaped = meta["localized"] === true ? `{ [locale: string]: ${base} }` : base
|
|
215
|
+
return required ? shaped : `${shaped} | null`
|
|
159
216
|
}
|
|
160
217
|
|
|
161
218
|
function quoteKey(key: string): string {
|
|
@@ -9,4 +9,4 @@
|
|
|
9
9
|
// Annotated `string`, not left to inference: once a release stamps a value here, the
|
|
10
10
|
// inferred literal type makes the `!== ""` test in cliPackageVersion() a compile error
|
|
11
11
|
// (TS2367, no overlap), so the build would only fail during a release.
|
|
12
|
-
export const EMBEDDED_CLI_VERSION: string = "0.3.
|
|
12
|
+
export const EMBEDDED_CLI_VERSION: string = "0.3.2"
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client a project imports, generated into its own source tree.
|
|
3
|
+
*
|
|
4
|
+
* One file, written by `supatype push`, that an app imports once:
|
|
5
|
+
*
|
|
6
|
+
* import { createClient } from "./supatype/client"
|
|
7
|
+
*
|
|
8
|
+
* It carries three things that used to arrive by three routes, two of which could fail without
|
|
9
|
+
* saying so.
|
|
10
|
+
*
|
|
11
|
+
* The **module augmentation** was a `.d.ts` that TypeScript found only because the project's
|
|
12
|
+
* tsconfig `include` happened to name the generated directory. Thirteen tsconfig files across the
|
|
13
|
+
* examples named it, one project never named it at all, and in that state `AugmentedTables` falls
|
|
14
|
+
* back to `Record<string, TableDef>`: every table name is accepted, every column is `unknown`, and
|
|
15
|
+
* the build passes. Reaching it by import instead means tsconfig cannot lose it.
|
|
16
|
+
*
|
|
17
|
+
* The **field kinds** were read from `field-kinds.json` on disk, falling back to fetching
|
|
18
|
+
* PostgREST's OpenAPI document. That fallback reconstructs the answer from the live database, so it
|
|
19
|
+
* always succeeds, which made a project that had never generated indistinguishable from one that
|
|
20
|
+
* had. Compiling them in removes both the read and the fallback, and makes absence detectable.
|
|
21
|
+
*
|
|
22
|
+
* And **`createClient`** itself, so the one import is the whole setup. A project that has not
|
|
23
|
+
* generated has no file here to import, so the build fails rather than the app running with a
|
|
24
|
+
* client that knows nothing about its schema.
|
|
25
|
+
*/
|
|
26
|
+
import { generateClientAugmentation } from "./augmentation-generator.js"
|
|
27
|
+
|
|
28
|
+
/** The kinds whose values no binary float holds, keyed by the schema kind that produces them. */
|
|
29
|
+
const EXACT_KIND_BY_FIELD_KIND: Readonly<Record<string, "bigint" | "numeric">> = {
|
|
30
|
+
bigInt: "bigint",
|
|
31
|
+
bigSerial: "bigint",
|
|
32
|
+
decimal: "numeric",
|
|
33
|
+
money: "numeric",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const FORMAT_VERSION = 1
|
|
37
|
+
|
|
38
|
+
type AstShape = {
|
|
39
|
+
models?: Array<{
|
|
40
|
+
name?: string
|
|
41
|
+
annotations?: Record<string, unknown>
|
|
42
|
+
fields?: Record<string, Record<string, unknown>>
|
|
43
|
+
}>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The full contents of `supatype/client.ts`. */
|
|
47
|
+
export function generateProjectClient(ast: unknown): string {
|
|
48
|
+
// Normalised first: `push` should not die on a malformed AST while writing a file, and the
|
|
49
|
+
// augmentation generator indexes `.models` without checking.
|
|
50
|
+
const safe = ast !== null && typeof ast === "object" ? ast : { models: [] }
|
|
51
|
+
const augmentation = generateClientAugmentation(safe)
|
|
52
|
+
|
|
53
|
+
return [
|
|
54
|
+
"// Generated by supatype CLI, do not edit manually.",
|
|
55
|
+
"//",
|
|
56
|
+
"// Import the client from here rather than from `@supatype/client`:",
|
|
57
|
+
"//",
|
|
58
|
+
'// import { createClient } from "./supatype/client"',
|
|
59
|
+
"//",
|
|
60
|
+
"// This file carries your schema's types and the columns that need exact handling, so one",
|
|
61
|
+
"// import is the whole setup. Importing `@supatype/client` directly gets a client that knows",
|
|
62
|
+
"// nothing about this project.",
|
|
63
|
+
"",
|
|
64
|
+
'import { registerFieldKinds } from "@supatype/client"',
|
|
65
|
+
"",
|
|
66
|
+
augmentationBody(augmentation),
|
|
67
|
+
"",
|
|
68
|
+
"// Columns holding values a JSON parser would round: bigInt, decimal and money. Registered at",
|
|
69
|
+
"// import time, so reading one returns what the database holds rather than the nearest double.",
|
|
70
|
+
`registerFieldKinds(${JSON.stringify(fieldKindsDocument(safe), null, 2)})`,
|
|
71
|
+
"",
|
|
72
|
+
'export * from "@supatype/client"',
|
|
73
|
+
"",
|
|
74
|
+
].join("\n")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The augmentation, without the pieces that only made sense in a file of its own.
|
|
79
|
+
*
|
|
80
|
+
* Its header said "generated", which this file says once already, and it ended in `export {}` to
|
|
81
|
+
* make itself a module. This file has imports and exports of its own, so it is one regardless.
|
|
82
|
+
*/
|
|
83
|
+
function augmentationBody(augmentation: string): string {
|
|
84
|
+
return augmentation
|
|
85
|
+
.replace(/^\/\/ Generated by supatype CLI[^\n]*\n+/, "")
|
|
86
|
+
.replace(/\n*export \{\}\s*$/, "")
|
|
87
|
+
.trimEnd()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The exact-value columns of every table, as the registry expects them. */
|
|
91
|
+
function fieldKindsDocument(ast: unknown): { version: number; tables: Record<string, unknown> } {
|
|
92
|
+
const tables: Record<string, Record<string, string>> = {}
|
|
93
|
+
|
|
94
|
+
for (const model of readModels(ast)) {
|
|
95
|
+
const exact: Record<string, string> = {}
|
|
96
|
+
for (const [column, meta] of Object.entries(model.fields ?? {})) {
|
|
97
|
+
const kind = typeof meta["kind"] === "string" ? meta["kind"] : ""
|
|
98
|
+
const exactKind = EXACT_KIND_BY_FIELD_KIND[kind]
|
|
99
|
+
if (exactKind !== undefined) exact[column] = exactKind
|
|
100
|
+
}
|
|
101
|
+
if (Object.keys(exact).length > 0) {
|
|
102
|
+
tables[resolveTableName(model)] = exact
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { version: FORMAT_VERSION, tables }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveTableName(model: { name?: string; annotations?: Record<string, unknown> }): string {
|
|
110
|
+
const db = model.annotations?.["db"]
|
|
111
|
+
if (db !== null && typeof db === "object") {
|
|
112
|
+
const named = (db as { tableName?: unknown }).tableName
|
|
113
|
+
if (typeof named === "string" && named !== "") return named
|
|
114
|
+
}
|
|
115
|
+
return model.name ?? ""
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readModels(ast: unknown): NonNullable<AstShape["models"]> {
|
|
119
|
+
if (ast === null || typeof ast !== "object") return []
|
|
120
|
+
const models = (ast as AstShape).models
|
|
121
|
+
return Array.isArray(models) ? models : []
|
|
122
|
+
}
|
package/src/commands/admin.ts
CHANGED
|
@@ -363,12 +363,7 @@ export async function ensureFirstAdminWithQuery(
|
|
|
363
363
|
|
|
364
364
|
const credentials = await resolveAdminCredentials(options, cwd)
|
|
365
365
|
if (!credentials) {
|
|
366
|
-
if (!isInteractive())
|
|
367
|
-
info(
|
|
368
|
-
"No admin users found. Set SUPATYPE_ADMIN_EMAIL / SUPATYPE_ADMIN_PASSWORD in .env, " +
|
|
369
|
-
"or run: supatype admin create-user",
|
|
370
|
-
)
|
|
371
|
-
}
|
|
366
|
+
if (!isInteractive()) info(missingAdminCredentialsHint(cwd, options))
|
|
372
367
|
return
|
|
373
368
|
}
|
|
374
369
|
|
|
@@ -397,6 +392,28 @@ function logFirstAdminCreated(email: string, role: string): void {
|
|
|
397
392
|
info("Log in at /admin after starting the dev server.")
|
|
398
393
|
}
|
|
399
394
|
|
|
395
|
+
/**
|
|
396
|
+
* What to say when there is no admin and no credentials to make one with.
|
|
397
|
+
*
|
|
398
|
+
* Naming both variables is wrong in the common case. `clearAdminSeedPassword` retires the seed
|
|
399
|
+
* password once it has been used, so a project that has ever created an admin keeps the email and
|
|
400
|
+
* has no password, and a later run against a reset database then reports both as missing. That
|
|
401
|
+
* reads as "you never set these" when in fact this tool consumed one of them, which is a dead end
|
|
402
|
+
* for anyone who knows they set it.
|
|
403
|
+
*/
|
|
404
|
+
function missingAdminCredentialsHint(cwd: string, options: EnsureFirstAdminOptions): string {
|
|
405
|
+
const email = options.email ?? readEnvValue(cwd, ADMIN_EMAIL_ENV, "").trim()
|
|
406
|
+
const create = "or run: supatype admin create-user"
|
|
407
|
+
if (!email) {
|
|
408
|
+
return `No admin users found. Set ${ADMIN_EMAIL_ENV} / ${ADMIN_PASSWORD_ENV} in .env, ${create}`
|
|
409
|
+
}
|
|
410
|
+
return (
|
|
411
|
+
`No admin users found. .env has ${ADMIN_EMAIL_ENV}="${email}" but no ${ADMIN_PASSWORD_ENV}: ` +
|
|
412
|
+
`it is a one-time seed and is removed once an admin has been created. ` +
|
|
413
|
+
`Set ${ADMIN_PASSWORD_ENV} again to seed one, ${create}`
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
400
417
|
async function resolveAdminCredentials(
|
|
401
418
|
options: EnsureFirstAdminOptions,
|
|
402
419
|
cwd: string,
|
|
@@ -25,7 +25,10 @@ import {
|
|
|
25
25
|
import { loadProjectLink } from "../link.js"
|
|
26
26
|
import { resolveTarget } from "../resolve-target.js"
|
|
27
27
|
import { targetFetch } from "../target-client.js"
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
ensureFunctionsDenoTypes,
|
|
30
|
+
sharedFunctionsReadmeSource,
|
|
31
|
+
} from "../functions-deno-types.js"
|
|
29
32
|
import { error, info, plain } from "../ui/messages.js"
|
|
30
33
|
import { nextSteps } from "../ui/next-steps.js"
|
|
31
34
|
|
|
@@ -173,7 +176,7 @@ export default async function handler(req: Request, ctx: FunctionContext): Promi
|
|
|
173
176
|
mkdirSync(sharedDir, { recursive: true })
|
|
174
177
|
writeFileSync(
|
|
175
178
|
join(sharedDir, "README.md"),
|
|
176
|
-
|
|
179
|
+
sharedFunctionsReadmeSource(),
|
|
177
180
|
"utf8",
|
|
178
181
|
)
|
|
179
182
|
}
|
package/src/commands/init.ts
CHANGED
|
@@ -14,7 +14,10 @@ import {
|
|
|
14
14
|
ensureComponentBinaries,
|
|
15
15
|
reportComponentBinaryFailures,
|
|
16
16
|
} from "../ensure-component-binaries.js"
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
ensureFunctionsDenoTypes,
|
|
19
|
+
sharedFunctionsReadmeSource,
|
|
20
|
+
} from "../functions-deno-types.js"
|
|
18
21
|
import { mergeSupatypePackageJson } from "../init-package-json.js"
|
|
19
22
|
import { cliPackageVersion } from "../cli-package-version.js"
|
|
20
23
|
import {
|
|
@@ -23,6 +26,7 @@ import {
|
|
|
23
26
|
type InitDependencyVersions,
|
|
24
27
|
} from "../init-dependency-versions.js"
|
|
25
28
|
import { detectProjectSetup, type DetectedProjectSetup } from "../init-project-detect.js"
|
|
29
|
+
import { SUPATYPE_GITIGNORE_PATHS } from "../gitignore.js"
|
|
26
30
|
|
|
27
31
|
// ─── Options model ─────────────────────────────────────────────────────────--
|
|
28
32
|
|
|
@@ -789,7 +793,7 @@ function scaffoldHelloFunction(
|
|
|
789
793
|
): void {
|
|
790
794
|
write("functions/hello/index.ts", helloFunctionTemplate())
|
|
791
795
|
if (!existsSync(join(dir, "functions/_shared/README.md"))) {
|
|
792
|
-
write("functions/_shared/README.md",
|
|
796
|
+
write("functions/_shared/README.md", sharedFunctionsReadmeSource())
|
|
793
797
|
}
|
|
794
798
|
if (!existsSync(join(dir, "functions/.env.local"))) {
|
|
795
799
|
write("functions/.env.local", functionsEnvLocalTemplate())
|
|
@@ -1396,22 +1400,18 @@ export default async function handler(req: Request, ctx: FunctionContext): Promi
|
|
|
1396
1400
|
`
|
|
1397
1401
|
}
|
|
1398
1402
|
|
|
1399
|
-
function sharedFunctionsReadme(): string {
|
|
1400
|
-
return "# Shared Code\n\nFiles in `_shared/` are available to all functions via relative imports.\nThis directory is not deployed as a function.\n\nExample: `import { sendEmail } from '../_shared/email.ts'`\n"
|
|
1401
|
-
}
|
|
1402
1403
|
|
|
1403
1404
|
function functionsEnvLocalTemplate(): string {
|
|
1404
1405
|
return "# Local environment variables for edge functions\n# These are NOT committed to git\n# Set production env vars via: npx supatype functions env set KEY=value\n"
|
|
1405
1406
|
}
|
|
1406
1407
|
|
|
1407
1408
|
function gitignoreTemplate(): string {
|
|
1408
|
-
|
|
1409
|
+
// Supatype's own paths come from `SUPATYPE_GITIGNORE_PATHS` rather than being written out again
|
|
1410
|
+
// here. This template and that list were two copies of the same thing and had already diverged;
|
|
1411
|
+
// the copy that drifts is found when a key reaches a public repository.
|
|
1412
|
+
return `${SUPATYPE_GITIGNORE_PATHS.join("\n")}
|
|
1409
1413
|
node_modules/
|
|
1410
1414
|
dist/
|
|
1411
|
-
.supatype/
|
|
1412
|
-
supatype.local.config.ts
|
|
1413
|
-
supatype.local.config.js
|
|
1414
|
-
supatype.local.config.mjs
|
|
1415
1415
|
# Generated by supatype push (legacy paths, prefer output.types in config)
|
|
1416
1416
|
src/types/supatype.d.ts
|
|
1417
1417
|
src/lib/supatype.ts
|
package/src/commands/keys.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Command } from "commander"
|
|
2
|
-
import {
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { resolve } from "node:path"
|
|
4
|
+
import { readEnvFile, upsertEnvFile } from "../env-file.js"
|
|
4
5
|
import { signJwt } from "../jwt.js"
|
|
5
6
|
import { error, plain } from "../ui/messages.js"
|
|
6
7
|
|
|
@@ -10,7 +11,9 @@ export function registerKeys(program: Command): void {
|
|
|
10
11
|
.description("Generate ANON_KEY and SERVICE_ROLE_KEY JWTs from your JWT_SECRET")
|
|
11
12
|
.option("--secret <secret>", "JWT secret (defaults to JWT_SECRET env var or value in .env)")
|
|
12
13
|
.option("--exp-years <years>", "Token expiry in years (default: 10)", "10")
|
|
13
|
-
.
|
|
14
|
+
.option("--write", "Write the keys into .env in the current directory instead of printing them")
|
|
15
|
+
.option("--force", "With --write, replace every key already in .env rather than filling blanks")
|
|
16
|
+
.action((opts: { secret?: string; expYears: string; write?: boolean; force?: boolean }) => {
|
|
14
17
|
const secret = opts.secret ?? resolveSecret()
|
|
15
18
|
if (!secret) {
|
|
16
19
|
error("JWT_SECRET not found. Set it in .env or pass --secret <value>")
|
|
@@ -23,19 +26,116 @@ export function registerKeys(program: Command): void {
|
|
|
23
26
|
process.exit(1)
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
const
|
|
27
|
-
const exp = now + expYears * 365 * 24 * 60 * 60
|
|
29
|
+
const { anonKey, serviceKey } = signKeyPair(secret, expYears)
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
if (opts.write) {
|
|
32
|
+
const written = writeKeysToEnv(
|
|
33
|
+
process.cwd(),
|
|
34
|
+
{ anonKey, serviceKey },
|
|
35
|
+
opts.force === true,
|
|
36
|
+
)
|
|
37
|
+
if (written.path === null) {
|
|
38
|
+
error("No .env here to write to. Create one, or drop --write to print the keys.")
|
|
39
|
+
process.exit(1)
|
|
40
|
+
}
|
|
41
|
+
plain(`\nKeys valid for ${expYears} years, written to ${written.path}:\n`)
|
|
42
|
+
// The names come back from the writer rather than being restated here, which is the whole
|
|
43
|
+
// point of there being one list.
|
|
44
|
+
for (const key of written.written) plain(` ${key}`)
|
|
45
|
+
if (written.kept.length > 0) {
|
|
46
|
+
plain(`\nLeft alone, because they already hold a value:\n`)
|
|
47
|
+
for (const key of written.kept) plain(` ${key}`)
|
|
48
|
+
plain("\nRe-run with --force to replace them.")
|
|
49
|
+
}
|
|
50
|
+
plain("\nDo not commit .env to source control.")
|
|
51
|
+
return
|
|
52
|
+
}
|
|
31
53
|
|
|
32
54
|
plain(`\nGenerated keys (valid for ${expYears} years):\n`)
|
|
33
55
|
plain("ANON_KEY=" + anonKey)
|
|
34
56
|
plain("SERVICE_ROLE_KEY=" + serviceKey)
|
|
35
|
-
plain("\nAdd these to your .env file. Do not commit .env to source control.")
|
|
57
|
+
plain("\nAdd these to your .env file, or re-run with --write. Do not commit .env to source control.")
|
|
36
58
|
})
|
|
37
59
|
}
|
|
38
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Every env name a minted key pair is written to, in one place.
|
|
63
|
+
*
|
|
64
|
+
* Shared with `supatype dev`, which writes the same pair when it starts a stack. They used to hold
|
|
65
|
+
* separate lists, so a front end's prefix could be written by one and forgotten by the other, and
|
|
66
|
+
* which names your `.env` ended up with depended on whichever command you happened to run last.
|
|
67
|
+
*
|
|
68
|
+
* The URL names are only included when there is a URL to write, because an empty
|
|
69
|
+
* `VITE_SUPATYPE_URL` is worse than an absent one: the client reads it, finds a blank string, and
|
|
70
|
+
* requests against the page origin without saying why.
|
|
71
|
+
*/
|
|
72
|
+
export function anonKeyEnvUpdates(
|
|
73
|
+
anonKey: string,
|
|
74
|
+
serviceKey: string,
|
|
75
|
+
apiUrl?: string | undefined,
|
|
76
|
+
): Record<string, string> {
|
|
77
|
+
return {
|
|
78
|
+
ANON_KEY: anonKey,
|
|
79
|
+
SERVICE_ROLE_KEY: serviceKey,
|
|
80
|
+
VITE_SUPATYPE_ANON_KEY: anonKey,
|
|
81
|
+
PUBLIC_SUPATYPE_ANON_KEY: anonKey,
|
|
82
|
+
EXPO_PUBLIC_SUPATYPE_ANON_KEY: anonKey,
|
|
83
|
+
...(apiUrl !== undefined && {
|
|
84
|
+
VITE_SUPATYPE_URL: apiUrl,
|
|
85
|
+
PUBLIC_SUPATYPE_URL: apiUrl,
|
|
86
|
+
EXPO_PUBLIC_SUPATYPE_URL: apiUrl,
|
|
87
|
+
}),
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface WriteKeysResult {
|
|
92
|
+
/** The .env written, or null when there is none here. */
|
|
93
|
+
path: string | null
|
|
94
|
+
written: string[]
|
|
95
|
+
kept: string[]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Write a minted pair into `dir`'s .env.
|
|
100
|
+
*
|
|
101
|
+
* Blanks only by default. An anon key already in `.env` is held by clients this command cannot
|
|
102
|
+
* reach, and reissuing it locks them out, so filling what is empty is the useful half and
|
|
103
|
+
* replacing what is not is a decision someone has to make out loud. `--force` is how they make it,
|
|
104
|
+
* and it rewrites every name this owns rather than only the empty ones.
|
|
105
|
+
*
|
|
106
|
+
* `--force` does not rewrite the file. `.env` also holds `JWT_SECRET`, which these keys are
|
|
107
|
+
* derived from, along with the database password and whatever else the project keeps there;
|
|
108
|
+
* replacing the file would leave a project whose keys cannot be regenerated and whose stack cannot
|
|
109
|
+
* start. Every line this command does not own is left exactly as it was, comments included.
|
|
110
|
+
*/
|
|
111
|
+
export function writeKeysToEnv(
|
|
112
|
+
dir: string,
|
|
113
|
+
keys: { anonKey: string; serviceKey: string },
|
|
114
|
+
force: boolean,
|
|
115
|
+
): WriteKeysResult {
|
|
116
|
+
const envPath = resolve(dir, ".env")
|
|
117
|
+
if (!existsSync(envPath)) return { path: null, written: [], kept: [] }
|
|
118
|
+
|
|
119
|
+
const existing = readEnvFile(dir)
|
|
120
|
+
const apiUrl = existing["PUBLIC_SUPATYPE_URL"] || existing["API_EXTERNAL_URL"] || undefined
|
|
121
|
+
const candidates = anonKeyEnvUpdates(keys.anonKey, keys.serviceKey, apiUrl)
|
|
122
|
+
|
|
123
|
+
const updates: Record<string, string> = {}
|
|
124
|
+
const kept: string[] = []
|
|
125
|
+
for (const [name, value] of Object.entries(candidates)) {
|
|
126
|
+
// Blank counts as absent: a template ships `ANON_KEY=` and that is exactly the line to fill.
|
|
127
|
+
const current = existing[name]?.trim() ?? ""
|
|
128
|
+
if (!force && current !== "") {
|
|
129
|
+
kept.push(name)
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
updates[name] = value
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (Object.keys(updates).length > 0) upsertEnvFile(dir, updates)
|
|
136
|
+
return { path: envPath, written: Object.keys(updates), kept }
|
|
137
|
+
}
|
|
138
|
+
|
|
39
139
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
40
140
|
|
|
41
141
|
/** Mint a long-lived anon + service_role JWT pair from a secret. */
|
|
@@ -91,6 +191,11 @@ function upsertEnvVar(content: string, key: string, value: string): string {
|
|
|
91
191
|
return `${content}${sep}${key}=${value}\n`
|
|
92
192
|
}
|
|
93
193
|
|
|
194
|
+
/** Whether `.env` declares `key` at all, with or without a value. */
|
|
195
|
+
function hasEnvLine(content: string, key: string): boolean {
|
|
196
|
+
return new RegExp(`^${key}=`, "m").test(content)
|
|
197
|
+
}
|
|
198
|
+
|
|
94
199
|
function readEnvVar(content: string, key: string): string | undefined {
|
|
95
200
|
const re = new RegExp(`^${key}=(.*)$`, "m")
|
|
96
201
|
const match = re.exec(content)
|
package/src/commands/push.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
validateModelValidators,
|
|
10
10
|
writeHooksModule,
|
|
11
11
|
} from "../model-hooks.js"
|
|
12
|
+
import { syncRowCacheEnv } from "../model-cache.js"
|
|
12
13
|
import { adapterEntry, readHookUpload } from "../hook-upload.js"
|
|
13
14
|
import { checkServiceRoleRoutes, serviceRoleProblemLines } from "../service-role-check.js"
|
|
14
15
|
import { fatalError } from "../ui/fatal.js"
|
|
@@ -32,7 +33,8 @@ import type { ExtractedSchemaAstV2 } from "../schema-ast-v2.js"
|
|
|
32
33
|
import { ensureFirstAdminUser } from "./admin.js"
|
|
33
34
|
import { withAdminRoles } from "../studio-admin-roles.js"
|
|
34
35
|
import { restoreSystemRelationTargets } from "../restore-system-relation-targets.js"
|
|
35
|
-
import {
|
|
36
|
+
import { cacheSeedingNotes, freeTierCacheNote } from "../api-config-cache.js"
|
|
37
|
+
import { refreshFunctionsContext } from "../functions-context-refresh.js"
|
|
36
38
|
import type { SupatypeProjectConfig } from "../project-config.js"
|
|
37
39
|
import {
|
|
38
40
|
resolveTarget,
|
|
@@ -264,20 +266,7 @@ async function deployHooksToTarget(
|
|
|
264
266
|
* Never an error, and never a rewrite of an entry that already exists. See `seedApiConfigCache`.
|
|
265
267
|
*/
|
|
266
268
|
function reportCacheSeeding(cwd: string, ast: unknown): void {
|
|
267
|
-
const
|
|
268
|
-
if (result === null) return
|
|
269
|
-
|
|
270
|
-
if (result.seeded.length > 0) {
|
|
271
|
-
info(`Server cache enabled for ${result.seeded.join(", ")} in .supatype/api-config.json`)
|
|
272
|
-
}
|
|
273
|
-
if (result.ttlIsOff) {
|
|
274
|
-
// A note rather than a fix. Zero is an off switch someone may have chosen, and a push that
|
|
275
|
-
// turned caching on project-wide would be overriding a decision rather than filling in a blank.
|
|
276
|
-
info(
|
|
277
|
-
`${result.declared.length} table(s) declare a cache, but cache_max_ttl is 0 — nothing is ` +
|
|
278
|
-
`cached until it is set, under API → REST → Settings or in .supatype/api-config.json.`,
|
|
279
|
-
)
|
|
280
|
-
}
|
|
269
|
+
for (const note of cacheSeedingNotes(cwd, ast)) info(note)
|
|
281
270
|
}
|
|
282
271
|
|
|
283
272
|
/** The generated adapter, flattened to the name handlers were rewritten to import. */
|
|
@@ -293,8 +282,29 @@ async function generateTypesLocal(ast: unknown, config: SupatypeProjectConfig):
|
|
|
293
282
|
const cwd = process.cwd()
|
|
294
283
|
const hooksPath = writeHooksModule(cwd, hooksPathFromProject(config, cwd), ast)
|
|
295
284
|
if (hooksPath !== null) info(`Hook handler types written to ${hooksPath}`)
|
|
285
|
+
|
|
286
|
+
// `_shared/context.ts` too, for the same reason the hook module is here.
|
|
287
|
+
//
|
|
288
|
+
// It was written by `init` and `functions new` and by nothing else, so a project that predates
|
|
289
|
+
// the two-argument handler contract never received it: its functions had no `FunctionContext` to
|
|
290
|
+
// import, while the file itself claims "Regenerated by the CLI. Edits are overwritten." That
|
|
291
|
+
// claim was only true if you scaffolded again. `tests/integration` had four functions and no
|
|
292
|
+
// context module at all.
|
|
293
|
+
if (refreshFunctionsContext(cwd, config)) {
|
|
294
|
+
info("Function context type written to functions/_shared/context.ts")
|
|
295
|
+
}
|
|
296
296
|
// The server watches this file, so a changed hook takes effect without a restart.
|
|
297
297
|
if (syncManifestHooks(cwd, ast)) info("Hook map written to .supatype/manifest.json")
|
|
298
|
+
// The row cache is configured at postmaster start, so this is the one cache setting a push
|
|
299
|
+
// cannot make take effect on its own.
|
|
300
|
+
const rowCache = syncRowCacheEnv(cwd, ast)
|
|
301
|
+
if (rowCache !== null) {
|
|
302
|
+
info(
|
|
303
|
+
`Row cache switched ${rowCache} in .env. Recreate the database container for it to take ` +
|
|
304
|
+
"effect: the image writes pg_keyspace.conf at start, so a running Postgres keeps the " +
|
|
305
|
+
"setting it booted with.",
|
|
306
|
+
)
|
|
307
|
+
}
|
|
298
308
|
|
|
299
309
|
if (!config.output?.types && !config.output?.client) return
|
|
300
310
|
// The CLI writes these, it does not ask the engine to. Passing types_path and client_path and
|
package/src/commands/update.ts
CHANGED
|
@@ -23,6 +23,21 @@ function resolveConfigFile(cwd: string): string | null {
|
|
|
23
23
|
return null
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* New images alone are not a finished upgrade.
|
|
28
|
+
*
|
|
29
|
+
* Components carry schema with them: grants, policies and the rest are generated by the engine and
|
|
30
|
+
* applied by `push`, not baked into an image. Pulling a newer storage service onto a database that
|
|
31
|
+
* predates its grants leaves every private bucket read refused, and the only clue is in the
|
|
32
|
+
* service's log. The remedy is one command, so it is named here rather than left to be discovered.
|
|
33
|
+
*/
|
|
34
|
+
function pushReminder(): void {
|
|
35
|
+
plain("")
|
|
36
|
+
info("Run `supatype push` to apply schema changes the new components expect.")
|
|
37
|
+
plain(" Components ship code; grants, policies and functions come from your schema.")
|
|
38
|
+
plain(" Skipping it can leave storage reads and other rules refused until it runs.")
|
|
39
|
+
}
|
|
40
|
+
|
|
26
41
|
export function registerUpdate(program: Command): void {
|
|
27
42
|
program
|
|
28
43
|
.command("update")
|
|
@@ -37,7 +52,9 @@ export function registerUpdate(program: Command): void {
|
|
|
37
52
|
|
|
38
53
|
if (provider === "docker") {
|
|
39
54
|
if (opts.check) {
|
|
40
|
-
|
|
55
|
+
// There is no `self-host compose pull` subcommand; the earlier text named one and a reader
|
|
56
|
+
// following it got "unknown command". `update` is what pulls.
|
|
57
|
+
info("Docker provider: run `supatype update` without --check to pull the compose images.")
|
|
41
58
|
return
|
|
42
59
|
}
|
|
43
60
|
const paths = writeSelfHostCompose(cwd, config, { devLocal: true })
|
|
@@ -54,6 +71,7 @@ export function registerUpdate(program: Command): void {
|
|
|
54
71
|
exitComposeFailed(status, "Could not pull Compose images.", brand)
|
|
55
72
|
}
|
|
56
73
|
info("Compose images updated.")
|
|
74
|
+
pushReminder()
|
|
57
75
|
return
|
|
58
76
|
}
|
|
59
77
|
|
|
@@ -149,5 +167,6 @@ export function registerUpdate(program: Command): void {
|
|
|
149
167
|
|
|
150
168
|
writeFileSync(configPath, text, "utf8")
|
|
151
169
|
info(`${basename(configPath)} updated.`)
|
|
170
|
+
pushReminder()
|
|
152
171
|
})
|
|
153
172
|
}
|