@coreframe/scripts 0.0.0 → 0.1.0

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.
Files changed (96) hide show
  1. package/bin/coreframe.js +35 -0
  2. package/config.test.ts +15 -0
  3. package/config.ts +9 -0
  4. package/coreframe.ts +272 -0
  5. package/db/client.ts +72 -0
  6. package/db/config.ts +41 -0
  7. package/db/drizzle-kit.test.ts +27 -0
  8. package/db/drizzle-kit.ts +39 -0
  9. package/db/ensure.ts +67 -0
  10. package/db/migrate-held.test.ts +81 -0
  11. package/db/migrate-held.ts +166 -0
  12. package/db/migrate.ts +39 -0
  13. package/db/project.ts +28 -0
  14. package/db/reset.ts +24 -0
  15. package/db/seed.ts +38 -0
  16. package/db/setup.ts +18 -0
  17. package/deploy/check-migrations.ts +31 -0
  18. package/deploy/checks.ts +38 -0
  19. package/deploy/config.test.ts +24 -0
  20. package/deploy/config.ts +68 -0
  21. package/deploy/migrations.test.ts +110 -0
  22. package/deploy/migrations.ts +317 -0
  23. package/deploy/pending-migrations.test.ts +93 -0
  24. package/deploy/pending-migrations.ts +45 -0
  25. package/deploy/tauri-release.test.ts +196 -0
  26. package/deploy/tauri-release.ts +816 -0
  27. package/deploy/version.test.ts +29 -0
  28. package/deploy/version.ts +65 -0
  29. package/deploy.test.ts +101 -0
  30. package/deploy.ts +346 -0
  31. package/dev.test.ts +153 -0
  32. package/dev.ts +155 -0
  33. package/dist/config.d.ts +5 -0
  34. package/dist/config.js +3 -0
  35. package/dist/coreframe.d.ts +280 -0
  36. package/dist/coreframe.js +240 -0
  37. package/dist/db/client.d.ts +22 -0
  38. package/dist/db/client.js +29 -0
  39. package/dist/db/config.d.ts +21 -0
  40. package/dist/db/config.js +32 -0
  41. package/dist/db/drizzle-kit.d.ts +8 -0
  42. package/dist/db/drizzle-kit.js +22 -0
  43. package/dist/db/ensure.d.ts +2 -0
  44. package/dist/db/ensure.js +40 -0
  45. package/dist/db/migrate-held.d.ts +12 -0
  46. package/dist/db/migrate-held.js +119 -0
  47. package/dist/db/migrate.d.ts +2 -0
  48. package/dist/db/migrate.js +25 -0
  49. package/dist/db/project.d.ts +8 -0
  50. package/dist/db/project.js +9 -0
  51. package/dist/db/reset.d.ts +2 -0
  52. package/dist/db/reset.js +19 -0
  53. package/dist/db/seed.d.ts +3 -0
  54. package/dist/db/seed.js +23 -0
  55. package/dist/db/setup.d.ts +3 -0
  56. package/dist/db/setup.js +15 -0
  57. package/dist/deploy/check-migrations.d.ts +2 -0
  58. package/dist/deploy/check-migrations.js +18 -0
  59. package/dist/deploy/checks.d.ts +2 -0
  60. package/dist/deploy/checks.js +27 -0
  61. package/dist/deploy/config.d.ts +50 -0
  62. package/dist/deploy/config.js +9 -0
  63. package/dist/deploy/migrations.d.ts +21 -0
  64. package/dist/deploy/migrations.js +203 -0
  65. package/dist/deploy/pending-migrations.d.ts +5 -0
  66. package/dist/deploy/pending-migrations.js +30 -0
  67. package/dist/deploy/tauri-release.d.ts +85 -0
  68. package/dist/deploy/tauri-release.js +512 -0
  69. package/dist/deploy/version.d.ts +18 -0
  70. package/dist/deploy/version.js +41 -0
  71. package/dist/deploy.d.ts +17 -0
  72. package/dist/deploy.js +247 -0
  73. package/dist/dev.d.ts +13 -0
  74. package/dist/dev.js +96 -0
  75. package/dist/image-generator.d.ts +2 -0
  76. package/dist/image-generator.js +59 -0
  77. package/dist/project.d.ts +1 -0
  78. package/dist/project.js +13 -0
  79. package/dist/typecheck.d.ts +2 -0
  80. package/dist/typecheck.js +45 -0
  81. package/dist/upgrade/check-skill-current.d.ts +6 -0
  82. package/dist/upgrade/check-skill-current.js +88 -0
  83. package/dist/upgrade/ensure-template-remote.d.ts +6 -0
  84. package/dist/upgrade/ensure-template-remote.js +57 -0
  85. package/dist/upgrade/fast-forward-template-files.d.ts +7 -0
  86. package/dist/upgrade/fast-forward-template-files.js +280 -0
  87. package/image-generator.ts +84 -0
  88. package/package.json +48 -8
  89. package/project.ts +6 -0
  90. package/tsconfig.build.json +12 -0
  91. package/tsconfig.json +20 -0
  92. package/typecheck.ts +55 -0
  93. package/upgrade/check-skill-current.ts +112 -0
  94. package/upgrade/ensure-template-remote.ts +79 -0
  95. package/upgrade/fast-forward-template-files.ts +408 -0
  96. package/readme.md +0 -1
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest"
2
+
3
+ import { formatReleaseDate, nextReleaseVersion, releaseName } from "./version.ts"
4
+
5
+ describe("nextReleaseVersion", () => {
6
+ it("starts a date release at patch zero", () => {
7
+ expect(nextReleaseVersion("0.0.0", new Date("2026-07-04T12:00:00"))).toBe("0.20260704.0")
8
+ })
9
+
10
+ it("increments same-day patches", () => {
11
+ expect(nextReleaseVersion("0.20260704.0", new Date("2026-07-04T18:00:00"))).toBe("0.20260704.1")
12
+ })
13
+
14
+ it("keeps the current major version", () => {
15
+ expect(nextReleaseVersion("2.20260703.7", new Date("2026-07-04T12:00:00"))).toBe("2.20260704.0")
16
+ })
17
+ })
18
+
19
+ describe("releaseName", () => {
20
+ it("combines package name and version", () => {
21
+ expect(releaseName("coreframe", "0.20260704.0")).toBe("coreframe@0.20260704.0")
22
+ })
23
+ })
24
+
25
+ describe("formatReleaseDate", () => {
26
+ it("formats the local date as YYYYMMDD", () => {
27
+ expect(formatReleaseDate(new Date(2026, 6, 4, 12))).toBe("20260704")
28
+ })
29
+ })
@@ -0,0 +1,65 @@
1
+ import { readFile, writeFile } from "node:fs/promises"
2
+
3
+ type PackageJson = {
4
+ name?: string
5
+ version?: string
6
+ [key: string]: unknown
7
+ }
8
+
9
+ type ReleasePackageJson = PackageJson & {
10
+ name: string
11
+ version: string
12
+ }
13
+
14
+ export function nextReleaseVersion(currentVersion: string, now = new Date()) {
15
+ const current = parseVersion(currentVersion)
16
+ const releaseDate = formatReleaseDate(now)
17
+ const patch = current.minor === releaseDate ? current.patch + 1 : 0
18
+
19
+ return `${current.major}.${releaseDate}.${patch}`
20
+ }
21
+
22
+ export function releaseName(packageName: string, version: string) {
23
+ return `${packageName}@${version}`
24
+ }
25
+
26
+ export function formatReleaseDate(date: Date) {
27
+ const year = String(date.getFullYear())
28
+ const month = String(date.getMonth() + 1).padStart(2, "0")
29
+ const day = String(date.getDate()).padStart(2, "0")
30
+
31
+ return `${year}${month}${day}`
32
+ }
33
+
34
+ export async function readPackageJson(path = "package.json") {
35
+ const source = await readFile(path, "utf8")
36
+ const packageJson = JSON.parse(source) as PackageJson
37
+
38
+ if (!packageJson.name) {
39
+ throw new Error(`${path} is missing a package name.`)
40
+ }
41
+
42
+ if (!packageJson.version) {
43
+ throw new Error(`${path} is missing a package version.`)
44
+ }
45
+
46
+ return { packageJson: packageJson as ReleasePackageJson, source }
47
+ }
48
+
49
+ export async function writePackageVersion(path: string, packageJson: PackageJson, version: string) {
50
+ await writeFile(path, `${JSON.stringify({ ...packageJson, version }, null, 2)}\n`)
51
+ }
52
+
53
+ function parseVersion(version: string) {
54
+ const match = /^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)$/u.exec(version)
55
+
56
+ if (!match?.groups) {
57
+ throw new Error(`Invalid package version "${version}". Expected major.minor.patch.`)
58
+ }
59
+
60
+ return {
61
+ major: Number(match.groups.major),
62
+ minor: match.groups.minor,
63
+ patch: Number(match.groups.patch),
64
+ }
65
+ }
package/deploy.test.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+
5
+ import { describe, expect, it } from "vitest"
6
+
7
+ import { parseDeployArgs, readProjectConfig } from "./deploy.ts"
8
+
9
+ describe("parseDeployArgs", () => {
10
+ it("treats --yes as noninteractive migration approval", () => {
11
+ expect(parseDeployArgs(["--yes"])).toEqual({
12
+ runMigrations: true,
13
+ skipMigrations: false,
14
+ skipPostDeployChecks: false,
15
+ tauriLocalPlatforms: [],
16
+ tauriWorkflowName: undefined,
17
+ tauriWorkflowPlatforms: [],
18
+ tauriWorkflowRef: undefined,
19
+ yes: true,
20
+ })
21
+ })
22
+
23
+ it("supports explicit migration and post-deploy flags", () => {
24
+ expect(parseDeployArgs(["--skip-migrations", "--skip-post-deploy-checks"])).toEqual({
25
+ runMigrations: false,
26
+ skipMigrations: true,
27
+ skipPostDeployChecks: true,
28
+ tauriLocalPlatforms: [],
29
+ tauriWorkflowName: undefined,
30
+ tauriWorkflowPlatforms: [],
31
+ tauriWorkflowRef: undefined,
32
+ yes: false,
33
+ })
34
+ })
35
+
36
+ it("supports local and workflow Tauri platform selections", () => {
37
+ expect(
38
+ parseDeployArgs([
39
+ "--tauri-local-platform",
40
+ "macos,ios",
41
+ "--tauri-workflow-platform=windows",
42
+ "--tauri-workflow-platform",
43
+ "linux",
44
+ "--tauri-workflow-name",
45
+ "release-tauri.yml",
46
+ "--tauri-workflow-ref=main",
47
+ ]),
48
+ ).toEqual({
49
+ runMigrations: false,
50
+ skipMigrations: false,
51
+ skipPostDeployChecks: false,
52
+ tauriLocalPlatforms: ["macos", "ios"],
53
+ tauriWorkflowName: "release-tauri.yml",
54
+ tauriWorkflowPlatforms: ["windows", "linux"],
55
+ tauriWorkflowRef: "main",
56
+ yes: false,
57
+ })
58
+ })
59
+
60
+ it("rejects unknown Tauri platforms", () => {
61
+ expect(() => parseDeployArgs(["--tauri-local-platform", "solaris"])).toThrow(
62
+ 'Invalid Tauri release platform "solaris".',
63
+ )
64
+ })
65
+ })
66
+
67
+ describe("readProjectConfig", () => {
68
+ it("returns empty config when coreframe.config.ts is missing", async () => {
69
+ const root = await mkdtemp(join(tmpdir(), "coreframe-config-missing-"))
70
+ const originalCwd = process.cwd()
71
+
72
+ try {
73
+ process.chdir(root)
74
+ await expect(readProjectConfig()).resolves.toEqual({})
75
+ } finally {
76
+ process.chdir(originalCwd)
77
+ }
78
+ })
79
+
80
+ it("loads coreframe.config.ts with dynamic import", async () => {
81
+ const root = await mkdtemp(join(tmpdir(), "coreframe-config-"))
82
+ const originalCwd = process.cwd()
83
+
84
+ await mkdir(root, { recursive: true })
85
+ await writeFile(
86
+ join(root, "coreframe.config.ts"),
87
+ "export default { deploy: { smokeChecks: [{ name: 'api', path: '/api/health' }] } }\n",
88
+ )
89
+
90
+ try {
91
+ process.chdir(root)
92
+ await expect(readProjectConfig()).resolves.toEqual({
93
+ deploy: {
94
+ smokeChecks: [{ name: "api", path: "/api/health" }],
95
+ },
96
+ })
97
+ } finally {
98
+ process.chdir(originalCwd)
99
+ }
100
+ })
101
+ })
package/deploy.ts ADDED
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env node
2
+ import { access, writeFile } from "node:fs/promises"
3
+ import { resolve } from "node:path"
4
+ import { pathToFileURL } from "node:url"
5
+
6
+ import * as clack from "@clack/prompts"
7
+ import nanoSpawn from "nano-spawn"
8
+
9
+ import type { CoreframeConfig } from "./config.ts"
10
+ import type { CoreframeDatabaseClient } from "./db/client.ts"
11
+ import { createProjectDeployDatabase } from "./db/project.ts"
12
+ import { runPostDeployChecks } from "./deploy/checks.ts"
13
+ import { resolveDeployConfig } from "./deploy/config.ts"
14
+ import type { TauriReleasePlatform } from "./deploy/config.ts"
15
+ import {
16
+ collectDeployMigrationFiles,
17
+ formatMigrationList,
18
+ formatMigrationScanFailures,
19
+ scanDeployMigrationFiles,
20
+ } from "./deploy/migrations.ts"
21
+ import { filterPendingMigrations, readAppliedMigrationNames } from "./deploy/pending-migrations.ts"
22
+ import {
23
+ parseTauriReleasePlatform,
24
+ prepareTauriRelease,
25
+ resolveTauriReleasePlan,
26
+ restoreTauriReleaseMetadata,
27
+ uploadTauriReleaseToB2,
28
+ validateTauriReleasePlan,
29
+ writeTauriReleaseMetadata,
30
+ } from "./deploy/tauri-release.ts"
31
+ import type { TauriReleaseMetadataSnapshot } from "./deploy/tauri-release.ts"
32
+ import {
33
+ nextReleaseVersion,
34
+ readPackageJson,
35
+ releaseName,
36
+ writePackageVersion,
37
+ } from "./deploy/version.ts"
38
+
39
+ type DeployArgs = {
40
+ runMigrations: boolean
41
+ skipMigrations: boolean
42
+ skipPostDeployChecks: boolean
43
+ tauriLocalPlatforms: TauriReleasePlatform[]
44
+ tauriWorkflowName?: string
45
+ tauriWorkflowPlatforms: TauriReleasePlatform[]
46
+ tauriWorkflowRef?: string
47
+ yes: boolean
48
+ }
49
+
50
+ const packagePath = "package.json"
51
+ const projectConfigPath = "coreframe.config.ts"
52
+
53
+ export async function deployProject(argv = process.argv.slice(2), env = process.env) {
54
+ const args = parseDeployArgs(argv)
55
+ const { packageJson, source: originalPackageSource } = await readPackageJson(packagePath)
56
+ const version = nextReleaseVersion(packageJson.version)
57
+ const name = releaseName(packageJson.name, version)
58
+ const environment = env.DEPLOY_ENV ?? "production"
59
+ const releaseEnv = createReleaseEnv(env, name, packageJson.name, version)
60
+ const projectConfig = await readProjectConfig()
61
+ const config = resolveDeployConfig(projectConfig.deploy ?? {}, env)
62
+ const tauriReleasePlan = resolveTauriReleasePlan({
63
+ args,
64
+ config: config.tauriRelease,
65
+ env,
66
+ })
67
+ let externalMutated = false
68
+ let tauriMetadataSnapshot: TauriReleaseMetadataSnapshot = { files: [] }
69
+
70
+ clack.intro(`Deploy ${name}`)
71
+
72
+ validateDeployArgs(args)
73
+ requireEnv(env, ["POSTHOG_API_KEY", "POSTHOG_PROJECT_ID"])
74
+ await ensureCleanGit()
75
+ await validateTauriReleasePlan(tauriReleasePlan)
76
+
77
+ const database = await createProjectDeployDatabase({ env })
78
+ const pendingMigrations = await collectPendingMigrationsWithClose(database)
79
+ const migrationFailures = await scanDeployMigrationFiles(pendingMigrations)
80
+
81
+ if (migrationFailures.length > 0) {
82
+ throw new Error(
83
+ `Pending deploy migrations contain unsafe SQL. Move unsafe statements to held migrations.\n${formatMigrationScanFailures(migrationFailures)}`,
84
+ )
85
+ }
86
+
87
+ const shouldRunMigrations = await chooseMigrationExecution(args, pendingMigrations)
88
+
89
+ try {
90
+ await writePackageVersion(packagePath, packageJson, version)
91
+ tauriMetadataSnapshot = await writeTauriReleaseMetadata(tauriReleasePlan, version)
92
+
93
+ clack.log.step("Running pre-deploy build")
94
+ await nanoSpawn("pnpm", ["build"], {
95
+ env: createBuildCheckEnv(releaseEnv),
96
+ stdio: "inherit",
97
+ })
98
+
99
+ const tauriRelease = tauriReleasePlan.enabled
100
+ ? await prepareTauriRelease({
101
+ env: createBuildCheckEnv(releaseEnv),
102
+ plan: tauriReleasePlan,
103
+ releaseName: name,
104
+ version,
105
+ })
106
+ : undefined
107
+
108
+ clack.log.step("Running release build and uploading PostHog source maps")
109
+ externalMutated = true
110
+ await nanoSpawn("pnpm", ["build"], {
111
+ env: releaseEnv,
112
+ stdio: "inherit",
113
+ })
114
+
115
+ if (shouldRunMigrations) {
116
+ clack.log.step("Running deploy-safe database migrations")
117
+ await nanoSpawn("pnpm", ["db", "migrate"], {
118
+ env: releaseEnv,
119
+ stdio: "inherit",
120
+ })
121
+ }
122
+
123
+ clack.log.step("Deploying Worker and static assets to Cloudflare")
124
+ await nanoSpawn(
125
+ "pnpm",
126
+ [
127
+ "exec",
128
+ "wrangler",
129
+ "deploy",
130
+ "--strict",
131
+ "--keep-vars",
132
+ "--tag",
133
+ name,
134
+ "--message",
135
+ `Deploy ${name}`,
136
+ "--var",
137
+ `POSTHOG_RELEASE:${name}`,
138
+ ],
139
+ {
140
+ env: releaseEnv,
141
+ stdio: "inherit",
142
+ },
143
+ )
144
+
145
+ if (!args.skipPostDeployChecks) {
146
+ clack.log.step("Running post-deploy checks")
147
+ await runPostDeployChecks(config, {
148
+ environment,
149
+ productionUrl: config.productionUrl,
150
+ releaseName: name,
151
+ version,
152
+ })
153
+ }
154
+
155
+ if (tauriRelease) {
156
+ clack.log.step("Uploading Tauri release artifacts to Backblaze B2")
157
+ await uploadTauriReleaseToB2(tauriReleasePlan, tauriRelease)
158
+ }
159
+
160
+ clack.outro(`Deployed ${name}`)
161
+ } catch (error) {
162
+ if (!externalMutated) {
163
+ await writeFile(packagePath, originalPackageSource)
164
+ await restoreTauriReleaseMetadata(tauriMetadataSnapshot)
165
+ }
166
+
167
+ throw error
168
+ }
169
+ }
170
+
171
+ export async function readProjectConfig(): Promise<CoreframeConfig> {
172
+ const configPath = resolve(projectConfigPath)
173
+
174
+ try {
175
+ await access(configPath)
176
+ } catch {
177
+ return {}
178
+ }
179
+
180
+ const configUrl = pathToFileURL(configPath).href
181
+ const module = (await import(configUrl)) as { default?: CoreframeConfig }
182
+
183
+ return module.default ?? {}
184
+ }
185
+
186
+ export function parseDeployArgs(argv: string[]): DeployArgs {
187
+ return {
188
+ runMigrations: argv.includes("--run-migrations") || argv.includes("--yes"),
189
+ skipMigrations: argv.includes("--skip-migrations"),
190
+ skipPostDeployChecks: argv.includes("--skip-post-deploy-checks"),
191
+ tauriLocalPlatforms: parseRepeatedOption(argv, "--tauri-local-platform").map((platform) =>
192
+ parseTauriReleasePlatform(platform),
193
+ ),
194
+ tauriWorkflowName: parseOptionalOption(argv, "--tauri-workflow-name"),
195
+ tauriWorkflowPlatforms: parseRepeatedOption(argv, "--tauri-workflow-platform").map((platform) =>
196
+ parseTauriReleasePlatform(platform),
197
+ ),
198
+ tauriWorkflowRef: parseOptionalOption(argv, "--tauri-workflow-ref"),
199
+ yes: argv.includes("--yes"),
200
+ }
201
+ }
202
+
203
+ function parseRepeatedOption(argv: string[], name: string) {
204
+ const values = []
205
+
206
+ for (let index = 0; index < argv.length; index += 1) {
207
+ const arg = argv[index]
208
+
209
+ if (arg === name) {
210
+ const value = argv[index + 1]
211
+
212
+ if (!value || value.startsWith("--")) {
213
+ throw new Error(`${name} requires a value.`)
214
+ }
215
+
216
+ values.push(...value.split(",").filter(Boolean))
217
+ index += 1
218
+ continue
219
+ }
220
+
221
+ if (arg.startsWith(`${name}=`)) {
222
+ values.push(
223
+ ...arg
224
+ .slice(name.length + 1)
225
+ .split(",")
226
+ .filter(Boolean),
227
+ )
228
+ }
229
+ }
230
+
231
+ return values
232
+ }
233
+
234
+ function parseOptionalOption(argv: string[], name: string) {
235
+ const values = parseRepeatedOption(argv, name)
236
+
237
+ if (values.length > 1) {
238
+ throw new Error(`${name} can only be provided once.`)
239
+ }
240
+
241
+ return values[0]
242
+ }
243
+
244
+ async function collectPendingMigrationsWithClose(database: CoreframeDatabaseClient) {
245
+ try {
246
+ return await collectPendingMigrations(database)
247
+ } finally {
248
+ await database.close?.()
249
+ }
250
+ }
251
+
252
+ async function collectPendingMigrations(database: CoreframeDatabaseClient) {
253
+ const migrations = await collectDeployMigrationFiles()
254
+ const appliedMigrations = await readAppliedMigrationNames(database)
255
+
256
+ return filterPendingMigrations(migrations, appliedMigrations)
257
+ }
258
+
259
+ async function chooseMigrationExecution(
260
+ args: DeployArgs,
261
+ pendingMigrations: Awaited<ReturnType<typeof collectPendingMigrations>>,
262
+ ) {
263
+ if (pendingMigrations.length === 0) {
264
+ clack.log.info("No pending deploy migrations.")
265
+ return false
266
+ }
267
+
268
+ clack.note(formatMigrationList(pendingMigrations), "Pending deploy migrations")
269
+
270
+ if (args.runMigrations) {
271
+ return true
272
+ }
273
+
274
+ if (args.skipMigrations) {
275
+ return false
276
+ }
277
+
278
+ if (!process.stdout.isTTY) {
279
+ throw new Error(
280
+ "Pending migrations require --run-migrations or --skip-migrations in noninteractive deploys.",
281
+ )
282
+ }
283
+
284
+ const answer = await clack.confirm({
285
+ initialValue: true,
286
+ message: "Run these deploy-safe database migrations before Cloudflare deploy?",
287
+ })
288
+
289
+ if (clack.isCancel(answer)) {
290
+ clack.cancel("Deploy cancelled.")
291
+ throw new Error("Deploy cancelled.")
292
+ }
293
+
294
+ return answer
295
+ }
296
+
297
+ async function ensureCleanGit() {
298
+ const { stdout: status } = await nanoSpawn("git", ["status", "--porcelain"], { stdin: "ignore" })
299
+
300
+ if (status.trim().length > 0) {
301
+ throw new Error("Deploy requires a clean git tree before the release version is applied.")
302
+ }
303
+ }
304
+
305
+ function validateDeployArgs(args: DeployArgs) {
306
+ if (args.runMigrations && args.skipMigrations) {
307
+ throw new Error("Use only one of --run-migrations, --skip-migrations, or --yes.")
308
+ }
309
+ }
310
+
311
+ function requireEnv<const Name extends string>(env: NodeJS.ProcessEnv, names: readonly Name[]) {
312
+ const missing = names.filter((name) => !env[name])
313
+
314
+ if (missing.length > 0) {
315
+ throw new Error(`Missing required deploy environment variables: ${missing.join(", ")}`)
316
+ }
317
+
318
+ return Object.fromEntries(names.map((name) => [name, env[name]])) as Record<Name, string>
319
+ }
320
+
321
+ function createReleaseEnv(
322
+ env: NodeJS.ProcessEnv,
323
+ name: string,
324
+ packageName: string,
325
+ version: string,
326
+ ): NodeJS.ProcessEnv {
327
+ return {
328
+ ...env,
329
+ POSTHOG_RELEASE: name,
330
+ POSTHOG_RELEASE_NAME: env.POSTHOG_RELEASE_NAME ?? packageName,
331
+ POSTHOG_RELEASE_VERSION: env.POSTHOG_RELEASE_VERSION ?? version,
332
+ }
333
+ }
334
+
335
+ function createBuildCheckEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
336
+ const { POSTHOG_API_KEY: _apiKey, POSTHOG_PROJECT_ID: _projectId, ...buildEnv } = env
337
+
338
+ return buildEnv
339
+ }
340
+
341
+ if (import.meta.main) {
342
+ deployProject().catch((error) => {
343
+ console.error(error)
344
+ process.exit(1)
345
+ })
346
+ }
package/dev.test.ts ADDED
@@ -0,0 +1,153 @@
1
+ import type { spawn as nodeSpawn } from "node:child_process"
2
+ import { EventEmitter } from "node:events"
3
+ import { mkdtemp, readFile } from "node:fs/promises"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import { PassThrough } from "node:stream"
7
+
8
+ import { afterEach, describe, expect, it, vi } from "vitest"
9
+
10
+ import { runDevServer } from "./dev.ts"
11
+
12
+ type SpawnCall = {
13
+ args: readonly string[]
14
+ command: string
15
+ options: { cwd?: string; env?: NodeJS.ProcessEnv; stdio?: unknown }
16
+ }
17
+
18
+ const stdioSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true)
19
+ const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true)
20
+
21
+ afterEach(() => {
22
+ stdioSpy.mockClear()
23
+ stderrSpy.mockClear()
24
+ })
25
+
26
+ describe("runDevServer", () => {
27
+ it("ensures the database before starting React Router and tees output to a local log", async () => {
28
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "coreframe-dev-"))
29
+ const logPath = path.join(tempDir, ".agents/logs/dev-server.log")
30
+ const calls: SpawnCall[] = []
31
+ const spawn = createSpawn(calls, 0)
32
+
33
+ await runDevServer({
34
+ cwd: tempDir,
35
+ env: { PATH: "/usr/bin" },
36
+ logPath,
37
+ spawn,
38
+ })
39
+
40
+ expect(calls).toEqual([
41
+ expect.objectContaining({
42
+ args: ["db", "ensure"],
43
+ command: process.platform === "win32" ? "pnpm.cmd" : "pnpm",
44
+ options: expect.objectContaining({ cwd: tempDir }),
45
+ }),
46
+ expect.objectContaining({
47
+ args: ["dev"],
48
+ command: process.platform === "win32" ? "react-router.cmd" : "react-router",
49
+ options: expect.objectContaining({
50
+ cwd: tempDir,
51
+ env: expect.objectContaining({
52
+ CLOUDFLARE_ENV: "development",
53
+ PATH: expect.stringContaining(`node_modules${path.sep}.bin`),
54
+ }),
55
+ }),
56
+ }),
57
+ ])
58
+
59
+ await expect(readFile(logPath, "utf8")).resolves.toContain("pnpm db ensure stdout")
60
+ await expect(readFile(logPath, "utf8")).resolves.toContain("react-router dev stderr")
61
+ })
62
+
63
+ it("runs Tauri dev for a requested platform and tees its output", async () => {
64
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "coreframe-dev-"))
65
+ const logPath = path.join(tempDir, ".agents/logs/dev-server.log")
66
+ const calls: SpawnCall[] = []
67
+ const spawn = createSpawn(calls, 0)
68
+
69
+ await runDevServer({
70
+ cwd: tempDir,
71
+ logPath,
72
+ platform: "ios",
73
+ spawn,
74
+ })
75
+
76
+ expect(calls).toEqual([
77
+ expect.objectContaining({
78
+ args: ["--dir=tauri", "tauri", "ios", "dev"],
79
+ command: process.platform === "win32" ? "pnpm.cmd" : "pnpm",
80
+ options: expect.objectContaining({ cwd: tempDir }),
81
+ }),
82
+ ])
83
+
84
+ await expect(readFile(logPath, "utf8")).resolves.toContain(
85
+ "pnpm --dir=tauri tauri ios dev stdout",
86
+ )
87
+ })
88
+
89
+ it("does not tee again when Tauri starts the web dev command", async () => {
90
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "coreframe-dev-"))
91
+ const logPath = path.join(tempDir, ".agents/logs/dev-server.log")
92
+ const calls: SpawnCall[] = []
93
+ const spawn = createSpawn(calls, 0)
94
+
95
+ await runDevServer({
96
+ cwd: tempDir,
97
+ env: { PATH: "/usr/bin", TAURI_ENV_PLATFORM: "ios" },
98
+ logPath,
99
+ spawn,
100
+ })
101
+
102
+ expect(calls).toEqual([
103
+ expect.objectContaining({
104
+ args: ["db", "ensure"],
105
+ options: expect.objectContaining({ stdio: "inherit" }),
106
+ }),
107
+ expect.objectContaining({
108
+ args: ["dev"],
109
+ options: expect.objectContaining({ stdio: "inherit" }),
110
+ }),
111
+ ])
112
+ await expect(readFile(logPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" })
113
+ })
114
+
115
+ it("does not start React Router when database setup fails", async () => {
116
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "coreframe-dev-"))
117
+ const calls: SpawnCall[] = []
118
+ const spawn = createSpawn(calls, 1)
119
+
120
+ await expect(
121
+ runDevServer({
122
+ cwd: tempDir,
123
+ logPath: path.join(tempDir, ".agents/logs/dev-server.log"),
124
+ spawn,
125
+ }),
126
+ ).rejects.toThrow("pnpm db ensure failed")
127
+
128
+ expect(calls).toHaveLength(1)
129
+ })
130
+ })
131
+
132
+ function createSpawn(calls: SpawnCall[], firstExitCode: number): typeof nodeSpawn {
133
+ return ((command: string, args: readonly string[], options: SpawnCall["options"]) => {
134
+ calls.push({ args, command, options })
135
+
136
+ const child = new EventEmitter() as EventEmitter & {
137
+ stderr: PassThrough
138
+ stdout: PassThrough
139
+ }
140
+ child.stderr = new PassThrough()
141
+ child.stdout = new PassThrough()
142
+
143
+ queueMicrotask(() => {
144
+ child.stdout.write(`${command} ${args.join(" ")} stdout\n`)
145
+ child.stderr.write(`${command} ${args.join(" ")} stderr\n`)
146
+ child.stdout.end()
147
+ child.stderr.end()
148
+ child.emit("close", calls.length === 1 ? firstExitCode : 0, null)
149
+ })
150
+
151
+ return child
152
+ }) as unknown as typeof nodeSpawn
153
+ }