@alcedocore/cli 0.0.1-rc.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/LICENSE.md +102 -0
- package/dist/commands/add-endpoint.js +104 -0
- package/dist/commands/add-migration.js +80 -0
- package/dist/commands/add-nav-item.js +72 -0
- package/dist/commands/add-page.js +64 -0
- package/dist/commands/build-frontend.js +126 -0
- package/dist/commands/compile-pages.js +120 -0
- package/dist/commands/connect.js +122 -0
- package/dist/commands/deploy.js +74 -0
- package/dist/commands/dev.js +13 -0
- package/dist/commands/init-core.js +900 -0
- package/dist/commands/init.js +124 -0
- package/dist/commands/init.test.js +84 -0
- package/dist/commands/migrate.js +22 -0
- package/dist/commands/proxy.js +157 -0
- package/dist/commands/publish.js +81 -0
- package/dist/commands/replay.js +142 -0
- package/dist/commands/serve-frontend.js +92 -0
- package/dist/config.js +110 -0
- package/dist/config.test.js +48 -0
- package/dist/index.js +81 -0
- package/dist/integration/cli-api.test.js +116 -0
- package/dist/utils/ejs-renderer.js +46 -0
- package/dist/utils/ejs-renderer.test.js +93 -0
- package/dist/utils/formatting.js +65 -0
- package/dist/utils/generateTimestamp.js +17 -0
- package/dist/utils/logger.js +33 -0
- package/dist/utils/validation.js +15 -0
- package/package.json +41 -0
- package/src/commands/add-endpoint.ts +157 -0
- package/src/commands/add-migration.ts +102 -0
- package/src/commands/add-nav-item.ts +106 -0
- package/src/commands/add-page.ts +100 -0
- package/src/commands/build-frontend.ts +148 -0
- package/src/commands/connect.ts +98 -0
- package/src/commands/deploy.ts +85 -0
- package/src/commands/dev.ts +12 -0
- package/src/commands/init-core.ts +1019 -0
- package/src/commands/init.test.ts +92 -0
- package/src/commands/init.ts +171 -0
- package/src/commands/migrate.ts +20 -0
- package/src/commands/proxy.ts +206 -0
- package/src/commands/publish.ts +106 -0
- package/src/commands/serve-frontend.ts +103 -0
- package/src/config.test.ts +50 -0
- package/src/config.ts +125 -0
- package/src/index.ts +100 -0
- package/src/integration/cli-api.test.ts +143 -0
- package/src/utils/ejs-renderer.ts +55 -0
- package/src/utils/formatting.ts +62 -0
- package/src/utils/generateTimestamp.ts +16 -0
- package/src/utils/logger.ts +27 -0
- package/src/utils/validation.ts +13 -0
- package/templates/endpoint/handler.js.ejs +23 -0
- package/templates/endpoint/handler.py.ejs +23 -0
- package/templates/migration/down.sql.ejs +6 -0
- package/templates/migration/up.sql.ejs +11 -0
- package/templates/page/page.vue.ejs +63 -0
- package/templates/plugin/Dockerfile.ejs +13 -0
- package/templates/plugin/Dockerfile.node.ejs +14 -0
- package/templates/plugin/README.md.ejs +19 -0
- package/templates/plugin/gitignore.ejs +6 -0
- package/templates/plugin/manifest.json.ejs +18 -0
- package/templates/plugin/migrations/.gitkeep +0 -0
- package/templates/plugin/pages/.gitkeep +0 -0
- package/templates/plugin/public/.gitkeep +0 -0
- package/templates/plugin/server.js.ejs +27 -0
- package/templates/plugin/server.py.ejs +32 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.success = success;
|
|
7
|
+
exports.error = error;
|
|
8
|
+
exports.info = info;
|
|
9
|
+
exports.warn = warn;
|
|
10
|
+
exports.createSpinner = createSpinner;
|
|
11
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
12
|
+
const ora_1 = __importDefault(require("ora"));
|
|
13
|
+
function success(msg) {
|
|
14
|
+
console.log(chalk_1.default.green("✔"), msg);
|
|
15
|
+
}
|
|
16
|
+
function error(msg) {
|
|
17
|
+
console.error(chalk_1.default.red("✖"), msg);
|
|
18
|
+
}
|
|
19
|
+
function info(msg) {
|
|
20
|
+
console.log(chalk_1.default.blue("ℹ"), msg);
|
|
21
|
+
}
|
|
22
|
+
function warn(msg) {
|
|
23
|
+
console.warn(chalk_1.default.yellow("⚠"), msg);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Create an ora spinner with the given text.
|
|
27
|
+
* Starts immediately. Call `.succeed()`, `.fail()`, or `.stop()` on the returned instance.
|
|
28
|
+
* Ora auto-detects CI/non-TTY environments and disables spinners appropriately.
|
|
29
|
+
*/
|
|
30
|
+
function createSpinner(text) {
|
|
31
|
+
return (0, ora_1.default)({ text, color: "cyan" }).start();
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=logger.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.assertSafeName = assertSafeName;
|
|
4
|
+
/**
|
|
5
|
+
* Validate that a name contains only safe characters for use in filenames.
|
|
6
|
+
* Rejects names containing path separators (/ or \), null bytes, or "..".
|
|
7
|
+
* Allowed: letters, digits, hyphens, underscores (regex: /^[\w-]+$/).
|
|
8
|
+
* Throws an error with the given label if the name is invalid.
|
|
9
|
+
*/
|
|
10
|
+
function assertSafeName(name, label) {
|
|
11
|
+
if (!/^[\w-]+$/.test(name)) {
|
|
12
|
+
throw new Error(`Invalid ${label} "${name}". Use only letters, digits, hyphens, and underscores.`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=validation.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alcedocore/cli",
|
|
3
|
+
"version": "0.0.1-rc.0",
|
|
4
|
+
"description": "Alcedo plugin development CLI - scaffold, develop, and test plugins",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"alcedo": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"start": "node dist/index.js",
|
|
12
|
+
"dev": "tsx src/index.ts",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@chialab/esbuild-plugin-postcss": "^1.0.1",
|
|
18
|
+
"@types/express": "^5.0.6",
|
|
19
|
+
"chalk": "^4.1.2",
|
|
20
|
+
"check-disk-space": "^3.4.0",
|
|
21
|
+
"chokidar": "^5.0.0",
|
|
22
|
+
"commander": "^11.1.0",
|
|
23
|
+
"cosmiconfig": "^8.3.6",
|
|
24
|
+
"detect-port": "^2.1.0",
|
|
25
|
+
"dotenv": "^17.4.2",
|
|
26
|
+
"ejs": "^3.1.9",
|
|
27
|
+
"express": "^5.2.1",
|
|
28
|
+
"inquirer": "^8.2.7",
|
|
29
|
+
"ora": "^5.4.1",
|
|
30
|
+
"unplugin-vue": "^7.2.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/detect-port": "^1.3.5",
|
|
34
|
+
"@types/ejs": "^3.1.5",
|
|
35
|
+
"@types/inquirer": "^8.2.12",
|
|
36
|
+
"@types/node": "^20.11.0",
|
|
37
|
+
"tsx": "^4.7.0",
|
|
38
|
+
"typescript": "^5.3.0",
|
|
39
|
+
"vitest": "^3.2.4"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
createSpinner,
|
|
6
|
+
success,
|
|
7
|
+
error as logError,
|
|
8
|
+
info,
|
|
9
|
+
} from "../utils/logger";
|
|
10
|
+
import { renderAndWrite } from "../utils/ejs-renderer";
|
|
11
|
+
import { loadConfig, ConfigSchema } from "../config";
|
|
12
|
+
import { assertSafeName } from "../utils/validation";
|
|
13
|
+
import {
|
|
14
|
+
toPascalCase,
|
|
15
|
+
toCamelCase,
|
|
16
|
+
derivePath,
|
|
17
|
+
deriveMethod,
|
|
18
|
+
} from "../utils/formatting";
|
|
19
|
+
import { generateTimestamp } from "../utils/generateTimestamp";
|
|
20
|
+
|
|
21
|
+
export const addEndpointCommand = new Command("endpoint")
|
|
22
|
+
.argument("<name>", "Endpoint name (e.g., get_users)")
|
|
23
|
+
.option(
|
|
24
|
+
"-m, --method <method>",
|
|
25
|
+
"HTTP method (GET, POST, PUT, DELETE, PATCH)",
|
|
26
|
+
)
|
|
27
|
+
.option("-p, --path <path>", "URL path (e.g., /api/users)")
|
|
28
|
+
.description("Generate a handler stub and update manifest.json endpoints")
|
|
29
|
+
.action(
|
|
30
|
+
async (
|
|
31
|
+
name: string,
|
|
32
|
+
options: { method?: string; path?: string },
|
|
33
|
+
cmd: Command,
|
|
34
|
+
) => {
|
|
35
|
+
const config = loadConfig(
|
|
36
|
+
cmd.optsWithGlobals() as Partial<ConfigSchema>,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
assertSafeName(name, "endpoint name");
|
|
40
|
+
|
|
41
|
+
const pluginDir = config.pluginDir || process.cwd();
|
|
42
|
+
const manifestPath = path.resolve(pluginDir, "manifest.json");
|
|
43
|
+
|
|
44
|
+
// Derive endpoint metadata
|
|
45
|
+
const VALID_METHODS = [
|
|
46
|
+
"GET",
|
|
47
|
+
"POST",
|
|
48
|
+
"PUT",
|
|
49
|
+
"DELETE",
|
|
50
|
+
"PATCH",
|
|
51
|
+
"HEAD",
|
|
52
|
+
"OPTIONS",
|
|
53
|
+
];
|
|
54
|
+
const method = (options.method || deriveMethod(name)).toUpperCase();
|
|
55
|
+
if (!VALID_METHODS.includes(method)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Invalid HTTP method "${method}". Valid methods: ${VALID_METHODS.join(", ")}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const urlPath = options.path || derivePath(name);
|
|
61
|
+
const className = toPascalCase(name);
|
|
62
|
+
const camelName = toCamelCase(name);
|
|
63
|
+
const timestamp = generateTimestamp();
|
|
64
|
+
|
|
65
|
+
// Detect language from existing server files
|
|
66
|
+
let language: "python" | "node" = "python";
|
|
67
|
+
if (fs.existsSync(path.join(pluginDir, "server.js"))) {
|
|
68
|
+
language = "node";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const templatesDir = path.resolve(__dirname, "../../templates");
|
|
72
|
+
const endpointsDir = path.resolve(pluginDir, "endpoints");
|
|
73
|
+
|
|
74
|
+
const spinner = createSpinner(`Generating endpoint: ${name}`);
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
// Create endpoints directory
|
|
78
|
+
fs.mkdirSync(endpointsDir, { recursive: true });
|
|
79
|
+
|
|
80
|
+
// Generate handler stub file
|
|
81
|
+
const handlerTemplate =
|
|
82
|
+
language === "python" ? "handler.py.ejs" : "handler.js.ejs";
|
|
83
|
+
const handlerFilename = `${name}.${language === "python" ? "py" : "js"}`;
|
|
84
|
+
|
|
85
|
+
renderAndWrite(
|
|
86
|
+
path.join(templatesDir, "endpoint", handlerTemplate),
|
|
87
|
+
path.join(endpointsDir, handlerFilename),
|
|
88
|
+
{
|
|
89
|
+
name,
|
|
90
|
+
className,
|
|
91
|
+
camelName,
|
|
92
|
+
method,
|
|
93
|
+
path: urlPath,
|
|
94
|
+
timestamp,
|
|
95
|
+
language,
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Update manifest.json endpoints array
|
|
100
|
+
if (!fs.existsSync(manifestPath)) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`No manifest.json found at ${manifestPath} — skipping manifest update.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const manifest = JSON.parse(
|
|
107
|
+
fs.readFileSync(manifestPath, "utf-8"),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Initialize endpoints array if it doesn't exist
|
|
111
|
+
if (!manifest.endpoints) {
|
|
112
|
+
manifest.endpoints = [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Append new endpoint entry (never modify existing entries — GEN-05)
|
|
116
|
+
const newEntry = {
|
|
117
|
+
method,
|
|
118
|
+
path: urlPath,
|
|
119
|
+
description: `${camelName} endpoint`,
|
|
120
|
+
group: "api",
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Check for duplicate before appending (GEN-05 "never modify existing entries"
|
|
124
|
+
// also implies no duplicate entries for the same method + path)
|
|
125
|
+
const isDuplicate = manifest.endpoints.some(
|
|
126
|
+
(e: any) => e.method === method && e.path === urlPath,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
if (isDuplicate) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Endpoint ${method} ${urlPath} already exists in manifest.json — skipping.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
manifest.endpoints.push(newEntry);
|
|
135
|
+
|
|
136
|
+
// Write updated manifest.json back (preserving existing data)
|
|
137
|
+
fs.writeFileSync(
|
|
138
|
+
manifestPath,
|
|
139
|
+
JSON.stringify(manifest, null, 2) + "\n",
|
|
140
|
+
"utf-8",
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
spinner.succeed();
|
|
144
|
+
|
|
145
|
+
success(`Endpoint created:`);
|
|
146
|
+
info(` endpoints/${handlerFilename}`);
|
|
147
|
+
info(` manifest.json ← ${method} ${urlPath}`);
|
|
148
|
+
info(
|
|
149
|
+
`Import the handler in your main server file and add the route.`,
|
|
150
|
+
);
|
|
151
|
+
} catch (err: any) {
|
|
152
|
+
spinner.fail();
|
|
153
|
+
logError(`Failed to generate endpoint: ${err.message}`);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
createSpinner,
|
|
6
|
+
success,
|
|
7
|
+
error as logError,
|
|
8
|
+
info,
|
|
9
|
+
} from "../utils/logger";
|
|
10
|
+
import { renderAndWrite } from "../utils/ejs-renderer";
|
|
11
|
+
import { loadConfig } from "../config";
|
|
12
|
+
import { assertSafeName } from "../utils/validation";
|
|
13
|
+
import { generateTimestamp } from "../utils/generateTimestamp";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Derive a table name from migration name.
|
|
17
|
+
* Converts "create_users_table" → "users", "add_category_to_items" → "category"
|
|
18
|
+
* Falls back to a safe default if pattern doesn't match.
|
|
19
|
+
*/
|
|
20
|
+
function deriveTableName(migrationName: string): string {
|
|
21
|
+
// Pattern: create_{table_name}_table -> table_name
|
|
22
|
+
const createMatch = migrationName.match(/^create_(.+)_table$/);
|
|
23
|
+
if (createMatch) return createMatch[1];
|
|
24
|
+
|
|
25
|
+
// Pattern: add_{column}_to_{table} -> table
|
|
26
|
+
const addMatch = migrationName.match(/^add_\w+_to_(.+)$/);
|
|
27
|
+
if (addMatch) return addMatch[1];
|
|
28
|
+
|
|
29
|
+
// Fallback: use the migration name itself (already validated as safe by assertSafeName)
|
|
30
|
+
return migrationName;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Shared migration generation action. Creates a versioned SQL migration file pair
|
|
35
|
+
* (.up.sql + .down.sql) in the plugin's migrations/ directory.
|
|
36
|
+
* Can be used by both `alcedo add migration` and `alcedo migrate generate`.
|
|
37
|
+
*/
|
|
38
|
+
export async function addMigrationAction(name: string): Promise<void> {
|
|
39
|
+
const config = loadConfig();
|
|
40
|
+
|
|
41
|
+
assertSafeName(name, "migration name");
|
|
42
|
+
|
|
43
|
+
const pluginDir = config.pluginDir || process.cwd();
|
|
44
|
+
const migrationsDir = path.resolve(pluginDir, "migrations");
|
|
45
|
+
|
|
46
|
+
if (!fs.existsSync(migrationsDir)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`No migrations/ directory found in ${pluginDir}. Run \`alcedo init\` first or create the directory.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const timestamp = generateTimestamp();
|
|
53
|
+
const tableName = deriveTableName(name);
|
|
54
|
+
const templatesDir = path.resolve(__dirname, "../../templates");
|
|
55
|
+
|
|
56
|
+
const data = {
|
|
57
|
+
name,
|
|
58
|
+
timestamp,
|
|
59
|
+
tableName,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const upFilename = `${timestamp}_${name}.up.sql`;
|
|
63
|
+
const downFilename = `${timestamp}_${name}.down.sql`;
|
|
64
|
+
|
|
65
|
+
const spinner = createSpinner(`Generating migration: ${name}`);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
// Generate up migration
|
|
69
|
+
renderAndWrite(
|
|
70
|
+
path.join(templatesDir, "migration", "up.sql.ejs"),
|
|
71
|
+
path.join(migrationsDir, upFilename),
|
|
72
|
+
data,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Generate down migration
|
|
76
|
+
renderAndWrite(
|
|
77
|
+
path.join(templatesDir, "migration", "down.sql.ejs"),
|
|
78
|
+
path.join(migrationsDir, downFilename),
|
|
79
|
+
data,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
spinner.succeed();
|
|
83
|
+
|
|
84
|
+
success(`Migration created:`);
|
|
85
|
+
info(` ${upFilename}`);
|
|
86
|
+
info(` ${downFilename}`);
|
|
87
|
+
info(`Edit these files to add your SQL logic.`);
|
|
88
|
+
} catch (err: any) {
|
|
89
|
+
spinner.fail();
|
|
90
|
+
logError(`Failed to generate migration: ${err.message}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const addMigrationCommand = new Command("migration")
|
|
96
|
+
.argument("<name>", "Migration name (e.g., create_users_table)")
|
|
97
|
+
.description(
|
|
98
|
+
"Generate a versioned SQL migration file pair (.up.sql + .down.sql)",
|
|
99
|
+
)
|
|
100
|
+
.action(async (name: string) => {
|
|
101
|
+
await addMigrationAction(name);
|
|
102
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
createSpinner,
|
|
6
|
+
success,
|
|
7
|
+
error as logError,
|
|
8
|
+
info,
|
|
9
|
+
} from "../utils/logger";
|
|
10
|
+
import { loadConfig, ConfigSchema } from "../config";
|
|
11
|
+
import { assertSafeName } from "../utils/validation";
|
|
12
|
+
import { toPascalCase } from "../utils/formatting";
|
|
13
|
+
|
|
14
|
+
export const addNavItemCommand = new Command("nav-item")
|
|
15
|
+
.argument("<name>", "Nav item name (e.g., settings, user-management)")
|
|
16
|
+
.option("-p, --path <path>", "Destination URL path (default: /{name})")
|
|
17
|
+
.option("-i, --icon <icon>", "Material Symbol icon name (default: page)")
|
|
18
|
+
.option("--parent <parent>", "Parent nav-item path for nesting")
|
|
19
|
+
.option("-b, --badge <badge>", "Badge text for the nav item")
|
|
20
|
+
.description(
|
|
21
|
+
"Add a sidebar navigation entry to manifest.json (no file created)",
|
|
22
|
+
)
|
|
23
|
+
.action(
|
|
24
|
+
async (
|
|
25
|
+
name: string,
|
|
26
|
+
options: {
|
|
27
|
+
path?: string;
|
|
28
|
+
icon?: string;
|
|
29
|
+
parent?: string;
|
|
30
|
+
badge?: string;
|
|
31
|
+
},
|
|
32
|
+
cmd: Command,
|
|
33
|
+
) => {
|
|
34
|
+
const config = loadConfig(
|
|
35
|
+
cmd.optsWithGlobals() as Partial<ConfigSchema>,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
assertSafeName(name, "nav-item name");
|
|
39
|
+
|
|
40
|
+
const pluginDir = config.pluginDir || process.cwd();
|
|
41
|
+
const manifestPath = path.resolve(pluginDir, "manifest.json");
|
|
42
|
+
|
|
43
|
+
// Derive nav-item metadata
|
|
44
|
+
const label = toPascalCase(name);
|
|
45
|
+
const targetPath = options.path || "/" + name;
|
|
46
|
+
const icon = options.icon || "page";
|
|
47
|
+
const parent = options.parent;
|
|
48
|
+
const badge = options.badge;
|
|
49
|
+
|
|
50
|
+
const spinner = createSpinner(`Adding nav-item: ${name}`);
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
// Nav-item is manifest-only — no file created
|
|
54
|
+
if (!fs.existsSync(manifestPath)) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`No manifest.json found at ${manifestPath} — skipping.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const manifest = JSON.parse(
|
|
60
|
+
fs.readFileSync(manifestPath, "utf-8"),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Initialize pages array if it doesn't exist
|
|
64
|
+
if (!manifest.pages) {
|
|
65
|
+
manifest.pages = [];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check for duplicate before appending
|
|
69
|
+
const isDuplicate = manifest.pages.some(
|
|
70
|
+
(e: any) => e.path === targetPath,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (isDuplicate) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Nav-item at ${targetPath} already exists in manifest.json — skipping.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
// Construct nav-entry as a plain JS object (avoids JSON injection via EJS)
|
|
79
|
+
const navEntry: Record<string, any> = {
|
|
80
|
+
label,
|
|
81
|
+
path: targetPath,
|
|
82
|
+
icon,
|
|
83
|
+
sidebar: true,
|
|
84
|
+
};
|
|
85
|
+
if (parent) navEntry.parent = parent;
|
|
86
|
+
if (badge) navEntry.badge = badge;
|
|
87
|
+
manifest.pages.push(navEntry);
|
|
88
|
+
|
|
89
|
+
// Write updated manifest.json back (preserving existing data)
|
|
90
|
+
fs.writeFileSync(
|
|
91
|
+
manifestPath,
|
|
92
|
+
JSON.stringify(manifest, null, 2) + "\n",
|
|
93
|
+
"utf-8",
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
spinner.succeed();
|
|
97
|
+
|
|
98
|
+
success(`Nav-item added:`);
|
|
99
|
+
info(` manifest.json ← ${label} (${targetPath})`);
|
|
100
|
+
} catch (err: any) {
|
|
101
|
+
spinner.fail();
|
|
102
|
+
logError(`Failed to add nav-item: ${err.message}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
createSpinner,
|
|
6
|
+
success,
|
|
7
|
+
error as logError,
|
|
8
|
+
info,
|
|
9
|
+
} from "../utils/logger";
|
|
10
|
+
import { renderAndWrite } from "../utils/ejs-renderer";
|
|
11
|
+
import { loadConfig, ConfigSchema } from "../config";
|
|
12
|
+
import { assertSafeName } from "../utils/validation";
|
|
13
|
+
import { toPascalCase } from "../utils/formatting";
|
|
14
|
+
|
|
15
|
+
export const addPageCommand = new Command("page")
|
|
16
|
+
.argument("<name>", "Page name (e.g., dashboard)")
|
|
17
|
+
.option("-r, --route <path>", "URL path (e.g., /my-page)")
|
|
18
|
+
.option("-i, --icon <icon>", "Material Symbol icon name")
|
|
19
|
+
.description("Generate a Vue 3 page component and update manifest.json")
|
|
20
|
+
.action(
|
|
21
|
+
async (
|
|
22
|
+
name: string,
|
|
23
|
+
options: { route?: string; icon?: string },
|
|
24
|
+
cmd: Command,
|
|
25
|
+
) => {
|
|
26
|
+
const config = loadConfig(
|
|
27
|
+
cmd.optsWithGlobals() as Partial<ConfigSchema>,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
assertSafeName(name, "page name");
|
|
31
|
+
|
|
32
|
+
const pluginDir = config.pluginDir || process.cwd();
|
|
33
|
+
const manifestPath = path.resolve(pluginDir, "manifest.json");
|
|
34
|
+
|
|
35
|
+
// Derive page metadata
|
|
36
|
+
const label = toPascalCase(name);
|
|
37
|
+
const route = options.route || "/" + name;
|
|
38
|
+
const icon = options.icon || "page";
|
|
39
|
+
|
|
40
|
+
const templatesDir = path.resolve(__dirname, "../../templates");
|
|
41
|
+
const pagesDir = path.resolve(pluginDir, "pages");
|
|
42
|
+
|
|
43
|
+
const spinner = createSpinner(`Generating page: ${name}`);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
if (!fs.existsSync(manifestPath)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`No manifest.json found at ${manifestPath} — skipping manifest update.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
renderAndWrite(
|
|
52
|
+
path.join(templatesDir, "page", "page.vue.ejs"),
|
|
53
|
+
path.join(pagesDir, `${name}.vue`),
|
|
54
|
+
{ name, label, route, icon },
|
|
55
|
+
);
|
|
56
|
+
const manifest = JSON.parse(
|
|
57
|
+
fs.readFileSync(manifestPath, "utf-8"),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Initialize pages array if it doesn't exist
|
|
61
|
+
if (!manifest.pages) {
|
|
62
|
+
manifest.pages = [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check for duplicate before appending (GEN-05)
|
|
66
|
+
const isDuplicate = manifest.pages.some(
|
|
67
|
+
(e: any) => e.path === route,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (isDuplicate) {
|
|
71
|
+
spinner.succeed();
|
|
72
|
+
info(
|
|
73
|
+
`Page ${route} already exists in manifest.json — skipping.`,
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
manifest.pages.push({ label, path: route, sidebar: true });
|
|
78
|
+
|
|
79
|
+
// Write updated manifest.json back (preserving existing data)
|
|
80
|
+
fs.writeFileSync(
|
|
81
|
+
manifestPath,
|
|
82
|
+
JSON.stringify(manifest, null, 2) + "\n",
|
|
83
|
+
"utf-8",
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
spinner.succeed();
|
|
87
|
+
|
|
88
|
+
success(`Page created:`);
|
|
89
|
+
info(` pages/${name}.vue`);
|
|
90
|
+
info(` manifest.json ← ${label} (${route})`);
|
|
91
|
+
info(
|
|
92
|
+
`Register this page in pages/main.ts by importing and adding to the pages array.`,
|
|
93
|
+
);
|
|
94
|
+
} catch (err: any) {
|
|
95
|
+
spinner.fail();
|
|
96
|
+
logError(`Failed to generate page: ${err.message}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
);
|