@supatype/cli 0.2.1 → 0.3.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 +193 -157
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/api-config-cache.d.ts +34 -0
- package/dist/api-config-cache.d.ts.map +1 -0
- package/dist/api-config-cache.js +123 -0
- package/dist/api-config-cache.js.map +1 -0
- package/dist/cache-identity-scope.d.ts +38 -0
- package/dist/cache-identity-scope.d.ts.map +1 -0
- package/dist/cache-identity-scope.js +70 -0
- package/dist/cache-identity-scope.js.map +1 -0
- package/dist/cache-provider.d.ts +30 -0
- package/dist/cache-provider.d.ts.map +1 -0
- package/dist/cache-provider.js +60 -0
- package/dist/cache-provider.js.map +1 -0
- package/dist/cli-version-embedded.js +1 -1
- package/dist/commands/add.js +1 -1
- package/dist/commands/add.js.map +1 -1
- 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/cache.d.ts +1 -1
- package/dist/commands/cache.js +3 -3
- package/dist/commands/cache.js.map +1 -1
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +2 -1
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +2 -1
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +61 -8
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/diff.d.ts.map +1 -1
- package/dist/commands/diff.js +2 -1
- package/dist/commands/diff.js.map +1 -1
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +6 -2
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +13 -9
- 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/migrate.d.ts.map +1 -1
- package/dist/commands/migrate.js +2 -1
- package/dist/commands/migrate.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +61 -1
- package/dist/commands/push.js.map +1 -1
- package/dist/compose-services.d.ts.map +1 -1
- package/dist/compose-services.js +2 -1
- package/dist/compose-services.js.map +1 -1
- package/dist/dev-compose.d.ts +13 -0
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +119 -16
- package/dist/dev-compose.js.map +1 -1
- package/dist/engine-floor.d.ts +11 -1
- package/dist/engine-floor.d.ts.map +1 -1
- package/dist/engine-floor.js +49 -16
- package/dist/engine-floor.js.map +1 -1
- 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 +45 -1
- package/dist/functions-deno-types.js.map +1 -1
- package/dist/functions-router-gen.d.ts.map +1 -1
- package/dist/functions-router-gen.js +113 -81
- package/dist/functions-router-gen.js.map +1 -1
- package/dist/kong-config.d.ts +1 -1
- package/dist/model-cache.d.ts +44 -0
- package/dist/model-cache.d.ts.map +1 -0
- package/dist/model-cache.js +106 -0
- package/dist/model-cache.js.map +1 -0
- package/dist/model-hooks.d.ts +5 -0
- package/dist/model-hooks.d.ts.map +1 -1
- package/dist/model-hooks.js +29 -1
- package/dist/model-hooks.js.map +1 -1
- package/dist/model-versioning.d.ts +80 -0
- package/dist/model-versioning.d.ts.map +1 -0
- package/dist/model-versioning.js +140 -0
- package/dist/model-versioning.js.map +1 -0
- package/dist/postgres-ctl.d.ts +55 -0
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +84 -8
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/preview-config-check.d.ts +21 -0
- package/dist/preview-config-check.d.ts.map +1 -0
- package/dist/preview-config-check.js +47 -0
- package/dist/preview-config-check.js.map +1 -0
- package/dist/project-config.d.ts +182 -5
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js +125 -8
- package/dist/project-config.js.map +1 -1
- package/dist/resolve-target.d.ts +9 -0
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/resolve-target.js.map +1 -1
- package/dist/rest-cache-admin.d.ts.map +1 -1
- package/dist/rest-cache-admin.js +6 -2
- package/dist/rest-cache-admin.js.map +1 -1
- package/dist/schema-ast-v2.d.ts +32 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +14 -1
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/self-host-compose.d.ts +20 -1
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +227 -29
- package/dist/self-host-compose.js.map +1 -1
- package/dist/studio-admin-roles.d.ts +8 -1
- package/dist/studio-admin-roles.d.ts.map +1 -1
- package/dist/studio-admin-roles.js +16 -2
- package/dist/studio-admin-roles.js.map +1 -1
- package/dist/type-extractor.d.ts +7 -0
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +234 -3
- package/dist/type-extractor.js.map +1 -1
- package/package.json +2 -2
- package/src/api-config-cache.ts +142 -0
- package/src/cache-identity-scope.ts +92 -0
- package/src/cache-provider.ts +61 -0
- package/src/cli-version-embedded.ts +1 -1
- package/src/commands/add.ts +1 -1
- package/src/commands/admin.ts +23 -6
- package/src/commands/cache.ts +3 -3
- package/src/commands/cloud.ts +2 -1
- package/src/commands/deploy.ts +2 -1
- package/src/commands/dev.ts +68 -7
- package/src/commands/diff.ts +2 -1
- package/src/commands/functions.ts +6 -2
- package/src/commands/init.ts +13 -9
- package/src/commands/keys.ts +112 -7
- package/src/commands/migrate.ts +2 -1
- package/src/commands/push.ts +68 -1
- package/src/compose-services.ts +2 -1
- package/src/dev-compose.ts +151 -15
- package/src/engine-floor.ts +73 -16
- package/src/functions-deno-types.ts +48 -1
- package/src/functions-router-gen.ts +113 -81
- package/src/kong-config.ts +1 -1
- package/src/model-cache.ts +122 -0
- package/src/model-hooks.ts +26 -2
- package/src/model-versioning.ts +167 -0
- package/src/postgres-ctl.ts +103 -9
- package/src/preview-config-check.ts +52 -0
- package/src/project-config.ts +273 -11
- package/src/resolve-target.ts +11 -1
- package/src/rest-cache-admin.ts +8 -2
- package/src/schema-ast-v2.ts +47 -0
- package/src/self-host-compose.ts +250 -29
- package/src/studio-admin-roles.ts +16 -2
- package/src/type-extractor.ts +281 -2
- package/tests/api-config-cache-seed.test.ts +180 -0
- package/tests/cache-identity-scope.test.ts +61 -0
- package/tests/cache-provider-config.test.ts +132 -0
- package/tests/engine-floor.test.ts +43 -0
- package/tests/external-database-compose.test.ts +74 -3
- package/tests/field-masking-tier.test.ts +3 -3
- package/tests/fixtures/identity-scope-corpus.json +42 -0
- package/tests/functions-concurrency.test.ts +71 -0
- package/tests/init.test.ts +36 -2
- package/tests/keys-write.test.ts +148 -0
- package/tests/model-cache-declaration.test.ts +229 -0
- package/tests/model-cache-manifest.test.ts +99 -0
- package/tests/model-hooks.test.ts +55 -0
- package/tests/model-versions.test.ts +229 -0
- package/tests/native-keyspace.test.ts +130 -0
- package/tests/preview-config-check.test.ts +104 -0
- package/tests/rest-cache-admin.test.ts +36 -0
- package/tests/runtime-contract.test.ts +176 -22
- package/tests/studio-admin-roles.test.ts +32 -0
- package/tests/type-extractor.test.ts +113 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether an access rule's outcome varies by *who* is asking.
|
|
3
|
+
*
|
|
4
|
+
* This decides whether a model may declare `cache: { public: true }`. A public cache entry is
|
|
5
|
+
* global — one response served to every caller — so on a table whose read rule varies by caller it
|
|
6
|
+
* serves one user's rows to another.
|
|
7
|
+
*
|
|
8
|
+
* **Row-dependence is not the same question, and getting that wrong in either direction is a bug.**
|
|
9
|
+
* `Lte<"published_at", Now>` varies by row and by time but not by caller, so a shared entry is
|
|
10
|
+
* exactly right for it: that is the case public caching exists to serve. Refusing it would make the
|
|
11
|
+
* feature useless. Allowing `Owner<"user_id">` would make it a data leak.
|
|
12
|
+
*
|
|
13
|
+
* ## This is the second implementation of one rule
|
|
14
|
+
*
|
|
15
|
+
* supatype-server already answers this at runtime, in
|
|
16
|
+
* `internal/studiobootstrap.IsIdentityDependent`, to decide whether a response may be cached under
|
|
17
|
+
* a shared scope. This one answers it at *push* time, where the error can name the model and the
|
|
18
|
+
* rule instead of a request quietly not being cached.
|
|
19
|
+
*
|
|
20
|
+
* Two implementations of one rule drift, and the drift is silent: the push says "shareable" and the
|
|
21
|
+
* runtime says "not", or worse the other way round. `tests/fixtures/identity-scope-corpus.json` is
|
|
22
|
+
* the defence — a shared corpus of rules and expected answers that both sides run against. Change
|
|
23
|
+
* this file and you change that corpus; the Go side reads the same cases.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** A rule node, in the shape the extractor emits and the server parses. */
|
|
27
|
+
export interface AccessRuleNode {
|
|
28
|
+
type?: string
|
|
29
|
+
left?: AccessOperand
|
|
30
|
+
right?: AccessOperand
|
|
31
|
+
operand?: AccessOperand
|
|
32
|
+
rules?: AccessRuleNode[]
|
|
33
|
+
rule?: AccessRuleNode
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AccessOperand {
|
|
37
|
+
kind?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The operands whose value differs per caller. Columns, literals and the temporal operands
|
|
42
|
+
* (`Now` and friends) are the same for everyone, so they are not here.
|
|
43
|
+
*/
|
|
44
|
+
const IDENTITY_OPERANDS = new Set(["authUid", "authRole", "claim"])
|
|
45
|
+
|
|
46
|
+
function operandIsIdentityDependent(o: AccessOperand | undefined): boolean {
|
|
47
|
+
if (!o) return false
|
|
48
|
+
return IDENTITY_OPERANDS.has(o.kind ?? "")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isIdentityDependent(rule: AccessRuleNode | undefined | null): boolean {
|
|
52
|
+
// No rule at all is not a caller-varying rule. An absent `access.read` means the table's
|
|
53
|
+
// readability is decided elsewhere, and treating that as identity-dependent would refuse public
|
|
54
|
+
// caching on every model that never wrote one.
|
|
55
|
+
if (!rule) return false
|
|
56
|
+
|
|
57
|
+
switch (rule.type) {
|
|
58
|
+
case "public":
|
|
59
|
+
case "private":
|
|
60
|
+
return false
|
|
61
|
+
|
|
62
|
+
// "Any authenticated user" still distinguishes signed-in from anonymous, so a shared entry
|
|
63
|
+
// could serve a member's view to a stranger.
|
|
64
|
+
case "authenticated":
|
|
65
|
+
case "role":
|
|
66
|
+
case "owner":
|
|
67
|
+
return true
|
|
68
|
+
|
|
69
|
+
// Membership is per caller by construction.
|
|
70
|
+
case "in":
|
|
71
|
+
case "exists":
|
|
72
|
+
return true
|
|
73
|
+
|
|
74
|
+
case "compare":
|
|
75
|
+
return operandIsIdentityDependent(rule.left) || operandIsIdentityDependent(rule.right)
|
|
76
|
+
|
|
77
|
+
case "nullCheck":
|
|
78
|
+
return operandIsIdentityDependent(rule.operand)
|
|
79
|
+
|
|
80
|
+
case "any":
|
|
81
|
+
case "all":
|
|
82
|
+
return (rule.rules ?? []).some(isIdentityDependent)
|
|
83
|
+
|
|
84
|
+
case "not":
|
|
85
|
+
return rule.rule ? isIdentityDependent(rule.rule) : false
|
|
86
|
+
|
|
87
|
+
default:
|
|
88
|
+
// An unrecognised rule is assumed to depend on identity. Guessing the other way would share
|
|
89
|
+
// a cache entry across callers on the strength of not recognising something.
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which RESP server this stack runs: one decision, in one place.
|
|
3
|
+
*
|
|
4
|
+
* The response cache and Kong's ACME certificates share a RESP server. Since P1 that can be
|
|
5
|
+
* pg_keyspace inside the Postgres container — one stateful service instead of two, no second
|
|
6
|
+
* image to keep patched, and a cache whose memory is accounted for in the database's own
|
|
7
|
+
* reservation. That is now the default rather than the option.
|
|
8
|
+
*
|
|
9
|
+
* **Valkey stays selectable**, and stays the answer for `database.external`: pg_keyspace is loaded
|
|
10
|
+
* through `shared_preload_libraries`, which is a property of a Postgres this stack starts. Against
|
|
11
|
+
* a database Supatype does not manage there is nothing to configure, so the fallback is not a
|
|
12
|
+
* preference — it is the only thing that can run.
|
|
13
|
+
*
|
|
14
|
+
* A resolver rather than a default written into the config type, because the answer depends on
|
|
15
|
+
* more than one field: the same absent `cache.provider` means pg_keyspace beside a managed
|
|
16
|
+
* database and Valkey beside an external one. A default baked into the parser would have to be
|
|
17
|
+
* either wrong for one of them or applied twice, and applied twice is how the compose file and the
|
|
18
|
+
* native path end up disagreeing about which server is running — a cache that silently never hits
|
|
19
|
+
* and, with TLS on, a Kong that cannot store its certificate.
|
|
20
|
+
*/
|
|
21
|
+
import { pinnedVersion } from "./binary-cache.js"
|
|
22
|
+
import type { SupatypeProjectConfig } from "./project-config.js"
|
|
23
|
+
|
|
24
|
+
export type CacheProvider = "valkey" | "pg_keyspace"
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The provider this project runs, explicit or resolved.
|
|
28
|
+
*
|
|
29
|
+
* An explicit `cache.provider` is honoured exactly — including `"valkey"`, which is how a project
|
|
30
|
+
* keeps the sidecar it has. `validateProjectConfig` has already refused the one explicit
|
|
31
|
+
* combination that cannot run (pg_keyspace against `database.external`), so this never overrides
|
|
32
|
+
* what someone wrote.
|
|
33
|
+
*
|
|
34
|
+
* The two things that resolve an absent one to Valkey are both cases where this cannot know that a
|
|
35
|
+
* keyspace would work:
|
|
36
|
+
*
|
|
37
|
+
* - **`database.external`**: pg_keyspace is loaded through `shared_preload_libraries`, and there
|
|
38
|
+
* is no container here to set that on.
|
|
39
|
+
* - **A pinned `versions.postgres`**: the keyspace is served by the image, and the entrypoint
|
|
40
|
+
* honours `SUPATYPE_KEYSPACE_ENABLED` only from the release that introduced it. An older image
|
|
41
|
+
* ignores the variable and starts with no RESP listener, which is not an error anywhere — the
|
|
42
|
+
* stack comes up and the cache never hits. A pin says this project's image is fixed, and a
|
|
43
|
+
* default must not assume something about a fixed image it cannot check. Unpinned tracks
|
|
44
|
+
* latest, which carries the toggle by definition.
|
|
45
|
+
*
|
|
46
|
+
* A project on a pin that *does* carry the toggle says `cache: { provider: "pg_keyspace" }` and
|
|
47
|
+
* gets it — which is also the only instruction anyone needs, and is better than a version floor
|
|
48
|
+
* here that would have to be right about a release number in another repository.
|
|
49
|
+
*/
|
|
50
|
+
export function cacheProvider(config: SupatypeProjectConfig): CacheProvider {
|
|
51
|
+
const declared = config.cache?.provider
|
|
52
|
+
if (declared === "valkey" || declared === "pg_keyspace") return declared
|
|
53
|
+
if (config.database?.external) return "valkey"
|
|
54
|
+
if (pinnedVersion("postgres", config) !== undefined) return "valkey"
|
|
55
|
+
return "pg_keyspace"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether the RESP keyspace is served by the Postgres this stack starts. */
|
|
59
|
+
export function keyspaceInPostgres(config: SupatypeProjectConfig): boolean {
|
|
60
|
+
return cacheProvider(config) === "pg_keyspace"
|
|
61
|
+
}
|
|
@@ -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.
|
|
12
|
+
export const EMBEDDED_CLI_VERSION: string = "0.3.1"
|
package/src/commands/add.ts
CHANGED
|
@@ -101,5 +101,5 @@ function printDomainNextSteps(cwd: string, domain: string): void {
|
|
|
101
101
|
`Platform URL: https://${domain}`,
|
|
102
102
|
])
|
|
103
103
|
plain(" App, REST, Auth, Storage, Realtime, Functions, and Studio, one HTTPS domain.")
|
|
104
|
-
plain(" Certificates
|
|
104
|
+
plain(" Certificates outlive restarts — in the keyspace's durable store, or the valkey-data volume.\n")
|
|
105
105
|
}
|
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,
|
package/src/commands/cache.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* supatype cache: list and clean cached component binaries + REST
|
|
2
|
+
* supatype cache: list and clean cached component binaries + the REST response cache.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Command } from "commander"
|
|
@@ -135,7 +135,7 @@ export function registerCache(program: Command): void {
|
|
|
135
135
|
|
|
136
136
|
const rest = cache
|
|
137
137
|
.command("rest")
|
|
138
|
-
.description("Manage REST API response cache
|
|
138
|
+
.description("Manage the server-side REST API response cache")
|
|
139
139
|
|
|
140
140
|
rest
|
|
141
141
|
.command("list")
|
|
@@ -170,7 +170,7 @@ export function registerCache(program: Command): void {
|
|
|
170
170
|
|
|
171
171
|
rest
|
|
172
172
|
.command("get <key>")
|
|
173
|
-
.description("Show one cache entry (full
|
|
173
|
+
.description("Show one cache entry (full key)")
|
|
174
174
|
.option("--json", "Output JSON")
|
|
175
175
|
.action(async (key: string, opts: { json?: boolean }) => {
|
|
176
176
|
const cwd = process.cwd()
|
package/src/commands/cloud.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withPublishing } from "../model-versioning.js"
|
|
1
2
|
import type { Command } from "commander"
|
|
2
3
|
import { readFileSync, writeFileSync, existsSync } from "node:fs"
|
|
3
4
|
import { resolve } from "node:path"
|
|
@@ -91,7 +92,7 @@ export async function pushSchemaToLinkedProject(
|
|
|
91
92
|
process.exit(1)
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
const ast = loadSchemaAst(schemaPathFromProject(config, cwd), cwd)
|
|
95
|
+
const ast = withPublishing(loadSchemaAst(schemaPathFromProject(config, cwd), cwd), config)
|
|
95
96
|
info(`Pushing schema to ${target.mode} project ${target.projectRef} (${target.environment})...`)
|
|
96
97
|
|
|
97
98
|
const result = await targetSchemaPush(target, ast, {
|
package/src/commands/deploy.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* supatype deploy logs <version>, show build logs
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { withPublishing } from "../model-versioning.js"
|
|
14
15
|
import type { Command } from "commander"
|
|
15
16
|
import { existsSync, readdirSync, statSync, createReadStream, mkdirSync, cpSync } from "node:fs"
|
|
16
17
|
import { join, relative } from "node:path"
|
|
@@ -74,7 +75,7 @@ export function registerDeploy(program: Command): void {
|
|
|
74
75
|
|
|
75
76
|
// Step 1: Schema push (unless --app-only or --skip-build, or already done via cloud.json)
|
|
76
77
|
if (!opts.appOnly && !opts.skipBuild && !schemaDone) {
|
|
77
|
-
const ast = loadSchemaAst(schemaPathFromProject(config, cwd), cwd)
|
|
78
|
+
const ast = withPublishing(loadSchemaAst(schemaPathFromProject(config, cwd), cwd), config)
|
|
78
79
|
|
|
79
80
|
if (opts.local) {
|
|
80
81
|
step("Schema Push (local)")
|
package/src/commands/dev.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Deno is not provisioned by the CLI on those paths.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { keyspaceInPostgres } from "../cache-provider.js"
|
|
13
14
|
import type { Command } from "commander"
|
|
14
15
|
import { spawnSync } from "node:child_process"
|
|
15
16
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
} from "../project-config.js"
|
|
27
28
|
import { discoverTsFunctionsInDir, writeDevFunctionsRouter } from "../functions-router-gen.js"
|
|
28
29
|
import { fieldMaskingTierFromProject } from "../field-masking-tier.js"
|
|
30
|
+
import { projectHasVersionedModels } from "../model-versioning.js"
|
|
29
31
|
import { signJwt } from "../jwt.js"
|
|
30
32
|
import {
|
|
31
33
|
devAuthenticatorPassword,
|
|
@@ -55,9 +57,13 @@ import { registerDevShutdown } from "../dev-shutdown.js"
|
|
|
55
57
|
import { patchRouteManifest } from "../route-manifest.js"
|
|
56
58
|
import { resolveRealtimeLaunch } from "../realtime-launch.js"
|
|
57
59
|
import { writeAppViteEnv } from "../app-vite-env.js"
|
|
58
|
-
import { ensureValkeySidecar, stopValkeySidecar } from "../valkey-sidecar.js"
|
|
60
|
+
import { ensureValkeySidecar, probeTcp, stopValkeySidecar } from "../valkey-sidecar.js"
|
|
59
61
|
import {
|
|
62
|
+
firstFreePort,
|
|
60
63
|
initdb,
|
|
64
|
+
KEYSPACE_PORT_BASE,
|
|
65
|
+
KEYSPACE_PORT_SPAN,
|
|
66
|
+
nativeKeyspaceLibraryPresent,
|
|
61
67
|
nativeMaskLibraryPresent,
|
|
62
68
|
start as pgStart,
|
|
63
69
|
stop as pgStop,
|
|
@@ -193,13 +199,46 @@ export function registerDev(program: Command): void {
|
|
|
193
199
|
// pgBinDir is set on the native path and used to add DLL search path for
|
|
194
200
|
// PostgREST on Windows (PostgREST links against libpq + SSL from MinGW).
|
|
195
201
|
let pgBinDir: string | null = null
|
|
202
|
+
// The port the native Postgres serves RESP on, or null when this archive
|
|
203
|
+
// has no pg_keyspace and the Valkey sidecar is what the cache talks to.
|
|
204
|
+
let keyspacePort: number | null = null
|
|
196
205
|
|
|
197
206
|
{
|
|
198
207
|
// native: resolve pg bin dir and manage with pg_ctl
|
|
199
208
|
pgBinDir = await resolvePgBinDir(config)
|
|
200
209
|
const dataDir = config.database.data_dir ?? join(stateRoot, "data")
|
|
201
210
|
mkdirSync(dataDir, { recursive: true })
|
|
202
|
-
|
|
211
|
+
// The RESP keyspace, when this archive carries it: one process instead
|
|
212
|
+
// of a Postgres and a Valkey container. The port is chosen rather than
|
|
213
|
+
// fixed because 6379 is the first thing a developer's own Redis or a
|
|
214
|
+
// leftover sidecar takes, and a keyspace that cannot bind its port
|
|
215
|
+
// fails inside the postmaster log where nobody is looking.
|
|
216
|
+
//
|
|
217
|
+
// `cache.provider: "valkey"` is honoured here too, and this is the only place it could
|
|
218
|
+
// be: the native path asks the archive what it carries rather than the config what it
|
|
219
|
+
// wants, so a project that deliberately kept the sidecar would otherwise find Postgres
|
|
220
|
+
// serving RESP anyway and the sidecar never started.
|
|
221
|
+
const wantsPgKeyspace = keyspaceInPostgres(config)
|
|
222
|
+
keyspacePort =
|
|
223
|
+
wantsPgKeyspace && nativeKeyspaceLibraryPresent(pgBinDir)
|
|
224
|
+
? await firstFreePort(KEYSPACE_PORT_BASE, KEYSPACE_PORT_SPAN)
|
|
225
|
+
: null
|
|
226
|
+
if (wantsPgKeyspace && nativeKeyspaceLibraryPresent(pgBinDir) && keyspacePort === null) {
|
|
227
|
+
console.warn(
|
|
228
|
+
`[supatype] ⚠ No free port in ${KEYSPACE_PORT_BASE}-${KEYSPACE_PORT_BASE + KEYSPACE_PORT_SPAN - 1} ` +
|
|
229
|
+
"for the Postgres keyspace — starting without it.",
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const pgOpts = {
|
|
234
|
+
pgBinDir,
|
|
235
|
+
dataDir,
|
|
236
|
+
port: pgPort,
|
|
237
|
+
logPath: join(logsDir, "postgres.log"),
|
|
238
|
+
...(keyspacePort !== null
|
|
239
|
+
? { keyspacePort, keyspaceDatabase: projectName }
|
|
240
|
+
: {}),
|
|
241
|
+
}
|
|
203
242
|
|
|
204
243
|
console.log("[supatype] Initialising Postgres data directory...")
|
|
205
244
|
initdb(pgOpts)
|
|
@@ -399,7 +438,27 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticate
|
|
|
399
438
|
? "ses"
|
|
400
439
|
: "smtp"
|
|
401
440
|
|
|
402
|
-
|
|
441
|
+
// Postgres serves RESP itself when the archive had pg_keyspace and the
|
|
442
|
+
// port came up. The probe is not ceremony: shared memory limits, a
|
|
443
|
+
// preload ordering mistake or a port taken between the check and the
|
|
444
|
+
// start all leave a Postgres that is running and a keyspace that is not,
|
|
445
|
+
// and the difference is a cache that silently never hits.
|
|
446
|
+
const nativeKeyspaceAddr =
|
|
447
|
+
keyspacePort !== null && (await probeTcp("127.0.0.1", keyspacePort))
|
|
448
|
+
? `127.0.0.1:${keyspacePort}`
|
|
449
|
+
: null
|
|
450
|
+
if (keyspacePort !== null && nativeKeyspaceAddr === null) {
|
|
451
|
+
console.warn(
|
|
452
|
+
`[supatype] ⚠ Postgres has pg_keyspace but is not serving RESP on :${keyspacePort} — ` +
|
|
453
|
+
"see logs/postgres.log. Falling back to the Valkey sidecar.",
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
if (nativeKeyspaceAddr) {
|
|
457
|
+
console.log(`[supatype] Keyspace served by Postgres (${nativeKeyspaceAddr}).`)
|
|
458
|
+
}
|
|
459
|
+
const valkeySidecar = nativeKeyspaceAddr
|
|
460
|
+
? { addr: nativeKeyspaceAddr, containerName: null, started: false }
|
|
461
|
+
: ensureValkeySidecar(projectName)
|
|
403
462
|
|
|
404
463
|
const serverEnv: Record<string, string> = {
|
|
405
464
|
// supatype-server outer layer
|
|
@@ -513,12 +572,14 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticate
|
|
|
513
572
|
// a non-default pg_schema pushed correctly and then answered PGRST106 on every request.
|
|
514
573
|
// Native dev has no masking extension either, so a schema with field rules is served from
|
|
515
574
|
// `api` here too: the case an "is the database external" rule would have got wrong.
|
|
516
|
-
PGRST_DB_SCHEMA: apiSchemaList(
|
|
517
|
-
config,
|
|
575
|
+
PGRST_DB_SCHEMA: apiSchemaList(config, {
|
|
518
576
|
// The installed archive decides: one downloaded before the masking library was bundled
|
|
519
577
|
// has no extension, so its field rules are enforced by views instead.
|
|
520
|
-
fieldMaskingTierFromProject(cwd, config, nativeMaskLibraryPresent(pgBinDir)),
|
|
521
|
-
|
|
578
|
+
tier: fieldMaskingTierFromProject(cwd, config, nativeMaskLibraryPresent(pgBinDir)),
|
|
579
|
+
// A versioned model adds the generated `draft` schema, or `.draft()` would answer
|
|
580
|
+
// PGRST106 against a stack that had migrated the views into place.
|
|
581
|
+
drafts: projectHasVersionedModels(cwd, config),
|
|
582
|
+
}),
|
|
522
583
|
// Parity with self-host, which has always set this. Unqualified names in column defaults
|
|
523
584
|
// (`uuid_generate_v4()`) resolve here, and with a non-public pg_schema the request schema
|
|
524
585
|
// alone does not reach them.
|
package/src/commands/diff.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withPublishing } from "../model-versioning.js"
|
|
1
2
|
import type { Command } from "commander"
|
|
2
3
|
import { loadConfig, loadSchemaAst } from "../config.js"
|
|
3
4
|
import { resolveRuntimeProvider, schemaPathFromProject } from "../project-config.js"
|
|
@@ -19,7 +20,7 @@ export function registerDiff(program: Command): void {
|
|
|
19
20
|
const pgSchema = schemaPgSchema(cwd)
|
|
20
21
|
|
|
21
22
|
const ast = await withSpinner("Loading schema", async () =>
|
|
22
|
-
loadSchemaAst(schemaPathFromProject(config, cwd), cwd),
|
|
23
|
+
withPublishing(loadSchemaAst(schemaPathFromProject(config, cwd), cwd), config),
|
|
23
24
|
)
|
|
24
25
|
|
|
25
26
|
const linked = loadProjectLink(cwd)
|
|
@@ -141,9 +141,13 @@ function scaffoldFunction(cwd: string, name: string): void {
|
|
|
141
141
|
const indexContent = `// ${name}, Supatype Edge Function
|
|
142
142
|
// Docs: https://supatype.com/docs/edge-functions
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
import type { FunctionContext } from "../_shared/context.ts"
|
|
145
|
+
|
|
146
|
+
export default async function handler(req: Request, ctx: FunctionContext): Promise<Response> {
|
|
145
147
|
const { method } = req
|
|
146
|
-
|
|
148
|
+
// From the context, not the environment. Per-invocation values are passed in, which is what lets
|
|
149
|
+
// the worker run your functions concurrently instead of one at a time.
|
|
150
|
+
const { url } = ctx
|
|
147
151
|
|
|
148
152
|
// Example: read request body for POST requests
|
|
149
153
|
if (method === "POST") {
|
package/src/commands/init.ts
CHANGED
|
@@ -70,7 +70,7 @@ const STORAGE_PROVIDER_OPTIONS: {
|
|
|
70
70
|
label: string
|
|
71
71
|
hint: string
|
|
72
72
|
}[] = [
|
|
73
|
-
{ value: "local", label: "Local", hint: "storage you host yourself (
|
|
73
|
+
{ value: "local", label: "Local", hint: "storage you host yourself (SeaweedFS)" },
|
|
74
74
|
{ value: "s3", label: "S3", hint: "external bucket (AWS S3 or compatible)" },
|
|
75
75
|
]
|
|
76
76
|
|
|
@@ -1164,7 +1164,7 @@ function storageConfigLines(
|
|
|
1164
1164
|
if (storageProduction === "s3" && storageLocal !== "s3") {
|
|
1165
1165
|
lines.push(` // Production storage: external S3 bucket, set production S3_* in .env`)
|
|
1166
1166
|
} else if (storageProduction === "local" && storageLocal === "s3") {
|
|
1167
|
-
lines.push(` // Production storage:
|
|
1167
|
+
lines.push(` // Production storage: SeaweedFS on your server (included in self-host compose)`)
|
|
1168
1168
|
}
|
|
1169
1169
|
return lines
|
|
1170
1170
|
}
|
|
@@ -1305,7 +1305,7 @@ S3_SECRET_KEY=`
|
|
|
1305
1305
|
}
|
|
1306
1306
|
return `${localStorageEnvSection("local")}
|
|
1307
1307
|
|
|
1308
|
-
# Production storage (
|
|
1308
|
+
# Production storage (SeaweedFS on your server)
|
|
1309
1309
|
# Included in the self-host compose stack, no extra configuration needed.`
|
|
1310
1310
|
}
|
|
1311
1311
|
|
|
@@ -1323,8 +1323,8 @@ S3_BUCKET=
|
|
|
1323
1323
|
S3_ACCESS_KEY=
|
|
1324
1324
|
S3_SECRET_KEY=`
|
|
1325
1325
|
}
|
|
1326
|
-
return `# Storage (local development,
|
|
1327
|
-
S3_ENDPOINT=http://localhost:
|
|
1326
|
+
return `# Storage (local development, SeaweedFS)
|
|
1327
|
+
S3_ENDPOINT=http://localhost:8333
|
|
1328
1328
|
S3_ACCESS_KEY=supatype
|
|
1329
1329
|
S3_SECRET_KEY=supatype-secret`
|
|
1330
1330
|
}
|
|
@@ -1338,7 +1338,7 @@ S3_BUCKET=
|
|
|
1338
1338
|
S3_ACCESS_KEY=
|
|
1339
1339
|
S3_SECRET_KEY=`
|
|
1340
1340
|
}
|
|
1341
|
-
return `# Storage (production,
|
|
1341
|
+
return `# Storage (production, SeaweedFS on your server)
|
|
1342
1342
|
# Included in the self-host compose stack, no extra configuration needed.`
|
|
1343
1343
|
}
|
|
1344
1344
|
|
|
@@ -1372,9 +1372,13 @@ function helloFunctionTemplate(): string {
|
|
|
1372
1372
|
return `// hello: Supatype Edge Function
|
|
1373
1373
|
// Docs: https://supatype.com/docs/edge-functions
|
|
1374
1374
|
|
|
1375
|
-
|
|
1375
|
+
import type { FunctionContext } from "../_shared/context.ts"
|
|
1376
|
+
|
|
1377
|
+
export default async function handler(req: Request, ctx: FunctionContext): Promise<Response> {
|
|
1376
1378
|
const { method } = req
|
|
1377
|
-
|
|
1379
|
+
// From the context, not the environment. Per-invocation values are passed in, which is what lets
|
|
1380
|
+
// the worker run your functions concurrently instead of one at a time.
|
|
1381
|
+
const { url } = ctx
|
|
1378
1382
|
|
|
1379
1383
|
if (method === "POST") {
|
|
1380
1384
|
const body = await req.json()
|
|
@@ -1507,7 +1511,7 @@ function printNextSteps(args: {
|
|
|
1507
1511
|
selfHostSteps.push("supatype self-host compose up -d # Kong provisions HTTPS automatically")
|
|
1508
1512
|
selfHostSteps.push(`Your Supatype platform goes live at https://${domain}`)
|
|
1509
1513
|
selfHostSteps.push(
|
|
1510
|
-
"Your app, REST, Auth, Storage, Realtime, Functions, and Studio, all behind one HTTPS domain (
|
|
1514
|
+
"Your app, REST, Auth, Storage, Realtime, Functions, and Studio, all behind one HTTPS domain (certificates outlive restarts)",
|
|
1511
1515
|
)
|
|
1512
1516
|
} else {
|
|
1513
1517
|
selfHostSteps.push(
|
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)
|