@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.
- package/bin/coreframe.js +35 -0
- package/config.test.ts +15 -0
- package/config.ts +9 -0
- package/coreframe.ts +272 -0
- package/db/client.ts +72 -0
- package/db/config.ts +41 -0
- package/db/drizzle-kit.test.ts +27 -0
- package/db/drizzle-kit.ts +39 -0
- package/db/ensure.ts +67 -0
- package/db/migrate-held.test.ts +81 -0
- package/db/migrate-held.ts +166 -0
- package/db/migrate.ts +39 -0
- package/db/project.ts +28 -0
- package/db/reset.ts +24 -0
- package/db/seed.ts +38 -0
- package/db/setup.ts +18 -0
- package/deploy/check-migrations.ts +31 -0
- package/deploy/checks.ts +38 -0
- package/deploy/config.test.ts +24 -0
- package/deploy/config.ts +68 -0
- package/deploy/migrations.test.ts +110 -0
- package/deploy/migrations.ts +317 -0
- package/deploy/pending-migrations.test.ts +93 -0
- package/deploy/pending-migrations.ts +45 -0
- package/deploy/tauri-release.test.ts +196 -0
- package/deploy/tauri-release.ts +816 -0
- package/deploy/version.test.ts +29 -0
- package/deploy/version.ts +65 -0
- package/deploy.test.ts +101 -0
- package/deploy.ts +346 -0
- package/dev.test.ts +153 -0
- package/dev.ts +155 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +3 -0
- package/dist/coreframe.d.ts +280 -0
- package/dist/coreframe.js +240 -0
- package/dist/db/client.d.ts +22 -0
- package/dist/db/client.js +29 -0
- package/dist/db/config.d.ts +21 -0
- package/dist/db/config.js +32 -0
- package/dist/db/drizzle-kit.d.ts +8 -0
- package/dist/db/drizzle-kit.js +22 -0
- package/dist/db/ensure.d.ts +2 -0
- package/dist/db/ensure.js +40 -0
- package/dist/db/migrate-held.d.ts +12 -0
- package/dist/db/migrate-held.js +119 -0
- package/dist/db/migrate.d.ts +2 -0
- package/dist/db/migrate.js +25 -0
- package/dist/db/project.d.ts +8 -0
- package/dist/db/project.js +9 -0
- package/dist/db/reset.d.ts +2 -0
- package/dist/db/reset.js +19 -0
- package/dist/db/seed.d.ts +3 -0
- package/dist/db/seed.js +23 -0
- package/dist/db/setup.d.ts +3 -0
- package/dist/db/setup.js +15 -0
- package/dist/deploy/check-migrations.d.ts +2 -0
- package/dist/deploy/check-migrations.js +18 -0
- package/dist/deploy/checks.d.ts +2 -0
- package/dist/deploy/checks.js +27 -0
- package/dist/deploy/config.d.ts +50 -0
- package/dist/deploy/config.js +9 -0
- package/dist/deploy/migrations.d.ts +21 -0
- package/dist/deploy/migrations.js +203 -0
- package/dist/deploy/pending-migrations.d.ts +5 -0
- package/dist/deploy/pending-migrations.js +30 -0
- package/dist/deploy/tauri-release.d.ts +85 -0
- package/dist/deploy/tauri-release.js +512 -0
- package/dist/deploy/version.d.ts +18 -0
- package/dist/deploy/version.js +41 -0
- package/dist/deploy.d.ts +17 -0
- package/dist/deploy.js +247 -0
- package/dist/dev.d.ts +13 -0
- package/dist/dev.js +96 -0
- package/dist/image-generator.d.ts +2 -0
- package/dist/image-generator.js +59 -0
- package/dist/project.d.ts +1 -0
- package/dist/project.js +13 -0
- package/dist/typecheck.d.ts +2 -0
- package/dist/typecheck.js +45 -0
- package/dist/upgrade/check-skill-current.d.ts +6 -0
- package/dist/upgrade/check-skill-current.js +88 -0
- package/dist/upgrade/ensure-template-remote.d.ts +6 -0
- package/dist/upgrade/ensure-template-remote.js +57 -0
- package/dist/upgrade/fast-forward-template-files.d.ts +7 -0
- package/dist/upgrade/fast-forward-template-files.js +280 -0
- package/image-generator.ts +84 -0
- package/package.json +48 -8
- package/project.ts +6 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.json +20 -0
- package/typecheck.ts +55 -0
- package/upgrade/check-skill-current.ts +112 -0
- package/upgrade/ensure-template-remote.ts +79 -0
- package/upgrade/fast-forward-template-files.ts +408 -0
- 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
|
+
}
|