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 +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +176 -0
- package/dist/tests/integration.test.d.ts +2 -0
- package/dist/tests/integration.test.d.ts.map +1 -0
- package/dist/tests/integration.test.js +80 -0
- package/package.json +54 -0
package/dist/cli.d.ts
ADDED
|
@@ -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 @@
|
|
|
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
|
+
}
|