@supatype/cli 0.1.13 → 0.2.1
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 +151 -143
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/cli-version-embedded.d.ts.map +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/cli-version-embedded.js.map +1 -1
- package/dist/commands/admin.d.ts +5 -5
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +10 -10
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/dev.js +51 -51
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/keys.d.ts +14 -0
- package/dist/commands/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +37 -0
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/logs.d.ts +14 -1
- package/dist/commands/logs.d.ts.map +1 -1
- package/dist/commands/logs.js +33 -37
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +3 -0
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/self-host.d.ts.map +1 -1
- package/dist/commands/self-host.js +9 -0
- package/dist/commands/self-host.js.map +1 -1
- package/dist/commands/status.d.ts +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +33 -64
- package/dist/commands/status.js.map +1 -1
- package/dist/compose-services.d.ts +59 -0
- package/dist/compose-services.d.ts.map +1 -0
- package/dist/compose-services.js +160 -0
- package/dist/compose-services.js.map +1 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +123 -20
- package/dist/dev-compose.js.map +1 -1
- package/dist/dev-ports.d.ts +24 -0
- package/dist/dev-ports.d.ts.map +1 -1
- package/dist/dev-ports.js +37 -0
- package/dist/dev-ports.js.map +1 -1
- package/dist/docker-postgres.d.ts +1 -1
- package/dist/docker-postgres.d.ts.map +1 -1
- package/dist/docker-postgres.js +2 -2
- package/dist/docker-postgres.js.map +1 -1
- package/dist/engine-floor.d.ts +50 -0
- package/dist/engine-floor.d.ts.map +1 -0
- package/dist/engine-floor.js +80 -0
- package/dist/engine-floor.js.map +1 -0
- package/dist/link.d.ts +1 -1
- package/dist/link.d.ts.map +1 -1
- package/dist/pgbouncer-userlist.d.ts +1 -1
- package/dist/pgbouncer-userlist.d.ts.map +1 -1
- package/dist/pgbouncer-userlist.js +1 -1
- package/dist/pgbouncer-userlist.js.map +1 -1
- package/dist/postgres-ctl.d.ts +5 -1
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +43 -4
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/project-config.d.ts +4 -4
- package/dist/resolve-target.d.ts +1 -1
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +41 -18
- package/dist/self-host-compose.js.map +1 -1
- package/dist/studio-dev-server.d.ts +8 -1
- package/dist/studio-dev-server.d.ts.map +1 -1
- package/dist/studio-dev-server.js +1 -2
- package/dist/studio-dev-server.js.map +1 -1
- package/package.json +2 -2
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/admin.ts +10 -10
- package/src/commands/dev.ts +51 -51
- package/src/commands/init.ts +1 -1
- package/src/commands/keys.ts +40 -0
- package/src/commands/logs.ts +34 -37
- package/src/commands/push.ts +3 -0
- package/src/commands/self-host.ts +9 -0
- package/src/commands/status.ts +34 -70
- package/src/compose-services.ts +174 -0
- package/src/config.ts +1 -1
- package/src/dev-compose.ts +130 -20
- package/src/dev-ports.ts +48 -0
- package/src/docker-postgres.ts +2 -2
- package/src/engine-floor.ts +113 -0
- package/src/link.ts +1 -1
- package/src/pgbouncer-userlist.ts +1 -1
- package/src/postgres-ctl.ts +44 -4
- package/src/project-config.ts +4 -4
- package/src/resolve-target.ts +1 -1
- package/src/self-host-compose.ts +41 -18
- package/src/studio-dev-server.ts +9 -3
- package/tests/admin-ensure.test.ts +10 -10
- package/tests/compose-parsable-defaults.test.ts +60 -0
- package/tests/compose-services.test.ts +144 -0
- package/tests/dev-ports-owner.test.ts +40 -0
- package/tests/engine-floor.test.ts +102 -0
- package/tests/external-database-compose.test.ts +3 -3
- package/tests/port-in-use.test.ts +58 -0
- package/tests/readiness-poll.test.ts +84 -0
- package/tests/runtime-contract.test.ts +1 -1
- package/tests/self-host-keys.test.ts +92 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createServer, type Server } from "node:net"
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
3
|
+
import { isPortInUse } from "../src/postgres-ctl.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `supatype dev` asks this before handing a port to Docker Compose, so a wrong answer costs a
|
|
7
|
+
* failed `compose up`, not a warning:
|
|
8
|
+
*
|
|
9
|
+
* Bind for 0.0.0.0:5432 failed: port is already allocated
|
|
10
|
+
*
|
|
11
|
+
* The Docker case cannot be reproduced here, because it needs Docker Desktop's port proxy, which
|
|
12
|
+
* publishes without holding a bind that a socket can see. It is why this function connects as well
|
|
13
|
+
* as binds; the loopback case below is what a unit test can hold onto.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// Ports in the high range this repository does not otherwise use.
|
|
17
|
+
const PORT = 19871
|
|
18
|
+
|
|
19
|
+
let server: Server | null = null
|
|
20
|
+
|
|
21
|
+
function listen(port: number, host: string): Promise<Server> {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const created = createServer()
|
|
24
|
+
created.once("error", reject)
|
|
25
|
+
created.listen(port, host, () => resolve(created))
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
if (server !== null) {
|
|
31
|
+
await new Promise<void>((resolve) => server!.close(() => resolve()))
|
|
32
|
+
server = null
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe("isPortInUse", () => {
|
|
37
|
+
it("reports a free port as free", async () => {
|
|
38
|
+
await expect(isPortInUse(PORT)).resolves.toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("reports a port with a loopback listener as in use", async () => {
|
|
42
|
+
server = await listen(PORT, "127.0.0.1")
|
|
43
|
+
await expect(isPortInUse(PORT)).resolves.toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it("reports a port with a wildcard listener as in use", async () => {
|
|
47
|
+
server = await listen(PORT, "0.0.0.0")
|
|
48
|
+
await expect(isPortInUse(PORT)).resolves.toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("reports the port free again once the listener closes", async () => {
|
|
52
|
+
server = await listen(PORT, "127.0.0.1")
|
|
53
|
+
await expect(isPortInUse(PORT)).resolves.toBe(true)
|
|
54
|
+
await new Promise<void>((resolve) => server!.close(() => resolve()))
|
|
55
|
+
server = null
|
|
56
|
+
await expect(isPortInUse(PORT)).resolves.toBe(false)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createServer, type Server } from "node:http"
|
|
2
|
+
import { readFileSync } from "node:fs"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
import type { AddressInfo } from "node:net"
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A readiness poll has to be able to give up, and has to poll an address that is bound.
|
|
9
|
+
*
|
|
10
|
+
* `supatype push` reported the schema applied and then appeared to hang, on a wait whose own error
|
|
11
|
+
* message advertised 120 seconds. Two things were wrong. The loop was bounded by iterations rather
|
|
12
|
+
* than by time, and each `fetch` carried no deadline of its own, so against a socket that accepts
|
|
13
|
+
* and never answers undici's ~30s default applied instead of the intended 1s: "120 attempts, one
|
|
14
|
+
* second apart" is really closer to an hour. And it polled `localhost`, which resolves to `::1`
|
|
15
|
+
* first, where Docker Desktop's IPv6 relay accepts and then goes silent when unhealthy. Docker
|
|
16
|
+
* publishes on `0.0.0.0`, so IPv4 is the address certainly bound.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const servers: Server[] = []
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await Promise.all(
|
|
23
|
+
servers.splice(0).map((s) => new Promise<void>((resolve) => s.close(() => resolve()))),
|
|
24
|
+
)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/** A server that accepts connections and never responds, like the unhealthy relay. */
|
|
28
|
+
async function blackHole(): Promise<number> {
|
|
29
|
+
const server = createServer(() => {
|
|
30
|
+
/* deliberately no response, and no socket close */
|
|
31
|
+
})
|
|
32
|
+
servers.push(server)
|
|
33
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
|
|
34
|
+
return (server.address() as AddressInfo).port
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const devCompose = (): string =>
|
|
38
|
+
readFileSync(fileURLToPath(new URL("../src/dev-compose.ts", import.meta.url)), "utf8")
|
|
39
|
+
|
|
40
|
+
describe("a readiness attempt against a port that accepts and never answers", () => {
|
|
41
|
+
it("ends promptly when it carries a deadline", async () => {
|
|
42
|
+
const port = await blackHole()
|
|
43
|
+
const started = Date.now()
|
|
44
|
+
|
|
45
|
+
await expect(
|
|
46
|
+
fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(300) }),
|
|
47
|
+
).rejects.toThrow()
|
|
48
|
+
|
|
49
|
+
// Generous, because the point is that it returns at all rather than how fast.
|
|
50
|
+
expect(Date.now() - started).toBeLessThan(3000)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe("the polls the CLI actually runs", () => {
|
|
55
|
+
it("give every attempt a deadline", () => {
|
|
56
|
+
const source = devCompose()
|
|
57
|
+
// `fetchWithin` is the only way to reach the gateway from a poll loop. A bare `fetch` inside
|
|
58
|
+
// one is the bug: the loop's iteration count stops being its real bound.
|
|
59
|
+
const helper = source.match(/async function fetchWithin\(/g) ?? []
|
|
60
|
+
expect(helper.length, "the timeout helper should exist").toBe(1)
|
|
61
|
+
|
|
62
|
+
const kongPoll = source.slice(
|
|
63
|
+
source.indexOf("async function waitKongReady("),
|
|
64
|
+
source.indexOf("async function provisionDockerStorageBuckets("),
|
|
65
|
+
)
|
|
66
|
+
// Strip the wrapper's own name before looking for a bare call, or `fetchWithin(` matches
|
|
67
|
+
// `fetch(` and the check passes on exactly the code it exists to reject.
|
|
68
|
+
const bare = kongPoll.replaceAll("fetchWithin(", "<<ok>>")
|
|
69
|
+
expect(bare, "a poll loop must not call fetch without a deadline").not.toMatch(/\bfetch\(/)
|
|
70
|
+
expect(kongPoll).toMatch(/fetchWithin\(/)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("target IPv4, not a name that may resolve to an unbound address", () => {
|
|
74
|
+
const source = devCompose()
|
|
75
|
+
const kongPoll = source.slice(
|
|
76
|
+
source.indexOf("async function waitKongReady("),
|
|
77
|
+
source.indexOf("async function provisionDockerStorageBuckets("),
|
|
78
|
+
)
|
|
79
|
+
// The URLs written to `.env` and printed for the operator stay on `localhost`, deliberately:
|
|
80
|
+
// those are for a browser. This is about what the CLI dials itself.
|
|
81
|
+
expect(kongPoll).not.toContain("http://localhost:")
|
|
82
|
+
expect(kongPoll).toMatch(/loopbackBase\(/)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -163,7 +163,7 @@ describe("runtime contract", () => {
|
|
|
163
163
|
})
|
|
164
164
|
|
|
165
165
|
// The whole point of requiring them: a service must never fall back to a secret published in
|
|
166
|
-
// this repository. That default used to reach PostgREST, storage and
|
|
166
|
+
// this repository. That default used to reach PostgREST, storage and auth.
|
|
167
167
|
it("never defaults a secret to a published constant", () => {
|
|
168
168
|
const compose = renderSelfHostCompose(baseConfig)
|
|
169
169
|
expect(compose).not.toContain("super-secret-jwt-token")
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
|
2
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { fillMissingKeys } from "../src/commands/keys.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A self-host `.env` copied from the template arrives with ANON_KEY and
|
|
9
|
+
* SERVICE_ROLE_KEY blank, and the server refuses to serve without a service
|
|
10
|
+
* role key outside dev mode. The documented quick start could not start.
|
|
11
|
+
*
|
|
12
|
+
* Filling them has to be conservative: an operator's existing key must never be
|
|
13
|
+
* reissued, because every client holding the old one would stop working.
|
|
14
|
+
*/
|
|
15
|
+
const SECRET = "a-local-development-secret-at-least-32-chars"
|
|
16
|
+
|
|
17
|
+
let dir: string
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
dir = mkdtempSync(join(tmpdir(), "selfhost-keys-"))
|
|
20
|
+
})
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
rmSync(dir, { recursive: true, force: true })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
const envPath = () => join(dir, ".env")
|
|
26
|
+
const read = () => readFileSync(envPath(), "utf8")
|
|
27
|
+
const valueOf = (name: string): string => {
|
|
28
|
+
const line = read().split(/\r?\n/).find((l) => l.startsWith(`${name}=`))
|
|
29
|
+
return line === undefined ? "" : line.slice(name.length + 1)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("filling the self-host keys", () => {
|
|
33
|
+
it("mints both when the template leaves them blank", () => {
|
|
34
|
+
writeFileSync(envPath(), `JWT_SECRET=${SECRET}\nANON_KEY=\nSERVICE_ROLE_KEY=\n`)
|
|
35
|
+
|
|
36
|
+
const filled = fillMissingKeys(dir)
|
|
37
|
+
|
|
38
|
+
expect(filled.sort()).toEqual(["ANON_KEY", "SERVICE_ROLE_KEY"])
|
|
39
|
+
expect(valueOf("ANON_KEY").length).toBeGreaterThan(20)
|
|
40
|
+
expect(valueOf("SERVICE_ROLE_KEY").length).toBeGreaterThan(20)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("signs them with the secret in the file, so they verify against it", () => {
|
|
44
|
+
writeFileSync(envPath(), `JWT_SECRET=${SECRET}\nANON_KEY=\nSERVICE_ROLE_KEY=\n`)
|
|
45
|
+
fillMissingKeys(dir)
|
|
46
|
+
|
|
47
|
+
const claims = (jwt: string): Record<string, unknown> =>
|
|
48
|
+
JSON.parse(Buffer.from(jwt.split(".")[1] as string, "base64url").toString("utf8"))
|
|
49
|
+
|
|
50
|
+
expect(claims(valueOf("ANON_KEY")).role).toBe("anon")
|
|
51
|
+
expect(claims(valueOf("SERVICE_ROLE_KEY")).role).toBe("service_role")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("leaves a key the operator already set alone", () => {
|
|
55
|
+
writeFileSync(
|
|
56
|
+
envPath(),
|
|
57
|
+
`JWT_SECRET=${SECRET}\nANON_KEY=an-existing-key-in-use\nSERVICE_ROLE_KEY=\n`,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const filled = fillMissingKeys(dir)
|
|
61
|
+
|
|
62
|
+
expect(filled).toEqual(["SERVICE_ROLE_KEY"])
|
|
63
|
+
expect(valueOf("ANON_KEY")).toBe("an-existing-key-in-use")
|
|
64
|
+
expect(valueOf("SERVICE_ROLE_KEY").length).toBeGreaterThan(20)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("does nothing when both are already set", () => {
|
|
68
|
+
const before = `JWT_SECRET=${SECRET}\nANON_KEY=key-one\nSERVICE_ROLE_KEY=key-two\n`
|
|
69
|
+
writeFileSync(envPath(), before)
|
|
70
|
+
|
|
71
|
+
expect(fillMissingKeys(dir)).toEqual([])
|
|
72
|
+
expect(read()).toBe(before)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("treats whitespace as blank, since a stray space is not a key", () => {
|
|
76
|
+
writeFileSync(envPath(), `JWT_SECRET=${SECRET}\nANON_KEY= \nSERVICE_ROLE_KEY=key-two\n`)
|
|
77
|
+
|
|
78
|
+
expect(fillMissingKeys(dir)).toEqual(["ANON_KEY"])
|
|
79
|
+
expect(valueOf("ANON_KEY").length).toBeGreaterThan(20)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("does nothing without a secret, rather than minting keys nothing can verify", () => {
|
|
83
|
+
writeFileSync(envPath(), "ANON_KEY=\nSERVICE_ROLE_KEY=\n")
|
|
84
|
+
|
|
85
|
+
expect(fillMissingKeys(dir)).toEqual([])
|
|
86
|
+
expect(valueOf("ANON_KEY")).toBe("")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("does nothing when there is no .env to write to", () => {
|
|
90
|
+
expect(fillMissingKeys(dir)).toEqual([])
|
|
91
|
+
})
|
|
92
|
+
})
|