@impulselab/cli 0.1.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/dist/index.d.ts +1 -0
- package/dist/index.js +891 -0
- package/package.json +33 -0
- package/src/commands/add.test.ts +147 -0
- package/src/commands/add.ts +335 -0
- package/src/commands/init.ts +114 -0
- package/src/commands/list.ts +79 -0
- package/src/config/config-path.ts +7 -0
- package/src/config/has-config.ts +9 -0
- package/src/config/index.ts +4 -0
- package/src/config/read-config.ts +20 -0
- package/src/config/write-config.ts +11 -0
- package/src/config.test.ts +64 -0
- package/src/index.ts +64 -0
- package/src/installer.ts +71 -0
- package/src/registry/fetch-module-file.ts +21 -0
- package/src/registry/fetch-module-manifest.ts +43 -0
- package/src/registry/github-urls.ts +13 -0
- package/src/registry/index.ts +5 -0
- package/src/registry/list-available-modules.ts +113 -0
- package/src/registry/parse-module-id.ts +30 -0
- package/src/registry/registry.test.ts +181 -0
- package/src/schemas/impulse-config.ts +21 -0
- package/src/schemas/index.ts +9 -0
- package/src/schemas/module-dependency.ts +3 -0
- package/src/schemas/module-file.ts +8 -0
- package/src/schemas/module-manifest.ts +23 -0
- package/src/schemas/module-transform.ts +15 -0
- package/src/transforms/add-env.ts +53 -0
- package/src/transforms/add-nav-item.test.ts +125 -0
- package/src/transforms/add-nav-item.ts +70 -0
- package/src/transforms/append-export.test.ts +50 -0
- package/src/transforms/append-export.ts +34 -0
- package/src/transforms/index.ts +32 -0
- package/src/transforms/merge-schema.test.ts +70 -0
- package/src/transforms/merge-schema.ts +35 -0
- package/src/transforms/register-route.test.ts +177 -0
- package/src/transforms/register-route.ts +47 -0
- package/src/types.ts +9 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fsExtra from "fs-extra";
|
|
2
|
+
const { readJson, pathExists } = fsExtra;
|
|
3
|
+
import { ImpulseConfigSchema } from "../schemas/impulse-config";
|
|
4
|
+
import type { ImpulseConfig } from "../schemas/impulse-config";
|
|
5
|
+
import { configPath } from "./config-path";
|
|
6
|
+
|
|
7
|
+
export async function readConfig(
|
|
8
|
+
cwd: string = process.cwd()
|
|
9
|
+
): Promise<ImpulseConfig | null> {
|
|
10
|
+
const file = configPath(cwd);
|
|
11
|
+
if (!(await pathExists(file))) return null;
|
|
12
|
+
const raw = await readJson(file);
|
|
13
|
+
const parsed = ImpulseConfigSchema.safeParse(raw);
|
|
14
|
+
if (!parsed.success) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Invalid .impulse.json: ${parsed.error.message}`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return parsed.data;
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import fsExtra from "fs-extra";
|
|
2
|
+
const { writeJson } = fsExtra;
|
|
3
|
+
import type { ImpulseConfig } from "../schemas/impulse-config";
|
|
4
|
+
import { configPath } from "./config-path";
|
|
5
|
+
|
|
6
|
+
export async function writeConfig(
|
|
7
|
+
config: ImpulseConfig,
|
|
8
|
+
cwd: string = process.cwd()
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
await writeJson(configPath(cwd), config, { spaces: 2 });
|
|
11
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import { readConfig, writeConfig, hasConfig } from "./config/index";
|
|
6
|
+
import type { ImpulseConfig } from "./types";
|
|
7
|
+
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tmpDir = await mkdtemp(path.join(os.tmpdir(), "impulse-config-test-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const sampleConfig: ImpulseConfig = {
|
|
19
|
+
version: "1",
|
|
20
|
+
projectName: "my-project",
|
|
21
|
+
srcPath: "src",
|
|
22
|
+
dbPath: "src/server/db",
|
|
23
|
+
routesPath: "src/server/api",
|
|
24
|
+
installedModules: [],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("config", () => {
|
|
28
|
+
it("returns null if no config file", async () => {
|
|
29
|
+
expect(await readConfig(tmpDir)).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("writes and reads config", async () => {
|
|
33
|
+
await writeConfig(sampleConfig, tmpDir);
|
|
34
|
+
const config = await readConfig(tmpDir);
|
|
35
|
+
expect(config).toEqual(sampleConfig);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("hasConfig returns false before init", async () => {
|
|
39
|
+
expect(await hasConfig(tmpDir)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("hasConfig returns true after write", async () => {
|
|
43
|
+
await writeConfig(sampleConfig, tmpDir);
|
|
44
|
+
expect(await hasConfig(tmpDir)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("tracks installed modules", async () => {
|
|
48
|
+
const config: ImpulseConfig = {
|
|
49
|
+
...sampleConfig,
|
|
50
|
+
installedModules: [
|
|
51
|
+
{
|
|
52
|
+
name: "auth",
|
|
53
|
+
version: "1.0.0",
|
|
54
|
+
installedAt: "2026-01-01T00:00:00.000Z",
|
|
55
|
+
files: ["src/server/api/auth.ts"],
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
await writeConfig(config, tmpDir);
|
|
60
|
+
const read = await readConfig(tmpDir);
|
|
61
|
+
expect(read?.installedModules).toHaveLength(1);
|
|
62
|
+
expect(read?.installedModules[0]?.name).toBe("auth");
|
|
63
|
+
});
|
|
64
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { runInit } from "./commands/init";
|
|
4
|
+
import { runAdd } from "./commands/add";
|
|
5
|
+
import { runList } from "./commands/list";
|
|
6
|
+
|
|
7
|
+
const program = new Command();
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.name("impulse")
|
|
11
|
+
.description("ImpulseLab CLI — install and manage modules for your projects")
|
|
12
|
+
.version("0.1.0");
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.command("init")
|
|
16
|
+
.description("Initialize impulse in the current project")
|
|
17
|
+
.option("--force", "Reinitialize even if .impulse.json already exists", false)
|
|
18
|
+
.action(async (options: { force: boolean }) => {
|
|
19
|
+
await runInit({ cwd: process.cwd(), force: options.force });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.command("add <module>")
|
|
24
|
+
.description(
|
|
25
|
+
"Add a module to the current project.\n" +
|
|
26
|
+
" Supports sub-module syntax: `add attio/quote-to-cash`\n" +
|
|
27
|
+
" Use --with to install multiple sub-modules at once: `add attio --with quote-to-cash,gocardless`"
|
|
28
|
+
)
|
|
29
|
+
.option("--dry-run", "Preview changes without writing files", false)
|
|
30
|
+
.option(
|
|
31
|
+
"--local <path>",
|
|
32
|
+
"Use a local modules directory (for development)"
|
|
33
|
+
)
|
|
34
|
+
.option(
|
|
35
|
+
"--with <submodules>",
|
|
36
|
+
"Comma-separated sub-modules to install alongside the parent (e.g. --with quote-to-cash,gocardless)"
|
|
37
|
+
)
|
|
38
|
+
.action(async (moduleName: string, options: { dryRun: boolean; local?: string; with?: string }) => {
|
|
39
|
+
const addOpts: Parameters<typeof runAdd>[0] = {
|
|
40
|
+
moduleName,
|
|
41
|
+
cwd: process.cwd(),
|
|
42
|
+
dryRun: options.dryRun,
|
|
43
|
+
};
|
|
44
|
+
if (options.local !== undefined) addOpts.localPath = options.local;
|
|
45
|
+
if (options.with !== undefined) {
|
|
46
|
+
addOpts.withSubModules = options.with.split(",").map((s) => s.trim()).filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
await runAdd(addOpts);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
program
|
|
52
|
+
.command("list")
|
|
53
|
+
.description("List available and installed modules")
|
|
54
|
+
.option(
|
|
55
|
+
"--local <path>",
|
|
56
|
+
"Use a local modules directory (for development)"
|
|
57
|
+
)
|
|
58
|
+
.action(async (options: { local?: string }) => {
|
|
59
|
+
const listOpts: Parameters<typeof runList>[0] = { cwd: process.cwd() };
|
|
60
|
+
if (options.local !== undefined) listOpts.localPath = options.local;
|
|
61
|
+
await runList(listOpts);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
program.parse(process.argv);
|
package/src/installer.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fsExtra from "fs-extra";
|
|
2
|
+
const { outputFile, pathExists } = fsExtra;
|
|
3
|
+
import path from "path";
|
|
4
|
+
import * as p from "@clack/prompts";
|
|
5
|
+
import { fetchModuleFile } from "./registry/index";
|
|
6
|
+
import type { ModuleFile } from "./types";
|
|
7
|
+
|
|
8
|
+
export interface InstallFilesOptions {
|
|
9
|
+
moduleName: string;
|
|
10
|
+
files: ModuleFile[];
|
|
11
|
+
cwd: string;
|
|
12
|
+
dryRun: boolean;
|
|
13
|
+
localPath?: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface InstalledFile {
|
|
17
|
+
dest: string;
|
|
18
|
+
action: "created" | "overwritten" | "skipped" | "would-create" | "would-overwrite";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function installFiles(
|
|
22
|
+
options: InstallFilesOptions
|
|
23
|
+
): Promise<InstalledFile[]> {
|
|
24
|
+
const { moduleName, files, cwd, dryRun, localPath } = options;
|
|
25
|
+
const results: InstalledFile[] = [];
|
|
26
|
+
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
const destAbs = path.join(cwd, file.dest);
|
|
29
|
+
const exists = await pathExists(destAbs);
|
|
30
|
+
|
|
31
|
+
if (exists) {
|
|
32
|
+
const content = await fetchModuleFile(moduleName, file.src, localPath);
|
|
33
|
+
const existing = await import("fs/promises").then((fs) =>
|
|
34
|
+
fs.readFile(destAbs, "utf-8")
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (existing === content) {
|
|
38
|
+
results.push({ dest: file.dest, action: "skipped" });
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (dryRun) {
|
|
43
|
+
results.push({ dest: file.dest, action: "would-overwrite" });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const answer = await p.confirm({
|
|
48
|
+
message: `File already exists: ${file.dest} — overwrite?`,
|
|
49
|
+
initialValue: false,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (p.isCancel(answer) || !answer) {
|
|
53
|
+
results.push({ dest: file.dest, action: "skipped" });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await outputFile(destAbs, content, "utf-8");
|
|
58
|
+
results.push({ dest: file.dest, action: "overwritten" });
|
|
59
|
+
} else {
|
|
60
|
+
if (dryRun) {
|
|
61
|
+
results.push({ dest: file.dest, action: "would-create" });
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const content = await fetchModuleFile(moduleName, file.src, localPath);
|
|
65
|
+
await outputFile(destAbs, content, "utf-8");
|
|
66
|
+
results.push({ dest: file.dest, action: "created" });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return results;
|
|
71
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { githubUrls } from "./github-urls";
|
|
2
|
+
|
|
3
|
+
export async function fetchModuleFile(
|
|
4
|
+
moduleName: string,
|
|
5
|
+
fileSrc: string,
|
|
6
|
+
localPath?: string
|
|
7
|
+
): Promise<string> {
|
|
8
|
+
if (localPath) {
|
|
9
|
+
const { readFile } = await import("fs/promises");
|
|
10
|
+
const { join } = await import("path");
|
|
11
|
+
const file = join(localPath, moduleName, fileSrc);
|
|
12
|
+
return readFile(file, "utf-8");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const url = githubUrls.rawFile(moduleName, fileSrc);
|
|
16
|
+
const res = await fetch(url);
|
|
17
|
+
if (!res.ok) {
|
|
18
|
+
throw new Error(`Failed to fetch file ${fileSrc} for module ${moduleName}: ${res.status}`);
|
|
19
|
+
}
|
|
20
|
+
return res.text();
|
|
21
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fsExtra from "fs-extra";
|
|
2
|
+
const { readJson, pathExists } = fsExtra;
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { ModuleManifestSchema } from "../schemas/module-manifest";
|
|
5
|
+
import type { ModuleManifest } from "../schemas/module-manifest";
|
|
6
|
+
import { githubUrls } from "./github-urls";
|
|
7
|
+
import { moduleRegistryPath } from "./parse-module-id";
|
|
8
|
+
|
|
9
|
+
export async function fetchModuleManifest(
|
|
10
|
+
moduleId: string,
|
|
11
|
+
localPath?: string
|
|
12
|
+
): Promise<ModuleManifest> {
|
|
13
|
+
const registryPath = moduleRegistryPath(moduleId);
|
|
14
|
+
|
|
15
|
+
if (localPath) {
|
|
16
|
+
const file = path.join(localPath, registryPath, "module.json");
|
|
17
|
+
if (!(await pathExists(file))) {
|
|
18
|
+
throw new Error(`Local module not found: ${file}`);
|
|
19
|
+
}
|
|
20
|
+
const raw = await readJson(file);
|
|
21
|
+
const parsed = ModuleManifestSchema.safeParse(raw);
|
|
22
|
+
if (!parsed.success) {
|
|
23
|
+
throw new Error(`Invalid module.json for ${moduleId}: ${parsed.error.message}`);
|
|
24
|
+
}
|
|
25
|
+
return parsed.data;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const url = githubUrls.rawFile(registryPath, "module.json");
|
|
29
|
+
const res = await fetch(url);
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
if (res.status === 404) {
|
|
32
|
+
throw new Error(`Module not found in registry: ${moduleId}`);
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`Failed to fetch module manifest: ${res.status} ${res.statusText}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const raw: unknown = await res.json();
|
|
38
|
+
const parsed = ModuleManifestSchema.safeParse(raw);
|
|
39
|
+
if (!parsed.success) {
|
|
40
|
+
throw new Error(`Invalid module.json for ${moduleId}: ${parsed.error.message}`);
|
|
41
|
+
}
|
|
42
|
+
return parsed.data;
|
|
43
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const GITHUB_ORG = "impulse-studio";
|
|
2
|
+
const GITHUB_REPO = "impulse-modules";
|
|
3
|
+
const GITHUB_BRANCH = "main";
|
|
4
|
+
const MODULES_DIR = "modules";
|
|
5
|
+
|
|
6
|
+
export const githubUrls = {
|
|
7
|
+
rawFile: (registryPath: string, file: string): string =>
|
|
8
|
+
`https://raw.githubusercontent.com/${GITHUB_ORG}/${GITHUB_REPO}/${GITHUB_BRANCH}/${MODULES_DIR}/${registryPath}/${file}`,
|
|
9
|
+
moduleList: (): string =>
|
|
10
|
+
`https://api.github.com/repos/${GITHUB_ORG}/${GITHUB_REPO}/contents/${MODULES_DIR}`,
|
|
11
|
+
subModulesList: (parentModule: string): string =>
|
|
12
|
+
`https://api.github.com/repos/${GITHUB_ORG}/${GITHUB_REPO}/contents/${MODULES_DIR}/${parentModule}/sub-modules`,
|
|
13
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { fetchModuleManifest } from "./fetch-module-manifest";
|
|
2
|
+
export { listAvailableModules } from "./list-available-modules";
|
|
3
|
+
export type { RegistryModule } from "./list-available-modules";
|
|
4
|
+
export { fetchModuleFile } from "./fetch-module-file";
|
|
5
|
+
export { parseModuleId, isSubModuleId, moduleRegistryPath } from "./parse-module-id";
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fsExtra from "fs-extra";
|
|
2
|
+
const { readJson, pathExists } = fsExtra;
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { ModuleManifestSchema } from "../schemas/module-manifest";
|
|
5
|
+
import { githubUrls } from "./github-urls";
|
|
6
|
+
|
|
7
|
+
export interface RegistryModule {
|
|
8
|
+
name: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
subModules?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let _cachedModuleList: RegistryModule[] | null = null;
|
|
14
|
+
|
|
15
|
+
export async function listAvailableModules(
|
|
16
|
+
localPath?: string
|
|
17
|
+
): Promise<RegistryModule[]> {
|
|
18
|
+
if (localPath) {
|
|
19
|
+
const { readdir } = await import("fs/promises");
|
|
20
|
+
const entries = await readdir(localPath, { withFileTypes: true });
|
|
21
|
+
const result: RegistryModule[] = [];
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
if (!entry.isDirectory()) continue;
|
|
24
|
+
const manifestFile = path.join(localPath, entry.name, "module.json");
|
|
25
|
+
if (!(await pathExists(manifestFile))) continue;
|
|
26
|
+
try {
|
|
27
|
+
const raw = await readJson(manifestFile);
|
|
28
|
+
const parsed = ModuleManifestSchema.safeParse(raw);
|
|
29
|
+
if (!parsed.success) continue;
|
|
30
|
+
|
|
31
|
+
// Discover sub-modules from the sub-modules/ subdirectory
|
|
32
|
+
let subModules: string[] | undefined;
|
|
33
|
+
const subModulesDir = path.join(localPath, entry.name, "sub-modules");
|
|
34
|
+
if (await pathExists(subModulesDir)) {
|
|
35
|
+
const subEntries = await readdir(subModulesDir, { withFileTypes: true });
|
|
36
|
+
subModules = subEntries
|
|
37
|
+
.filter((e) => e.isDirectory())
|
|
38
|
+
.map((e) => e.name);
|
|
39
|
+
if (subModules.length === 0) subModules = undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
result.push({
|
|
43
|
+
name: parsed.data.name,
|
|
44
|
+
description: parsed.data.description,
|
|
45
|
+
...(subModules ? { subModules } : {}),
|
|
46
|
+
});
|
|
47
|
+
} catch {
|
|
48
|
+
// skip invalid modules
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (_cachedModuleList) return _cachedModuleList;
|
|
55
|
+
|
|
56
|
+
const res = await fetch(githubUrls.moduleList(), {
|
|
57
|
+
headers: { Accept: "application/vnd.github.v3+json" },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
throw new Error(`Failed to fetch module list: ${res.status} ${res.statusText}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const entries: unknown = await res.json();
|
|
65
|
+
if (!Array.isArray(entries)) {
|
|
66
|
+
throw new Error("Unexpected response from GitHub API");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const baseModules: RegistryModule[] = [];
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (
|
|
72
|
+
typeof entry === "object" &&
|
|
73
|
+
entry !== null &&
|
|
74
|
+
"type" in entry &&
|
|
75
|
+
entry.type === "dir" &&
|
|
76
|
+
"name" in entry &&
|
|
77
|
+
typeof entry.name === "string"
|
|
78
|
+
) {
|
|
79
|
+
baseModules.push({ name: entry.name });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fetch sub-modules for each module in parallel (404 = no sub-modules dir, safe to ignore)
|
|
84
|
+
const modules = await Promise.all(
|
|
85
|
+
baseModules.map(async (mod) => {
|
|
86
|
+
try {
|
|
87
|
+
const subRes = await fetch(githubUrls.subModulesList(mod.name), {
|
|
88
|
+
headers: { Accept: "application/vnd.github.v3+json" },
|
|
89
|
+
});
|
|
90
|
+
if (!subRes.ok) return mod;
|
|
91
|
+
const subEntries: unknown = await subRes.json();
|
|
92
|
+
if (!Array.isArray(subEntries)) return mod;
|
|
93
|
+
const subModules = subEntries
|
|
94
|
+
.filter(
|
|
95
|
+
(e): e is { type: string; name: string } =>
|
|
96
|
+
typeof e === "object" &&
|
|
97
|
+
e !== null &&
|
|
98
|
+
"type" in e &&
|
|
99
|
+
(e as { type: unknown }).type === "dir" &&
|
|
100
|
+
"name" in e &&
|
|
101
|
+
typeof (e as { name: unknown }).name === "string",
|
|
102
|
+
)
|
|
103
|
+
.map((e) => e.name);
|
|
104
|
+
return subModules.length > 0 ? { ...mod, subModules } : mod;
|
|
105
|
+
} catch {
|
|
106
|
+
return mod;
|
|
107
|
+
}
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
_cachedModuleList = modules;
|
|
112
|
+
return modules;
|
|
113
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a module identifier which may use slash notation for sub-modules.
|
|
3
|
+
* "attio" → { parent: "attio", child: null }
|
|
4
|
+
* "attio/quote-to-cash" → { parent: "attio", child: "quote-to-cash" }
|
|
5
|
+
*/
|
|
6
|
+
export function parseModuleId(moduleId: string): {
|
|
7
|
+
parent: string;
|
|
8
|
+
child: string | null;
|
|
9
|
+
} {
|
|
10
|
+
const slashIndex = moduleId.indexOf("/");
|
|
11
|
+
if (slashIndex === -1) return { parent: moduleId, child: null };
|
|
12
|
+
return {
|
|
13
|
+
parent: moduleId.slice(0, slashIndex),
|
|
14
|
+
child: moduleId.slice(slashIndex + 1),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Returns true when moduleId uses the sub-module slash syntax.
|
|
20
|
+
*/
|
|
21
|
+
export function isSubModuleId(moduleId: string): boolean {
|
|
22
|
+
return moduleId.includes("/");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Resolve module identifier to the path prefix used inside the modules/ directory. */
|
|
26
|
+
export function moduleRegistryPath(moduleId: string): string {
|
|
27
|
+
const { parent, child } = parseModuleId(moduleId);
|
|
28
|
+
if (child === null) return parent;
|
|
29
|
+
return `${parent}/sub-modules/${child}`;
|
|
30
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, mkdir } from "fs/promises";
|
|
3
|
+
import { outputJson } from "fs-extra";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import {
|
|
7
|
+
parseModuleId,
|
|
8
|
+
isSubModuleId,
|
|
9
|
+
fetchModuleManifest,
|
|
10
|
+
listAvailableModules,
|
|
11
|
+
} from "./index";
|
|
12
|
+
|
|
13
|
+
let tmpDir: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
tmpDir = await mkdtemp(path.join(os.tmpdir(), "impulse-registry-test-"));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// parseModuleId
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
describe("parseModuleId", () => {
|
|
28
|
+
it("returns parent=name, child=null for a plain module", () => {
|
|
29
|
+
expect(parseModuleId("attio")).toEqual({ parent: "attio", child: null });
|
|
30
|
+
expect(parseModuleId("email")).toEqual({ parent: "email", child: null });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("parses parent/child for sub-module syntax", () => {
|
|
34
|
+
expect(parseModuleId("attio/quote-to-cash")).toEqual({
|
|
35
|
+
parent: "attio",
|
|
36
|
+
child: "quote-to-cash",
|
|
37
|
+
});
|
|
38
|
+
expect(parseModuleId("attio/gocardless")).toEqual({
|
|
39
|
+
parent: "attio",
|
|
40
|
+
child: "gocardless",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("handles only the first slash (ignores nested slashes)", () => {
|
|
45
|
+
expect(parseModuleId("a/b/c")).toEqual({ parent: "a", child: "b/c" });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// isSubModuleId
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
describe("isSubModuleId", () => {
|
|
54
|
+
it("returns false for plain module names", () => {
|
|
55
|
+
expect(isSubModuleId("attio")).toBe(false);
|
|
56
|
+
expect(isSubModuleId("email")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns true for slash notation", () => {
|
|
60
|
+
expect(isSubModuleId("attio/quote-to-cash")).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// fetchModuleManifest — local mode
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
const baseManifest = {
|
|
69
|
+
name: "attio",
|
|
70
|
+
version: "1.0.0",
|
|
71
|
+
description: "Attio module",
|
|
72
|
+
files: [],
|
|
73
|
+
transforms: [],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
async function writeManifest(dir: string, manifest: Record<string, unknown>): Promise<void> {
|
|
77
|
+
await mkdir(dir, { recursive: true });
|
|
78
|
+
await outputJson(path.join(dir, "module.json"), manifest);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("fetchModuleManifest (local)", () => {
|
|
82
|
+
it("resolves a top-level module from localPath", async () => {
|
|
83
|
+
await writeManifest(path.join(tmpDir, "attio"), baseManifest);
|
|
84
|
+
const result = await fetchModuleManifest("attio", tmpDir);
|
|
85
|
+
expect(result.name).toBe("attio");
|
|
86
|
+
expect(result.version).toBe("1.0.0");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("resolves a sub-module from localPath using slash syntax", async () => {
|
|
90
|
+
const subManifest = {
|
|
91
|
+
name: "attio/quote-to-cash",
|
|
92
|
+
version: "1.0.0",
|
|
93
|
+
description: "Quote-to-cash sub-module",
|
|
94
|
+
parentModule: "attio",
|
|
95
|
+
files: [],
|
|
96
|
+
transforms: [],
|
|
97
|
+
};
|
|
98
|
+
await writeManifest(
|
|
99
|
+
path.join(tmpDir, "attio", "sub-modules", "quote-to-cash"),
|
|
100
|
+
subManifest
|
|
101
|
+
);
|
|
102
|
+
const result = await fetchModuleManifest("attio/quote-to-cash", tmpDir);
|
|
103
|
+
expect(result.name).toBe("attio/quote-to-cash");
|
|
104
|
+
expect(result.parentModule).toBe("attio");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("throws when local module is not found", async () => {
|
|
108
|
+
await expect(fetchModuleManifest("nonexistent", tmpDir)).rejects.toThrow(
|
|
109
|
+
"Local module not found"
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("throws when local sub-module is not found", async () => {
|
|
114
|
+
// parent exists but sub-module does not
|
|
115
|
+
await writeManifest(path.join(tmpDir, "attio"), baseManifest);
|
|
116
|
+
await expect(
|
|
117
|
+
fetchModuleManifest("attio/missing-sub", tmpDir)
|
|
118
|
+
).rejects.toThrow("Local module not found");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// listAvailableModules — local mode with sub-modules
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
describe("listAvailableModules (local)", () => {
|
|
127
|
+
it("lists top-level modules from local directory", async () => {
|
|
128
|
+
await writeManifest(path.join(tmpDir, "attio"), baseManifest);
|
|
129
|
+
await writeManifest(path.join(tmpDir, "email"), {
|
|
130
|
+
...baseManifest,
|
|
131
|
+
name: "email",
|
|
132
|
+
description: "Email module",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const result = await listAvailableModules(tmpDir);
|
|
136
|
+
const names = result.map((m) => m.name);
|
|
137
|
+
expect(names).toContain("attio");
|
|
138
|
+
expect(names).toContain("email");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("includes subModules array when sub-modules directory exists", async () => {
|
|
142
|
+
await writeManifest(path.join(tmpDir, "attio"), baseManifest);
|
|
143
|
+
await writeManifest(
|
|
144
|
+
path.join(tmpDir, "attio", "sub-modules", "quote-to-cash"),
|
|
145
|
+
{ ...baseManifest, name: "attio/quote-to-cash", parentModule: "attio" }
|
|
146
|
+
);
|
|
147
|
+
await writeManifest(
|
|
148
|
+
path.join(tmpDir, "attio", "sub-modules", "gocardless"),
|
|
149
|
+
{ ...baseManifest, name: "attio/gocardless", parentModule: "attio" }
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const result = await listAvailableModules(tmpDir);
|
|
153
|
+
const attio = result.find((m) => m.name === "attio");
|
|
154
|
+
expect(attio?.subModules).toBeDefined();
|
|
155
|
+
expect(attio?.subModules).toContain("quote-to-cash");
|
|
156
|
+
expect(attio?.subModules).toContain("gocardless");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("returns undefined subModules when no sub-modules directory", async () => {
|
|
160
|
+
await writeManifest(path.join(tmpDir, "email"), {
|
|
161
|
+
...baseManifest,
|
|
162
|
+
name: "email",
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const result = await listAvailableModules(tmpDir);
|
|
166
|
+
const email = result.find((m) => m.name === "email");
|
|
167
|
+
expect(email?.subModules).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("skips directories without module.json", async () => {
|
|
171
|
+
// Create a dir without module.json
|
|
172
|
+
await mkdir(path.join(tmpDir, "not-a-module"), { recursive: true });
|
|
173
|
+
await writeManifest(path.join(tmpDir, "email"), {
|
|
174
|
+
...baseManifest,
|
|
175
|
+
name: "email",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const result = await listAvailableModules(tmpDir);
|
|
179
|
+
expect(result.map((m) => m.name)).not.toContain("not-a-module");
|
|
180
|
+
});
|
|
181
|
+
});
|