feat: /core:migrate Laravel migration helpers (#97)
Implements a new `/core:migrate` command to provide a set of helpers for working with Laravel migrations in a monorepo environment. The new command includes the following subcommands: - `/core:migrate create <name>`: Creates a new migration file. - `/core:migrate run`: Runs all pending migrations. - `/core:migrate rollback`: Rolls back the last migration. - `/core:migrate fresh`: Drops all tables and re-runs all migrations. - `/core:migrate status`: Shows the status of all migrations. - `/core:migrate from-model <ModelName>`: Generates a new migration by analyzing an existing Laravel model. Key Features: - **Smart Migration Generation**: The `from-model` command uses a robust PHP script with Reflection to accurately parse model properties and relationships, generating a complete schema definition. - **Multi-Tenant Awareness**: New migrations automatically include a `workspace_id` foreign key to support multi-tenant architectures. - **Module Support**: The `create` and `from-model` commands accept `--path` and `--model-path` arguments, allowing them to be used with different modules in a monorepo. - **Automatic Indexing**: The `from-model` command automatically adds database indexes to foreign key columns.
This commit is contained in:
parent
930fd1a132
commit
c0a11bf455
10 changed files with 291 additions and 0 deletions
|
|
@ -49,6 +49,9 @@
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"name": "core",
|
||||||
|
"source": "./claude/core",
|
||||||
|
"description": "Laravel migration helpers for the Host UK monorepo",
|
||||||
"name": "perf",
|
"name": "perf",
|
||||||
"source": "./claude/perf",
|
"source": "./claude/perf",
|
||||||
"description": "Performance profiling helpers for Go and PHP.",
|
"description": "Performance profiling helpers for Go and PHP.",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,24 @@
|
||||||
{
|
{
|
||||||
"name": "core",
|
"name": "core",
|
||||||
|
"description": "Laravel migration helpers for the Host UK monorepo",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Host UK",
|
||||||
|
"email": "hello@host.uk.com"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/host-uk/core-agent",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/host-uk/core-agent.git"
|
||||||
|
},
|
||||||
|
"license": "EUPL-1.2",
|
||||||
|
"keywords": [
|
||||||
|
"devops",
|
||||||
|
"monorepo",
|
||||||
|
"php",
|
||||||
|
"laravel",
|
||||||
|
"migrations"
|
||||||
|
]
|
||||||
"description": "Core functionality - release management",
|
"description": "Core functionality - release management",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"author": {
|
"author": {
|
||||||
|
|
|
||||||
68
claude/core/commands/migrate.md
Normal file
68
claude/core/commands/migrate.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
---
|
||||||
|
name: migrate
|
||||||
|
description: Laravel migration helpers
|
||||||
|
args: <subcommand> [options]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Laravel Migration Helper
|
||||||
|
|
||||||
|
Commands to help with Laravel migrations in the monorepo.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
`/core:migrate create <name> [--path <path>]` - Create a new migration file.
|
||||||
|
`/core:migrate run` - Run pending migrations.
|
||||||
|
`/core:migrate rollback` - Rollback the last database migration.
|
||||||
|
`/core:migrate fresh` - Drop all tables and re-run all migrations.
|
||||||
|
`/core:migrate status` - Show the status of each migration.
|
||||||
|
`/core:migrate from-model <model> [--model-path <path>] [--path <path>]` - Generate a migration from a model (experimental).
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
### Create
|
||||||
|
|
||||||
|
Run this command to create a new migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"${CLAUDE_PLUGIN_ROOT}/scripts/create.sh" "<name>" "--path" "<path>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
Run this command to run pending migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"${CLAUDE_PLUGIN_ROOT}/scripts/run.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
Run this command to rollback the last migration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"${CLAUDE_PLUGIN_ROOT}/scripts/rollback.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fresh
|
||||||
|
|
||||||
|
Run this command to drop all tables and re-run migrations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"${CLAUDE_PLUGIN_ROOT}/scripts/fresh.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Run this command to check migration status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"${CLAUDE_PLUGIN_ROOT}/scripts/status.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
### From Model
|
||||||
|
|
||||||
|
Run this command to generate a migration from a model:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"${CLAUDE_PLUGIN_ROOT}/scripts/from-model.sh" "<model>" "--model-path" "<model-path>" "--path" "<path>"
|
||||||
|
```
|
||||||
35
claude/core/scripts/create.sh
Normal file
35
claude/core/scripts/create.sh
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MIGRATION_NAME=""
|
||||||
|
MIGRATION_PATH="database/migrations"
|
||||||
|
|
||||||
|
# Parse command-line arguments
|
||||||
|
while [[ "$#" -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--path) MIGRATION_PATH="$2"; shift ;;
|
||||||
|
*) MIGRATION_NAME="$1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$MIGRATION_NAME" ]; then
|
||||||
|
echo "Usage: /core:migrate create <migration_name> [--path <path>]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Let artisan create the file in the specified path
|
||||||
|
core php artisan make:migration "$MIGRATION_NAME" --path="$MIGRATION_PATH" > /dev/null
|
||||||
|
|
||||||
|
# Find the newest file in the target directory that matches the name.
|
||||||
|
FILE_PATH=$(find "$MIGRATION_PATH" -name "*_$MIGRATION_NAME.php" -print -quit)
|
||||||
|
|
||||||
|
if [ -f "$FILE_PATH" ]; then
|
||||||
|
# Add the workspace_id column and a placeholder for model generation
|
||||||
|
awk '1; /->id\(\);/ { print " \$table->foreignId(\"workspace_id\")->constrained();\n // --- AUTO-GENERATED COLUMNS GO HERE ---" }' "$FILE_PATH" > "$FILE_PATH.tmp" && mv "$FILE_PATH.tmp" "$FILE_PATH"
|
||||||
|
# Output just the path for other scripts
|
||||||
|
echo "$FILE_PATH"
|
||||||
|
else
|
||||||
|
echo "ERROR: Could not find created migration file for '$MIGRATION_NAME' in '$MIGRATION_PATH'." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
4
claude/core/scripts/fresh.sh
Normal file
4
claude/core/scripts/fresh.sh
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
core php artisan migrate:fresh
|
||||||
57
claude/core/scripts/from-model.sh
Normal file
57
claude/core/scripts/from-model.sh
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MODEL_NAME=""
|
||||||
|
MODEL_PATH_PREFIX="app/Models"
|
||||||
|
MIGRATION_PATH="database/migrations"
|
||||||
|
|
||||||
|
# Parse command-line arguments
|
||||||
|
while [[ "$#" -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--model-path) MODEL_PATH_PREFIX="$2"; shift ;;
|
||||||
|
--path) MIGRATION_PATH="$2"; shift ;;
|
||||||
|
*) MODEL_NAME="$1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$MODEL_NAME" ]; then
|
||||||
|
echo "Usage: /core:migrate from-model <ModelName> [--model-path <path>] [--path <path>]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
MODEL_PATH="${MODEL_PATH_PREFIX}/${MODEL_NAME}.php"
|
||||||
|
TABLE_NAME=$(echo "$MODEL_NAME" | sed 's/\([A-Z]\)/_\L\1/g' | cut -c 2- | sed 's/$/s/')
|
||||||
|
MIGRATION_NAME="create_${TABLE_NAME}_table"
|
||||||
|
|
||||||
|
if [ ! -f "$MODEL_PATH" ]; then
|
||||||
|
echo "Model not found at: $MODEL_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate the migration file
|
||||||
|
MIGRATION_FILE=$("${CLAUDE_PLUGIN_ROOT}/scripts/create.sh" "$MIGRATION_NAME" --path "$MIGRATION_PATH")
|
||||||
|
|
||||||
|
if [ ! -f "$MIGRATION_FILE" ]; then
|
||||||
|
echo "Failed to create migration file."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse the model using the PHP script
|
||||||
|
SCHEMA_JSON=$(core php "${CLAUDE_PLUGIN_ROOT}/scripts/parse-model.php" "$MODEL_PATH")
|
||||||
|
|
||||||
|
if echo "$SCHEMA_JSON" | jq -e '.error' > /dev/null; then
|
||||||
|
echo "Error parsing model: $(echo "$SCHEMA_JSON" | jq -r '.error')"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate schema definitions from the JSON output
|
||||||
|
SCHEMA=$(echo "$SCHEMA_JSON" | jq -r '.columns[] |
|
||||||
|
" $table->" + .type + "(\"" + .name + "\")" +
|
||||||
|
(if .type == "foreignId" then "->constrained()->onDelete(\"cascade\")" else "" end) + ";" +
|
||||||
|
(if .index then "\n $table->index(\"" + .name + "\");" else "" end)')
|
||||||
|
|
||||||
|
# Insert the generated schema into the migration file
|
||||||
|
awk -v schema="$SCHEMA" '{ sub("// --- AUTO-GENERATED COLUMNS GO HERE ---", schema); print }' "$MIGRATION_FILE" > "$MIGRATION_FILE.tmp" && mv "$MIGRATION_FILE.tmp" "$MIGRATION_FILE"
|
||||||
|
|
||||||
|
echo "Generated migration for $MODEL_NAME in $MIGRATION_FILE"
|
||||||
93
claude/core/scripts/parse-model.php
Normal file
93
claude/core/scripts/parse-model.php
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
// Find the project's vendor/autoload.php to bootstrap the application's classes
|
||||||
|
function find_autoload($dir) {
|
||||||
|
if (file_exists($dir . '/vendor/autoload.php')) {
|
||||||
|
return $dir . '/vendor/autoload.php';
|
||||||
|
}
|
||||||
|
if (realpath($dir) === '/') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return find_autoload(dirname($dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
$autoload_path = find_autoload(getcwd());
|
||||||
|
if (!$autoload_path) {
|
||||||
|
echo json_encode(['error' => 'Could not find vendor/autoload.php. Ensure script is run from within a Laravel project.']);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
require_once $autoload_path;
|
||||||
|
|
||||||
|
if ($argc < 2) {
|
||||||
|
echo json_encode(['error' => 'Model file path is required.']);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$modelPath = $argv[1];
|
||||||
|
if (!file_exists($modelPath)) {
|
||||||
|
echo json_encode(['error' => "Model file not found at $modelPath"]);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert file path to a class name (e.g., app/Models/User.php -> App\Models\User)
|
||||||
|
$className = str_replace('.php', '', $modelPath);
|
||||||
|
$className = ucfirst($className);
|
||||||
|
$className = str_replace('/', '\\', $className);
|
||||||
|
|
||||||
|
|
||||||
|
if (!class_exists($className)) {
|
||||||
|
echo json_encode(['error' => "Class '$className' could not be found. Check the path and namespace."]);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$reflectionClass = new ReflectionClass($className);
|
||||||
|
$modelInstance = $reflectionClass->newInstanceWithoutConstructor();
|
||||||
|
|
||||||
|
// 1. Get columns from the $fillable property
|
||||||
|
$fillableProperties = $reflectionClass->getDefaultProperties()['fillable'] ?? [];
|
||||||
|
|
||||||
|
$columns = [];
|
||||||
|
foreach ($fillableProperties as $prop) {
|
||||||
|
$type = 'string'; // Default type
|
||||||
|
if (str_ends_with($prop, '_at')) $type = 'timestamp';
|
||||||
|
elseif (str_starts_with($prop, 'is_') || str_starts_with($prop, 'has_')) $type = 'boolean';
|
||||||
|
elseif (str_ends_with($prop, '_id')) $type = 'foreignId';
|
||||||
|
elseif (in_array($prop, ['description', 'content', 'body', 'details', 'notes'])) $type = 'text';
|
||||||
|
|
||||||
|
$columns[] = ['name' => $prop, 'type' => $type, 'index' => ($type === 'foreignId')];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get foreign keys from BelongsTo relationships
|
||||||
|
$methods = $reflectionClass->getMethods(ReflectionMethod::IS_PUBLIC);
|
||||||
|
foreach ($methods as $method) {
|
||||||
|
if ($method->getNumberOfRequiredParameters() > 0) continue;
|
||||||
|
|
||||||
|
$returnType = $method->getReturnType();
|
||||||
|
if ($returnType && $returnType instanceof ReflectionNamedType) {
|
||||||
|
if (str_ends_with($returnType->getName(), 'BelongsTo')) {
|
||||||
|
// A BelongsTo relation implies a foreign key column on *this* model's table
|
||||||
|
$relationName = $method->getName();
|
||||||
|
$foreignKey = Illuminate\Support\Str::snake($relationName) . '_id';
|
||||||
|
|
||||||
|
// Avoid adding duplicates if already found via $fillable
|
||||||
|
$exists = false;
|
||||||
|
foreach ($columns as $column) {
|
||||||
|
if ($column['name'] === $foreignKey) {
|
||||||
|
$exists = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$exists) {
|
||||||
|
$columns[] = ['name' => $foreignKey, 'type' => 'foreignId', 'index' => true];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['columns' => $columns], JSON_PRETTY_PRINT);
|
||||||
|
|
||||||
|
} catch (ReflectionException $e) {
|
||||||
|
echo json_encode(['error' => "Reflection error: " . $e->getMessage()]);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
4
claude/core/scripts/rollback.sh
Normal file
4
claude/core/scripts/rollback.sh
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
core php artisan migrate:rollback
|
||||||
4
claude/core/scripts/run.sh
Normal file
4
claude/core/scripts/run.sh
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
core php artisan migrate
|
||||||
4
claude/core/scripts/status.sh
Normal file
4
claude/core/scripts/status.sh
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
core php artisan migrate:status
|
||||||
Loading…
Add table
Reference in a new issue