@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client a project imports, generated into its own source tree.
|
|
3
|
+
*
|
|
4
|
+
* An app writes one import and nothing else:
|
|
5
|
+
*
|
|
6
|
+
* import { createClient } from "./supatype/client"
|
|
7
|
+
*
|
|
8
|
+
* That single file carries three things that previously arrived by three different routes, two of
|
|
9
|
+
* which could silently fail:
|
|
10
|
+
*
|
|
11
|
+
* the module augmentation was a `.d.ts` found only through tsconfig `include`, so a stale or
|
|
12
|
+
* missing entry left every table name accepted and every column
|
|
13
|
+
* `unknown`, with the build passing
|
|
14
|
+
* the field kinds were read off disk and, failing that, reconstructed from PostgREST's
|
|
15
|
+
* OpenAPI document, which always succeeds and so could never report a
|
|
16
|
+
* project that had not generated
|
|
17
|
+
* `createClient` came from the package, which knows nothing about the project
|
|
18
|
+
*
|
|
19
|
+
* Arriving by import means the bundler carries them, tsconfig cannot lose them, nothing is
|
|
20
|
+
* fetched, and a project that has not generated has no file to import, so the build fails.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, expect, it } from "vitest"
|
|
23
|
+
import { generateProjectClient } from "../src/client-generator.js"
|
|
24
|
+
|
|
25
|
+
const ast = {
|
|
26
|
+
models: [
|
|
27
|
+
{
|
|
28
|
+
name: "JobPost",
|
|
29
|
+
annotations: { db: { tableName: "job_post" } },
|
|
30
|
+
fields: {
|
|
31
|
+
id: { kind: "uuid", required: true },
|
|
32
|
+
title: { kind: "text", required: true },
|
|
33
|
+
views: { kind: "bigInt", required: true },
|
|
34
|
+
equityPct: { kind: "decimal", required: false },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const plainAst = {
|
|
41
|
+
models: [
|
|
42
|
+
{
|
|
43
|
+
name: "LandingPage",
|
|
44
|
+
annotations: { db: { tableName: "landing_page" } },
|
|
45
|
+
fields: { id: { kind: "uuid", required: true } },
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("generateProjectClient", () => {
|
|
51
|
+
it("re-exports the client package, so one import is enough", () => {
|
|
52
|
+
expect(generateProjectClient(ast)).toContain('export * from "@supatype/client"')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it("carries the module augmentation, rather than relying on tsconfig to find it", () => {
|
|
56
|
+
const out = generateProjectClient(ast)
|
|
57
|
+
expect(out).toContain('declare module "@supatype/client"')
|
|
58
|
+
expect(out).toContain("interface SupatypeModels")
|
|
59
|
+
expect(out).toContain("job_post")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("registers the columns a JSON parser would destroy", () => {
|
|
63
|
+
const out = generateProjectClient(ast)
|
|
64
|
+
expect(out).toContain("registerFieldKinds(")
|
|
65
|
+
expect(out).toContain('"views": "bigint"')
|
|
66
|
+
expect(out).toContain('"equityPct": "numeric"')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("leaves ordinary columns out of the registration", () => {
|
|
70
|
+
// Listing a column that survives a parse would invite the client to convert one that was
|
|
71
|
+
// already correct.
|
|
72
|
+
const out = generateProjectClient(ast)
|
|
73
|
+
expect(out).not.toContain('"title"')
|
|
74
|
+
expect(out).not.toContain('"id": "')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("still registers for a schema with nothing exact", () => {
|
|
78
|
+
// Registered-and-empty is how the client tells "this project generated and has no exact
|
|
79
|
+
// columns" from "this project never generated". Both must not look the same.
|
|
80
|
+
const out = generateProjectClient(plainAst)
|
|
81
|
+
expect(out).toContain("registerFieldKinds(")
|
|
82
|
+
expect(out).toContain('"tables": {}')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("imports the registration function from the package", () => {
|
|
86
|
+
expect(generateProjectClient(ast)).toContain(
|
|
87
|
+
'import { registerFieldKinds } from "@supatype/client"',
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("says it is generated, on the first line", () => {
|
|
92
|
+
expect(generateProjectClient(ast).split("\n")[0]).toContain("Generated by supatype")
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("is stable across runs, so it does not churn the diff", () => {
|
|
96
|
+
expect(generateProjectClient(ast)).toBe(generateProjectClient(ast))
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("survives an ast it does not recognise", () => {
|
|
100
|
+
expect(() => generateProjectClient({})).not.toThrow()
|
|
101
|
+
expect(() => generateProjectClient(null)).not.toThrow()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DATABASE_URL` has to name the port Postgres is published on.
|
|
3
|
+
*
|
|
4
|
+
* `SUPATYPE_DB_PORT` moves the host side of the compose mapping so two projects can coexist on one
|
|
5
|
+
* machine. `DATABASE_URL` is what every host-side script uses. Nothing reconciled them, so a
|
|
6
|
+
* project moved to 5433 started cleanly, served every request, and failed on its own seed script
|
|
7
|
+
* with `ECONNREFUSED 127.0.0.1:5432`, naming a port the person never chose.
|
|
8
|
+
*
|
|
9
|
+
* The cases that matter most here are the ones where it must NOT act: a remote database is not
|
|
10
|
+
* ours to repoint, and a malformed URL is not ours to half-fix.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it, beforeEach, afterEach } from "vitest"
|
|
14
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
15
|
+
import { join } from "node:path"
|
|
16
|
+
import { tmpdir } from "node:os"
|
|
17
|
+
import { DEFAULT_DB_PORT, publishedDbPort, syncDatabaseUrlPort } from "../src/db-port.js"
|
|
18
|
+
|
|
19
|
+
let dir: string
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
dir = mkdtempSync(join(tmpdir(), "supatype-dbport-"))
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
rmSync(dir, { recursive: true, force: true })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
function writeEnv(lines: string): void {
|
|
30
|
+
writeFileSync(join(dir, ".env"), lines, "utf8")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function envText(): string {
|
|
34
|
+
return readFileSync(join(dir, ".env"), "utf8")
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("publishedDbPort", () => {
|
|
38
|
+
it("defaults to the port compose binds when unset", () => {
|
|
39
|
+
expect(publishedDbPort({})).toBe(DEFAULT_DB_PORT)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("reads the configured port", () => {
|
|
43
|
+
expect(publishedDbPort({ SUPATYPE_DB_PORT: "5433" })).toBe(5433)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("prefers the dev-local port, because its presence marks the deployment shape", () => {
|
|
47
|
+
// Found by running the validation e2e: that project publishes on SUPATYPE_DEV_DB_PORT=54330
|
|
48
|
+
// for its host-side engine, and an earlier version of this file rewrote a correct URL to 5432.
|
|
49
|
+
expect(publishedDbPort({ SUPATYPE_DEV_DB_PORT: "54330" })).toBe(54330)
|
|
50
|
+
expect(publishedDbPort({ SUPATYPE_DEV_DB_PORT: "54330", SUPATYPE_DB_PORT: "5433" })).toBe(54330)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("uses the self-host port when there is no dev-local one", () => {
|
|
54
|
+
expect(publishedDbPort({ SUPATYPE_DB_PORT: "5433" })).toBe(5433)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("falls through a nonsense dev-local port to the self-host one", () => {
|
|
58
|
+
expect(publishedDbPort({ SUPATYPE_DEV_DB_PORT: "abc", SUPATYPE_DB_PORT: "5433" })).toBe(5433)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("falls back rather than inventing a port from nonsense", () => {
|
|
62
|
+
// Compose reports a bad value far better than we can, and a made-up port would send the seed
|
|
63
|
+
// somewhere nothing is listening with no clue why.
|
|
64
|
+
for (const bad of ["", " ", "abc", "-1", "0", "70000", "5433.5"]) {
|
|
65
|
+
expect(publishedDbPort({ SUPATYPE_DB_PORT: bad }), `value ${JSON.stringify(bad)}`).toBe(
|
|
66
|
+
DEFAULT_DB_PORT,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe("syncDatabaseUrlPort", () => {
|
|
73
|
+
it("rewrites the port when the two disagree", () => {
|
|
74
|
+
writeEnv("DATABASE_URL=postgresql://u:p@localhost:5432/acme\nSUPATYPE_DB_PORT=5433\n")
|
|
75
|
+
expect(syncDatabaseUrlPort(dir)).toEqual({ from: "5432", to: "5433" })
|
|
76
|
+
expect(envText()).toContain("localhost:5433/acme")
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("keeps the credentials, database and query string intact", () => {
|
|
80
|
+
// The whole point is one field. A rewrite that dropped the password would take the stack down
|
|
81
|
+
// in a way that looks nothing like a port problem.
|
|
82
|
+
writeEnv(
|
|
83
|
+
"DATABASE_URL=postgresql://supatype_admin:s3cr3t@127.0.0.1:5432/acme?sslmode=disable\nSUPATYPE_DB_PORT=5440\n",
|
|
84
|
+
)
|
|
85
|
+
syncDatabaseUrlPort(dir)
|
|
86
|
+
const url = new URL(readFileSync(join(dir, ".env"), "utf8").match(/^DATABASE_URL=(.+)$/m)![1]!)
|
|
87
|
+
expect(url.port).toBe("5440")
|
|
88
|
+
expect(url.username).toBe("supatype_admin")
|
|
89
|
+
expect(url.password).toBe("s3cr3t")
|
|
90
|
+
expect(url.pathname).toBe("/acme")
|
|
91
|
+
expect(url.searchParams.get("sslmode")).toBe("disable")
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("does nothing when they already agree", () => {
|
|
95
|
+
writeEnv("DATABASE_URL=postgresql://u:p@localhost:5433/acme\nSUPATYPE_DB_PORT=5433\n")
|
|
96
|
+
expect(syncDatabaseUrlPort(dir)).toBeNull()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("treats an absent port as the default rather than rewriting it every run", () => {
|
|
100
|
+
writeEnv("DATABASE_URL=postgresql://u:p@localhost/acme\n")
|
|
101
|
+
expect(syncDatabaseUrlPort(dir)).toBeNull()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("leaves a remote database alone", () => {
|
|
105
|
+
// Somebody else's Postgres. Its port has nothing to do with our compose mapping, and
|
|
106
|
+
// rewriting it would repoint the project at a database that does not exist.
|
|
107
|
+
const remote = "postgresql://u:p@db.example.com:5432/acme"
|
|
108
|
+
writeEnv(`DATABASE_URL=${remote}\nSUPATYPE_DB_PORT=5433\n`)
|
|
109
|
+
expect(syncDatabaseUrlPort(dir)).toBeNull()
|
|
110
|
+
expect(envText()).toContain(remote)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("leaves a malformed URL alone rather than half-fixing it", () => {
|
|
114
|
+
const broken = "postgres//u:p@localhost:5432/acme"
|
|
115
|
+
writeEnv(`DATABASE_URL=${broken}\nSUPATYPE_DB_PORT=5433\n`)
|
|
116
|
+
expect(syncDatabaseUrlPort(dir)).toBeNull()
|
|
117
|
+
expect(envText()).toContain(broken)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("does nothing when there is no DATABASE_URL at all", () => {
|
|
121
|
+
writeEnv("SUPATYPE_DB_PORT=5433\n")
|
|
122
|
+
expect(syncDatabaseUrlPort(dir)).toBeNull()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("does nothing when there is no .env", () => {
|
|
126
|
+
expect(syncDatabaseUrlPort(dir)).toBeNull()
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -42,13 +42,20 @@ describe("external database: compose", () => {
|
|
|
42
42
|
const compose = renderSelfHostCompose(managed())
|
|
43
43
|
expect(compose).toMatch(/^ {2}db:$/m)
|
|
44
44
|
expect(compose).toContain("pg_isready")
|
|
45
|
+
// The positive half of "leaves no reference to the db host anywhere" below. Without this, that
|
|
46
|
+
// test's negative assertion could pass against a pattern that matches nothing at all.
|
|
47
|
+
expect(compose).toMatch(/^\s+db:\s*\n\s+condition:/m)
|
|
45
48
|
})
|
|
46
49
|
|
|
47
50
|
it("leaves no reference to the db host anywhere", () => {
|
|
48
51
|
const compose = renderSelfHostCompose(external())
|
|
49
52
|
expect(compose).not.toContain("@db:5432")
|
|
50
53
|
expect(compose).not.toContain("db-data")
|
|
51
|
-
|
|
54
|
+
// Nothing may wait on a `db` service that is not there. Asserted as a dependency on `db`
|
|
55
|
+
// rather than as the absence of any health condition, which is what this used to say: storage
|
|
56
|
+
// waits for the object store to be healthy in every deployment, because that race is about
|
|
57
|
+
// seaweedfs and has nothing to do with where Postgres lives.
|
|
58
|
+
expect(compose).not.toMatch(/^\s+db:\s*\n\s+condition:/m)
|
|
52
59
|
})
|
|
53
60
|
|
|
54
61
|
it("points every database consumer at the one URL", () => {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `functions/_shared/context.ts` reaches a project that already exists.
|
|
3
|
+
*
|
|
4
|
+
* The module was written by `init` and `supatype functions new`, and by nothing else. So it arrived
|
|
5
|
+
* with new projects and new functions, and never arrived at a project that already had functions
|
|
6
|
+
* when the two-argument handler contract landed: those keep `(req)` handlers with no
|
|
7
|
+
* `FunctionContext` to import, while the generated file itself asserts
|
|
8
|
+
*
|
|
9
|
+
* Regenerated by the CLI. Edits are overwritten.
|
|
10
|
+
*
|
|
11
|
+
* which was true only if you scaffolded again. `tests/integration` in this repo had four functions
|
|
12
|
+
* and no context module at all, which is how it was found.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it, beforeEach, afterEach } from "vitest"
|
|
15
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
16
|
+
import { join } from "node:path"
|
|
17
|
+
import { tmpdir } from "node:os"
|
|
18
|
+
import { refreshFunctionsContext } from "../src/functions-context-refresh.js"
|
|
19
|
+
import type { SupatypeProjectConfig } from "../src/project-config.js"
|
|
20
|
+
|
|
21
|
+
const config: SupatypeProjectConfig = {
|
|
22
|
+
project: { name: "ctx" },
|
|
23
|
+
database: { provider: "docker" },
|
|
24
|
+
} as SupatypeProjectConfig
|
|
25
|
+
|
|
26
|
+
let dir: string
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
dir = mkdtempSync(join(tmpdir(), "supatype-ctx-"))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/** A project with functions but no `_shared`, which is what a pre-migration project looks like. */
|
|
37
|
+
function projectWithFunctions(): string {
|
|
38
|
+
const functions = join(dir, "functions")
|
|
39
|
+
mkdirSync(functions, { recursive: true })
|
|
40
|
+
writeFileSync(
|
|
41
|
+
join(functions, "ping.ts"),
|
|
42
|
+
"export default function ping(_req: Request) { return new Response('ok') }\n",
|
|
43
|
+
"utf8",
|
|
44
|
+
)
|
|
45
|
+
return functions
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("a project that already has functions", () => {
|
|
49
|
+
it("gets the context module written", () => {
|
|
50
|
+
const functions = projectWithFunctions()
|
|
51
|
+
expect(existsSync(join(functions, "_shared", "context.ts"))).toBe(false)
|
|
52
|
+
|
|
53
|
+
expect(refreshFunctionsContext(dir, config)).toBe(true)
|
|
54
|
+
expect(existsSync(join(functions, "_shared", "context.ts"))).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("writes a module that actually declares the contract", () => {
|
|
58
|
+
// Not merely that a file appeared. The point is the type handlers import.
|
|
59
|
+
projectWithFunctions()
|
|
60
|
+
refreshFunctionsContext(dir, config)
|
|
61
|
+
const source = readFileSync(join(dir, "functions", "_shared", "context.ts"), "utf8")
|
|
62
|
+
expect(source).toContain("FunctionContext")
|
|
63
|
+
expect(source).toContain("executionId")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("rewrites a stale copy rather than leaving it", () => {
|
|
67
|
+
// The file says edits are overwritten, so they have to be.
|
|
68
|
+
const functions = projectWithFunctions()
|
|
69
|
+
mkdirSync(join(functions, "_shared"), { recursive: true })
|
|
70
|
+
writeFileSync(join(functions, "_shared", "context.ts"), "// stale\n", "utf8")
|
|
71
|
+
|
|
72
|
+
refreshFunctionsContext(dir, config)
|
|
73
|
+
const source = readFileSync(join(functions, "_shared", "context.ts"), "utf8")
|
|
74
|
+
expect(source).not.toBe("// stale\n")
|
|
75
|
+
expect(source).toContain("FunctionContext")
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe("a project with no functions", () => {
|
|
80
|
+
it("is left alone", () => {
|
|
81
|
+
// Writing `_shared/` into a project with no functions would create the thing it describes out
|
|
82
|
+
// of nothing, and leave a directory nobody asked for in the diff.
|
|
83
|
+
expect(refreshFunctionsContext(dir, config)).toBe(false)
|
|
84
|
+
expect(existsSync(join(dir, "functions"))).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `.gitignore` we write has to cover the secrets we write.
|
|
3
|
+
*
|
|
4
|
+
* `supatype functions new` writes `functions/.env.local` with "These are NOT committed to git" at
|
|
5
|
+
* the top of it, and nothing made that true: `.env` matches a file named exactly `.env`, and both
|
|
6
|
+
* `.env.local` and `.env.<function>.local` were tracked.
|
|
7
|
+
*
|
|
8
|
+
* Asserted through `git check-ignore` rather than by reading the patterns, because the question is
|
|
9
|
+
* what git does with them and a pattern that looks right is how this got shipped.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it, beforeEach, afterEach } from "vitest"
|
|
13
|
+
import { execFileSync } from "node:child_process"
|
|
14
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
|
15
|
+
import { join } from "node:path"
|
|
16
|
+
import { tmpdir } from "node:os"
|
|
17
|
+
import { SUPATYPE_GITIGNORE_BLOCK, SUPATYPE_GITIGNORE_PATHS } from "../src/gitignore.js"
|
|
18
|
+
|
|
19
|
+
let dir: string
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
dir = mkdtempSync(join(tmpdir(), "supatype-ignore-"))
|
|
23
|
+
execFileSync("git", ["init", "-q", "."], { cwd: dir })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
rmSync(dir, { recursive: true, force: true })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/** What git itself says about a path, which is the only answer that counts. */
|
|
31
|
+
function ignored(relPath: string): boolean {
|
|
32
|
+
try {
|
|
33
|
+
execFileSync("git", ["check-ignore", "-q", relPath], { cwd: dir })
|
|
34
|
+
return true
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeIgnore(contents: string): void {
|
|
41
|
+
writeFileSync(join(dir, ".gitignore"), contents, "utf8")
|
|
42
|
+
mkdirSync(join(dir, "functions"), { recursive: true })
|
|
43
|
+
mkdirSync(join(dir, "src"), { recursive: true })
|
|
44
|
+
for (const f of [
|
|
45
|
+
".env",
|
|
46
|
+
".env.local",
|
|
47
|
+
"functions/.env.local",
|
|
48
|
+
"functions/.env.checkout.local",
|
|
49
|
+
"src/index.ts",
|
|
50
|
+
"src/env.local.ts",
|
|
51
|
+
]) {
|
|
52
|
+
writeFileSync(join(dir, f), "x", "utf8")
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("the block written into an existing .gitignore", () => {
|
|
57
|
+
beforeEach(() => writeIgnore(SUPATYPE_GITIGNORE_BLOCK))
|
|
58
|
+
|
|
59
|
+
it("ignores the plain env file", () => {
|
|
60
|
+
expect(ignored(".env")).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("ignores the function env files, at the root and nested", () => {
|
|
64
|
+
// The ones `supatype functions new` writes and the worker reads.
|
|
65
|
+
expect(ignored(".env.local")).toBe(true)
|
|
66
|
+
expect(ignored("functions/.env.local")).toBe(true)
|
|
67
|
+
expect(ignored("functions/.env.checkout.local")).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("does not swallow source files", () => {
|
|
71
|
+
// `.env.*.local` is a glob, and a careless widening of it catches real code.
|
|
72
|
+
expect(ignored("src/index.ts")).toBe(false)
|
|
73
|
+
expect(ignored("src/env.local.ts")).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe("the two emitters", () => {
|
|
78
|
+
it("agree, because they read one list", () => {
|
|
79
|
+
// They were separate copies and had already drifted. The copy that drifts is the one found
|
|
80
|
+
// when a key reaches a public repository.
|
|
81
|
+
for (const path of SUPATYPE_GITIGNORE_PATHS) {
|
|
82
|
+
expect(SUPATYPE_GITIGNORE_BLOCK).toContain(path)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("carries the env patterns `.env` alone does not cover", () => {
|
|
87
|
+
expect(SUPATYPE_GITIGNORE_PATHS).toContain(".env.local")
|
|
88
|
+
expect(SUPATYPE_GITIGNORE_PATHS).toContain(".env.*.local")
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `supatype keys --write` writes the pair it mints.
|
|
3
|
+
*
|
|
4
|
+
* The command printed the keys and wrote nothing, while every README in this repository says it
|
|
5
|
+
* mints them into `.env`. Following the docs therefore produced a front end built with
|
|
6
|
+
* `anonKey: undefined` and a gateway refusing its every request, with nothing in the output saying
|
|
7
|
+
* so.
|
|
8
|
+
*
|
|
9
|
+
* Writing is opt-in rather than the default: a command that reads a secret and edits a file on
|
|
10
|
+
* sight is worse than one that has to be asked. What it writes is blanks only, because an anon key
|
|
11
|
+
* already in `.env` is held by clients this command cannot reach and reissuing it locks them out.
|
|
12
|
+
* `--force` is how someone says they meant to rotate.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
|
15
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs"
|
|
16
|
+
import { tmpdir } from "node:os"
|
|
17
|
+
import { join } from "node:path"
|
|
18
|
+
import { anonKeyEnvUpdates, writeKeysToEnv } from "../src/commands/keys.js"
|
|
19
|
+
|
|
20
|
+
const PAIR = { anonKey: "anon.jwt.value", serviceKey: "service.jwt.value" }
|
|
21
|
+
|
|
22
|
+
function envValue(dir: string, key: string): string | undefined {
|
|
23
|
+
const line = readFileSync(join(dir, ".env"), "utf8")
|
|
24
|
+
.split("\n")
|
|
25
|
+
.find((l) => l.startsWith(`${key}=`))
|
|
26
|
+
return line?.slice(key.length + 1).trim()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("writeKeysToEnv", () => {
|
|
30
|
+
let dir: string
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
dir = mkdtempSync(join(tmpdir(), "supatype-keys-"))
|
|
34
|
+
})
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
rmSync(dir, { recursive: true, force: true })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("fills blank keys", () => {
|
|
40
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nANON_KEY=\nSERVICE_ROLE_KEY=\n")
|
|
41
|
+
const result = writeKeysToEnv(dir, PAIR, false)
|
|
42
|
+
|
|
43
|
+
expect(result.written).toContain("ANON_KEY")
|
|
44
|
+
expect(result.written).toContain("SERVICE_ROLE_KEY")
|
|
45
|
+
expect(envValue(dir, "ANON_KEY")).toBe(PAIR.anonKey)
|
|
46
|
+
expect(envValue(dir, "SERVICE_ROLE_KEY")).toBe(PAIR.serviceKey)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("adds keys that are absent entirely", () => {
|
|
50
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\n")
|
|
51
|
+
writeKeysToEnv(dir, PAIR, false)
|
|
52
|
+
|
|
53
|
+
expect(envValue(dir, "ANON_KEY")).toBe(PAIR.anonKey)
|
|
54
|
+
expect(envValue(dir, "VITE_SUPATYPE_ANON_KEY")).toBe(PAIR.anonKey)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("treats a declared-but-blank line as a blank to fill", () => {
|
|
58
|
+
// The templates ship `ANON_KEY=` with nothing after it, which is exactly the line to write.
|
|
59
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nVITE_SUPATYPE_ANON_KEY=\n")
|
|
60
|
+
const result = writeKeysToEnv(dir, PAIR, false)
|
|
61
|
+
|
|
62
|
+
expect(result.written).toContain("VITE_SUPATYPE_ANON_KEY")
|
|
63
|
+
expect(envValue(dir, "VITE_SUPATYPE_ANON_KEY")).toBe(PAIR.anonKey)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("leaves a key that already holds a value, and says which", () => {
|
|
67
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nANON_KEY=in-use-by-clients\nSERVICE_ROLE_KEY=\n")
|
|
68
|
+
const result = writeKeysToEnv(dir, PAIR, false)
|
|
69
|
+
|
|
70
|
+
expect(result.kept).toContain("ANON_KEY")
|
|
71
|
+
expect(result.written).not.toContain("ANON_KEY")
|
|
72
|
+
expect(envValue(dir, "ANON_KEY")).toBe("in-use-by-clients")
|
|
73
|
+
expect(envValue(dir, "SERVICE_ROLE_KEY")).toBe(PAIR.serviceKey)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("replaces every key it owns under --force, not only the blank ones", () => {
|
|
77
|
+
writeFileSync(
|
|
78
|
+
join(dir, ".env"),
|
|
79
|
+
"JWT_SECRET=s\nANON_KEY=old-anon\nSERVICE_ROLE_KEY=old-service\nVITE_SUPATYPE_ANON_KEY=old-vite\n",
|
|
80
|
+
)
|
|
81
|
+
const result = writeKeysToEnv(dir, PAIR, true)
|
|
82
|
+
|
|
83
|
+
expect(result.kept).toEqual([])
|
|
84
|
+
expect(envValue(dir, "ANON_KEY")).toBe(PAIR.anonKey)
|
|
85
|
+
expect(envValue(dir, "SERVICE_ROLE_KEY")).toBe(PAIR.serviceKey)
|
|
86
|
+
expect(envValue(dir, "VITE_SUPATYPE_ANON_KEY")).toBe(PAIR.anonKey)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("does not rewrite the file, even under --force", () => {
|
|
90
|
+
// `.env` holds JWT_SECRET, which these keys are derived from, plus the database password and
|
|
91
|
+
// whatever else the project keeps there. Replacing the file would leave a project whose keys
|
|
92
|
+
// cannot be regenerated and whose stack cannot start.
|
|
93
|
+
writeFileSync(
|
|
94
|
+
join(dir, ".env"),
|
|
95
|
+
"# a comment someone wrote\nJWT_SECRET=s\nPOSTGRES_PASSWORD=pw\nANON_KEY=old\nCUSTOM_THING=mine\n",
|
|
96
|
+
)
|
|
97
|
+
writeKeysToEnv(dir, PAIR, true)
|
|
98
|
+
|
|
99
|
+
const content = readFileSync(join(dir, ".env"), "utf8")
|
|
100
|
+
expect(content).toContain("# a comment someone wrote")
|
|
101
|
+
expect(envValue(dir, "JWT_SECRET")).toBe("s")
|
|
102
|
+
expect(envValue(dir, "POSTGRES_PASSWORD")).toBe("pw")
|
|
103
|
+
expect(envValue(dir, "CUSTOM_THING")).toBe("mine")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("carries the API URL through when the project records one", () => {
|
|
107
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\nPUBLIC_SUPATYPE_URL=http://localhost:18475\n")
|
|
108
|
+
writeKeysToEnv(dir, PAIR, true)
|
|
109
|
+
|
|
110
|
+
expect(envValue(dir, "VITE_SUPATYPE_URL")).toBe("http://localhost:18475")
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it("writes no URL names when the project records no URL", () => {
|
|
114
|
+
// An empty `VITE_SUPATYPE_URL` is worse than an absent one: the client reads it, finds a blank
|
|
115
|
+
// string, and requests against the page origin without saying why.
|
|
116
|
+
writeFileSync(join(dir, ".env"), "JWT_SECRET=s\n")
|
|
117
|
+
writeKeysToEnv(dir, PAIR, false)
|
|
118
|
+
|
|
119
|
+
expect(readFileSync(join(dir, ".env"), "utf8")).not.toContain("VITE_SUPATYPE_URL")
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("reports no .env rather than creating one", () => {
|
|
123
|
+
const result = writeKeysToEnv(dir, PAIR, false)
|
|
124
|
+
|
|
125
|
+
expect(result.path).toBeNull()
|
|
126
|
+
expect(result.written).toEqual([])
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe("anonKeyEnvUpdates", () => {
|
|
131
|
+
it("is the one list both `keys` and `dev` write from", () => {
|
|
132
|
+
// They held separate lists, so a front end's prefix could be written by one and forgotten by
|
|
133
|
+
// the other, and which names a project's .env ended up with depended on which command ran last.
|
|
134
|
+
const names = Object.keys(anonKeyEnvUpdates("a", "s"))
|
|
135
|
+
expect(names).toEqual([
|
|
136
|
+
"ANON_KEY",
|
|
137
|
+
"SERVICE_ROLE_KEY",
|
|
138
|
+
"VITE_SUPATYPE_ANON_KEY",
|
|
139
|
+
"PUBLIC_SUPATYPE_ANON_KEY",
|
|
140
|
+
"EXPO_PUBLIC_SUPATYPE_ANON_KEY",
|
|
141
|
+
])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("adds the URL names only when given a URL", () => {
|
|
145
|
+
expect(Object.keys(anonKeyEnvUpdates("a", "s", "http://x"))).toContain("VITE_SUPATYPE_URL")
|
|
146
|
+
expect(Object.keys(anonKeyEnvUpdates("a", "s"))).not.toContain("VITE_SUPATYPE_URL")
|
|
147
|
+
})
|
|
148
|
+
})
|