@impulselab/cli 0.1.0 → 0.1.1
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.js +86 -8
- package/package.json +18 -9
- package/src/commands/add.test.ts +0 -147
- package/src/commands/add.ts +0 -335
- package/src/commands/init.ts +0 -114
- package/src/commands/list.ts +0 -79
- package/src/config/config-path.ts +0 -7
- package/src/config/has-config.ts +0 -9
- package/src/config/index.ts +0 -4
- package/src/config/read-config.ts +0 -20
- package/src/config/write-config.ts +0 -11
- package/src/config.test.ts +0 -64
- package/src/index.ts +0 -64
- package/src/installer.ts +0 -71
- package/src/registry/fetch-module-file.ts +0 -21
- package/src/registry/fetch-module-manifest.ts +0 -43
- package/src/registry/github-urls.ts +0 -13
- package/src/registry/index.ts +0 -5
- package/src/registry/list-available-modules.ts +0 -113
- package/src/registry/parse-module-id.ts +0 -30
- package/src/registry/registry.test.ts +0 -181
- package/src/schemas/impulse-config.ts +0 -21
- package/src/schemas/index.ts +0 -9
- package/src/schemas/module-dependency.ts +0 -3
- package/src/schemas/module-file.ts +0 -8
- package/src/schemas/module-manifest.ts +0 -23
- package/src/schemas/module-transform.ts +0 -15
- package/src/transforms/add-env.ts +0 -53
- package/src/transforms/add-nav-item.test.ts +0 -125
- package/src/transforms/add-nav-item.ts +0 -70
- package/src/transforms/append-export.test.ts +0 -50
- package/src/transforms/append-export.ts +0 -34
- package/src/transforms/index.ts +0 -32
- package/src/transforms/merge-schema.test.ts +0 -70
- package/src/transforms/merge-schema.ts +0 -35
- package/src/transforms/register-route.test.ts +0 -177
- package/src/transforms/register-route.ts +0 -47
- package/src/types.ts +0 -9
- package/tsconfig.json +0 -8
package/dist/index.js
CHANGED
|
@@ -71,6 +71,11 @@ var ULTIMATE_TEMPLATE_MARKERS = [
|
|
|
71
71
|
"@impulselab/ultimate-template",
|
|
72
72
|
"ultimate-template"
|
|
73
73
|
];
|
|
74
|
+
var STRUCTURAL_MARKERS = {
|
|
75
|
+
requiredDirs: ["packages/server", "packages/database"],
|
|
76
|
+
requiredFiles: ["turbo.json", "pnpm-workspace.yaml"],
|
|
77
|
+
workspaceDeps: ["@orpc/server", "drizzle-orm", "better-auth"]
|
|
78
|
+
};
|
|
74
79
|
async function detectProjectName(cwd) {
|
|
75
80
|
const pkgPath = path2.join(cwd, "package.json");
|
|
76
81
|
if (await pathExists3(pkgPath)) {
|
|
@@ -81,6 +86,18 @@ async function detectProjectName(cwd) {
|
|
|
81
86
|
}
|
|
82
87
|
return path2.basename(cwd);
|
|
83
88
|
}
|
|
89
|
+
function getDeps(pkg) {
|
|
90
|
+
const deps = /* @__PURE__ */ new Set();
|
|
91
|
+
for (const key of ["dependencies", "devDependencies"]) {
|
|
92
|
+
const d = pkg[key];
|
|
93
|
+
if (d && typeof d === "object") {
|
|
94
|
+
for (const name of Object.keys(d)) {
|
|
95
|
+
deps.add(name);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return deps;
|
|
100
|
+
}
|
|
84
101
|
async function isUltimateTemplate(cwd) {
|
|
85
102
|
const pkgPath = path2.join(cwd, "package.json");
|
|
86
103
|
if (!await pathExists3(pkgPath)) return false;
|
|
@@ -93,7 +110,40 @@ async function isUltimateTemplate(cwd) {
|
|
|
93
110
|
const keywordMatches = ULTIMATE_TEMPLATE_MARKERS.some(
|
|
94
111
|
(m) => keywords.includes(m)
|
|
95
112
|
);
|
|
96
|
-
|
|
113
|
+
if (nameMatches || keywordMatches) return true;
|
|
114
|
+
if (pkg["impulseTemplate"] === true) return true;
|
|
115
|
+
const hasRequiredFiles = (await Promise.all(
|
|
116
|
+
STRUCTURAL_MARKERS.requiredFiles.map(
|
|
117
|
+
(f) => pathExists3(path2.join(cwd, f))
|
|
118
|
+
)
|
|
119
|
+
)).every(Boolean);
|
|
120
|
+
if (!hasRequiredFiles) return false;
|
|
121
|
+
const hasRequiredDirs = (await Promise.all(
|
|
122
|
+
STRUCTURAL_MARKERS.requiredDirs.map(
|
|
123
|
+
(d) => pathExists3(path2.join(cwd, d))
|
|
124
|
+
)
|
|
125
|
+
)).every(Boolean);
|
|
126
|
+
if (!hasRequiredDirs) return false;
|
|
127
|
+
const workspaceDirs = ["packages", "apps"];
|
|
128
|
+
const foundDeps = /* @__PURE__ */ new Set();
|
|
129
|
+
for (const dir of workspaceDirs) {
|
|
130
|
+
const dirPath = path2.join(cwd, dir);
|
|
131
|
+
if (!await pathExists3(dirPath)) continue;
|
|
132
|
+
const { readdir } = await import("fs/promises");
|
|
133
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
if (!entry.isDirectory()) continue;
|
|
136
|
+
const childPkg = path2.join(dirPath, entry.name, "package.json");
|
|
137
|
+
if (!await pathExists3(childPkg)) continue;
|
|
138
|
+
const childRaw = await readJson2(childPkg);
|
|
139
|
+
if (!childRaw || typeof childRaw !== "object") continue;
|
|
140
|
+
const deps = getDeps(childRaw);
|
|
141
|
+
for (const dep of STRUCTURAL_MARKERS.workspaceDeps) {
|
|
142
|
+
if (deps.has(dep)) foundDeps.add(dep);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return foundDeps.size >= STRUCTURAL_MARKERS.workspaceDeps.length;
|
|
97
147
|
}
|
|
98
148
|
async function runInit(options) {
|
|
99
149
|
const { cwd, force } = options;
|
|
@@ -145,7 +195,7 @@ async function runInit(options) {
|
|
|
145
195
|
}
|
|
146
196
|
|
|
147
197
|
// src/commands/add.ts
|
|
148
|
-
import { execSync, execFileSync } from "child_process";
|
|
198
|
+
import { execSync as execSync2, execFileSync } from "child_process";
|
|
149
199
|
import { existsSync } from "fs";
|
|
150
200
|
import path11 from "path";
|
|
151
201
|
import * as p4 from "@clack/prompts";
|
|
@@ -201,6 +251,7 @@ var ModuleManifestSchema = z5.object({
|
|
|
201
251
|
});
|
|
202
252
|
|
|
203
253
|
// src/registry/github-urls.ts
|
|
254
|
+
import { execSync } from "child_process";
|
|
204
255
|
var GITHUB_ORG = "impulse-studio";
|
|
205
256
|
var GITHUB_REPO = "impulse-modules";
|
|
206
257
|
var GITHUB_BRANCH = "main";
|
|
@@ -210,6 +261,33 @@ var githubUrls = {
|
|
|
210
261
|
moduleList: () => `https://api.github.com/repos/${GITHUB_ORG}/${GITHUB_REPO}/contents/${MODULES_DIR}`,
|
|
211
262
|
subModulesList: (parentModule) => `https://api.github.com/repos/${GITHUB_ORG}/${GITHUB_REPO}/contents/${MODULES_DIR}/${parentModule}/sub-modules`
|
|
212
263
|
};
|
|
264
|
+
var _cachedToken;
|
|
265
|
+
function getGitHubToken() {
|
|
266
|
+
if (_cachedToken !== void 0) return _cachedToken;
|
|
267
|
+
const envToken = process.env.GITHUB_TOKEN;
|
|
268
|
+
if (envToken) {
|
|
269
|
+
_cachedToken = envToken;
|
|
270
|
+
return envToken;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const token = execSync("gh auth token", { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
274
|
+
if (token) {
|
|
275
|
+
_cachedToken = token;
|
|
276
|
+
return token;
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
}
|
|
280
|
+
_cachedToken = null;
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
function getGitHubHeaders(extra) {
|
|
284
|
+
const headers = { ...extra };
|
|
285
|
+
const token = getGitHubToken();
|
|
286
|
+
if (token) {
|
|
287
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
288
|
+
}
|
|
289
|
+
return headers;
|
|
290
|
+
}
|
|
213
291
|
|
|
214
292
|
// src/registry/parse-module-id.ts
|
|
215
293
|
function parseModuleId(moduleId) {
|
|
@@ -243,7 +321,7 @@ async function fetchModuleManifest(moduleId, localPath) {
|
|
|
243
321
|
return parsed2.data;
|
|
244
322
|
}
|
|
245
323
|
const url = githubUrls.rawFile(registryPath, "module.json");
|
|
246
|
-
const res = await fetch(url);
|
|
324
|
+
const res = await fetch(url, { headers: getGitHubHeaders() });
|
|
247
325
|
if (!res.ok) {
|
|
248
326
|
if (res.status === 404) {
|
|
249
327
|
throw new Error(`Module not found in registry: ${moduleId}`);
|
|
@@ -295,7 +373,7 @@ async function listAvailableModules(localPath) {
|
|
|
295
373
|
}
|
|
296
374
|
if (_cachedModuleList) return _cachedModuleList;
|
|
297
375
|
const res = await fetch(githubUrls.moduleList(), {
|
|
298
|
-
headers: { Accept: "application/vnd.github.v3+json" }
|
|
376
|
+
headers: getGitHubHeaders({ Accept: "application/vnd.github.v3+json" })
|
|
299
377
|
});
|
|
300
378
|
if (!res.ok) {
|
|
301
379
|
throw new Error(`Failed to fetch module list: ${res.status} ${res.statusText}`);
|
|
@@ -314,7 +392,7 @@ async function listAvailableModules(localPath) {
|
|
|
314
392
|
baseModules.map(async (mod) => {
|
|
315
393
|
try {
|
|
316
394
|
const subRes = await fetch(githubUrls.subModulesList(mod.name), {
|
|
317
|
-
headers: { Accept: "application/vnd.github.v3+json" }
|
|
395
|
+
headers: getGitHubHeaders({ Accept: "application/vnd.github.v3+json" })
|
|
318
396
|
});
|
|
319
397
|
if (!subRes.ok) return mod;
|
|
320
398
|
const subEntries = await subRes.json();
|
|
@@ -341,7 +419,7 @@ async function fetchModuleFile(moduleName, fileSrc, localPath) {
|
|
|
341
419
|
return readFile6(file, "utf-8");
|
|
342
420
|
}
|
|
343
421
|
const url = githubUrls.rawFile(moduleName, fileSrc);
|
|
344
|
-
const res = await fetch(url);
|
|
422
|
+
const res = await fetch(url, { headers: getGitHubHeaders() });
|
|
345
423
|
if (!res.ok) {
|
|
346
424
|
throw new Error(`Failed to fetch file ${fileSrc} for module ${moduleName}: ${res.status}`);
|
|
347
425
|
}
|
|
@@ -767,7 +845,7 @@ Available: ${parentManifest.subModules.join(", ")}`
|
|
|
767
845
|
p4.log.step(`Running post-install hooks for ${name}...`);
|
|
768
846
|
for (const hook of hooks) {
|
|
769
847
|
p4.log.message(` $ ${hook}`);
|
|
770
|
-
|
|
848
|
+
execSync2(hook, { cwd, stdio: "inherit" });
|
|
771
849
|
}
|
|
772
850
|
}
|
|
773
851
|
}
|
|
@@ -778,7 +856,7 @@ Available: ${parentManifest.subModules.join(", ")}`
|
|
|
778
856
|
p4.log.step(`Running post-install hooks for ${targetId}...`);
|
|
779
857
|
for (const hook of targetManifest.postInstall) {
|
|
780
858
|
p4.log.message(` $ ${hook}`);
|
|
781
|
-
|
|
859
|
+
execSync2(hook, { cwd, stdio: "inherit" });
|
|
782
860
|
}
|
|
783
861
|
}
|
|
784
862
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@impulselab/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "ImpulseLab CLI — install and manage modules for your projects",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -8,13 +8,16 @@
|
|
|
8
8
|
"bin": {
|
|
9
9
|
"impulse": "dist/index.js"
|
|
10
10
|
},
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/impulse-studio/impulse-modules",
|
|
20
|
+
"directory": "cli"
|
|
18
21
|
},
|
|
19
22
|
"devDependencies": {
|
|
20
23
|
"@types/fs-extra": "^11.0.4",
|
|
@@ -29,5 +32,11 @@
|
|
|
29
32
|
"commander": "^14.0.3",
|
|
30
33
|
"fs-extra": "^11.3.4",
|
|
31
34
|
"zod": "^4.3.6"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup src/index.ts --format esm --target es2022 --dts",
|
|
38
|
+
"dev": "tsup src/index.ts --format esm --target es2022 --watch",
|
|
39
|
+
"lint": "oxlint .",
|
|
40
|
+
"test": "vitest run"
|
|
32
41
|
}
|
|
33
|
-
}
|
|
42
|
+
}
|
package/src/commands/add.test.ts
DELETED
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for sub-module resolution logic in the add command.
|
|
3
|
-
* Uses a local modules fixture directory to avoid network calls.
|
|
4
|
-
*/
|
|
5
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
6
|
-
import { mkdtemp, rm, mkdir } from "fs/promises";
|
|
7
|
-
import { outputJson } from "fs-extra";
|
|
8
|
-
import path from "path";
|
|
9
|
-
import os from "os";
|
|
10
|
-
import { parseModuleId } from "../registry/index";
|
|
11
|
-
|
|
12
|
-
// Re-export parseModuleId through the test as a sanity check
|
|
13
|
-
describe("parseModuleId integration", () => {
|
|
14
|
-
it("correctly identifies sub-module ids", () => {
|
|
15
|
-
const { parent, child } = parseModuleId("attio/gocardless");
|
|
16
|
-
expect(parent).toBe("attio");
|
|
17
|
-
expect(child).toBe("gocardless");
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("correctly identifies top-level ids", () => {
|
|
21
|
-
const { parent, child } = parseModuleId("attio");
|
|
22
|
-
expect(parent).toBe("attio");
|
|
23
|
-
expect(child).toBeNull();
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// resolveWithParent behaviour — validated via fetchModuleManifest + local fixtures
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
let tmpDir: string;
|
|
32
|
-
let localModules: string;
|
|
33
|
-
|
|
34
|
-
beforeEach(async () => {
|
|
35
|
-
tmpDir = await mkdtemp(path.join(os.tmpdir(), "impulse-add-test-"));
|
|
36
|
-
localModules = path.join(tmpDir, "modules");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
afterEach(async () => {
|
|
40
|
-
await rm(tmpDir, { recursive: true, force: true });
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
async function writeManifest(dir: string, manifest: Record<string, unknown>): Promise<void> {
|
|
44
|
-
await mkdir(dir, { recursive: true });
|
|
45
|
-
await outputJson(path.join(dir, "module.json"), manifest);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
describe("sub-module dependency resolution (via fetchModuleManifest)", () => {
|
|
49
|
-
it("resolves parent manifest when installing a sub-module", async () => {
|
|
50
|
-
const { fetchModuleManifest } = await import("../registry/index");
|
|
51
|
-
|
|
52
|
-
await writeManifest(path.join(localModules, "attio"), {
|
|
53
|
-
name: "attio",
|
|
54
|
-
version: "1.0.0",
|
|
55
|
-
description: "Attio",
|
|
56
|
-
subModules: ["quote-to-cash", "gocardless"],
|
|
57
|
-
files: [],
|
|
58
|
-
transforms: [],
|
|
59
|
-
});
|
|
60
|
-
await writeManifest(
|
|
61
|
-
path.join(localModules, "attio", "sub-modules", "quote-to-cash"),
|
|
62
|
-
{
|
|
63
|
-
name: "attio/quote-to-cash",
|
|
64
|
-
version: "1.0.0",
|
|
65
|
-
description: "Quote-to-cash",
|
|
66
|
-
parentModule: "attio",
|
|
67
|
-
files: [],
|
|
68
|
-
transforms: [],
|
|
69
|
-
}
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
const parentManifest = await fetchModuleManifest("attio", localModules);
|
|
73
|
-
expect(parentManifest.name).toBe("attio");
|
|
74
|
-
expect(parentManifest.subModules).toContain("quote-to-cash");
|
|
75
|
-
|
|
76
|
-
const subManifest = await fetchModuleManifest("attio/quote-to-cash", localModules);
|
|
77
|
-
expect(subManifest.parentModule).toBe("attio");
|
|
78
|
-
expect(subManifest.name).toBe("attio/quote-to-cash");
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("installs multiple --with sub-modules (validates all IDs resolvable)", async () => {
|
|
82
|
-
const { fetchModuleManifest } = await import("../registry/index");
|
|
83
|
-
|
|
84
|
-
await writeManifest(path.join(localModules, "attio"), {
|
|
85
|
-
name: "attio",
|
|
86
|
-
version: "1.0.0",
|
|
87
|
-
description: "Attio",
|
|
88
|
-
subModules: ["quote-to-cash", "gocardless", "pennylane"],
|
|
89
|
-
files: [],
|
|
90
|
-
transforms: [],
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
const subNames = ["quote-to-cash", "gocardless", "pennylane"];
|
|
94
|
-
for (const sub of subNames) {
|
|
95
|
-
await writeManifest(
|
|
96
|
-
path.join(localModules, "attio", "sub-modules", sub),
|
|
97
|
-
{
|
|
98
|
-
name: `attio/${sub}`,
|
|
99
|
-
version: "1.0.0",
|
|
100
|
-
description: sub,
|
|
101
|
-
parentModule: "attio",
|
|
102
|
-
files: [],
|
|
103
|
-
transforms: [],
|
|
104
|
-
}
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Verify each sub-module can be fetched via slash syntax
|
|
109
|
-
for (const sub of subNames) {
|
|
110
|
-
const manifest = await fetchModuleManifest(`attio/${sub}`, localModules);
|
|
111
|
-
expect(manifest.parentModule).toBe("attio");
|
|
112
|
-
}
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("auto-dependency: sub-module declares parent in moduleDependencies", async () => {
|
|
116
|
-
const { fetchModuleManifest } = await import("../registry/index");
|
|
117
|
-
|
|
118
|
-
await writeManifest(path.join(localModules, "attio"), {
|
|
119
|
-
name: "attio",
|
|
120
|
-
version: "1.0.0",
|
|
121
|
-
description: "Attio",
|
|
122
|
-
subModules: ["gocardless"],
|
|
123
|
-
files: [],
|
|
124
|
-
transforms: [],
|
|
125
|
-
});
|
|
126
|
-
await writeManifest(
|
|
127
|
-
path.join(localModules, "attio", "sub-modules", "gocardless"),
|
|
128
|
-
{
|
|
129
|
-
name: "attio/gocardless",
|
|
130
|
-
version: "1.0.0",
|
|
131
|
-
description: "GoCardless",
|
|
132
|
-
parentModule: "attio",
|
|
133
|
-
moduleDependencies: [],
|
|
134
|
-
files: [],
|
|
135
|
-
transforms: [],
|
|
136
|
-
}
|
|
137
|
-
);
|
|
138
|
-
|
|
139
|
-
// The parent manifest should be valid
|
|
140
|
-
const parent = await fetchModuleManifest("attio", localModules);
|
|
141
|
-
expect(parent.subModules).toContain("gocardless");
|
|
142
|
-
|
|
143
|
-
// The sub-module should have parentModule set
|
|
144
|
-
const sub = await fetchModuleManifest("attio/gocardless", localModules);
|
|
145
|
-
expect(sub.parentModule).toBe("attio");
|
|
146
|
-
});
|
|
147
|
-
});
|
package/src/commands/add.ts
DELETED
|
@@ -1,335 +0,0 @@
|
|
|
1
|
-
import { execSync, execFileSync } from "child_process";
|
|
2
|
-
import { existsSync } from "fs";
|
|
3
|
-
import path from "path";
|
|
4
|
-
import * as p from "@clack/prompts";
|
|
5
|
-
import { readConfig, writeConfig } from "../config/index";
|
|
6
|
-
import { fetchModuleManifest, parseModuleId } from "../registry/index";
|
|
7
|
-
import { installFiles } from "../installer";
|
|
8
|
-
import { runTransform } from "../transforms/index";
|
|
9
|
-
import type { ModuleManifest } from "../types";
|
|
10
|
-
import type { ImpulseConfig } from "../schemas/impulse-config";
|
|
11
|
-
|
|
12
|
-
async function resolveModuleDeps(
|
|
13
|
-
moduleId: string,
|
|
14
|
-
localPath: string | undefined,
|
|
15
|
-
resolved: Set<string>,
|
|
16
|
-
orderedModules: string[]
|
|
17
|
-
): Promise<void> {
|
|
18
|
-
if (resolved.has(moduleId)) return;
|
|
19
|
-
resolved.add(moduleId);
|
|
20
|
-
|
|
21
|
-
const manifest = await fetchModuleManifest(moduleId, localPath);
|
|
22
|
-
for (const dep of manifest.moduleDependencies) {
|
|
23
|
-
await resolveModuleDeps(dep, localPath, resolved, orderedModules);
|
|
24
|
-
}
|
|
25
|
-
orderedModules.push(moduleId);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* For a sub-module install (`parent/child`), ensure the parent module itself
|
|
30
|
-
* is resolved first if it is not already installed.
|
|
31
|
-
*/
|
|
32
|
-
async function resolveWithParent(
|
|
33
|
-
moduleId: string,
|
|
34
|
-
localPath: string | undefined,
|
|
35
|
-
installedNames: Set<string>,
|
|
36
|
-
resolved: Set<string>,
|
|
37
|
-
orderedModules: string[]
|
|
38
|
-
): Promise<void> {
|
|
39
|
-
const { parent, child } = parseModuleId(moduleId);
|
|
40
|
-
if (child !== null && !installedNames.has(parent)) {
|
|
41
|
-
await resolveModuleDeps(parent, localPath, resolved, orderedModules);
|
|
42
|
-
}
|
|
43
|
-
await resolveModuleDeps(moduleId, localPath, resolved, orderedModules);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function detectPackageManager(cwd: string): "pnpm" | "npm" | "yarn" {
|
|
47
|
-
if (existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
48
|
-
if (existsSync(path.join(cwd, "yarn.lock"))) return "yarn";
|
|
49
|
-
return "npm";
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function installNpmDeps(
|
|
53
|
-
deps: string[],
|
|
54
|
-
cwd: string,
|
|
55
|
-
dryRun: boolean
|
|
56
|
-
): void {
|
|
57
|
-
if (deps.length === 0) return;
|
|
58
|
-
const pm = detectPackageManager(cwd);
|
|
59
|
-
const args = ["add", ...deps];
|
|
60
|
-
|
|
61
|
-
if (dryRun) {
|
|
62
|
-
p.log.info(`[dry-run] Would run: ${pm} ${args.join(" ")}`);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
p.log.step(`Installing dependencies: ${deps.join(", ")}`);
|
|
67
|
-
execFileSync(pm, args, { cwd, stdio: "inherit" });
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async function installModule(
|
|
71
|
-
moduleId: string,
|
|
72
|
-
manifest: ModuleManifest,
|
|
73
|
-
cwd: string,
|
|
74
|
-
dryRun: boolean,
|
|
75
|
-
localPath?: string
|
|
76
|
-
): Promise<void> {
|
|
77
|
-
p.log.step(`Installing ${moduleId}@${manifest.version}...`);
|
|
78
|
-
|
|
79
|
-
if (manifest.files.length > 0) {
|
|
80
|
-
const installed = await installFiles({
|
|
81
|
-
moduleName: moduleId,
|
|
82
|
-
files: manifest.files,
|
|
83
|
-
cwd,
|
|
84
|
-
dryRun,
|
|
85
|
-
localPath,
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
for (const f of installed) {
|
|
89
|
-
const icon =
|
|
90
|
-
f.action === "created" || f.action === "would-create"
|
|
91
|
-
? "+"
|
|
92
|
-
: f.action === "overwritten" || f.action === "would-overwrite"
|
|
93
|
-
? "~"
|
|
94
|
-
: "=";
|
|
95
|
-
p.log.message(` ${icon} ${f.dest}`);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
for (const transform of manifest.transforms) {
|
|
100
|
-
p.log.step(` transform: ${transform.type} → ${transform.target}`);
|
|
101
|
-
await runTransform(transform, cwd, dryRun);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function recordModule(
|
|
106
|
-
config: ImpulseConfig,
|
|
107
|
-
moduleId: string,
|
|
108
|
-
manifest: ModuleManifest,
|
|
109
|
-
now: string
|
|
110
|
-
): void {
|
|
111
|
-
const existing = config.installedModules.findIndex((m) => m.name === moduleId);
|
|
112
|
-
const record = {
|
|
113
|
-
name: moduleId,
|
|
114
|
-
version: manifest.version,
|
|
115
|
-
installedAt: now,
|
|
116
|
-
files: manifest.files.map((f) => f.dest),
|
|
117
|
-
};
|
|
118
|
-
if (existing >= 0) {
|
|
119
|
-
config.installedModules[existing] = record;
|
|
120
|
-
} else {
|
|
121
|
-
config.installedModules.push(record);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export async function runAdd(options: {
|
|
126
|
-
moduleName: string;
|
|
127
|
-
cwd: string;
|
|
128
|
-
dryRun: boolean;
|
|
129
|
-
localPath?: string;
|
|
130
|
-
withSubModules?: string[];
|
|
131
|
-
}): Promise<void> {
|
|
132
|
-
const { moduleName, cwd, dryRun, localPath, withSubModules = [] } = options;
|
|
133
|
-
|
|
134
|
-
// Build full list: main target + --with sub-modules
|
|
135
|
-
const withIds = withSubModules.map((sub) => `${moduleName}/${sub}`);
|
|
136
|
-
const allTargets = [moduleName, ...withIds];
|
|
137
|
-
|
|
138
|
-
p.intro(`impulse add ${allTargets.join(", ")}${dryRun ? " [dry-run]" : ""}`);
|
|
139
|
-
|
|
140
|
-
const config = await readConfig(cwd);
|
|
141
|
-
if (!config) {
|
|
142
|
-
p.cancel("No .impulse.json found. Run `impulse init` first.");
|
|
143
|
-
process.exit(1);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const installedNames = new Set(config.installedModules.map((m) => m.name));
|
|
147
|
-
|
|
148
|
-
// Check already-installed only for a simple (non-sub-module, no --with) install
|
|
149
|
-
const { child: mainChild } = parseModuleId(moduleName);
|
|
150
|
-
if (mainChild === null && withSubModules.length === 0 && installedNames.has(moduleName)) {
|
|
151
|
-
const existing = config.installedModules.find((m) => m.name === moduleName);
|
|
152
|
-
p.log.warn(`Module "${moduleName}" is already installed (v${existing?.version ?? "?"}).`);
|
|
153
|
-
const reinstall = await p.confirm({
|
|
154
|
-
message: "Reinstall?",
|
|
155
|
-
initialValue: false,
|
|
156
|
-
});
|
|
157
|
-
if (p.isCancel(reinstall) || !reinstall) {
|
|
158
|
-
p.outro("Cancelled.");
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Validate --with sub-module names against the parent manifest's declared sub-modules
|
|
164
|
-
if (withSubModules.length > 0) {
|
|
165
|
-
const parentManifest = await fetchModuleManifest(moduleName, localPath).catch(() => null);
|
|
166
|
-
if (parentManifest) {
|
|
167
|
-
if (parentManifest.subModules.length === 0) {
|
|
168
|
-
p.cancel(`"${moduleName}" has no declared sub-modules.`);
|
|
169
|
-
process.exit(1);
|
|
170
|
-
}
|
|
171
|
-
const invalid = withSubModules.filter((sub) => !parentManifest.subModules.includes(sub));
|
|
172
|
-
if (invalid.length > 0) {
|
|
173
|
-
p.cancel(
|
|
174
|
-
`Unknown sub-module(s) for "${moduleName}": ${invalid.join(", ")}.\nAvailable: ${parentManifest.subModules.join(", ")}`
|
|
175
|
-
);
|
|
176
|
-
process.exit(1);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Resolve dependency order for all targets
|
|
182
|
-
const s = p.spinner();
|
|
183
|
-
s.start("Resolving dependencies...");
|
|
184
|
-
|
|
185
|
-
const resolved = new Set<string>();
|
|
186
|
-
const orderedModules: string[] = [];
|
|
187
|
-
|
|
188
|
-
try {
|
|
189
|
-
for (const target of allTargets) {
|
|
190
|
-
await resolveWithParent(target, localPath, installedNames, resolved, orderedModules);
|
|
191
|
-
}
|
|
192
|
-
} catch (err) {
|
|
193
|
-
s.stop("Dependency resolution failed.");
|
|
194
|
-
p.cancel(err instanceof Error ? err.message : String(err));
|
|
195
|
-
process.exit(1);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
s.stop(`Resolved: ${orderedModules.join(" → ")}`);
|
|
199
|
-
|
|
200
|
-
// Fetch all manifests
|
|
201
|
-
const manifests = new Map<string, ModuleManifest>();
|
|
202
|
-
for (const id of orderedModules) {
|
|
203
|
-
manifests.set(id, await fetchModuleManifest(id, localPath));
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Collect all npm deps
|
|
207
|
-
const allDeps = new Set<string>();
|
|
208
|
-
for (const manifest of manifests.values()) {
|
|
209
|
-
for (const dep of manifest.dependencies) {
|
|
210
|
-
allDeps.add(dep);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Show summary
|
|
215
|
-
p.log.message("\nSummary of changes:");
|
|
216
|
-
for (const [id, manifest] of manifests) {
|
|
217
|
-
p.log.message(`\n Module: ${id}@${manifest.version}`);
|
|
218
|
-
for (const file of manifest.files) {
|
|
219
|
-
p.log.message(` + ${file.dest}`);
|
|
220
|
-
}
|
|
221
|
-
for (const transform of manifest.transforms) {
|
|
222
|
-
p.log.message(` ~ ${transform.type} → ${transform.target}`);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Collect env vars documented across all modules (informational display)
|
|
227
|
-
const allEnvVars = new Set<string>();
|
|
228
|
-
for (const manifest of manifests.values()) {
|
|
229
|
-
for (const envVar of manifest.envVars) {
|
|
230
|
-
allEnvVars.add(envVar);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (allDeps.size > 0) {
|
|
235
|
-
p.log.message(`\n npm deps: ${[...allDeps].join(", ")}`);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
if (allEnvVars.size > 0) {
|
|
239
|
-
p.log.message(`\n env vars required: ${[...allEnvVars].join(", ")}`);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (!dryRun) {
|
|
243
|
-
const confirm = await p.confirm({
|
|
244
|
-
message: "Proceed?",
|
|
245
|
-
initialValue: true,
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
if (p.isCancel(confirm) || !confirm) {
|
|
249
|
-
p.outro("Cancelled.");
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Split ordered modules into auto-dependencies vs primary targets
|
|
255
|
-
const primaryTargetSet = new Set(allTargets);
|
|
256
|
-
const depModules = orderedModules.filter((id) => !primaryTargetSet.has(id) && !installedNames.has(id));
|
|
257
|
-
const targetModules = orderedModules.filter((id) => primaryTargetSet.has(id));
|
|
258
|
-
|
|
259
|
-
// Collect dep postInstall hooks
|
|
260
|
-
const depPostInstallHooks: Array<{ name: string; hooks: string[] }> = [];
|
|
261
|
-
|
|
262
|
-
if (depModules.length > 0) {
|
|
263
|
-
p.log.step(`Installing module dependencies: ${depModules.join(", ")}`);
|
|
264
|
-
for (const dep of depModules) {
|
|
265
|
-
const depManifest = manifests.get(dep);
|
|
266
|
-
if (!depManifest) continue;
|
|
267
|
-
await installModule(dep, depManifest, cwd, dryRun, localPath);
|
|
268
|
-
if (depManifest.postInstall && depManifest.postInstall.length > 0) {
|
|
269
|
-
depPostInstallHooks.push({ name: dep, hooks: depManifest.postInstall });
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Install primary targets in resolved order
|
|
275
|
-
for (const targetId of targetModules) {
|
|
276
|
-
const targetManifest = manifests.get(targetId);
|
|
277
|
-
if (!targetManifest) continue;
|
|
278
|
-
await installModule(targetId, targetManifest, cwd, dryRun, localPath);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Install npm deps so all postInstall hooks have their packages available
|
|
282
|
-
installNpmDeps([...allDeps], cwd, dryRun);
|
|
283
|
-
|
|
284
|
-
// Run post-install hooks for dependency modules
|
|
285
|
-
if (!dryRun) {
|
|
286
|
-
for (const { name, hooks } of depPostInstallHooks) {
|
|
287
|
-
p.log.step(`Running post-install hooks for ${name}...`);
|
|
288
|
-
for (const hook of hooks) {
|
|
289
|
-
p.log.message(` $ ${hook}`);
|
|
290
|
-
execSync(hook, { cwd, stdio: "inherit" });
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Run post-install hooks for primary targets
|
|
296
|
-
if (!dryRun) {
|
|
297
|
-
for (const targetId of targetModules) {
|
|
298
|
-
const targetManifest = manifests.get(targetId);
|
|
299
|
-
if (!targetManifest?.postInstall?.length) continue;
|
|
300
|
-
p.log.step(`Running post-install hooks for ${targetId}...`);
|
|
301
|
-
for (const hook of targetManifest.postInstall) {
|
|
302
|
-
p.log.message(` $ ${hook}`);
|
|
303
|
-
execSync(hook, { cwd, stdio: "inherit" });
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Update .impulse.json
|
|
309
|
-
if (!dryRun) {
|
|
310
|
-
const now = new Date().toISOString();
|
|
311
|
-
|
|
312
|
-
for (const dep of depModules) {
|
|
313
|
-
const depManifest = manifests.get(dep);
|
|
314
|
-
if (depManifest) recordModule(config, dep, depManifest, now);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
for (const targetId of targetModules) {
|
|
318
|
-
const targetManifest = manifests.get(targetId);
|
|
319
|
-
if (targetManifest) recordModule(config, targetId, targetManifest, now);
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
await writeConfig(config, cwd);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
const label =
|
|
326
|
-
allTargets.length === 1
|
|
327
|
-
? `"${allTargets[0]}"`
|
|
328
|
-
: allTargets.map((t) => `"${t}"`).join(", ");
|
|
329
|
-
|
|
330
|
-
p.outro(
|
|
331
|
-
dryRun
|
|
332
|
-
? "Dry run complete — no files were modified."
|
|
333
|
-
: `Module(s) ${label} installed successfully!`
|
|
334
|
-
);
|
|
335
|
-
}
|