@supatype/cli 0.1.6 → 0.1.7
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 +144 -94
- package/.turbo/turbo-typecheck.log +1 -1
- package/deno.d.ts +52 -0
- package/dist/augmentation-generator.d.ts +22 -0
- package/dist/augmentation-generator.d.ts.map +1 -1
- package/dist/augmentation-generator.js +18 -4
- package/dist/augmentation-generator.js.map +1 -1
- package/dist/binary-cache.d.ts +14 -0
- package/dist/binary-cache.d.ts.map +1 -1
- package/dist/binary-cache.js +33 -13
- package/dist/binary-cache.js.map +1 -1
- package/dist/cache-clean.d.ts +15 -0
- package/dist/cache-clean.d.ts.map +1 -0
- package/dist/cache-clean.js +76 -0
- package/dist/cache-clean.js.map +1 -0
- package/dist/cache-pins.d.ts +51 -0
- package/dist/cache-pins.d.ts.map +1 -0
- package/dist/cache-pins.js +184 -0
- package/dist/cache-pins.js.map +1 -0
- package/dist/cli-install-method.d.ts +19 -0
- package/dist/cli-install-method.d.ts.map +1 -0
- package/dist/cli-install-method.js +60 -0
- package/dist/cli-install-method.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/cloud-credentials.d.ts +34 -0
- package/dist/cloud-credentials.d.ts.map +1 -0
- package/dist/cloud-credentials.js +139 -0
- package/dist/cloud-credentials.js.map +1 -0
- package/dist/commands/admin.d.ts +11 -0
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +121 -19
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/cache.d.ts.map +1 -1
- package/dist/commands/cache.js +67 -71
- package/dist/commands/cache.js.map +1 -1
- package/dist/commands/cloud.d.ts.map +1 -1
- package/dist/commands/cloud.js +32 -8
- package/dist/commands/cloud.js.map +1 -1
- package/dist/commands/db.d.ts +1 -0
- package/dist/commands/db.d.ts.map +1 -1
- package/dist/commands/db.js +147 -1
- package/dist/commands/db.js.map +1 -1
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +50 -10
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/doctor.d.ts +28 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +60 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/functions.d.ts +7 -0
- package/dist/commands/functions.d.ts.map +1 -1
- package/dist/commands/functions.js +40 -6
- package/dist/commands/functions.js.map +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +5 -1
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/hooks.d.ts +12 -0
- package/dist/commands/hooks.d.ts.map +1 -0
- package/dist/commands/hooks.js +118 -0
- package/dist/commands/hooks.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +41 -7
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/internal.d.ts +6 -0
- package/dist/commands/internal.d.ts.map +1 -0
- package/dist/commands/internal.js +22 -0
- package/dist/commands/internal.js.map +1 -0
- package/dist/commands/link-helpers.d.ts.map +1 -1
- package/dist/commands/link-helpers.js +8 -2
- package/dist/commands/link-helpers.js.map +1 -1
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +110 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +110 -7
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/self-update.d.ts +7 -0
- package/dist/commands/self-update.d.ts.map +1 -1
- package/dist/commands/self-update.js +18 -11
- package/dist/commands/self-update.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -3
- package/dist/config.js.map +1 -1
- package/dist/db-preflight.d.ts +78 -0
- package/dist/db-preflight.d.ts.map +1 -0
- package/dist/db-preflight.js +452 -0
- package/dist/db-preflight.js.map +1 -0
- package/dist/dev-compose.d.ts +1 -1
- package/dist/dev-compose.d.ts.map +1 -1
- package/dist/dev-compose.js +127 -72
- package/dist/dev-compose.js.map +1 -1
- package/dist/env-file.d.ts +40 -2
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +132 -11
- package/dist/env-file.js.map +1 -1
- package/dist/field-masking-tier.d.ts +50 -0
- package/dist/field-masking-tier.d.ts.map +1 -0
- package/dist/field-masking-tier.js +91 -0
- package/dist/field-masking-tier.js.map +1 -0
- package/dist/functions-deno-types.d.ts +24 -0
- package/dist/functions-deno-types.d.ts.map +1 -0
- package/dist/functions-deno-types.js +155 -0
- package/dist/functions-deno-types.js.map +1 -0
- package/dist/hook-upload.d.ts +30 -0
- package/dist/hook-upload.d.ts.map +1 -0
- package/dist/hook-upload.js +86 -0
- package/dist/hook-upload.js.map +1 -0
- package/dist/hooks-generator.d.ts +8 -0
- package/dist/hooks-generator.d.ts.map +1 -0
- package/dist/hooks-generator.js +332 -0
- package/dist/hooks-generator.js.map +1 -0
- package/dist/link.d.ts +4 -0
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +2 -0
- package/dist/link.js.map +1 -1
- package/dist/local-secrets.d.ts +71 -0
- package/dist/local-secrets.d.ts.map +1 -0
- package/dist/local-secrets.js +100 -0
- package/dist/local-secrets.js.map +1 -0
- package/dist/model-hooks.d.ts +73 -0
- package/dist/model-hooks.d.ts.map +1 -0
- package/dist/model-hooks.js +211 -0
- package/dist/model-hooks.js.map +1 -0
- package/dist/postgres-ctl.d.ts +11 -0
- package/dist/postgres-ctl.d.ts.map +1 -1
- package/dist/postgres-ctl.js +25 -2
- package/dist/postgres-ctl.js.map +1 -1
- package/dist/project-config.d.ts +121 -1
- package/dist/project-config.d.ts.map +1 -1
- package/dist/project-config.js +150 -1
- package/dist/project-config.js.map +1 -1
- package/dist/resolve-target.d.ts +4 -0
- package/dist/resolve-target.d.ts.map +1 -1
- package/dist/resolve-target.js +69 -73
- package/dist/resolve-target.js.map +1 -1
- package/dist/schema-ast-v2.d.ts +2 -1
- package/dist/schema-ast-v2.d.ts.map +1 -1
- package/dist/schema-ast-v2.js +4 -2
- package/dist/schema-ast-v2.js.map +1 -1
- package/dist/schema-push-lock.d.ts.map +1 -1
- package/dist/schema-push-lock.js +43 -16
- package/dist/schema-push-lock.js.map +1 -1
- package/dist/self-host-compose.d.ts +24 -0
- package/dist/self-host-compose.d.ts.map +1 -1
- package/dist/self-host-compose.js +301 -69
- package/dist/self-host-compose.js.map +1 -1
- package/dist/service-role-check.d.ts +21 -0
- package/dist/service-role-check.d.ts.map +1 -0
- package/dist/service-role-check.js +85 -0
- package/dist/service-role-check.js.map +1 -0
- package/dist/target-client.d.ts +17 -0
- package/dist/target-client.d.ts.map +1 -1
- package/dist/target-client.js +69 -17
- package/dist/target-client.js.map +1 -1
- package/dist/type-extractor.d.ts.map +1 -1
- package/dist/type-extractor.js +736 -32
- package/dist/type-extractor.js.map +1 -1
- package/dist/ui/runtime/command-chrome.js +1 -1
- package/dist/ui/runtime/command-chrome.js.map +1 -1
- package/dist/ui/runtime/stdin-after-ink.d.ts.map +1 -1
- package/dist/ui/runtime/stdin-after-ink.js +3 -1
- package/dist/ui/runtime/stdin-after-ink.js.map +1 -1
- package/package.json +4 -1
- package/scripts/generate-homebrew-formula.mjs +121 -0
- package/src/augmentation-generator.ts +18 -5
- package/src/binary-cache.ts +47 -15
- package/src/cache-clean.ts +81 -0
- package/src/cache-pins.ts +243 -0
- package/src/cli-install-method.ts +67 -0
- package/src/cli.ts +6 -0
- package/src/cloud-credentials.ts +183 -0
- package/src/commands/admin.ts +140 -25
- package/src/commands/cache.ts +90 -75
- package/src/commands/cloud.ts +32 -8
- package/src/commands/db.ts +162 -1
- package/src/commands/dev.ts +57 -8
- package/src/commands/doctor.ts +69 -3
- package/src/commands/functions.ts +42 -5
- package/src/commands/generate.ts +5 -1
- package/src/commands/hooks.ts +138 -0
- package/src/commands/init.ts +42 -8
- package/src/commands/internal.ts +23 -0
- package/src/commands/link-helpers.ts +9 -2
- package/src/commands/login.ts +127 -0
- package/src/commands/push.ts +124 -7
- package/src/commands/self-update.ts +85 -13
- package/src/config.ts +22 -3
- package/src/db-preflight.ts +575 -0
- package/src/dev-compose.ts +142 -73
- package/src/env-file.ts +157 -11
- package/src/field-masking-tier.ts +107 -0
- package/src/functions-deno-types.ts +188 -0
- package/src/hook-upload.ts +107 -0
- package/src/hooks-generator.ts +352 -0
- package/src/link.ts +7 -0
- package/src/local-secrets.ts +104 -0
- package/src/model-hooks.ts +260 -0
- package/src/postgres-ctl.ts +29 -2
- package/src/project-config.ts +236 -2
- package/src/resolve-target.ts +113 -68
- package/src/schema-ast-v2.ts +5 -2
- package/src/schema-push-lock.ts +56 -17
- package/src/self-host-compose.ts +344 -68
- package/src/service-role-check.ts +111 -0
- package/src/target-client.ts +105 -18
- package/src/type-extractor.ts +883 -28
- package/src/ui/runtime/command-chrome.ts +1 -1
- package/src/ui/runtime/stdin-after-ink.ts +3 -1
- package/tests/access-composition.test.ts +667 -0
- package/tests/admin-studio-membership.test.ts +121 -0
- package/tests/api-schemas.test.ts +83 -0
- package/tests/archive-name-alias.test.ts +42 -0
- package/tests/augmentation-generator.test.ts +28 -0
- package/tests/cache-clean.test.ts +206 -0
- package/tests/cli-help.test.ts +3 -0
- package/tests/cli-install-method.test.ts +66 -0
- package/tests/command-chrome-coverage.test.ts +31 -26
- package/tests/command-chrome-wrap.test.ts +1 -0
- package/tests/db-preflight.test.ts +72 -0
- package/tests/doctor-render.test.ts +62 -0
- package/tests/doctor-service-role.test.ts +55 -0
- package/tests/env-file.test.ts +121 -0
- package/tests/external-database-compose.test.ts +184 -0
- package/tests/external-database-config.test.ts +154 -0
- package/tests/field-masking-tier.test.ts +184 -0
- package/tests/functions-deno-types.test.ts +197 -0
- package/tests/homebrew-formula.test.ts +58 -0
- package/tests/hook-upload.test.ts +125 -0
- package/tests/hooks-generator.test.ts +212 -0
- package/tests/init.test.ts +3 -0
- package/tests/local-secrets.test.ts +92 -0
- package/tests/model-hooks.test.ts +322 -0
- package/tests/runtime-contract.test.ts +113 -1
- package/tests/service-role-check.test.ts +109 -0
- package/tests/service-role-routes.test.ts +51 -0
- package/tests/target-client-refresh.test.ts +94 -0
- package/tests/type-extractor.test.ts +187 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/tests/engine-distribution.test.ts +0 -418
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud session credentials (access + refresh) for CLI ↔ control plane.
|
|
3
|
+
* Self-host links keep using long-lived SERVICE_ROLE_KEY and ignore this store.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
|
6
|
+
import { homedir } from "node:os"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
import {
|
|
9
|
+
loadProjectLink,
|
|
10
|
+
saveProjectLink,
|
|
11
|
+
type ProjectLink,
|
|
12
|
+
} from "./link.js"
|
|
13
|
+
|
|
14
|
+
export const CREDENTIALS_VERSION = 1 as const
|
|
15
|
+
|
|
16
|
+
export interface CloudCredentials {
|
|
17
|
+
version: typeof CREDENTIALS_VERSION
|
|
18
|
+
apiUrl: string
|
|
19
|
+
accessToken: string
|
|
20
|
+
refreshToken?: string
|
|
21
|
+
email?: string
|
|
22
|
+
updatedAt: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function credentialsPath(): string {
|
|
26
|
+
return join(homedir(), ".supatype", "credentials.json")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadCloudCredentials(): CloudCredentials | null {
|
|
30
|
+
const path = credentialsPath()
|
|
31
|
+
if (!existsSync(path)) return null
|
|
32
|
+
try {
|
|
33
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as CloudCredentials
|
|
34
|
+
if (!raw.accessToken || !raw.apiUrl) return null
|
|
35
|
+
return raw
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function saveCloudCredentials(creds: CloudCredentials): void {
|
|
42
|
+
const dir = join(homedir(), ".supatype")
|
|
43
|
+
mkdirSync(dir, { recursive: true })
|
|
44
|
+
writeFileSync(credentialsPath(), `${JSON.stringify(creds, null, 2)}\n`, "utf8")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function clearCloudCredentials(): void {
|
|
48
|
+
const path = credentialsPath()
|
|
49
|
+
if (!existsSync(path)) return
|
|
50
|
+
try {
|
|
51
|
+
unlinkSync(path)
|
|
52
|
+
} catch {
|
|
53
|
+
/* ignore */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Prefer link tokens; fall back to ~/.supatype/credentials.json for cloud. */
|
|
58
|
+
export function resolveCloudAccessToken(link: ProjectLink): string | undefined {
|
|
59
|
+
if (link.token) return link.token
|
|
60
|
+
if (link.kind !== "cloud") return undefined
|
|
61
|
+
const creds = loadCloudCredentials()
|
|
62
|
+
if (!creds) return undefined
|
|
63
|
+
const linkApi = (link.cloudApiUrl ?? "").replace(/\/$/, "")
|
|
64
|
+
const credApi = creds.apiUrl.replace(/\/$/, "")
|
|
65
|
+
if (linkApi && credApi && linkApi !== credApi) return undefined
|
|
66
|
+
return creds.accessToken
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function resolveCloudRefreshToken(link: ProjectLink): string | undefined {
|
|
70
|
+
if (link.refreshToken) return link.refreshToken
|
|
71
|
+
if (link.kind !== "cloud") return undefined
|
|
72
|
+
const creds = loadCloudCredentials()
|
|
73
|
+
if (!creds?.refreshToken) return undefined
|
|
74
|
+
const linkApi = (link.cloudApiUrl ?? "").replace(/\/$/, "")
|
|
75
|
+
const credApi = creds.apiUrl.replace(/\/$/, "")
|
|
76
|
+
if (linkApi && credApi && linkApi !== credApi) return undefined
|
|
77
|
+
return creds.refreshToken
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Write tokens to global credentials and the current project's cloud link (if any). */
|
|
81
|
+
export function persistCloudSession(
|
|
82
|
+
cwd: string,
|
|
83
|
+
params: {
|
|
84
|
+
apiUrl: string
|
|
85
|
+
accessToken: string
|
|
86
|
+
refreshToken?: string
|
|
87
|
+
email?: string
|
|
88
|
+
},
|
|
89
|
+
): void {
|
|
90
|
+
const apiUrl = params.apiUrl.replace(/\/$/, "")
|
|
91
|
+
const existing = loadCloudCredentials()
|
|
92
|
+
saveCloudCredentials({
|
|
93
|
+
version: CREDENTIALS_VERSION,
|
|
94
|
+
apiUrl,
|
|
95
|
+
accessToken: params.accessToken,
|
|
96
|
+
updatedAt: new Date().toISOString(),
|
|
97
|
+
...(params.refreshToken !== undefined
|
|
98
|
+
? { refreshToken: params.refreshToken }
|
|
99
|
+
: existing?.refreshToken !== undefined
|
|
100
|
+
? { refreshToken: existing.refreshToken }
|
|
101
|
+
: {}),
|
|
102
|
+
...(params.email !== undefined
|
|
103
|
+
? { email: params.email }
|
|
104
|
+
: existing?.email !== undefined
|
|
105
|
+
? { email: existing.email }
|
|
106
|
+
: {}),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
const link = loadProjectLink(cwd)
|
|
110
|
+
if (!link || link.kind !== "cloud") return
|
|
111
|
+
link.token = params.accessToken
|
|
112
|
+
if (params.refreshToken !== undefined) {
|
|
113
|
+
link.refreshToken = params.refreshToken
|
|
114
|
+
}
|
|
115
|
+
saveProjectLink(cwd, link)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface CloudLoginResult {
|
|
119
|
+
accessToken: string
|
|
120
|
+
refreshToken?: string
|
|
121
|
+
expiresIn?: number
|
|
122
|
+
email?: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Password login against control-plane `/auth/login` (not `/api/v1`). */
|
|
126
|
+
export async function cloudPasswordLogin(
|
|
127
|
+
apiUrl: string,
|
|
128
|
+
email: string,
|
|
129
|
+
password: string,
|
|
130
|
+
): Promise<CloudLoginResult> {
|
|
131
|
+
const base = apiUrl.replace(/\/$/, "")
|
|
132
|
+
const res = await fetch(`${base}/auth/login`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { "Content-Type": "application/json" },
|
|
135
|
+
body: JSON.stringify({ email: email.toLowerCase(), password }),
|
|
136
|
+
})
|
|
137
|
+
const json = (await res.json().catch(() => ({}))) as {
|
|
138
|
+
message?: string
|
|
139
|
+
error?: string
|
|
140
|
+
access_token?: string
|
|
141
|
+
refresh_token?: string
|
|
142
|
+
expires_in?: number
|
|
143
|
+
user?: { email?: string }
|
|
144
|
+
}
|
|
145
|
+
if (!res.ok || !json.access_token) {
|
|
146
|
+
throw new Error(json.message ?? json.error ?? `Login failed (${res.status})`)
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
accessToken: json.access_token,
|
|
150
|
+
...(json.refresh_token !== undefined ? { refreshToken: json.refresh_token } : {}),
|
|
151
|
+
...(json.expires_in !== undefined ? { expiresIn: json.expires_in } : {}),
|
|
152
|
+
...(json.user?.email !== undefined
|
|
153
|
+
? { email: json.user.email }
|
|
154
|
+
: { email: email.toLowerCase() }),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function cloudRefreshSession(
|
|
159
|
+
apiUrl: string,
|
|
160
|
+
refreshToken: string,
|
|
161
|
+
): Promise<CloudLoginResult> {
|
|
162
|
+
const base = apiUrl.replace(/\/$/, "")
|
|
163
|
+
const res = await fetch(`${base}/auth/refresh`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { "Content-Type": "application/json" },
|
|
166
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
167
|
+
})
|
|
168
|
+
const json = (await res.json().catch(() => ({}))) as {
|
|
169
|
+
message?: string
|
|
170
|
+
error?: string
|
|
171
|
+
access_token?: string
|
|
172
|
+
refresh_token?: string
|
|
173
|
+
expires_in?: number
|
|
174
|
+
}
|
|
175
|
+
if (!res.ok || !json.access_token) {
|
|
176
|
+
throw new Error(json.message ?? json.error ?? `Token refresh failed (${res.status})`)
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
accessToken: json.access_token,
|
|
180
|
+
...(json.refresh_token !== undefined ? { refreshToken: json.refresh_token } : {}),
|
|
181
|
+
...(json.expires_in !== undefined ? { expiresIn: json.expires_in } : {}),
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/commands/admin.ts
CHANGED
|
@@ -32,6 +32,26 @@ export const ADMIN_PASSWORD_ENV = "SUPATYPE_ADMIN_PASSWORD"
|
|
|
32
32
|
|
|
33
33
|
const BCRYPT_ROUNDS = 10
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Studio roles a membership row may hold, most privileged first.
|
|
37
|
+
*
|
|
38
|
+
* Must match `studioRolePermissions` in supatype-server and
|
|
39
|
+
* `STUDIO_ROLE_PERMISSIONS` in the control plane: capability checks refuse a role
|
|
40
|
+
* they do not recognise, so an unvalidated `--role` here would create a user who
|
|
41
|
+
* is locked out of the very panel they were made for.
|
|
42
|
+
*/
|
|
43
|
+
export const STUDIO_ROLES = ["admin", "developer", "editor"] as const
|
|
44
|
+
|
|
45
|
+
function assertStudioRole(role: string): void {
|
|
46
|
+
if ((STUDIO_ROLES as readonly string[]).includes(role)) return
|
|
47
|
+
error(
|
|
48
|
+
`Unknown Studio role "${role}". Choose one of: ${STUDIO_ROLES.join(", ")}.\n` +
|
|
49
|
+
" Studio roles are Supatype's own; your application's roles are separate " +
|
|
50
|
+
"and belong in your access rules.",
|
|
51
|
+
)
|
|
52
|
+
process.exit(1)
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
/** GoTrue scopes users to the nil instance id in single-tenant/self-host mode. */
|
|
36
56
|
export const GOTRUE_NIL_INSTANCE_ID = "00000000-0000-0000-0000-000000000000"
|
|
37
57
|
|
|
@@ -89,7 +109,7 @@ export function registerAdmin(program: Command): void {
|
|
|
89
109
|
.description("Create an admin user for the admin panel")
|
|
90
110
|
.option("--email <email>", "Admin user email address")
|
|
91
111
|
.option("--password <password>", "Admin user password (prompted if not provided)")
|
|
92
|
-
.option("--role <role>",
|
|
112
|
+
.option("--role <role>", `Studio role to assign (${STUDIO_ROLES.join(" | ")})`, "admin")
|
|
93
113
|
.option("--connection <url>", "Database connection URL (overrides config)")
|
|
94
114
|
.action(
|
|
95
115
|
async (opts: {
|
|
@@ -116,6 +136,7 @@ export function registerAdmin(program: Command): void {
|
|
|
116
136
|
}
|
|
117
137
|
|
|
118
138
|
const role = opts.role
|
|
139
|
+
assertStudioRole(role)
|
|
119
140
|
|
|
120
141
|
info(`Creating admin user: ${email} (role: ${role})...`)
|
|
121
142
|
|
|
@@ -154,10 +175,11 @@ export function registerAdmin(program: Command): void {
|
|
|
154
175
|
.command("set-role")
|
|
155
176
|
.description("Change an existing user's admin role")
|
|
156
177
|
.requiredOption("--email <email>", "User email address")
|
|
157
|
-
.requiredOption("--role <role>",
|
|
178
|
+
.requiredOption("--role <role>", `New Studio role (${STUDIO_ROLES.join(" | ")})`)
|
|
158
179
|
.option("--connection <url>", "Database connection URL (overrides config)")
|
|
159
180
|
.action(
|
|
160
181
|
async (opts: { email: string; role: string; connection?: string }) => {
|
|
182
|
+
assertStudioRole(opts.role)
|
|
161
183
|
const cwd = process.cwd()
|
|
162
184
|
const config = loadConfig(cwd)
|
|
163
185
|
const connection = resolveAdminConnection(cwd, config, opts.connection)
|
|
@@ -166,13 +188,23 @@ export function registerAdmin(program: Command): void {
|
|
|
166
188
|
const pool = new pg.Pool({ connectionString: connection, max: 2 })
|
|
167
189
|
|
|
168
190
|
try {
|
|
191
|
+
// Studio roles live in `_supatype.studio_members`, not in
|
|
192
|
+
// `app_metadata`: that key is the developer's namespace for their own
|
|
193
|
+
// app roles, and writing Studio access there means assigning an app
|
|
194
|
+
// role could hand out admin UI access by accident.
|
|
195
|
+
await ensureStudioMembersTable((sql, params) => pool.query(sql, params))
|
|
169
196
|
const result = await pool.query(
|
|
170
|
-
`
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
197
|
+
`WITH target AS (
|
|
198
|
+
SELECT id, email FROM auth.users WHERE email = $2
|
|
199
|
+
), upsert AS (
|
|
200
|
+
INSERT INTO _supatype.studio_members (user_id, role)
|
|
201
|
+
SELECT id, $1 FROM target
|
|
202
|
+
ON CONFLICT (user_id) DO UPDATE
|
|
203
|
+
SET role = EXCLUDED.role, updated_at = now()
|
|
204
|
+
RETURNING user_id
|
|
205
|
+
)
|
|
206
|
+
SELECT id, email FROM target`,
|
|
207
|
+
[opts.role, opts.email.toLowerCase()],
|
|
176
208
|
)
|
|
177
209
|
|
|
178
210
|
if (result.rows.length === 0) {
|
|
@@ -180,11 +212,7 @@ export function registerAdmin(program: Command): void {
|
|
|
180
212
|
process.exit(1)
|
|
181
213
|
}
|
|
182
214
|
|
|
183
|
-
const user = result.rows[0] as {
|
|
184
|
-
id: string
|
|
185
|
-
email: string
|
|
186
|
-
raw_app_meta_data: Record<string, unknown>
|
|
187
|
-
}
|
|
215
|
+
const user = result.rows[0] as { id: string; email: string }
|
|
188
216
|
|
|
189
217
|
info("Role updated successfully.")
|
|
190
218
|
plain(` ID: ${user.id}`)
|
|
@@ -203,7 +231,7 @@ export function registerAdmin(program: Command): void {
|
|
|
203
231
|
|
|
204
232
|
adminCmd
|
|
205
233
|
.command("list-users")
|
|
206
|
-
.description("List users with
|
|
234
|
+
.description("List users with Studio access")
|
|
207
235
|
.option("--connection <url>", "Database connection URL (overrides config)")
|
|
208
236
|
.action(async (opts: { connection?: string }) => {
|
|
209
237
|
const cwd = process.cwd()
|
|
@@ -214,12 +242,17 @@ export function registerAdmin(program: Command): void {
|
|
|
214
242
|
const pool = new pg.Pool({ connectionString: connection, max: 2 })
|
|
215
243
|
|
|
216
244
|
try {
|
|
245
|
+
// Studio access is membership, not a claim — list the grant that
|
|
246
|
+
// actually decides admission. LEFT JOIN, because a membership held by a
|
|
247
|
+
// Supatype Cloud account has no row in this project's `auth.users`;
|
|
248
|
+
// hiding those would make `list-users` disagree with who can log in.
|
|
217
249
|
const result = await pool.query(
|
|
218
|
-
`SELECT
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
250
|
+
`SELECT COALESCE(m.user_id, m.platform_user_id) AS id,
|
|
251
|
+
COALESCE(u.email, '(cloud account)') AS email,
|
|
252
|
+
m.role, m.created_at
|
|
253
|
+
FROM _supatype.studio_members m
|
|
254
|
+
LEFT JOIN auth.users u ON u.id = m.user_id
|
|
255
|
+
ORDER BY m.created_at ASC`,
|
|
223
256
|
)
|
|
224
257
|
|
|
225
258
|
if (result.rows.length === 0) {
|
|
@@ -318,7 +351,8 @@ export async function ensureFirstAdminUserForProject(
|
|
|
318
351
|
await ensureFirstAdminUser(connection, merged)
|
|
319
352
|
}
|
|
320
353
|
|
|
321
|
-
|
|
354
|
+
/** Exported for tests: the DB seam both the pool and compose paths share. */
|
|
355
|
+
export async function ensureFirstAdminWithQuery(
|
|
322
356
|
query: DbQuery,
|
|
323
357
|
options: EnsureFirstAdminOptions,
|
|
324
358
|
): Promise<void> {
|
|
@@ -408,7 +442,9 @@ async function resolveAdminCredentials(
|
|
|
408
442
|
}
|
|
409
443
|
|
|
410
444
|
export function clearAdminSeedPassword(cwd: string): void {
|
|
411
|
-
|
|
445
|
+
// Unconditional: this is a one-time seed password being retired, and whoever wrote it wanted it
|
|
446
|
+
// gone once used.
|
|
447
|
+
upsertEnvFile(cwd, {}, { remove: [ADMIN_PASSWORD_ENV] })
|
|
412
448
|
}
|
|
413
449
|
|
|
414
450
|
export async function hashPasswordForAuth(password: string): Promise<string> {
|
|
@@ -442,8 +478,11 @@ async function createAdminUser(
|
|
|
442
478
|
}
|
|
443
479
|
|
|
444
480
|
const passwordHash = await hashPasswordForAuth(password)
|
|
481
|
+
// No `role` here: `app_metadata` is the developer's namespace for their own
|
|
482
|
+
// application roles, and Studio access is granted through
|
|
483
|
+
// `_supatype.studio_members` instead. Writing it in both places means an app
|
|
484
|
+
// role assignment can silently confer admin UI access.
|
|
445
485
|
const appMetadata = JSON.stringify({
|
|
446
|
-
role,
|
|
447
486
|
provider: "email",
|
|
448
487
|
providers: ["email"],
|
|
449
488
|
})
|
|
@@ -472,6 +511,7 @@ async function createAdminUser(
|
|
|
472
511
|
)
|
|
473
512
|
|
|
474
513
|
const user = result.rows[0] as { id: string; email: string }
|
|
514
|
+
await recordStudioMembership(query, user.id, role)
|
|
475
515
|
if (!opts.quiet) {
|
|
476
516
|
info("Admin user created successfully.")
|
|
477
517
|
plain(` ID: ${user.id}`)
|
|
@@ -481,6 +521,31 @@ async function createAdminUser(
|
|
|
481
521
|
return user
|
|
482
522
|
}
|
|
483
523
|
|
|
524
|
+
/**
|
|
525
|
+
* Record Studio access in `_supatype.studio_members`.
|
|
526
|
+
*
|
|
527
|
+
* Studio capability is deliberately not a JWT claim: `app_metadata` belongs to
|
|
528
|
+
* the developer's own app roles, and letting it grant Studio access means a
|
|
529
|
+
* developer assigning an app role could hand out admin UI access by accident.
|
|
530
|
+
*
|
|
531
|
+
* This is now the *only* grant, so a failure here is fatal to the command — a
|
|
532
|
+
* user created without a membership row cannot reach Studio at all, and
|
|
533
|
+
* reporting success would leave no clue why.
|
|
534
|
+
*/
|
|
535
|
+
async function recordStudioMembership(
|
|
536
|
+
query: DbQuery,
|
|
537
|
+
userId: string,
|
|
538
|
+
role: string,
|
|
539
|
+
): Promise<void> {
|
|
540
|
+
await ensureStudioMembersTable(query)
|
|
541
|
+
await query(
|
|
542
|
+
`INSERT INTO _supatype.studio_members (user_id, role)
|
|
543
|
+
VALUES ($1, $2)
|
|
544
|
+
ON CONFLICT (user_id) DO UPDATE SET role = EXCLUDED.role, updated_at = now()`,
|
|
545
|
+
[userId, role],
|
|
546
|
+
)
|
|
547
|
+
}
|
|
548
|
+
|
|
484
549
|
async function ensureAuthUsersTable(pool: Pool): Promise<void> {
|
|
485
550
|
await pool.query(`
|
|
486
551
|
CREATE SCHEMA IF NOT EXISTS auth;
|
|
@@ -521,16 +586,66 @@ async function authUsersTableExists(query: DbQuery): Promise<boolean> {
|
|
|
521
586
|
return Boolean(result.rows[0]?.exists)
|
|
522
587
|
}
|
|
523
588
|
|
|
589
|
+
/**
|
|
590
|
+
* Whether anyone can already reach Studio.
|
|
591
|
+
*
|
|
592
|
+
* Counts membership rows, not `app_metadata` claims: the claim no longer grants
|
|
593
|
+
* anything, so counting it would report an admin exists while nobody can log in.
|
|
594
|
+
* A missing table means the project has never been pushed — no admins.
|
|
595
|
+
*/
|
|
524
596
|
async function hasAdminUsers(query: DbQuery): Promise<boolean> {
|
|
597
|
+
// Postgres resolves relations at parse time, so the table has to be checked
|
|
598
|
+
// separately rather than guarded inside the count query. Absent means the
|
|
599
|
+
// project has never been pushed — no admins, and `createAdminUser` will
|
|
600
|
+
// create the table itself.
|
|
601
|
+
if (!(await studioMembersTableExists(query))) return false
|
|
602
|
+
|
|
603
|
+
// Inner JOIN on purpose, unlike `list-users`: a membership held by a cloud
|
|
604
|
+
// account cannot log in to a self-hosted GoTrue, so a project exported from
|
|
605
|
+
// cloud with only those grants genuinely still needs a first admin.
|
|
525
606
|
const adminCount = await query(
|
|
526
|
-
`SELECT COUNT(*)::int as count
|
|
527
|
-
|
|
528
|
-
|
|
607
|
+
`SELECT COUNT(*)::int as count
|
|
608
|
+
FROM _supatype.studio_members m
|
|
609
|
+
JOIN auth.users u ON u.id = m.user_id
|
|
610
|
+
WHERE m.role <> 'authenticated'`,
|
|
529
611
|
)
|
|
530
612
|
const count = (adminCount.rows[0] as { count: number } | undefined)?.count ?? 0
|
|
531
613
|
return count > 0
|
|
532
614
|
}
|
|
533
615
|
|
|
616
|
+
/**
|
|
617
|
+
* A project whose engine predates the membership table, or one that has never
|
|
618
|
+
* been pushed, still needs working `admin create-user` / `admin set-role`.
|
|
619
|
+
* Mirrors the engine's own definition (`create_studio_members_table_sql`).
|
|
620
|
+
*
|
|
621
|
+
* A row is held by exactly one identity: `user_id` for a project user (what the
|
|
622
|
+
* CLI grants), or `platform_user_id` for a Supatype Cloud account. The engine
|
|
623
|
+
* migrates older single-identity tables; this only has to create the current
|
|
624
|
+
* shape when there is nothing there at all.
|
|
625
|
+
*/
|
|
626
|
+
async function ensureStudioMembersTable(query: DbQuery): Promise<void> {
|
|
627
|
+
await query(`CREATE SCHEMA IF NOT EXISTS _supatype`)
|
|
628
|
+
await query(
|
|
629
|
+
`CREATE TABLE IF NOT EXISTS _supatype.studio_members (
|
|
630
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
631
|
+
user_id UUID UNIQUE,
|
|
632
|
+
platform_user_id UUID UNIQUE,
|
|
633
|
+
role TEXT NOT NULL,
|
|
634
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
635
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
636
|
+
CONSTRAINT studio_members_one_identity
|
|
637
|
+
CHECK (num_nonnulls(user_id, platform_user_id) = 1)
|
|
638
|
+
)`,
|
|
639
|
+
)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function studioMembersTableExists(query: DbQuery): Promise<boolean> {
|
|
643
|
+
const result = await query(
|
|
644
|
+
`SELECT to_regclass('_supatype.studio_members') IS NOT NULL as exists`,
|
|
645
|
+
)
|
|
646
|
+
return Boolean(result.rows[0]?.exists)
|
|
647
|
+
}
|
|
648
|
+
|
|
534
649
|
/** Postgres password for compose `exec psql` (db is not published to the host). */
|
|
535
650
|
export function composePostgresPassword(cwd: string): string {
|
|
536
651
|
return readEnvValue(cwd, "POSTGRES_PASSWORD", "postgres")
|
package/src/commands/cache.ts
CHANGED
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Command } from "commander"
|
|
6
|
-
import { existsSync
|
|
7
|
-
import {
|
|
6
|
+
import { existsSync } from "node:fs"
|
|
7
|
+
import {
|
|
8
|
+
BINARY_COMPONENTS,
|
|
9
|
+
formatBytes,
|
|
10
|
+
listCacheEntries,
|
|
11
|
+
resolveCacheProjectDirs,
|
|
12
|
+
} from "../cache-clean.js"
|
|
13
|
+
import { cleanCachedBinaries } from "../cache-pins.js"
|
|
8
14
|
import { cacheRoot, type Component } from "../binary-cache.js"
|
|
9
15
|
import {
|
|
10
16
|
deleteRestCacheEntry,
|
|
@@ -12,9 +18,11 @@ import {
|
|
|
12
18
|
getRestCacheEntry,
|
|
13
19
|
listRestCacheEntries,
|
|
14
20
|
} from "../rest-cache-admin.js"
|
|
15
|
-
import { error, info, plain } from "../ui/messages.js"
|
|
21
|
+
import { error, info, plain, warn } from "../ui/messages.js"
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
function isComponent(value: string): value is Component {
|
|
24
|
+
return (BINARY_COMPONENTS as readonly string[]).includes(value)
|
|
25
|
+
}
|
|
18
26
|
|
|
19
27
|
export function registerCache(program: Command): void {
|
|
20
28
|
const cache = program
|
|
@@ -24,79 +32,106 @@ export function registerCache(program: Command): void {
|
|
|
24
32
|
cache
|
|
25
33
|
.command("list")
|
|
26
34
|
.description("List cached component binaries and their sizes")
|
|
27
|
-
.
|
|
35
|
+
.option("--project <path>", "Include pins from another project directory", collectPaths, [])
|
|
36
|
+
.action(async (opts: { project: string[] }) => {
|
|
37
|
+
const cwd = process.cwd()
|
|
38
|
+
const projectDirs = resolveCacheProjectDirs(cwd, opts.project)
|
|
28
39
|
const root = cacheRoot()
|
|
40
|
+
|
|
29
41
|
if (!existsSync(root)) {
|
|
30
42
|
info("Cache is empty.")
|
|
31
43
|
return
|
|
32
44
|
}
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for (const component of COMPONENTS) {
|
|
38
|
-
const compDir = join(root, component)
|
|
39
|
-
if (!existsSync(compDir)) continue
|
|
40
|
-
|
|
41
|
-
const versions = readdirSync(compDir).filter(
|
|
42
|
-
(v) => statSync(join(compDir, v)).isDirectory(),
|
|
43
|
-
)
|
|
44
|
-
for (const version of versions) {
|
|
45
|
-
const vDir = join(compDir, version)
|
|
46
|
-
const size = dirSize(vDir)
|
|
47
|
-
totalBytes += size
|
|
48
|
-
found = true
|
|
49
|
-
plain(` ${component}@${version} ${formatBytes(size)}`)
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (!found) {
|
|
46
|
+
const entries = listCacheEntries(projectDirs)
|
|
47
|
+
if (entries.length === 0) {
|
|
54
48
|
info("Cache is empty.")
|
|
55
49
|
return
|
|
56
50
|
}
|
|
57
51
|
|
|
52
|
+
let totalBytes = 0
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
totalBytes += entry.bytes
|
|
55
|
+
const pinLabel = entry.pinned ? " (pinned)" : ""
|
|
56
|
+
plain(` ${entry.component}@${entry.version} ${formatBytes(entry.bytes)}${pinLabel}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
58
59
|
plain(`\nTotal: ${formatBytes(totalBytes)}`)
|
|
59
60
|
info(`Cache root: ${root}`)
|
|
61
|
+
if (projectDirs.length > 0) {
|
|
62
|
+
info(`Pin scan: ${projectDirs.length} project(s)`)
|
|
63
|
+
}
|
|
60
64
|
})
|
|
61
65
|
|
|
62
66
|
cache
|
|
63
67
|
.command("clean [component] [version]")
|
|
64
68
|
.description(
|
|
65
|
-
"Remove cached binaries
|
|
69
|
+
"Remove cached binaries not pinned by local project configs.\n" +
|
|
70
|
+
"Use --force to remove everything (legacy behavior).\n" +
|
|
66
71
|
"Examples:\n" +
|
|
67
|
-
" supatype cache clean
|
|
68
|
-
" supatype cache clean engine
|
|
69
|
-
" supatype cache clean engine 0.4.2"
|
|
72
|
+
" supatype cache clean # remove unpinned versions\n" +
|
|
73
|
+
" supatype cache clean engine # remove unpinned engine versions\n" +
|
|
74
|
+
" supatype cache clean engine 0.4.2 # remove one version (warns if pinned)\n" +
|
|
75
|
+
" supatype cache clean --force # remove all cached binaries",
|
|
70
76
|
)
|
|
71
|
-
.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
.option("--dry-run", "Show what would be removed without deleting")
|
|
78
|
+
.option("--force", "Remove all matching cache entries, ignoring version pins")
|
|
79
|
+
.option("--project <path>", "Include pins from another project directory", collectPaths, [])
|
|
80
|
+
.action(
|
|
81
|
+
async (
|
|
82
|
+
component?: string,
|
|
83
|
+
version?: string,
|
|
84
|
+
opts?: { dryRun?: boolean; force?: boolean; project?: string[] },
|
|
85
|
+
) => {
|
|
86
|
+
const cwd = process.cwd()
|
|
87
|
+
const projectDirs = resolveCacheProjectDirs(cwd, opts?.project ?? [])
|
|
88
|
+
const root = cacheRoot()
|
|
89
|
+
|
|
90
|
+
if (!existsSync(root)) {
|
|
91
|
+
info("Cache is already empty.")
|
|
92
|
+
return
|
|
93
|
+
}
|
|
77
94
|
|
|
78
|
-
|
|
95
|
+
if (component && !isComponent(component)) {
|
|
96
|
+
error(
|
|
97
|
+
`Unknown component "${component}". Valid: ${BINARY_COMPONENTS.join(", ")}`,
|
|
98
|
+
)
|
|
99
|
+
process.exitCode = 1
|
|
100
|
+
return
|
|
101
|
+
}
|
|
79
102
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
103
|
+
const components: Component[] =
|
|
104
|
+
component !== undefined ? [component as Component] : [...BINARY_COMPONENTS]
|
|
105
|
+
const result = cleanCachedBinaries({
|
|
106
|
+
components,
|
|
107
|
+
...(version !== undefined && version !== "" ? { version } : {}),
|
|
108
|
+
...(opts?.force !== undefined ? { force: opts.force } : {}),
|
|
109
|
+
...(opts?.dryRun !== undefined ? { dryRun: opts.dryRun } : {}),
|
|
110
|
+
projectDirs,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
for (const message of result.warned) {
|
|
114
|
+
warn(message)
|
|
115
|
+
}
|
|
83
116
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!existsSync(vDir)) {
|
|
87
|
-
info(`${comp}@${version} not cached.`)
|
|
88
|
-
continue
|
|
89
|
-
}
|
|
90
|
-
rmSync(vDir, { recursive: true, force: true })
|
|
91
|
-
plain(` removed ${comp}@${version}`)
|
|
92
|
-
} else {
|
|
93
|
-
rmSync(compDir, { recursive: true, force: true })
|
|
94
|
-
plain(` removed ${comp} (all versions)`)
|
|
117
|
+
for (const entry of result.skipped) {
|
|
118
|
+
plain(` kept ${entry.component}@${entry.version} (${entry.reason})`)
|
|
95
119
|
}
|
|
96
|
-
}
|
|
97
120
|
|
|
98
|
-
|
|
99
|
-
|
|
121
|
+
for (const entry of result.removed) {
|
|
122
|
+
const prefix = opts?.dryRun ? "would remove" : "removed"
|
|
123
|
+
plain(` ${prefix} ${entry.component}@${entry.version}`)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (result.removed.length === 0 && result.skipped.length === 0) {
|
|
127
|
+
info("Nothing to clean.")
|
|
128
|
+
} else if (opts?.dryRun) {
|
|
129
|
+
info(`Dry run — ${result.removed.length} entr${result.removed.length === 1 ? "y" : "ies"} would be removed.`)
|
|
130
|
+
} else {
|
|
131
|
+
info("Done.")
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
)
|
|
100
135
|
|
|
101
136
|
const rest = cache
|
|
102
137
|
.command("rest")
|
|
@@ -193,26 +228,6 @@ export function registerCache(program: Command): void {
|
|
|
193
228
|
})
|
|
194
229
|
}
|
|
195
230
|
|
|
196
|
-
function
|
|
197
|
-
|
|
198
|
-
try {
|
|
199
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
200
|
-
const full = join(dir, entry.name)
|
|
201
|
-
if (entry.isDirectory()) {
|
|
202
|
-
total += dirSize(full)
|
|
203
|
-
} else {
|
|
204
|
-
total += statSync(full).size
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
} catch {
|
|
208
|
-
// Skip unreadable entries.
|
|
209
|
-
}
|
|
210
|
-
return total
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function formatBytes(bytes: number): string {
|
|
214
|
-
if (bytes < 1024) return `${bytes} B`
|
|
215
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
216
|
-
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
217
|
-
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
|
231
|
+
function collectPaths(value: string, previous: string[]): string[] {
|
|
232
|
+
return [...previous, value]
|
|
218
233
|
}
|