create-airframe-dag 1.0.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/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # Airframe
2
+
3
+ > The blueprint for scalable Airflow DAGs
4
+
5
+ Airframe is a CLI tool designed to scaffold production-ready Apache Airflow DAG projects with best practices built-in. It helps you quickly customize and generate DAG structures from curated templates, ensuring consistency and scalability across your data pipelines.
6
+
7
+ ## Features
8
+
9
+ - 🚀 **Interactive Scaffolding**: Easy-to-use CLI wizard to set up your project.
10
+ - 📦 **Curated Templates**: Choose from a variety of pre-defined DAG patterns (fetched dynamically from the [Airframe Template Registry](https://github.com/hraza01/airframe-templates)).
11
+ - 🔧 **Best Practices**: Enforces a structured project layout within your `dags/` directory.
12
+ - 🛠 **Automated Setup**: Handles directory creation, variable substitution, and Git initialization.
13
+
14
+ ## Prerequisites
15
+
16
+ - **Node.js**: Version 20.5.0 or higher.
17
+ - **Airflow Environment**: You must run this tool from the root of your Airflow project (specifically, a directory that contains a `dags/` folder).
18
+
19
+ ## Usage
20
+
21
+ To start a new Airframe project, run the following command in your terminal:
22
+
23
+ ```bash
24
+ npx create-airframe-dag@latest
25
+ ```
26
+
27
+ Follow the interactive prompts to:
28
+
29
+ 1. Define your project details (Name, Author).
30
+ 2. Select a template.
31
+ 3. Scaffold the project into your `dags/` directory.
32
+
33
+ ### Options
34
+
35
+ - `--airflow-path <path>`: Specify the path to your Airflow root directory if you are not running the command from there.
36
+ - `-v, --version`: Show the current CLI version.
37
+ - `-h, --help`: Show help information.
38
+
39
+ ## How It Works
40
+
41
+ 1. **Checks Environment**: Verifies that a `dags/` directory exists in the current (or specified) location.
42
+ 2. **Fetches Templates**: Retrieves the latest templates from the central registry.
43
+ 3. **Collects Details**: Prompts you for formatting configuration and project metadata.
44
+ 4. **Generates Code**: Creates a new directory `dags/<project-name>`, generates the boilerplate code for the selected template, and customizes it with your provided details.
45
+ 5. **Git Init**: Initializes a new Git repository within the specific project folder for version control.
46
+
47
+ ## Project Structure
48
+
49
+ Airframe creates the following structure for each project:
50
+
51
+ ```
52
+ your-airflow-env/
53
+ └── dags/
54
+ └── <airframe_project_name>/ # Created by Airframe
55
+ ├── README.md # Project-specific documentation
56
+ ├── .gitignore # Git ignore file for the project
57
+ ├── main.py # DAG definition
58
+ ├── sql/ # Directory for SQL files
59
+ └── ... # Other files
60
+ ```
61
+
62
+ ## Contributing
63
+
64
+ Created by [@hraza01](https://github.com/hraza01).
package/bin/cli.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from "../src/main.js"
4
+ import chalk from "chalk"
5
+
6
+ main().catch((err) => {
7
+ console.error(chalk.red("Unexpected error:"), err)
8
+ process.exit(1)
9
+ })
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "create-airframe-dag",
3
+ "version": "1.0.0",
4
+ "description": "CLI tool to scaffold Airflow DAG projects",
5
+ "main": "bin/cli.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "create-airframe-dag": "./bin/cli.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node bin/cli.js",
12
+ "test": "echo \"Error: no test specified\" && exit 1"
13
+ },
14
+ "keywords": [
15
+ "cli",
16
+ "airflow",
17
+ "cloud-composer",
18
+ "scaffold",
19
+ "dag",
20
+ "gcp"
21
+ ],
22
+ "author": "Hasan Raza",
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "@clack/prompts": "^1.0.0",
26
+ "chalk": "^5.6.2",
27
+ "execa": "^9.6.1",
28
+ "figlet": "^1.10.0",
29
+ "fs-extra": "^11.3.3",
30
+ "ora": "^9.3.0",
31
+ "terminal-link": "^5.0.0",
32
+ "tiged": "^2.12.7"
33
+ },
34
+ "overrides": {
35
+ "tiged": {
36
+ "tar": "^7.5.7"
37
+ }
38
+ },
39
+ "engines": {
40
+ "node": ">=20.5.0"
41
+ },
42
+ "devDependencies": {
43
+ "prettier": "^3.8.1"
44
+ }
45
+ }
package/src/api.js ADDED
@@ -0,0 +1,17 @@
1
+ export async function fetchTemplates() {
2
+ const MANIFEST_URL =
3
+ "https://raw.githubusercontent.com/hraza01/airframe-templates/main/templates.json"
4
+
5
+ try {
6
+ const response = await fetch(MANIFEST_URL)
7
+ if (!response.ok) {
8
+ throw new Error(`Failed to fetch templates: ${response.statusText}`)
9
+ }
10
+ const data = await response.json()
11
+ return data
12
+ } catch (error) {
13
+ throw new Error(
14
+ `Could not connect to template registry: ${error.message}`,
15
+ )
16
+ }
17
+ }
@@ -0,0 +1,99 @@
1
+ import { fetchTemplates } from "../api.js"
2
+ import { scaffoldProject, customizeProject } from "../scaffolder.js"
3
+ import {
4
+ checkDagsDirectory,
5
+ formatTargetDir,
6
+ getExistingProjects,
7
+ } from "../lib/env.js"
8
+ import { initGitRepo } from "../lib/git.js"
9
+ import { showWelcome, displayTemplates, showCompletion } from "../ui/display.js"
10
+ import {
11
+ createSpinner,
12
+ askForProjectDetails,
13
+ selectTemplate,
14
+ } from "../ui/prompts.js"
15
+ import { log } from "@clack/prompts"
16
+ import chalk from "chalk"
17
+ import readline from "node:readline"
18
+
19
+ export async function initCommand({ airflowPath } = {}) {
20
+ const baseDir = airflowPath ? String(airflowPath) : process.cwd()
21
+
22
+ await showWelcome(baseDir)
23
+
24
+ // Step 0: Check for dags directory
25
+ if (!checkDagsDirectory(baseDir)) {
26
+ log.error(`No "dags" directory found in path: ${baseDir}`)
27
+ log.warn(
28
+ "Please run `airframe init` from your airflow environment directory.",
29
+ )
30
+ process.exit(1)
31
+ }
32
+
33
+ const s = createSpinner()
34
+
35
+ // Step 1: Fetch Templates
36
+ let templates
37
+ try {
38
+ templates = await fetchTemplates()
39
+ } catch (error) {
40
+ console.error(chalk.red(error.message))
41
+ process.exit(1)
42
+ }
43
+
44
+ // Step 2: Project Details
45
+ const existingProjects = await getExistingProjects(baseDir)
46
+ const { projectName, dagAuthor } =
47
+ await askForProjectDetails(existingProjects)
48
+
49
+ // Step 3: Select Template
50
+ displayTemplates(templates)
51
+ const template = await selectTemplate(templates)
52
+
53
+ const targetDir = formatTargetDir(projectName, baseDir)
54
+
55
+ // Step 4: Scaffold
56
+ console.log(chalk.dim("│"))
57
+ s.start(`Scaffolding project in ${targetDir}...`)
58
+ try {
59
+ await scaffoldProject({ template, targetDir })
60
+ s.stop()
61
+ readline.clearLine(process.stdout, 0)
62
+ readline.cursorTo(process.stdout, 0)
63
+ log.success("Scaffolding complete.")
64
+ } catch (error) {
65
+ s.stop()
66
+ readline.clearLine(process.stdout, 0)
67
+ readline.cursorTo(process.stdout, 0)
68
+ log.error("Scaffolding failed.")
69
+ console.error(chalk.red(error.message))
70
+ process.exit(1)
71
+ }
72
+
73
+ // Step 5: Setup project
74
+ s.start("Setting up project...")
75
+ try {
76
+ await customizeProject({ targetDir, projectName, dagAuthor, template })
77
+ s.stop()
78
+ readline.clearLine(process.stdout, 0)
79
+ readline.cursorTo(process.stdout, 0)
80
+ log.success("Project setup complete.")
81
+ } catch (error) {
82
+ s.stop()
83
+ readline.clearLine(process.stdout, 0)
84
+ readline.cursorTo(process.stdout, 0)
85
+ log.error("Project setup failed.")
86
+ console.error(chalk.red(error.message))
87
+ process.exit(1)
88
+ }
89
+
90
+ // Step 6: Git Init
91
+ try {
92
+ await initGitRepo(targetDir)
93
+ } catch (error) {
94
+ log.warn(`Failed to initialize git repository: ${error.message}`)
95
+ }
96
+
97
+ // Step 7: Done
98
+ showCompletion({ targetDir })
99
+ }
package/src/lib/env.js ADDED
@@ -0,0 +1,20 @@
1
+ import fs from "fs-extra"
2
+ import path from "path"
3
+
4
+ export function formatTargetDir(projectName, baseDir = process.cwd()) {
5
+ return path.resolve(baseDir, "dags", projectName)
6
+ }
7
+
8
+ export function checkDagsDirectory(baseDir = process.cwd()) {
9
+ const dagsPath = path.resolve(baseDir, "dags")
10
+ return fs.existsSync(dagsPath) && fs.lstatSync(dagsPath).isDirectory()
11
+ }
12
+
13
+ export async function getExistingProjects(baseDir = process.cwd()) {
14
+ const dagsPath = path.resolve(baseDir, "dags")
15
+ if (!fs.existsSync(dagsPath)) return []
16
+ const dirents = await fs.readdir(dagsPath, { withFileTypes: true })
17
+ return dirents
18
+ .filter((dirent) => dirent.isDirectory())
19
+ .map((dirent) => dirent.name)
20
+ }
@@ -0,0 +1,45 @@
1
+ import fs from "fs-extra"
2
+ import path from "path"
3
+
4
+ export async function replaceInFiles(dir, replacements) {
5
+ const files = await getFiles(dir)
6
+
7
+ for (const file of files) {
8
+ // Only process text files we care about, e.g., .py, .md, .txt, .json
9
+ // For a DAG template, .py is critical.
10
+ if (shouldProcess(file)) {
11
+ let content = await fs.readFile(file, "utf8")
12
+ let changed = false
13
+
14
+ for (const [key, value] of Object.entries(replacements)) {
15
+ // Replace all occurrences
16
+ const regex = new RegExp(key, "g")
17
+ if (regex.test(content)) {
18
+ content = content.replace(regex, value)
19
+ changed = true
20
+ }
21
+ }
22
+
23
+ if (changed) {
24
+ await fs.writeFile(file, content, "utf8")
25
+ }
26
+ }
27
+ }
28
+ }
29
+
30
+ async function getFiles(dir) {
31
+ const dirents = await fs.readdir(dir, { withFileTypes: true })
32
+ const files = await Promise.all(
33
+ dirents.map((dirent) => {
34
+ const res = path.resolve(dir, dirent.name)
35
+ return dirent.isDirectory() ? getFiles(res) : res
36
+ }),
37
+ )
38
+ return Array.prototype.concat(...files)
39
+ }
40
+
41
+ function shouldProcess(filePath) {
42
+ const ext = path.extname(filePath)
43
+ // Add extensions you want to scan for replacement
44
+ return [".py", ".md", ".json", ".txt", ".yaml", ".yml"].includes(ext)
45
+ }
package/src/lib/git.js ADDED
@@ -0,0 +1,13 @@
1
+ import { execa } from "execa"
2
+
3
+ export async function initGitRepo(dir) {
4
+ try {
5
+ await execa("git", ["init"], { cwd: dir })
6
+ return true
7
+ } catch (error) {
8
+ // Silently fail if git init fails, or throw if critical.
9
+ // For a scaffolding tool, it's usually better to warn than crash.
10
+ // But for now, we'll just throw so the caller can decide.
11
+ throw new Error(`Failed to initialize git repository: ${error.message}`)
12
+ }
13
+ }
package/src/main.js ADDED
@@ -0,0 +1,71 @@
1
+ import { initCommand } from "./commands/init.js"
2
+ import { printHelp, printVersion } from "./ui/display.js"
3
+ import chalk from "chalk"
4
+ import fs from "fs-extra"
5
+
6
+ // Enforce Node Version Check
7
+ const requiredVersion = 20
8
+ const currentVersion = process.versions.node.split(".")[0]
9
+ if (currentVersion < requiredVersion) {
10
+ console.error(
11
+ chalk.red(
12
+ `Error: Node.js version ${requiredVersion}+ is required. You are running version ${process.versions.node}.`,
13
+ ),
14
+ )
15
+ process.exit(1)
16
+ }
17
+
18
+ // Read package.json for version
19
+ const pkg = fs.readJsonSync(new URL("../package.json", import.meta.url))
20
+
21
+ export async function main() {
22
+ const args = process.argv.slice(2)
23
+ // Basic argument parsing
24
+ let command = args[0]
25
+ let airflowPath
26
+
27
+ // Check for --airflow-path argument anywhere
28
+ const pathIndex = args.indexOf("--airflow-path")
29
+ if (pathIndex !== -1 && args[pathIndex + 1]) {
30
+ airflowPath = args[pathIndex + 1]
31
+ // If the command was actually the path flag (e.g. "airframe --airflow-path ... init")
32
+ // we need to find the real command.
33
+ // Simple heuristic: First arg that isn't a flag or a value of a flag.
34
+ // For now, let's just stick to typical usage or basic filter.
35
+ if (command === "--airflow-path") {
36
+ // Try to find the real command
37
+ const nonFlagArgs = args.filter(
38
+ (arg, idx) =>
39
+ arg !== "--airflow-path" &&
40
+ args[idx - 1] !== "--airflow-path",
41
+ )
42
+ command = nonFlagArgs[0]
43
+ }
44
+ }
45
+
46
+ // Handle flags anywhere
47
+ if (args.includes("--version") || args.includes("-v")) {
48
+ printVersion(pkg.version)
49
+ process.exit(0)
50
+ }
51
+
52
+ if (args.includes("--help") || args.includes("-h")) {
53
+ printHelp()
54
+ process.exit(0)
55
+ }
56
+
57
+ // If no command is provided, default to "init"
58
+ if (!command) {
59
+ command = "init"
60
+ }
61
+
62
+ // Handle commands
63
+ switch (command) {
64
+ case "init":
65
+ await initCommand({ airflowPath })
66
+ break
67
+ default:
68
+ printHelp()
69
+ break
70
+ }
71
+ }
@@ -0,0 +1,46 @@
1
+ import tiged from "tiged"
2
+ import { replaceInFiles } from "./lib/filesystem.js"
3
+
4
+ export async function scaffoldProject({ template, targetDir }) {
5
+ // template.id should be the branch name, tag, or value
6
+ const uri = `hraza01/airframe-templates#${template.branch || template.id || template.value}`
7
+
8
+ const emitter = tiged(uri, {
9
+ disableCache: true,
10
+ force: true,
11
+ verbose: false,
12
+ })
13
+
14
+ try {
15
+ await emitter.clone(targetDir)
16
+ } catch (error) {
17
+ throw new Error(`Failed to scaffold project: ${error.message}`)
18
+ }
19
+ }
20
+
21
+ export async function customizeProject({
22
+ targetDir,
23
+ projectName,
24
+ dagAuthor,
25
+ template,
26
+ }) {
27
+ try {
28
+ await replaceInFiles(targetDir, {
29
+ __PROJECT_NAME__: projectName,
30
+ __DAG_ID__: projectName,
31
+ __DAG_AUTHOR__: dagAuthor,
32
+ })
33
+
34
+ // Cleanup for Advanced template
35
+ if (template && template.label === "Advanced") {
36
+ await replaceInFiles(targetDir, {
37
+ "# fmt: off\\n": "",
38
+ "# isort: off\\n": "",
39
+ "# isort: on\\n": "",
40
+ "# fmt: on\\n": "",
41
+ })
42
+ }
43
+ } catch (error) {
44
+ throw new Error(`Failed to customize project files: ${error.message}`)
45
+ }
46
+ }
@@ -0,0 +1,101 @@
1
+ import { intro, outro, log } from "@clack/prompts"
2
+ import chalk from "chalk"
3
+ import terminalLink from "terminal-link"
4
+ import figlet from "figlet"
5
+
6
+ export function printBanner() {
7
+ console.clear()
8
+ console.log(
9
+ chalk.cyan(figlet.textSync("AIRFRAME", { font: "ANSI Shadow" })),
10
+ )
11
+ const authorLink = terminalLink("@hraza01", "https://github.com/hraza01")
12
+ console.log(
13
+ chalk.dim.blackBright(
14
+ ` — created by ${authorLink}\n`,
15
+ ),
16
+ )
17
+ }
18
+
19
+ export function showWelcome(workingDir) {
20
+ printBanner()
21
+ intro(chalk.bold.cyanBright("The blueprint for scalable Airflow DAGs"))
22
+
23
+ // Explicit note about Airflow support
24
+ log.info(chalk.dim("Supports Apache Airflow 2.0 (2.8 or higher)"))
25
+ logWorkingDir(workingDir)
26
+ log.warn(chalk.dim("Press CTRL+C to exit the Airframe CLI.\n"))
27
+ }
28
+
29
+ export function logWorkingDir(dir = process.cwd()) {
30
+ log.warn(
31
+ `You are about to initialize an Airframe project in this directory:\n${chalk.dim(dir)}`,
32
+ )
33
+ }
34
+
35
+ export function showCompletion({ targetDir }) {
36
+ outro(chalk.green.bold(`Airframe project initialized.`))
37
+
38
+ console.log(
39
+ chalk.bold.magenta(
40
+ `Project directory: ${chalk.underline(targetDir)}\n`,
41
+ ),
42
+ )
43
+
44
+ console.log(chalk.bold.blueBright("Happy Orchestrating!"))
45
+ }
46
+
47
+ export function printHelp() {
48
+ printBanner()
49
+
50
+ console.log(chalk.bold("Usage:"))
51
+ console.log(" npx create-airframe-dag@latest [options]\n")
52
+
53
+ console.log(chalk.bold("Options:"))
54
+ console.log(
55
+ " --airflow-path <path> Specify the path to your Airflow root directory",
56
+ )
57
+ console.log(" -v, --version Show version number")
58
+ console.log(" -h, --help Show this help message\n")
59
+
60
+ console.log(chalk.dim("Examples:"))
61
+ console.log(" npx create-airframe-dag@latest")
62
+ console.log(" npx create-airframe-dag@latest --version\n")
63
+ }
64
+
65
+ export function printVersion(version) {
66
+ console.log(chalk.bold(`v${version}`))
67
+ }
68
+
69
+ // Helper to wrap text with indentation for subsequent lines
70
+ function formatDescription(text, width, indent) {
71
+ if (!text) return ""
72
+ const words = text.split(" ")
73
+ let lines = []
74
+ let currentLine = words[0]
75
+
76
+ for (let i = 1; i < words.length; i++) {
77
+ if (currentLine.length + 1 + words[i].length <= width) {
78
+ currentLine += " " + words[i]
79
+ } else {
80
+ lines.push(currentLine)
81
+ currentLine = words[i]
82
+ }
83
+ }
84
+ lines.push(currentLine)
85
+
86
+ return lines.join("\n" + indent)
87
+ }
88
+
89
+ export function displayTemplates(templates) {
90
+ log.info(chalk.bold("Available Templates"))
91
+
92
+ const maxLabelLen = Math.max(...templates.map((t) => t.label.length))
93
+
94
+ templates.forEach((t) => {
95
+ const pad = " ".repeat(maxLabelLen - t.label.length)
96
+ const indent = " ".repeat(maxLabelLen + 4)
97
+ const desc = formatDescription(t.description || "", 45, indent)
98
+
99
+ log.message(`${chalk.cyan(t.label)}${pad} ${chalk.dim(desc)}`)
100
+ })
101
+ }
@@ -0,0 +1,83 @@
1
+ import { text, select, isCancel, cancel } from "@clack/prompts"
2
+ import ora from "ora"
3
+
4
+ export function createSpinner() {
5
+ return ora()
6
+ }
7
+
8
+ export async function askForProjectDetails(existingProjects = []) {
9
+ const projectName = await text({
10
+ message: "What is the name of your Airflow DAG?",
11
+ hint: "This will be the name of the DAG folder and the DAG ID.",
12
+ placeholder: "my_dag_project",
13
+ validate(value) {
14
+ if (!value || value.trim().length === 0)
15
+ return "Project name is required!"
16
+ if (/[^a-zA-Z_]/.test(value))
17
+ return "Project name should only contain letters and underscores (no numbers, dashes, or spaces)."
18
+ if (
19
+ existingProjects.some(
20
+ (p) => p.toLowerCase() === value.toLowerCase(),
21
+ )
22
+ )
23
+ return `Project "${value}" already exists in dags/ directory.`
24
+ },
25
+ })
26
+
27
+ if (isCancel(projectName)) {
28
+ cancel("Operation cancelled.")
29
+ process.exit(0)
30
+ }
31
+
32
+ const dagAuthor = await text({
33
+ message: "Who is the author of this Airflow DAG?",
34
+ placeholder: "Anakin Skywalker",
35
+ validate(value) {
36
+ if (!value || value.trim().length === 0)
37
+ return "Author name is required!"
38
+ if (value.toLowerCase() === "airflow")
39
+ return "Author name cannot be 'airflow'."
40
+ if (/[^a-zA-Z\s_]/.test(value))
41
+ return "Author name must only contain letters, spaces, and underscores."
42
+ },
43
+ })
44
+
45
+ if (isCancel(dagAuthor)) {
46
+ cancel("Operation cancelled.")
47
+ process.exit(0)
48
+ }
49
+
50
+ return { projectName, dagAuthor }
51
+ }
52
+
53
+ export async function selectTemplate(templates) {
54
+ // 1. Find max label length for padding
55
+ const maxLabelLen = Math.max(...templates.map((t) => t.label.length))
56
+
57
+ const options = templates.map((t) => {
58
+ // 2. Pad label
59
+ const pad = " ".repeat(maxLabelLen - t.label.length)
60
+ const label = `${t.label}${pad}`
61
+
62
+ // 3. Use standard hint property (accepting brackets to keep UI clean)
63
+ const hint = t.hint || ""
64
+
65
+ return {
66
+ value: t,
67
+ label: label,
68
+ hint: hint,
69
+ }
70
+ })
71
+
72
+ const template = await select({
73
+ message: "Select a template to scaffold:",
74
+ options: options,
75
+ })
76
+
77
+ if (isCancel(template)) {
78
+ cancel("Operation cancelled.")
79
+ process.exit(0)
80
+ }
81
+
82
+ return template
83
+ }