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 +64 -0
- package/bin/cli.js +9 -0
- package/package.json +45 -0
- package/src/api.js +17 -0
- package/src/commands/init.js +99 -0
- package/src/lib/env.js +20 -0
- package/src/lib/filesystem.js +45 -0
- package/src/lib/git.js +13 -0
- package/src/main.js +71 -0
- package/src/scaffolder.js +46 -0
- package/src/ui/display.js +101 -0
- package/src/ui/prompts.js +83 -0
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
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
|
+
}
|