@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,50 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "vitest";
|
|
2
|
+
import { loadConfig, findAlcedorc } from "./config";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
describe("loadConfig", () => {
|
|
6
|
+
const originalEnv = { ...process.env };
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
process.env = { ...originalEnv };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns defaults when no config sources are present", () => {
|
|
13
|
+
const config = loadConfig({});
|
|
14
|
+
expect(config.registryUrl).toBe("localhost:5000");
|
|
15
|
+
expect(config.coreUrl).toBe("http://localhost:8080");
|
|
16
|
+
expect(config.pluginDir).toBe(process.cwd());
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("prefers CLI flags over env vars", () => {
|
|
20
|
+
process.env.ALCEDO_CORE_URL = "http://env-url:8080";
|
|
21
|
+
const config = loadConfig({ coreUrl: "http://cli-url:8080" });
|
|
22
|
+
expect(config.coreUrl).toBe("http://cli-url:8080");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("prefers env vars over defaults", () => {
|
|
26
|
+
process.env.ALCEDO_CORE_URL = "http://env-url:8080";
|
|
27
|
+
const config = loadConfig({});
|
|
28
|
+
expect(config.coreUrl).toBe("http://env-url:8080");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("reads ALCEDO_REGISTRY_URL from env", () => {
|
|
32
|
+
process.env.ALCEDO_REGISTRY_URL = "my-registry:5000";
|
|
33
|
+
const config = loadConfig({});
|
|
34
|
+
expect(config.registryUrl).toBe("my-registry:5000");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reads ALCEDO_PLUGIN_DIR from env", () => {
|
|
38
|
+
process.env.ALCEDO_PLUGIN_DIR = "/custom/plugin/path";
|
|
39
|
+
const config = loadConfig({});
|
|
40
|
+
expect(config.pluginDir).toBe("/custom/plugin/path");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("findAlcedorc", () => {
|
|
45
|
+
it("returns null when no .alcedorc file exists", () => {
|
|
46
|
+
// Use system temp dir where no .alcedorc should exist
|
|
47
|
+
const result = findAlcedorc(os.tmpdir());
|
|
48
|
+
expect(result).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { cosmiconfigSync } from "cosmiconfig";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { parse as parseEnv } from "dotenv";
|
|
5
|
+
|
|
6
|
+
export interface ConfigSchema {
|
|
7
|
+
registryUrl?: string;
|
|
8
|
+
coreUrl?: string;
|
|
9
|
+
pluginDir?: string;
|
|
10
|
+
apiKey?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULTS: ConfigSchema = {
|
|
14
|
+
registryUrl: "localhost:5000",
|
|
15
|
+
coreUrl: "http://localhost:8080",
|
|
16
|
+
pluginDir: process.cwd(),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Load config with priority: CLI flags highest → env vars → .alcedorc → defaults lowest.
|
|
21
|
+
* `cliFlags` is the parsed Commander.js flags object (already processed by Commander).
|
|
22
|
+
*/
|
|
23
|
+
export function loadConfig(cliFlags: Partial<ConfigSchema> = {}): ConfigSchema {
|
|
24
|
+
// Start with defaults
|
|
25
|
+
const config: ConfigSchema = { ...DEFAULTS };
|
|
26
|
+
|
|
27
|
+
// Override with .alcedorc (lowest priority override)
|
|
28
|
+
const rcConfig = loadAlcedorc();
|
|
29
|
+
if (rcConfig) {
|
|
30
|
+
Object.assign(config, rcConfig);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Override with .alcedocore.dev.env (set by `alcedocore connect`)
|
|
34
|
+
const devEnvConfig = loadDevEnv();
|
|
35
|
+
Object.assign(config, devEnvConfig);
|
|
36
|
+
|
|
37
|
+
// Override with env vars (medium priority)
|
|
38
|
+
const envConfig = loadEnvConfig();
|
|
39
|
+
Object.assign(config, envConfig);
|
|
40
|
+
|
|
41
|
+
// Override with CLI flags (highest priority)
|
|
42
|
+
Object.assign(config, cliFlags);
|
|
43
|
+
|
|
44
|
+
return config;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Walk up directory tree from startDir (default: cwd) to find .alcedorc JSON file.
|
|
49
|
+
* Returns the parsed config or null if no file found.
|
|
50
|
+
*/
|
|
51
|
+
function loadAlcedorc(): ConfigSchema | null {
|
|
52
|
+
const explorer = cosmiconfigSync("alcedo", {
|
|
53
|
+
searchPlaces: [".alcedorc", ".alcedorc.json"],
|
|
54
|
+
stopDir: path.parse(process.cwd()).root,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// cosmiconfig's search walks up automatically
|
|
58
|
+
const result = explorer.search();
|
|
59
|
+
if (result && !result.isEmpty) {
|
|
60
|
+
return result.config as ConfigSchema;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read .alcedocore.dev.env from the current directory (set by `alcedocore connect`).
|
|
67
|
+
*/
|
|
68
|
+
function loadDevEnv(): Partial<ConfigSchema> {
|
|
69
|
+
const config: Partial<ConfigSchema> = {};
|
|
70
|
+
const envPath = path.join(process.cwd(), ".alcedocore.dev.env");
|
|
71
|
+
if (!fs.existsSync(envPath)) return config;
|
|
72
|
+
const envConfig = parseEnv(fs.readFileSync(envPath));
|
|
73
|
+
|
|
74
|
+
if (envConfig.CORE_URL) config.coreUrl = envConfig.CORE_URL;
|
|
75
|
+
if (envConfig.API_KEY) config.apiKey = envConfig.API_KEY;
|
|
76
|
+
|
|
77
|
+
return config;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read environment variables with ALCEDO_ prefix.
|
|
82
|
+
* e.g., ALCEDO_REGISTRY_URL → registryUrl, ALCEDO_CORE_URL → coreUrl
|
|
83
|
+
*/
|
|
84
|
+
function loadEnvConfig(): Partial<ConfigSchema> {
|
|
85
|
+
const config: Partial<ConfigSchema> = {};
|
|
86
|
+
|
|
87
|
+
const envMapping: Record<string, keyof ConfigSchema> = {
|
|
88
|
+
ALCEDO_REGISTRY_URL: "registryUrl",
|
|
89
|
+
ALCEDO_CORE_URL: "coreUrl",
|
|
90
|
+
ALCEDO_PLUGIN_DIR: "pluginDir",
|
|
91
|
+
ALCEDO_API_KEY: "apiKey",
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
for (const [envKey, configKey] of Object.entries(envMapping)) {
|
|
95
|
+
const value = process.env[envKey];
|
|
96
|
+
if (value) {
|
|
97
|
+
(config as any)[configKey] = value;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return config;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Explicit directory tree walk for .alcedorc discovery (CLI-06).
|
|
106
|
+
* Used when cosmiconfig's auto-search isn't desired and we need
|
|
107
|
+
* manual control over the walk.
|
|
108
|
+
*/
|
|
109
|
+
export function findAlcedorc(startDir: string = process.cwd()): string | null {
|
|
110
|
+
let currentDir = path.resolve(startDir);
|
|
111
|
+
|
|
112
|
+
while (true) {
|
|
113
|
+
const candidate = path.join(currentDir, ".alcedorc");
|
|
114
|
+
if (fs.existsSync(candidate)) {
|
|
115
|
+
return candidate;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const parent = path.dirname(currentDir);
|
|
119
|
+
if (parent === currentDir) {
|
|
120
|
+
// Reached filesystem root
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
currentDir = parent;
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { loadConfig, ConfigSchema } from "./config";
|
|
4
|
+
import { error as logError } from "./utils/logger";
|
|
5
|
+
import { initCoreCommand } from "./commands/init-core";
|
|
6
|
+
import { initCommand } from "./commands/init";
|
|
7
|
+
import { deployCommand } from "./commands/deploy";
|
|
8
|
+
import { addMigrationCommand } from "./commands/add-migration";
|
|
9
|
+
import { migrateCommand } from "./commands/migrate";
|
|
10
|
+
import { addEndpointCommand } from "./commands/add-endpoint";
|
|
11
|
+
import { addPageCommand } from "./commands/add-page";
|
|
12
|
+
import { addNavItemCommand } from "./commands/add-nav-item";
|
|
13
|
+
import { connectCommand } from "./commands/connect";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { devCommand } from "./commands/dev";
|
|
17
|
+
import { publishCommand } from "./commands/publish";
|
|
18
|
+
|
|
19
|
+
// Read version from package.json
|
|
20
|
+
function getVersion(): string {
|
|
21
|
+
const pkgPath = path.resolve(__dirname, "../package.json");
|
|
22
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
23
|
+
return pkg.version;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const program = new Command();
|
|
27
|
+
|
|
28
|
+
program
|
|
29
|
+
.name("alcedo")
|
|
30
|
+
.version(getVersion(), "-V, --version", "Output the version number")
|
|
31
|
+
.description(
|
|
32
|
+
"Alcedo plugin development CLI - scaffold, develop, and test plugins",
|
|
33
|
+
)
|
|
34
|
+
.hook("preAction", (thisCommand) => {
|
|
35
|
+
// Parse config before any action runs
|
|
36
|
+
// Walk parent chain to collect all options (including global flags)
|
|
37
|
+
const opts: Partial<ConfigSchema> = {};
|
|
38
|
+
let cmd: Command | null = thisCommand;
|
|
39
|
+
while (cmd) {
|
|
40
|
+
Object.assign(opts, cmd.opts());
|
|
41
|
+
cmd = cmd.parent as Command | null;
|
|
42
|
+
}
|
|
43
|
+
const config = loadConfig(opts);
|
|
44
|
+
// Store config on the command for subcommands to access
|
|
45
|
+
(thisCommand as any)._alcedoConfig = config;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Global options
|
|
49
|
+
program.option(
|
|
50
|
+
"-r, --registry-url <url>",
|
|
51
|
+
"Docker registry URL (default: localhost:5000)",
|
|
52
|
+
);
|
|
53
|
+
program.option(
|
|
54
|
+
"-c, --core-url <url>",
|
|
55
|
+
"core API URL (default: http://localhost:8080)",
|
|
56
|
+
);
|
|
57
|
+
program.option(
|
|
58
|
+
"-d, --plugin-dir <path>",
|
|
59
|
+
"Plugin project directory (default: current directory)",
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
program.addCommand(initCommand);
|
|
63
|
+
program.addCommand(initCoreCommand);
|
|
64
|
+
program.addCommand(deployCommand);
|
|
65
|
+
program.addCommand(publishCommand);
|
|
66
|
+
|
|
67
|
+
const addCommand = new Command("add").description(
|
|
68
|
+
"Generate plugin components (migrations, endpoints, pages, nav-items)",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
addCommand.addCommand(addMigrationCommand);
|
|
72
|
+
addCommand.addCommand(addEndpointCommand);
|
|
73
|
+
addCommand.addCommand(addPageCommand);
|
|
74
|
+
addCommand.addCommand(addNavItemCommand);
|
|
75
|
+
|
|
76
|
+
program.addCommand(addCommand);
|
|
77
|
+
program.addCommand(migrateCommand);
|
|
78
|
+
program.addCommand(connectCommand);
|
|
79
|
+
program.addCommand(devCommand);
|
|
80
|
+
|
|
81
|
+
// Error handling: non-zero exit on errors (CLI-04)
|
|
82
|
+
// Catch unhandled promise rejections and exceptions
|
|
83
|
+
process.on("unhandledRejection", (reason) => {
|
|
84
|
+
logError(`Unhandled error: ${reason}`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
process.on("uncaughtException", (err) => {
|
|
89
|
+
logError(`Fatal error: ${err.message}`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
async function main(): Promise<void> {
|
|
94
|
+
await program.parseAsync(process.argv);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
main().catch((err) => {
|
|
98
|
+
logError(`Fatal error: ${err.message}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
2
|
+
|
|
3
|
+
const CORE_URL = process.env.CORE_URL || "http://localhost:8080";
|
|
4
|
+
const TEST_SLUG = process.env.TEST_PLUGIN_SLUG || "hello-world";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Health check — skip all tests if AlcedoCore is not reachable
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
let coreReachable = false;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch(`${CORE_URL}/health`, {
|
|
15
|
+
signal: AbortSignal.timeout(3000),
|
|
16
|
+
});
|
|
17
|
+
coreReachable = res.ok;
|
|
18
|
+
} catch {
|
|
19
|
+
coreReachable = false;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Test helpers — replicate CLI patterns without Commander overhead
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
async function apiCall<T>(
|
|
28
|
+
apiPath: string,
|
|
29
|
+
options?: { method?: string; body?: unknown },
|
|
30
|
+
): Promise<T> {
|
|
31
|
+
const url = `${CORE_URL.replace(/\/$/, "")}${apiPath}`;
|
|
32
|
+
const res = await fetch(url, {
|
|
33
|
+
method: options?.method || "GET",
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
body: options?.body ? JSON.stringify(options.body) : undefined,
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const errBody = (await res
|
|
39
|
+
.json()
|
|
40
|
+
.catch(() => ({ error: res.statusText }))) as { error?: string };
|
|
41
|
+
throw new Error(
|
|
42
|
+
errBody.error || `HTTP ${res.status}: ${res.statusText}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return res.json() as Promise<T>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Migration API tests (used by `alcedo migrate` command)
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
describe("Migration API", () => {
|
|
53
|
+
it("lists migrations for a plugin via GET /api/plugins/:slug/migrations", async () => {
|
|
54
|
+
if (!coreReachable) return;
|
|
55
|
+
|
|
56
|
+
const migrations = await apiCall<
|
|
57
|
+
Array<{ version: string; name: string; status: string }>
|
|
58
|
+
>(`/api/plugins/${TEST_SLUG}/migrations`);
|
|
59
|
+
|
|
60
|
+
expect(Array.isArray(migrations)).toBe(true);
|
|
61
|
+
// Migration list should always return an array (possibly empty)
|
|
62
|
+
for (const m of migrations) {
|
|
63
|
+
expect(m.version).toBeTruthy();
|
|
64
|
+
expect(m.name).toBeTruthy();
|
|
65
|
+
expect(["applied", "pending"]).toContain(m.status);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Request log detail API tests (used by `alcedo dev replay` command)
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
describe("Request log detail API", () => {
|
|
75
|
+
it("returns 404 for non-existent request ID", async () => {
|
|
76
|
+
if (!coreReachable) return;
|
|
77
|
+
|
|
78
|
+
const url = `${CORE_URL.replace(/\/$/, "")}/api/plugins/${TEST_SLUG}/logs/nonexistent-id/detail`;
|
|
79
|
+
const res = await fetch(url);
|
|
80
|
+
expect(res.status).toBe(404);
|
|
81
|
+
|
|
82
|
+
const body = (await res.json()) as { error?: string };
|
|
83
|
+
expect(body.error).toBeTruthy();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns log entries listing via GET /api/plugins/:slug/logs", async () => {
|
|
87
|
+
if (!coreReachable) return;
|
|
88
|
+
|
|
89
|
+
// The API wraps logs under { data: { logs: [...] } }
|
|
90
|
+
const res = await apiCall<{
|
|
91
|
+
data: {
|
|
92
|
+
logs: Array<{
|
|
93
|
+
request_uuid: string;
|
|
94
|
+
method: string;
|
|
95
|
+
path: string;
|
|
96
|
+
}>;
|
|
97
|
+
};
|
|
98
|
+
}>(`/api/plugins/${TEST_SLUG}/logs`);
|
|
99
|
+
|
|
100
|
+
expect(res.data).toBeDefined();
|
|
101
|
+
expect(Array.isArray(res.data.logs)).toBe(true);
|
|
102
|
+
if (res.data.logs.length > 0) {
|
|
103
|
+
const entry = res.data.logs[0];
|
|
104
|
+
expect(entry.request_uuid).toBeTruthy();
|
|
105
|
+
expect(entry.method).toBeTruthy();
|
|
106
|
+
expect(entry.path).toBeDefined();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Error handling tests
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
describe("Error handling", () => {
|
|
116
|
+
it("returns 404 for non-existent plugin slug", async () => {
|
|
117
|
+
if (!coreReachable) return;
|
|
118
|
+
|
|
119
|
+
const url = `${CORE_URL.replace(/\/$/, "")}/api/plugins/nonexistent-plugin/logs`;
|
|
120
|
+
const res = await fetch(url);
|
|
121
|
+
expect(res.status).toBe(404);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("handles network errors gracefully", async () => {
|
|
125
|
+
// Use a non-routable address to simulate network failure
|
|
126
|
+
const badUrl = "http://192.0.2.1:9999/api/dev/start";
|
|
127
|
+
try {
|
|
128
|
+
await fetch(badUrl, {
|
|
129
|
+
method: "POST",
|
|
130
|
+
headers: { "Content-Type": "application/json" },
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
slug: "test",
|
|
133
|
+
url: "http://localhost:3000",
|
|
134
|
+
}),
|
|
135
|
+
signal: AbortSignal.timeout(1000),
|
|
136
|
+
});
|
|
137
|
+
// Should not reach here
|
|
138
|
+
expect(true).toBe(false);
|
|
139
|
+
} catch (err: unknown) {
|
|
140
|
+
expect(err).toBeTruthy();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import ejs from "ejs";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Render an EJS template string with the given data.
|
|
7
|
+
* Returns the rendered string.
|
|
8
|
+
*/
|
|
9
|
+
export function renderTemplate(
|
|
10
|
+
templateContent: string,
|
|
11
|
+
data: Record<string, any>,
|
|
12
|
+
): string {
|
|
13
|
+
return ejs.render(templateContent, data);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read an EJS template file, render it with data, and write to the target path.
|
|
18
|
+
* Creates parent directories if they don't exist.
|
|
19
|
+
* ONLY creates new files — never overwrites (GEN-05 compliance).
|
|
20
|
+
* Returns the target path on success, throws on error.
|
|
21
|
+
*/
|
|
22
|
+
export function renderAndWrite(
|
|
23
|
+
templatePath: string,
|
|
24
|
+
targetPath: string,
|
|
25
|
+
data: Record<string, any>,
|
|
26
|
+
): string {
|
|
27
|
+
if (fs.existsSync(targetPath)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Target file already exists: ${targetPath}. ` +
|
|
30
|
+
`Refusing to overwrite existing code (GEN-05).`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const templateContent = fs.readFileSync(templatePath, "utf-8");
|
|
35
|
+
const rendered = renderTemplate(templateContent, data);
|
|
36
|
+
|
|
37
|
+
const dir = path.dirname(targetPath);
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
39
|
+
|
|
40
|
+
fs.writeFileSync(targetPath, rendered, "utf-8");
|
|
41
|
+
return targetPath;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read an EJS template file and return the rendered content as a string
|
|
46
|
+
* (without writing to disk). Used for content that gets appended to existing files
|
|
47
|
+
* like manifest.json updates.
|
|
48
|
+
*/
|
|
49
|
+
export function renderToString(
|
|
50
|
+
templatePath: string,
|
|
51
|
+
data: Record<string, any>,
|
|
52
|
+
): string {
|
|
53
|
+
const templateContent = fs.readFileSync(templatePath, "utf-8");
|
|
54
|
+
return renderTemplate(templateContent, data);
|
|
55
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared naming/convention utilities used by CLI generator commands.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convert a name to PascalCase for display labels and class names.
|
|
7
|
+
* "dashboard" -> "Dashboard"
|
|
8
|
+
* "user-management" -> "UserManagement"
|
|
9
|
+
* "get_users" -> "GetUsers"
|
|
10
|
+
*/
|
|
11
|
+
export function toPascalCase(name: string): string {
|
|
12
|
+
return name
|
|
13
|
+
.split(/[_-]/)
|
|
14
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
15
|
+
.join("");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Convert a name to camelCase for function/variable names.
|
|
20
|
+
* "get_users" -> "getUsers"
|
|
21
|
+
*/
|
|
22
|
+
export function toCamelCase(name: string): string {
|
|
23
|
+
const pascal = toPascalCase(name);
|
|
24
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Derive a URL path from an endpoint name.
|
|
29
|
+
* "get_users" -> "/api/get_users"
|
|
30
|
+
* "health" -> "/health"
|
|
31
|
+
*/
|
|
32
|
+
export function derivePath(name: string): string {
|
|
33
|
+
const normalized = name.replace(/[_-]/g, "_");
|
|
34
|
+
if (normalized.startsWith("get_") || normalized.startsWith("post_") ||
|
|
35
|
+
normalized.startsWith("put_") || normalized.startsWith("delete_") ||
|
|
36
|
+
normalized.startsWith("patch_")) {
|
|
37
|
+
return "/" + normalized.replace(/_/g, "/");
|
|
38
|
+
}
|
|
39
|
+
return "/api/" + normalized.replace(/_/g, "/");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Derive HTTP method from endpoint name.
|
|
44
|
+
* "get_users" -> "GET"
|
|
45
|
+
* "create_user" -> "POST"
|
|
46
|
+
* "health" -> "GET" (default)
|
|
47
|
+
*/
|
|
48
|
+
export function deriveMethod(name: string): string {
|
|
49
|
+
const prefix = name.split("_")[0].toLowerCase();
|
|
50
|
+
const methodMap: Record<string, string> = {
|
|
51
|
+
get: "GET",
|
|
52
|
+
post: "POST",
|
|
53
|
+
create: "POST",
|
|
54
|
+
put: "PUT",
|
|
55
|
+
update: "PUT",
|
|
56
|
+
delete: "DELETE",
|
|
57
|
+
remove: "DELETE",
|
|
58
|
+
patch: "PATCH",
|
|
59
|
+
list: "GET",
|
|
60
|
+
};
|
|
61
|
+
return methodMap[prefix] || "GET";
|
|
62
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Generate a timestamp string (same format as migration command).
|
|
4
|
+
*/
|
|
5
|
+
export function generateTimestamp(): string {
|
|
6
|
+
const now = new Date();
|
|
7
|
+
const pad = (n: number, d = 2) => String(n).padStart(d, "0");
|
|
8
|
+
return (
|
|
9
|
+
`${now.getFullYear()}` +
|
|
10
|
+
`${pad(now.getMonth() + 1)}` +
|
|
11
|
+
`${pad(now.getDate())}` +
|
|
12
|
+
`${pad(now.getHours())}` +
|
|
13
|
+
`${pad(now.getMinutes())}` +
|
|
14
|
+
`${pad(now.getSeconds())}`
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
|
|
4
|
+
export function success(msg: string): void {
|
|
5
|
+
console.log(chalk.green("✔"), msg);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function error(msg: string): void {
|
|
9
|
+
console.error(chalk.red("✖"), msg);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function info(msg: string): void {
|
|
13
|
+
console.log(chalk.blue("ℹ"), msg);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function warn(msg: string): void {
|
|
17
|
+
console.warn(chalk.yellow("⚠"), msg);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create an ora spinner with the given text.
|
|
22
|
+
* Starts immediately. Call `.succeed()`, `.fail()`, or `.stop()` on the returned instance.
|
|
23
|
+
* Ora auto-detects CI/non-TTY environments and disables spinners appropriately.
|
|
24
|
+
*/
|
|
25
|
+
export function createSpinner(text: string): ora.Ora {
|
|
26
|
+
return ora({ text, color: "cyan" }).start();
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate that a name contains only safe characters for use in filenames.
|
|
3
|
+
* Rejects names containing path separators (/ or \), null bytes, or "..".
|
|
4
|
+
* Allowed: letters, digits, hyphens, underscores (regex: /^[\w-]+$/).
|
|
5
|
+
* Throws an error with the given label if the name is invalid.
|
|
6
|
+
*/
|
|
7
|
+
export function assertSafeName(name: string, label: string): void {
|
|
8
|
+
if (!/^[\w-]+$/.test(name)) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`Invalid ${label} "${name}". Use only letters, digits, hyphens, and underscores.`
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Endpoint: <%- name %>
|
|
2
|
+
// Generated: <%- timestamp %>
|
|
3
|
+
// This file was auto-generated by `alcedo add endpoint <%- name %>`
|
|
4
|
+
// DO NOT EDIT the imports section — add your logic below.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Handler for <%- method %> <%- path %>
|
|
8
|
+
*/
|
|
9
|
+
function <%- camelName %>Handler(req, res) {
|
|
10
|
+
const sendJson = (data, status = 200) => {
|
|
11
|
+
const body = JSON.stringify(data);
|
|
12
|
+
res.writeHead(status, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) });
|
|
13
|
+
res.end(body);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
sendJson({
|
|
17
|
+
status: "ok",
|
|
18
|
+
endpoint: "<%- path %>",
|
|
19
|
+
message: "<%- camelName %> handler ready",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { <%- camelName %>Handler };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Endpoint: <%- name %>
|
|
2
|
+
# Generated: <%- timestamp %>
|
|
3
|
+
# This file was auto-generated by `alcedo add endpoint <%- name %>`
|
|
4
|
+
# DO NOT EDIT the imports section — add your logic below.
|
|
5
|
+
import json
|
|
6
|
+
from http.server import BaseHTTPRequestHandler
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class <%- className %>Handler(BaseHTTPRequestHandler):
|
|
10
|
+
"""Handler for <%- method %> <%- path %>"""
|
|
11
|
+
|
|
12
|
+
def do_<%- method %>(self):
|
|
13
|
+
self._json({
|
|
14
|
+
"status": "ok",
|
|
15
|
+
"endpoint": "<%- path %>",
|
|
16
|
+
"message": "<%- className %> handler ready"
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
def _json(self, data, status=200):
|
|
20
|
+
self.send_response(status)
|
|
21
|
+
self.send_header("Content-Type", "application/json")
|
|
22
|
+
self.end_headers()
|
|
23
|
+
self.wfile.write(json.dumps(data).encode())
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Migration: <%- name %>
|
|
2
|
+
-- Generated: <%- timestamp %>
|
|
3
|
+
-- Up Migration
|
|
4
|
+
-- Write your migration SQL here.
|
|
5
|
+
|
|
6
|
+
CREATE TABLE IF NOT EXISTS <%- tableName %> (
|
|
7
|
+
id SERIAL PRIMARY KEY,
|
|
8
|
+
name VARCHAR(255) NOT NULL,
|
|
9
|
+
description TEXT,
|
|
10
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
11
|
+
);
|