clisma 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/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,176 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import crypto from "node:crypto";
4
+ import { Command } from "commander";
5
+ import { input } from "@inquirer/prompts";
6
+ import kleur from "kleur";
7
+ import { runMigrations, findConfigFile } from "@clisma/core";
8
+ import { listEnvironmentsFile, parseConfigFile } from "@clisma/config";
9
+ import { parse as parseDotenv } from "@dotenvx/dotenvx";
10
+ const program = new Command();
11
+ const withCommonOptions = (command) => {
12
+ command
13
+ .option("--config <path>", "Path to config file (default: clisma.hcl)")
14
+ .option("--env <name>", "Environment name from config file")
15
+ .option("--env-file <path>", "Load environment variables from file")
16
+ .option("--var <key=value>", "Set a variable value (can be used multiple times)", (value, previous = []) => {
17
+ return [...previous, value];
18
+ });
19
+ };
20
+ const parseVars = (varArgs = []) => {
21
+ const vars = {};
22
+ for (const arg of varArgs) {
23
+ const [key, ...valueParts] = arg.split("=");
24
+ if (key && valueParts.length > 0) {
25
+ vars[key] = valueParts.join("=");
26
+ }
27
+ }
28
+ return vars;
29
+ };
30
+ const parseEnvFile = async (filePath) => {
31
+ const contents = await fs.readFile(filePath, "utf8");
32
+ return parseDotenv(contents);
33
+ };
34
+ const resolveEnvName = async (configPath, envName) => {
35
+ if (envName) {
36
+ return envName;
37
+ }
38
+ const environments = await listEnvironmentsFile(configPath);
39
+ if (environments.length === 1) {
40
+ return environments[0];
41
+ }
42
+ if (environments.length === 0) {
43
+ throw new Error("No environments defined in config file");
44
+ }
45
+ throw new Error(`Multiple environments found (${environments.join(", ")}). ` +
46
+ "Specify one with --env.");
47
+ };
48
+ const runCommand = async (command, options) => {
49
+ const baseCwd = process.env.INIT_CWD || process.cwd();
50
+ const envOverrides = options.envFile
51
+ ? await parseEnvFile(path.resolve(baseCwd, options.envFile))
52
+ : undefined;
53
+ let migrationsDir;
54
+ let connectionString;
55
+ let tableName;
56
+ let clusterName;
57
+ let replicationPath;
58
+ let templateVars;
59
+ const configPath = options.config
60
+ ? path.resolve(baseCwd, options.config)
61
+ : await findConfigFile(baseCwd);
62
+ if (!configPath) {
63
+ throw new Error("Config file is required. Use --config or place clisma.hcl in the current directory.");
64
+ }
65
+ const envName = await resolveEnvName(configPath, options.env);
66
+ console.log(`Using config file: ${kleur.bold(configPath)}`);
67
+ const vars = parseVars(options.var);
68
+ const envConfig = await parseConfigFile(configPath, envName, vars, envOverrides);
69
+ connectionString = envConfig.url;
70
+ migrationsDir = path.resolve(path.dirname(configPath), envConfig.migrations.dir);
71
+ // Extract table_name, cluster_name, and replication_path from config
72
+ tableName = envConfig.migrations.table_name;
73
+ clusterName = envConfig.cluster_name;
74
+ replicationPath = envConfig.migrations.replication_path;
75
+ templateVars = {
76
+ ...(envConfig.migrations.vars || {}),
77
+ ...(envConfig.cluster_name ? { cluster_name: envConfig.cluster_name } : {}),
78
+ };
79
+ console.log(`Environment: ${kleur.bold(envName)}`);
80
+ console.log("");
81
+ await runMigrations({
82
+ migrationsDir,
83
+ connectionString,
84
+ tableName,
85
+ clusterName,
86
+ replicationPath,
87
+ templateVars,
88
+ }, command);
89
+ };
90
+ program.name("clisma").description("ClickHouse migrations CLI");
91
+ withCommonOptions(program
92
+ .command("run")
93
+ .description("Apply migrations")
94
+ .action((options) => runCommand("run", options)));
95
+ withCommonOptions(program
96
+ .command("status")
97
+ .description("Show migration status")
98
+ .action((options) => runCommand("status", options)));
99
+ program
100
+ .command("create")
101
+ .description("Create a new migration file")
102
+ .option("--config <path>", "Path to config file (default: clisma.hcl)")
103
+ .option("--env <name>", "Environment name from config file")
104
+ .option("--env-file <path>", "Load environment variables from file")
105
+ .option("--name <name>", "Migration name")
106
+ .action(async (options) => {
107
+ const baseCwd = process.env.INIT_CWD || process.cwd();
108
+ let migrationsDir;
109
+ const envOverrides = options.envFile
110
+ ? await parseEnvFile(path.resolve(baseCwd, options.envFile))
111
+ : undefined;
112
+ const configPath = options.config
113
+ ? path.resolve(baseCwd, options.config)
114
+ : await findConfigFile(baseCwd);
115
+ if (!configPath) {
116
+ throw new Error("Config file is required. Use --config or place clisma.hcl in the current directory.");
117
+ }
118
+ const envName = await resolveEnvName(configPath, options.env);
119
+ const envConfig = await parseConfigFile(configPath, envName, {}, envOverrides);
120
+ migrationsDir = path.resolve(path.dirname(configPath), envConfig.migrations.dir);
121
+ // Get migration name from flag or prompt
122
+ let migrationName = options.name;
123
+ if (!migrationName) {
124
+ migrationName = await input({
125
+ message: "Enter migration name:",
126
+ validate: (value) => {
127
+ if (!value || value.trim().length === 0) {
128
+ return "Migration name is required";
129
+ }
130
+ if (!/^[a-z0-9_]+$/.test(value)) {
131
+ return "Migration name must contain only lowercase letters, numbers, and underscores";
132
+ }
133
+ return true;
134
+ },
135
+ });
136
+ }
137
+ // Sanitize migration name
138
+ const sanitizedName = migrationName
139
+ .trim()
140
+ .toLowerCase()
141
+ .replace(/\s+/g, "_");
142
+ // Generate timestamp (YYYYMMDDhhmmss)
143
+ const now = new Date();
144
+ const pad2 = (value) => String(value).padStart(2, "0");
145
+ const timestamp = `${now.getUTCFullYear()}${pad2(now.getUTCMonth() + 1)}${pad2(now.getUTCDate())}${pad2(now.getUTCHours())}${pad2(now.getUTCMinutes())}${pad2(now.getUTCSeconds())}`;
146
+ // Create migration filename
147
+ const filename = `${timestamp}_${sanitizedName}.sql`;
148
+ const filepath = path.join(migrationsDir, filename);
149
+ // Ensure migrations directory exists
150
+ await fs.mkdir(migrationsDir, { recursive: true });
151
+ // Create empty migration file with template comment
152
+ const template = `-- Migration: ${sanitizedName}
153
+ -- Created: ${new Date().toISOString()}
154
+
155
+ -- Write your migration here
156
+ `;
157
+ await fs.writeFile(filepath, template, "utf8");
158
+ console.log(kleur.green(`Created migration: ${kleur.bold(filename)}`));
159
+ console.log(kleur.dim(`Location: ${filepath}`));
160
+ console.log("");
161
+ });
162
+ program
163
+ .command("checksum")
164
+ .description("Print checksum for a migration file")
165
+ .argument("<path>", "Path to migration file")
166
+ .action(async (filePath) => {
167
+ const baseCwd = process.env.INIT_CWD || process.cwd();
168
+ const absolutePath = path.resolve(baseCwd, filePath);
169
+ const content = await fs.readFile(absolutePath, "utf8");
170
+ const checksum = crypto.createHash("sha256").update(content).digest("hex");
171
+ console.log(`Checksum: ${kleur.bold(checksum)}`);
172
+ });
173
+ program.parseAsync(process.argv).catch((error) => {
174
+ console.error(kleur.red(error instanceof Error ? error.message : String(error)));
175
+ process.exit(1);
176
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=integration.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"integration.test.d.ts","sourceRoot":"","sources":["../../src/tests/integration.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,80 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import fs from "node:fs/promises";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ const exec = promisify(execFile);
10
+ const isDockerAvailable = async () => {
11
+ try {
12
+ await exec("docker", ["--version"]);
13
+ await exec("docker", ["compose", "version"]);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ };
20
+ const waitForClickHouse = async (baseUrl) => {
21
+ const deadline = Date.now() + 30_000;
22
+ while (Date.now() < deadline) {
23
+ try {
24
+ const response = await fetch(`${baseUrl}/ping`);
25
+ if (response.ok) {
26
+ return;
27
+ }
28
+ }
29
+ catch {
30
+ // Ignore until ready.
31
+ }
32
+ await new Promise((resolve) => setTimeout(resolve, 500));
33
+ }
34
+ throw new Error("ClickHouse did not become ready in time");
35
+ };
36
+ const runCli = async (repoRoot, args) => {
37
+ const cliPath = path.join(repoRoot, "packages/cli/src/cli.ts");
38
+ await exec("node", ["--import", "tsx", cliPath, ...args], { cwd: repoRoot });
39
+ };
40
+ const queryClickHouse = async (baseUrl, query) => {
41
+ const response = await fetch(`${baseUrl}/?query=${encodeURIComponent(query)}`);
42
+ if (!response.ok) {
43
+ const text = await response.text();
44
+ throw new Error(`ClickHouse query failed: ${text}`);
45
+ }
46
+ return response.text();
47
+ };
48
+ test("cli applies migrations against ClickHouse", async (t) => {
49
+ if (!(await isDockerAvailable())) {
50
+ t.skip("Docker not available");
51
+ return;
52
+ }
53
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../..");
54
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clisma-it-"));
55
+ const migrationsDir = path.join(tempDir, "migrations");
56
+ await fs.mkdir(migrationsDir, { recursive: true });
57
+ const baseUrl = "http://localhost:8123";
58
+ const dbName = `clisma_it_${Date.now()}`;
59
+ await exec("docker", ["compose", "up", "-d"], { cwd: repoRoot });
60
+ t.after(async () => {
61
+ try {
62
+ await exec("docker", ["compose", "down", "-v"], { cwd: repoRoot });
63
+ }
64
+ catch {
65
+ // Ignore cleanup failures.
66
+ }
67
+ });
68
+ await waitForClickHouse(baseUrl);
69
+ await queryClickHouse(baseUrl, `CREATE DATABASE IF NOT EXISTS ${dbName}`);
70
+ const configPath = path.join(tempDir, "clisma.hcl");
71
+ await fs.writeFile(configPath, `env "local" {\n url = "http://default:@localhost:8123/${dbName}"\n\n migrations {\n dir = "migrations"\n }\n}\n`, "utf8");
72
+ const migrationFile = path.join(migrationsDir, "20240101120000_create_table.sql");
73
+ await fs.writeFile(migrationFile, "CREATE TABLE IF NOT EXISTS test_table (id UInt64) ENGINE = MergeTree() ORDER BY id;", "utf8");
74
+ await runCli(repoRoot, ["run", "--config", configPath, "--env", "local"]);
75
+ const tableExists = await queryClickHouse(baseUrl, `EXISTS TABLE ${dbName}.test_table`);
76
+ assert.equal(tableExists.trim(), "1");
77
+ const migrationsCount = await queryClickHouse(baseUrl, `SELECT count() FROM ${dbName}.schema_migrations`);
78
+ assert.equal(migrationsCount.trim(), "1");
79
+ await queryClickHouse(baseUrl, `DROP DATABASE IF EXISTS ${dbName}`);
80
+ });
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "clisma",
3
+ "version": "0.1.0",
4
+ "description": "ClickHouse migration CLI",
5
+ "author": "github.com/will-work-for-meal",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/will-work-for-meal/clisma.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/will-work-for-meal/clisma/issues"
13
+ },
14
+ "homepage": "https://github.com/will-work-for-meal/clisma",
15
+ "keywords": [
16
+ "cli",
17
+ "clickhouse",
18
+ "migrations",
19
+ "clickhouse-migrations",
20
+ "clickhouse-migration",
21
+ "clickhouse-migrate",
22
+ "atlas",
23
+ "atlasgo"
24
+ ],
25
+ "type": "module",
26
+ "bin": {
27
+ "clisma": "dist/cli.js"
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "@clisma/core": "^0.1.0",
37
+ "@dotenvx/dotenvx": "^1.51.4",
38
+ "@inquirer/prompts": "^8.2.0",
39
+ "commander": "^12.1.0",
40
+ "kleur": "^4.1.5"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -b tsconfig.json",
44
+ "dev": "node --import tsx src/cli.ts",
45
+ "lint": "oxlint src",
46
+ "lint:fix": "oxlint --fix src",
47
+ "format": "oxfmt --check src",
48
+ "format:fix": "oxfmt src"
49
+ },
50
+ "devDependencies": {
51
+ "oxfmt": "^0.4.0",
52
+ "oxlint": "^0.12.0"
53
+ }
54
+ }