@impulselab/cli 0.1.3 → 0.1.5

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.
Files changed (2) hide show
  1. package/dist/index.js +285 -109
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -211,7 +211,11 @@ import { z as z5 } from "zod";
211
211
  import { z as z2 } from "zod";
212
212
  var ModuleFileSchema = z2.object({
213
213
  src: z2.string(),
214
- dest: z2.string()
214
+ dest: z2.string(),
215
+ /** If set, this file is only installed when the named module is already installed. */
216
+ when: z2.object({
217
+ moduleInstalled: z2.string()
218
+ }).optional()
215
219
  });
216
220
 
217
221
  // src/schemas/module-dependency.ts
@@ -242,24 +246,40 @@ var ModuleManifestSchema = z5.object({
242
246
  /** Names of sub-modules available under this parent module (e.g. ["quote-to-cash", "gocardless"]). */
243
247
  subModules: z5.array(z5.string()).default([]),
244
248
  dependencies: z5.array(ModuleDependencySchema).default([]),
245
- moduleDependencies: z5.array(z5.string()).default([]),
249
+ moduleDependencies: z5.array(ModuleDependencySchema).default([]),
250
+ /** Optional module dependencies that enhance this module but are not required. */
251
+ optionalModuleDependencies: z5.array(ModuleDependencySchema).default([]),
246
252
  files: z5.array(ModuleFileSchema).default([]),
247
253
  transforms: z5.array(ModuleTransformSchema).default([]),
248
254
  /** Documentation metadata listing env vars this module requires (displayed in install summary). */
249
255
  envVars: z5.array(z5.string()).default([]),
250
- postInstall: z5.array(z5.string()).optional()
256
+ postInstall: z5.array(z5.string()).optional(),
257
+ /** Logical category for grouping in `impulse list`. */
258
+ category: z5.enum(["core", "feature", "integration", "dx"]).optional(),
259
+ /** Module names that cannot be installed alongside this module. */
260
+ incompatibleWith: z5.array(z5.string()).default([]),
261
+ /** Capability tokens this module provides (for future dependency resolution). */
262
+ provides: z5.array(z5.string()).default([]),
263
+ /** Which package in a monorepo this module targets. */
264
+ targetPackage: z5.enum(["database", "server", "web", "root"]).default("root")
251
265
  });
252
266
 
267
+ // src/registry/constants.ts
268
+ var registryConstants = {
269
+ githubOrg: "impulse-studio",
270
+ githubRepo: "impulse-modules",
271
+ githubBranch: "main",
272
+ modulesDir: "modules",
273
+ subModulesDirName: "sub-modules",
274
+ manifestFileName: "module.json"
275
+ };
276
+
253
277
  // src/registry/github-urls.ts
254
278
  import { execSync } from "child_process";
255
- var GITHUB_ORG = "impulse-studio";
256
- var GITHUB_REPO = "impulse-modules";
257
- var GITHUB_BRANCH = "main";
258
- var MODULES_DIR = "modules";
259
279
  var githubUrls = {
260
- rawFile: (registryPath, file) => `https://raw.githubusercontent.com/${GITHUB_ORG}/${GITHUB_REPO}/${GITHUB_BRANCH}/${MODULES_DIR}/${registryPath}/${file}`,
261
- moduleList: () => `https://api.github.com/repos/${GITHUB_ORG}/${GITHUB_REPO}/contents/${MODULES_DIR}`,
262
- subModulesList: (parentModule) => `https://api.github.com/repos/${GITHUB_ORG}/${GITHUB_REPO}/contents/${MODULES_DIR}/${parentModule}/sub-modules`
280
+ rawFile: (registryPath, file) => `https://raw.githubusercontent.com/${registryConstants.githubOrg}/${registryConstants.githubRepo}/${registryConstants.githubBranch}/${registryConstants.modulesDir}/${registryPath}/${file}`,
281
+ moduleList: () => `https://api.github.com/repos/${registryConstants.githubOrg}/${registryConstants.githubRepo}/contents/${registryConstants.modulesDir}`,
282
+ subModulesList: (parentModule) => `https://api.github.com/repos/${registryConstants.githubOrg}/${registryConstants.githubRepo}/contents/${registryConstants.modulesDir}/${parentModule}/${registryConstants.subModulesDirName}`
263
283
  };
264
284
  var _cachedToken;
265
285
  function getGitHubToken() {
@@ -301,7 +321,7 @@ function parseModuleId(moduleId) {
301
321
  function moduleRegistryPath(moduleId) {
302
322
  const { parent, child } = parseModuleId(moduleId);
303
323
  if (child === null) return parent;
304
- return `${parent}/sub-modules/${child}`;
324
+ return `${parent}/${registryConstants.subModulesDirName}/${child}`;
305
325
  }
306
326
 
307
327
  // src/registry/fetch-module-manifest.ts
@@ -309,7 +329,7 @@ var { readJson: readJson3, pathExists: pathExists4 } = fsExtra5;
309
329
  async function fetchModuleManifest(moduleId, localPath) {
310
330
  const registryPath = moduleRegistryPath(moduleId);
311
331
  if (localPath) {
312
- const file = path3.join(localPath, registryPath, "module.json");
332
+ const file = path3.join(localPath, registryPath, registryConstants.manifestFileName);
313
333
  if (!await pathExists4(file)) {
314
334
  throw new Error(`Local module not found: ${file}`);
315
335
  }
@@ -320,7 +340,7 @@ async function fetchModuleManifest(moduleId, localPath) {
320
340
  }
321
341
  return parsed2.data;
322
342
  }
323
- const url = githubUrls.rawFile(registryPath, "module.json");
343
+ const url = githubUrls.rawFile(registryPath, registryConstants.manifestFileName);
324
344
  const res = await fetch(url, { headers: getGitHubHeaders() });
325
345
  if (!res.ok) {
326
346
  if (res.status === 404) {
@@ -348,14 +368,22 @@ async function listAvailableModules(localPath) {
348
368
  const result = [];
349
369
  for (const entry of entries2) {
350
370
  if (!entry.isDirectory()) continue;
351
- const manifestFile = path4.join(localPath, entry.name, "module.json");
371
+ const manifestFile = path4.join(
372
+ localPath,
373
+ entry.name,
374
+ registryConstants.manifestFileName
375
+ );
352
376
  if (!await pathExists5(manifestFile)) continue;
353
377
  try {
354
378
  const raw = await readJson4(manifestFile);
355
379
  const parsed = ModuleManifestSchema.safeParse(raw);
356
380
  if (!parsed.success) continue;
357
381
  let subModules;
358
- const subModulesDir = path4.join(localPath, entry.name, "sub-modules");
382
+ const subModulesDir = path4.join(
383
+ localPath,
384
+ entry.name,
385
+ registryConstants.subModulesDirName
386
+ );
359
387
  if (await pathExists5(subModulesDir)) {
360
388
  const subEntries = await readdir(subModulesDir, { withFileTypes: true });
361
389
  subModules = subEntries.filter((e) => e.isDirectory()).map((e) => e.name);
@@ -364,7 +392,8 @@ async function listAvailableModules(localPath) {
364
392
  result.push({
365
393
  name: parsed.data.name,
366
394
  description: parsed.data.description,
367
- ...subModules ? { subModules } : {}
395
+ ...subModules ? { subModules } : {},
396
+ ...parsed.data.category ? { category: parsed.data.category } : {}
368
397
  });
369
398
  } catch {
370
399
  }
@@ -413,10 +442,10 @@ async function listAvailableModules(localPath) {
413
442
  // src/registry/fetch-module-file.ts
414
443
  async function fetchModuleFile(moduleName, fileSrc, localPath) {
415
444
  if (localPath) {
416
- const { readFile: readFile6 } = await import("fs/promises");
445
+ const { readFile: readFile7 } = await import("fs/promises");
417
446
  const { join } = await import("path");
418
447
  const file = join(localPath, moduleName, fileSrc);
419
- return readFile6(file, "utf-8");
448
+ return readFile7(file, "utf-8");
420
449
  }
421
450
  const url = githubUrls.rawFile(moduleName, fileSrc);
422
451
  const res = await fetch(url, { headers: getGitHubHeaders() });
@@ -428,28 +457,35 @@ async function fetchModuleFile(moduleName, fileSrc, localPath) {
428
457
 
429
458
  // src/installer.ts
430
459
  import fsExtra7 from "fs-extra";
460
+ import { readFile } from "fs/promises";
431
461
  import path5 from "path";
432
462
  import * as p2 from "@clack/prompts";
433
463
  var { outputFile, pathExists: pathExists6 } = fsExtra7;
434
464
  async function installFiles(options) {
435
- const { moduleName, files, cwd, dryRun, localPath } = options;
465
+ const { moduleName, files, cwd, dryRun, localPath, installedModules = /* @__PURE__ */ new Set() } = options;
436
466
  const results = [];
437
467
  for (const file of files) {
468
+ if (file.when?.moduleInstalled !== void 0 && !installedModules.has(file.when.moduleInstalled)) {
469
+ results.push({
470
+ dest: file.dest,
471
+ action: "conditional-skip",
472
+ reason: `requires ${file.when.moduleInstalled}`
473
+ });
474
+ continue;
475
+ }
438
476
  const destAbs = path5.join(cwd, file.dest);
439
477
  const exists = await pathExists6(destAbs);
440
478
  if (exists) {
479
+ if (dryRun) {
480
+ results.push({ dest: file.dest, action: "would-overwrite" });
481
+ continue;
482
+ }
441
483
  const content = await fetchModuleFile(moduleName, file.src, localPath);
442
- const existing = await import("fs/promises").then(
443
- (fs) => fs.readFile(destAbs, "utf-8")
444
- );
484
+ const existing = await readFile(destAbs, "utf-8");
445
485
  if (existing === content) {
446
486
  results.push({ dest: file.dest, action: "skipped" });
447
487
  continue;
448
488
  }
449
- if (dryRun) {
450
- results.push({ dest: file.dest, action: "would-overwrite" });
451
- continue;
452
- }
453
489
  const answer = await p2.confirm({
454
490
  message: `File already exists: ${file.dest} \u2014 overwrite?`,
455
491
  initialValue: false
@@ -476,7 +512,7 @@ async function installFiles(options) {
476
512
  // src/transforms/append-export.ts
477
513
  import fsExtra8 from "fs-extra";
478
514
  import path6 from "path";
479
- var { readFile, outputFile: outputFile2, pathExists: pathExists7 } = fsExtra8;
515
+ var { readFile: readFile2, outputFile: outputFile2, pathExists: pathExists7 } = fsExtra8;
480
516
  async function appendExport(target, value, cwd, dryRun) {
481
517
  const file = path6.join(cwd, target);
482
518
  if (!await pathExists7(file)) {
@@ -486,7 +522,7 @@ async function appendExport(target, value, cwd, dryRun) {
486
522
  }
487
523
  return;
488
524
  }
489
- const content = await readFile(file, "utf-8");
525
+ const content = await readFile2(file, "utf-8");
490
526
  if (content.includes(value)) return;
491
527
  if (!dryRun) {
492
528
  const newContent = content.endsWith("\n") ? `${content}${value}
@@ -500,7 +536,7 @@ ${value}
500
536
  // src/transforms/register-route.ts
501
537
  import fsExtra9 from "fs-extra";
502
538
  import path7 from "path";
503
- var { readFile: readFile2, outputFile: outputFile3, pathExists: pathExists8 } = fsExtra9;
539
+ var { readFile: readFile3, outputFile: outputFile3, pathExists: pathExists8 } = fsExtra9;
504
540
  async function registerRoute(target, value, cwd, dryRun) {
505
541
  const file = path7.join(cwd, target);
506
542
  if (!await pathExists8(file)) {
@@ -508,7 +544,7 @@ async function registerRoute(target, value, cwd, dryRun) {
508
544
  `register-route: target file not found: ${target}. Please ensure your router file exists.`
509
545
  );
510
546
  }
511
- const content = await readFile2(file, "utf-8");
547
+ const content = await readFile3(file, "utf-8");
512
548
  if (content.includes(value)) return;
513
549
  const routerPattern = /^([ \t]*\})[ \t]*(?:satisfies|as)\s/m;
514
550
  const match = routerPattern.exec(content);
@@ -528,7 +564,7 @@ async function registerRoute(target, value, cwd, dryRun) {
528
564
  // src/transforms/add-nav-item.ts
529
565
  import fsExtra10 from "fs-extra";
530
566
  import path8 from "path";
531
- var { readFile: readFile3, outputFile: outputFile4, pathExists: pathExists9 } = fsExtra10;
567
+ var { readFile: readFile4, outputFile: outputFile4, pathExists: pathExists9 } = fsExtra10;
532
568
  async function addNavItem(target, value, cwd, dryRun) {
533
569
  const file = path8.join(cwd, target);
534
570
  if (!await pathExists9(file)) {
@@ -536,7 +572,7 @@ async function addNavItem(target, value, cwd, dryRun) {
536
572
  `add-nav-item: target file not found: ${target}`
537
573
  );
538
574
  }
539
- const content = await readFile3(file, "utf-8");
575
+ const content = await readFile4(file, "utf-8");
540
576
  if (content.includes(value)) return;
541
577
  const openPattern = /(?:const\s+\w+(?:\s*:\s*[\w<>\[\], ]+)?\s*=\s*\[|items\s*:\s*\[)/;
542
578
  const openMatch = openPattern.exec(content);
@@ -573,7 +609,7 @@ async function addNavItem(target, value, cwd, dryRun) {
573
609
  // src/transforms/merge-schema.ts
574
610
  import fsExtra11 from "fs-extra";
575
611
  import path9 from "path";
576
- var { readFile: readFile4, outputFile: outputFile5, pathExists: pathExists10 } = fsExtra11;
612
+ var { readFile: readFile5, outputFile: outputFile5, pathExists: pathExists10 } = fsExtra11;
577
613
  async function mergeSchema(target, value, cwd, dryRun) {
578
614
  const file = path9.join(cwd, target);
579
615
  if (!await pathExists10(file)) {
@@ -583,7 +619,7 @@ async function mergeSchema(target, value, cwd, dryRun) {
583
619
  }
584
620
  return;
585
621
  }
586
- const content = await readFile4(file, "utf-8");
622
+ const content = await readFile5(file, "utf-8");
587
623
  if (content.includes(value)) return;
588
624
  if (!dryRun) {
589
625
  const newContent = content.endsWith("\n") ? `${content}${value}
@@ -598,12 +634,12 @@ ${value}
598
634
  import fsExtra12 from "fs-extra";
599
635
  import path10 from "path";
600
636
  import * as p3 from "@clack/prompts";
601
- var { readFile: readFile5, outputFile: outputFile6, pathExists: pathExists11 } = fsExtra12;
637
+ var { readFile: readFile6, outputFile: outputFile6, pathExists: pathExists11 } = fsExtra12;
602
638
  async function addEnv(target, value, cwd, dryRun) {
603
639
  const file = path10.join(cwd, target);
604
640
  let content = "";
605
641
  if (await pathExists11(file)) {
606
- content = await readFile5(file, "utf-8");
642
+ content = await readFile6(file, "utf-8");
607
643
  const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
608
644
  const keyPattern = new RegExp(`^${escaped}=`, "m");
609
645
  if (keyPattern.test(content)) return;
@@ -669,9 +705,8 @@ function authPath() {
669
705
  import { z as z6 } from "zod";
670
706
  var AuthCredentialsSchema = z6.object({
671
707
  token: z6.string(),
672
- refreshToken: z6.string().optional(),
673
708
  expiresAt: z6.string().optional(),
674
- email: z6.string()
709
+ email: z6.string().optional()
675
710
  });
676
711
 
677
712
  // src/auth/read-auth.ts
@@ -701,21 +736,28 @@ async function requireAuth() {
701
736
  }
702
737
 
703
738
  // src/commands/add.ts
704
- async function resolveModuleDeps(moduleId, localPath, resolved, orderedModules) {
739
+ async function getManifest(moduleId, localPath, manifests) {
740
+ const cached = manifests.get(moduleId);
741
+ if (cached) return cached;
742
+ const manifest = await fetchModuleManifest(moduleId, localPath);
743
+ manifests.set(moduleId, manifest);
744
+ return manifest;
745
+ }
746
+ async function resolveModuleDeps(moduleId, localPath, manifests, resolved, orderedModules) {
705
747
  if (resolved.has(moduleId)) return;
706
748
  resolved.add(moduleId);
707
- const manifest = await fetchModuleManifest(moduleId, localPath);
749
+ const manifest = await getManifest(moduleId, localPath, manifests);
708
750
  for (const dep of manifest.moduleDependencies) {
709
- await resolveModuleDeps(dep, localPath, resolved, orderedModules);
751
+ await resolveModuleDeps(dep, localPath, manifests, resolved, orderedModules);
710
752
  }
711
753
  orderedModules.push(moduleId);
712
754
  }
713
- async function resolveWithParent(moduleId, localPath, installedNames, resolved, orderedModules) {
755
+ async function resolveWithParent(moduleId, localPath, installedNames, manifests, resolved, orderedModules) {
714
756
  const { parent, child } = parseModuleId(moduleId);
715
757
  if (child !== null && !installedNames.has(parent)) {
716
- await resolveModuleDeps(parent, localPath, resolved, orderedModules);
758
+ await resolveModuleDeps(parent, localPath, manifests, resolved, orderedModules);
717
759
  }
718
- await resolveModuleDeps(moduleId, localPath, resolved, orderedModules);
760
+ await resolveModuleDeps(moduleId, localPath, manifests, resolved, orderedModules);
719
761
  }
720
762
  function detectPackageManager(cwd) {
721
763
  if (existsSync(path12.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
@@ -737,17 +779,24 @@ function installNpmDeps(deps, cwd, dryRun) {
737
779
  p5.log.step(`Installing dependencies: ${deps.join(", ")}`);
738
780
  execFileSync(pm, args, { cwd, stdio: "inherit" });
739
781
  }
740
- async function installModule(moduleId, manifest, cwd, dryRun, localPath) {
782
+ async function installModule(moduleId, manifest, cwd, dryRun, installedModules, localPath) {
741
783
  p5.log.step(`Installing ${moduleId}@${manifest.version}...`);
784
+ const installedDests = [];
742
785
  if (manifest.files.length > 0) {
743
786
  const installed = await installFiles({
744
787
  moduleName: moduleId,
745
788
  files: manifest.files,
746
789
  cwd,
747
790
  dryRun,
748
- localPath
791
+ localPath,
792
+ installedModules
749
793
  });
750
794
  for (const f of installed) {
795
+ if (f.action === "conditional-skip") {
796
+ p5.log.message(` \u25CB ${f.dest} [skipped: ${f.reason}]`);
797
+ continue;
798
+ }
799
+ installedDests.push(f.dest);
751
800
  const icon = f.action === "created" || f.action === "would-create" ? "+" : f.action === "overwritten" || f.action === "would-overwrite" ? "~" : "=";
752
801
  p5.log.message(` ${icon} ${f.dest}`);
753
802
  }
@@ -756,14 +805,15 @@ async function installModule(moduleId, manifest, cwd, dryRun, localPath) {
756
805
  p5.log.step(` transform: ${transform.type} \u2192 ${transform.target}`);
757
806
  await runTransform(transform, cwd, dryRun);
758
807
  }
808
+ return installedDests;
759
809
  }
760
- function recordModule(config, moduleId, manifest, now) {
810
+ function recordModule(config, moduleId, manifest, installedFiles, now) {
761
811
  const existing = config.installedModules.findIndex((m) => m.name === moduleId);
762
812
  const record = {
763
813
  name: moduleId,
764
814
  version: manifest.version,
765
815
  installedAt: now,
766
- files: manifest.files.map((f) => f.dest)
816
+ files: installedFiles
767
817
  };
768
818
  if (existing >= 0) {
769
819
  config.installedModules[existing] = record;
@@ -820,7 +870,7 @@ async function pickModulesInteractively(localPath) {
820
870
  return [...result];
821
871
  }
822
872
  async function runAdd(options) {
823
- let { moduleNames, cwd, dryRun, localPath, withSubModules = [] } = options;
873
+ let { moduleNames, cwd, dryRun, localPath, withSubModules = [], allowScripts = false } = options;
824
874
  let allTargets;
825
875
  if (moduleNames.length === 0) {
826
876
  p5.intro(`impulse add${dryRun ? " [dry-run]" : ""}`);
@@ -852,6 +902,7 @@ async function runAdd(options) {
852
902
  process.exit(1);
853
903
  }
854
904
  const installedNames = new Set(config.installedModules.map((m) => m.name));
905
+ const manifests = /* @__PURE__ */ new Map();
855
906
  if (withSubModules.length === 0) {
856
907
  const alreadyInstalled = allTargets.filter((id) => {
857
908
  const { child } = parseModuleId(id);
@@ -873,7 +924,7 @@ async function runAdd(options) {
873
924
  }
874
925
  }
875
926
  if (moduleNames.length === 1 && withSubModules.length > 0) {
876
- const parentManifest = await fetchModuleManifest(moduleNames[0], localPath).catch(() => null);
927
+ const parentManifest = await getManifest(moduleNames[0], localPath, manifests).catch(() => null);
877
928
  if (parentManifest) {
878
929
  if (parentManifest.subModules.length === 0) {
879
930
  p5.cancel(`"${moduleNames[0]}" has no declared sub-modules.`);
@@ -895,7 +946,7 @@ Available: ${parentManifest.subModules.join(", ")}`
895
946
  const orderedModules = [];
896
947
  try {
897
948
  for (const target of allTargets) {
898
- await resolveWithParent(target, localPath, installedNames, resolved, orderedModules);
949
+ await resolveWithParent(target, localPath, installedNames, manifests, resolved, orderedModules);
899
950
  }
900
951
  } catch (err) {
901
952
  s.stop("Dependency resolution failed.");
@@ -903,18 +954,34 @@ Available: ${parentManifest.subModules.join(", ")}`
903
954
  process.exit(1);
904
955
  }
905
956
  s.stop(`Resolved: ${orderedModules.join(" \u2192 ")}`);
906
- const manifests = /* @__PURE__ */ new Map();
907
- for (const id of orderedModules) {
908
- manifests.set(id, await fetchModuleManifest(id, localPath));
957
+ const orderedManifests = orderedModules.map((id) => {
958
+ const manifest = manifests.get(id);
959
+ if (!manifest) {
960
+ throw new Error(`Missing manifest for resolved module "${id}".`);
961
+ }
962
+ return [id, manifest];
963
+ });
964
+ for (const [id, manifest] of orderedManifests) {
965
+ if (installedNames.has(id)) continue;
966
+ for (const incompatible of manifest.incompatibleWith) {
967
+ if (installedNames.has(incompatible)) {
968
+ p5.cancel(`Module "${id}" is incompatible with installed module "${incompatible}".`);
969
+ process.exit(1);
970
+ }
971
+ if (manifests.has(incompatible) && !installedNames.has(incompatible)) {
972
+ p5.cancel(`Module "${id}" is incompatible with module "${incompatible}" (also being installed).`);
973
+ process.exit(1);
974
+ }
975
+ }
909
976
  }
910
977
  const allDeps = /* @__PURE__ */ new Set();
911
- for (const manifest of manifests.values()) {
978
+ for (const [, manifest] of orderedManifests) {
912
979
  for (const dep of manifest.dependencies) {
913
980
  allDeps.add(dep);
914
981
  }
915
982
  }
916
983
  p5.log.message("\nSummary of changes:");
917
- for (const [id, manifest] of manifests) {
984
+ for (const [id, manifest] of orderedManifests) {
918
985
  p5.log.message(`
919
986
  Module: ${id}@${manifest.version}`);
920
987
  for (const file of manifest.files) {
@@ -925,7 +992,7 @@ Available: ${parentManifest.subModules.join(", ")}`
925
992
  }
926
993
  }
927
994
  const allEnvVars = /* @__PURE__ */ new Set();
928
- for (const manifest of manifests.values()) {
995
+ for (const [, manifest] of orderedManifests) {
929
996
  for (const envVar of manifest.envVars) {
930
997
  allEnvVars.add(envVar);
931
998
  }
@@ -951,13 +1018,16 @@ Available: ${parentManifest.subModules.join(", ")}`
951
1018
  const primaryTargetSet = new Set(allTargets);
952
1019
  const depModules = orderedModules.filter((id) => !primaryTargetSet.has(id) && !installedNames.has(id));
953
1020
  const targetModules = orderedModules.filter((id) => primaryTargetSet.has(id));
1021
+ const installedFilesMap = /* @__PURE__ */ new Map();
954
1022
  const depPostInstallHooks = [];
955
1023
  if (depModules.length > 0) {
956
1024
  p5.log.step(`Installing module dependencies: ${depModules.join(", ")}`);
957
1025
  for (const dep of depModules) {
958
1026
  const depManifest = manifests.get(dep);
959
1027
  if (!depManifest) continue;
960
- await installModule(dep, depManifest, cwd, dryRun, localPath);
1028
+ const dests = await installModule(dep, depManifest, cwd, dryRun, installedNames, localPath);
1029
+ installedFilesMap.set(dep, dests);
1030
+ installedNames.add(dep);
961
1031
  if (depManifest.postInstall && depManifest.postInstall.length > 0) {
962
1032
  depPostInstallHooks.push({ name: dep, hooks: depManifest.postInstall });
963
1033
  }
@@ -966,26 +1036,69 @@ Available: ${parentManifest.subModules.join(", ")}`
966
1036
  for (const targetId of targetModules) {
967
1037
  const targetManifest = manifests.get(targetId);
968
1038
  if (!targetManifest) continue;
969
- await installModule(targetId, targetManifest, cwd, dryRun, localPath);
1039
+ const dests = await installModule(targetId, targetManifest, cwd, dryRun, installedNames, localPath);
1040
+ installedFilesMap.set(targetId, dests);
1041
+ installedNames.add(targetId);
970
1042
  }
971
1043
  installNpmDeps([...allDeps], cwd, dryRun);
972
- if (!dryRun) {
973
- for (const { name, hooks } of depPostInstallHooks) {
974
- p5.log.step(`Running post-install hooks for ${name}...`);
1044
+ const allPostInstallHooks = [];
1045
+ for (const { name, hooks } of depPostInstallHooks) {
1046
+ if (hooks.length > 0) allPostInstallHooks.push({ name, hooks });
1047
+ }
1048
+ for (const targetId of targetModules) {
1049
+ const targetManifest = manifests.get(targetId);
1050
+ if (targetManifest?.postInstall?.length) {
1051
+ allPostInstallHooks.push({ name: targetId, hooks: targetManifest.postInstall });
1052
+ }
1053
+ }
1054
+ if (allPostInstallHooks.length > 0) {
1055
+ const allHookLines = [];
1056
+ for (const { name, hooks } of allPostInstallHooks) {
975
1057
  for (const hook of hooks) {
976
- p5.log.message(` $ ${hook}`);
977
- execSync2(hook, { cwd, stdio: "inherit" });
1058
+ allHookLines.push(` [${name}] $ ${hook}`);
978
1059
  }
979
1060
  }
980
- }
981
- if (!dryRun) {
982
- for (const targetId of targetModules) {
983
- const targetManifest = manifests.get(targetId);
984
- if (!targetManifest?.postInstall?.length) continue;
985
- p5.log.step(`Running post-install hooks for ${targetId}...`);
986
- for (const hook of targetManifest.postInstall) {
987
- p5.log.message(` $ ${hook}`);
988
- execSync2(hook, { cwd, stdio: "inherit" });
1061
+ if (dryRun) {
1062
+ p5.log.message("\nPost-install hooks that would run:");
1063
+ for (const line of allHookLines) {
1064
+ p5.log.message(line);
1065
+ }
1066
+ } else if (!allowScripts && process.stdin.isTTY === false) {
1067
+ p5.log.warn(
1068
+ `Skipping ${allHookLines.length} post-install hook(s) \u2014 non-interactive mode detected. Pass --allow-scripts to run them.`
1069
+ );
1070
+ for (const line of allHookLines) {
1071
+ p5.log.message(line);
1072
+ }
1073
+ } else {
1074
+ p5.log.message("\nPost-install hooks to run:");
1075
+ for (const line of allHookLines) {
1076
+ p5.log.message(line);
1077
+ }
1078
+ let runHooks;
1079
+ if (allowScripts) {
1080
+ runHooks = true;
1081
+ } else {
1082
+ const confirmed = await p5.confirm({
1083
+ message: "Run post-install hooks?",
1084
+ initialValue: true
1085
+ });
1086
+ if (p5.isCancel(confirmed)) {
1087
+ p5.outro("Cancelled.");
1088
+ return;
1089
+ }
1090
+ runHooks = confirmed;
1091
+ }
1092
+ if (runHooks) {
1093
+ for (const { name, hooks } of allPostInstallHooks) {
1094
+ p5.log.step(`Running post-install hooks for ${name}...`);
1095
+ for (const hook of hooks) {
1096
+ p5.log.message(` $ ${hook}`);
1097
+ execSync2(hook, { cwd, stdio: "inherit" });
1098
+ }
1099
+ }
1100
+ } else {
1101
+ p5.log.warn("Post-install hooks skipped.");
989
1102
  }
990
1103
  }
991
1104
  }
@@ -993,11 +1106,13 @@ Available: ${parentManifest.subModules.join(", ")}`
993
1106
  const now = (/* @__PURE__ */ new Date()).toISOString();
994
1107
  for (const dep of depModules) {
995
1108
  const depManifest = manifests.get(dep);
996
- if (depManifest) recordModule(config, dep, depManifest, now);
1109
+ const dests = installedFilesMap.get(dep) ?? [];
1110
+ if (depManifest) recordModule(config, dep, depManifest, dests, now);
997
1111
  }
998
1112
  for (const targetId of targetModules) {
999
1113
  const targetManifest = manifests.get(targetId);
1000
- if (targetManifest) recordModule(config, targetId, targetManifest, now);
1114
+ const dests = installedFilesMap.get(targetId) ?? [];
1115
+ if (targetManifest) recordModule(config, targetId, targetManifest, dests, now);
1001
1116
  }
1002
1117
  await writeConfig(config, cwd);
1003
1118
  }
@@ -1009,6 +1124,24 @@ Available: ${parentManifest.subModules.join(", ")}`
1009
1124
 
1010
1125
  // src/commands/list.ts
1011
1126
  import * as p6 from "@clack/prompts";
1127
+ function printModule(mod, installedNames, installedModules) {
1128
+ const installed = installedNames.has(mod.name);
1129
+ const installedInfo = installed ? installedModules?.find((m) => m.name === mod.name) : null;
1130
+ const status = installed ? `[installed v${installedInfo?.version ?? "?"}]` : "[available]";
1131
+ const desc = mod.description ? ` \u2014 ${mod.description}` : "";
1132
+ p6.log.message(` ${installed ? "\u2713" : "\u25CB"} ${mod.name} ${status}${desc}`);
1133
+ if (mod.subModules && mod.subModules.length > 0) {
1134
+ const last = mod.subModules.length - 1;
1135
+ mod.subModules.forEach((sub, i) => {
1136
+ const subId = `${mod.name}/${sub}`;
1137
+ const subInstalled = installedNames.has(subId);
1138
+ const subInfo = subInstalled ? installedModules?.find((m) => m.name === subId) : null;
1139
+ const subStatus = subInstalled ? `[installed v${subInfo?.version ?? "?"}]` : "[not installed]";
1140
+ const connector = i === last ? "\u2514\u2500" : "\u251C\u2500";
1141
+ p6.log.message(` ${connector} ${sub} ${subStatus}`);
1142
+ });
1143
+ }
1144
+ }
1012
1145
  async function runList(options) {
1013
1146
  const { cwd, localPath } = options;
1014
1147
  p6.intro("impulse list");
@@ -1034,22 +1167,33 @@ async function runList(options) {
1034
1167
  return;
1035
1168
  }
1036
1169
  p6.log.message("\nAvailable modules:\n");
1037
- for (const mod of available) {
1038
- const installed = installedNames.has(mod.name);
1039
- const installedInfo = installed ? config?.installedModules.find((m) => m.name === mod.name) : null;
1040
- const status = installed ? `[installed v${installedInfo?.version ?? "?"}]` : "[available]";
1041
- const desc = mod.description ? ` \u2014 ${mod.description}` : "";
1042
- p6.log.message(` ${installed ? "\u2713" : "\u25CB"} ${mod.name} ${status}${desc}`);
1043
- if (mod.subModules && mod.subModules.length > 0) {
1044
- const last = mod.subModules.length - 1;
1045
- mod.subModules.forEach((sub, i) => {
1046
- const subId = `${mod.name}/${sub}`;
1047
- const subInstalled = installedNames.has(subId);
1048
- const subInfo = subInstalled ? config?.installedModules.find((m) => m.name === subId) : null;
1049
- const subStatus = subInstalled ? `[installed v${subInfo?.version ?? "?"}]` : "[not installed]";
1050
- const connector = i === last ? "\u2514\u2500" : "\u251C\u2500";
1051
- p6.log.message(` ${connector} ${sub} ${subStatus}`);
1052
- });
1170
+ const hasCategories = available.some((m) => m.category !== void 0);
1171
+ if (hasCategories) {
1172
+ const groups = /* @__PURE__ */ new Map();
1173
+ for (const mod of available) {
1174
+ const key = mod.category ?? "other";
1175
+ const existing = groups.get(key);
1176
+ if (existing) {
1177
+ existing.push(mod);
1178
+ } else {
1179
+ groups.set(key, [mod]);
1180
+ }
1181
+ }
1182
+ const categoryOrder = ["core", "feature", "integration", "dx", "other"];
1183
+ const sortedKeys = [
1184
+ ...categoryOrder.filter((k) => groups.has(k)),
1185
+ ...[...groups.keys()].filter((k) => !categoryOrder.includes(k))
1186
+ ];
1187
+ for (const key of sortedKeys) {
1188
+ p6.log.message(`${key.toUpperCase()}`);
1189
+ for (const mod of groups.get(key)) {
1190
+ printModule(mod, installedNames, config?.installedModules);
1191
+ }
1192
+ p6.log.message("");
1193
+ }
1194
+ } else {
1195
+ for (const mod of available) {
1196
+ printModule(mod, installedNames, config?.installedModules);
1053
1197
  }
1054
1198
  }
1055
1199
  p6.log.message(
@@ -1067,19 +1211,20 @@ import * as p7 from "@clack/prompts";
1067
1211
 
1068
1212
  // src/auth/device-flow.ts
1069
1213
  import { execFileSync as execFileSync2 } from "child_process";
1070
- var IMPULSE_BASE_URL = process.env.IMPULSE_BASE_URL ?? "https://impulse.studio";
1214
+ var IMPULSE_BASE_URL = process.env.IMPULSE_BASE_URL ?? "https://impulselab.ai";
1215
+ var CLIENT_ID = "impulse-cli";
1071
1216
  async function requestDeviceCode() {
1072
1217
  const res = await fetch(`${IMPULSE_BASE_URL}/api/auth/device/code`, {
1073
1218
  method: "POST",
1074
1219
  headers: { "Content-Type": "application/json" },
1075
- body: JSON.stringify({ client: "impulse-cli" })
1220
+ body: JSON.stringify({ client_id: CLIENT_ID })
1076
1221
  });
1077
1222
  if (!res.ok) {
1078
1223
  throw new Error(`Failed to initiate device flow: ${res.status} ${res.statusText}`);
1079
1224
  }
1080
1225
  const data = await res.json();
1081
1226
  assertDeviceCodeResponse(data);
1082
- return data;
1227
+ return mapDeviceCodeResponse(data);
1083
1228
  }
1084
1229
  async function pollDeviceToken(deviceCode) {
1085
1230
  let res;
@@ -1087,7 +1232,11 @@ async function pollDeviceToken(deviceCode) {
1087
1232
  res = await fetch(`${IMPULSE_BASE_URL}/api/auth/device/token`, {
1088
1233
  method: "POST",
1089
1234
  headers: { "Content-Type": "application/json" },
1090
- body: JSON.stringify({ deviceCode })
1235
+ body: JSON.stringify({
1236
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
1237
+ device_code: deviceCode,
1238
+ client_id: CLIENT_ID
1239
+ })
1091
1240
  });
1092
1241
  } catch {
1093
1242
  return { status: "error", message: "Network error while polling for token" };
@@ -1095,10 +1244,7 @@ async function pollDeviceToken(deviceCode) {
1095
1244
  if (res.status === 200) {
1096
1245
  const data = await res.json();
1097
1246
  assertDeviceTokenResponse(data);
1098
- return { status: "authorized", data };
1099
- }
1100
- if (res.status === 202) {
1101
- return { status: "pending" };
1247
+ return { status: "authorized", data: mapDeviceTokenResponse(data) };
1102
1248
  }
1103
1249
  if (res.status === 400) {
1104
1250
  let body;
@@ -1108,8 +1254,10 @@ async function pollDeviceToken(deviceCode) {
1108
1254
  body = {};
1109
1255
  }
1110
1256
  const error = typeof body === "object" && body !== null && "error" in body ? String(body.error) : "";
1257
+ if (error === "authorization_pending") return { status: "pending" };
1111
1258
  if (error === "slow_down") return { status: "slow_down" };
1112
- if (error === "expired" || error === "authorization_expired") return { status: "expired" };
1259
+ if (error === "expired_token") return { status: "expired" };
1260
+ if (error === "access_denied") return { status: "denied" };
1113
1261
  return { status: "error", message: error || "Device code error" };
1114
1262
  }
1115
1263
  return { status: "error", message: `Unexpected response: ${res.status}` };
@@ -1128,15 +1276,32 @@ function openBrowser(url) {
1128
1276
  }
1129
1277
  }
1130
1278
  function assertDeviceCodeResponse(data) {
1131
- if (typeof data !== "object" || data === null || typeof data.deviceCode !== "string" || typeof data.userCode !== "string" || typeof data.verificationUri !== "string") {
1279
+ if (typeof data !== "object" || data === null || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string") {
1132
1280
  throw new Error("Invalid device code response from server");
1133
1281
  }
1134
1282
  }
1135
1283
  function assertDeviceTokenResponse(data) {
1136
- if (typeof data !== "object" || data === null || typeof data.token !== "string" || typeof data.email !== "string") {
1284
+ if (typeof data !== "object" || data === null || typeof data.access_token !== "string") {
1137
1285
  throw new Error("Invalid token response from server");
1138
1286
  }
1139
1287
  }
1288
+ function mapDeviceCodeResponse(raw) {
1289
+ return {
1290
+ deviceCode: raw.device_code,
1291
+ userCode: raw.user_code,
1292
+ verificationUri: raw.verification_uri,
1293
+ ...raw.verification_uri_complete !== void 0 ? { verificationUriComplete: raw.verification_uri_complete } : {},
1294
+ expiresIn: raw.expires_in,
1295
+ interval: raw.interval
1296
+ };
1297
+ }
1298
+ function mapDeviceTokenResponse(raw) {
1299
+ return {
1300
+ token: raw.access_token,
1301
+ ...raw.expires_in !== void 0 ? { expiresIn: raw.expires_in } : {},
1302
+ ...raw.email !== void 0 ? { email: raw.email } : {}
1303
+ };
1304
+ }
1140
1305
 
1141
1306
  // src/auth/write-auth.ts
1142
1307
  import fsExtra14 from "fs-extra";
@@ -1156,7 +1321,7 @@ async function runLogin() {
1156
1321
  const existing = await readAuth();
1157
1322
  if (existing) {
1158
1323
  const reauth = await p7.confirm({
1159
- message: `Already logged in as ${existing.email}. Log in again?`,
1324
+ message: `Already logged in as ${existing.email ?? "current session"}. Log in again?`,
1160
1325
  initialValue: false
1161
1326
  });
1162
1327
  if (p7.isCancel(reauth) || !reauth) {
@@ -1199,13 +1364,14 @@ Base URL: ${IMPULSE_BASE_URL}`);
1199
1364
  const result = await pollDeviceToken(deviceCode.deviceCode);
1200
1365
  if (result.status === "authorized") {
1201
1366
  pollSpinner.stop("Authentication successful!");
1367
+ const expiresAt2 = result.data.expiresIn !== void 0 ? new Date(Date.now() + result.data.expiresIn * 1e3).toISOString() : void 0;
1202
1368
  await writeAuth({
1203
1369
  token: result.data.token,
1204
- refreshToken: result.data.refreshToken,
1205
- expiresAt: result.data.expiresAt,
1370
+ expiresAt: expiresAt2,
1206
1371
  email: result.data.email
1207
1372
  });
1208
- p7.outro(`Logged in as ${result.data.email}`);
1373
+ const identity = result.data.email ?? "your account";
1374
+ p7.outro(`Logged in as ${identity}`);
1209
1375
  return;
1210
1376
  }
1211
1377
  if (result.status === "slow_down") {
@@ -1217,6 +1383,11 @@ Base URL: ${IMPULSE_BASE_URL}`);
1217
1383
  p7.cancel("Authentication timed out. Run `impulse login` again.");
1218
1384
  process.exit(1);
1219
1385
  }
1386
+ if (result.status === "denied") {
1387
+ pollSpinner.stop("Access denied.");
1388
+ p7.cancel("You denied the authorization request.");
1389
+ process.exit(1);
1390
+ }
1220
1391
  if (result.status === "error") {
1221
1392
  pollSpinner.stop("Authentication failed.");
1222
1393
  p7.cancel(result.message);
@@ -1253,7 +1424,7 @@ async function runLogout() {
1253
1424
  return;
1254
1425
  }
1255
1426
  const confirm6 = await p8.confirm({
1256
- message: `Log out ${credentials.email}?`,
1427
+ message: `Log out ${credentials.email ?? "current session"}?`,
1257
1428
  initialValue: true
1258
1429
  });
1259
1430
  if (p8.isCancel(confirm6) || !confirm6) {
@@ -1273,7 +1444,7 @@ async function runWhoami() {
1273
1444
  p9.cancel("Not authenticated. Run `impulse login` first.");
1274
1445
  process.exit(1);
1275
1446
  }
1276
- p9.log.message(`Logged in as: ${credentials.email}`);
1447
+ p9.log.message(`Logged in as: ${credentials.email ?? "(unknown)"}`);
1277
1448
  if (credentials.expiresAt) {
1278
1449
  const expires = new Date(credentials.expiresAt);
1279
1450
  const now = /* @__PURE__ */ new Date();
@@ -1305,11 +1476,16 @@ program.command("add [modules...]").description(
1305
1476
  ).option(
1306
1477
  "--with <submodules>",
1307
1478
  "Comma-separated sub-modules to install alongside the parent (e.g. --with quote-to-cash,gocardless)"
1479
+ ).option(
1480
+ "--allow-scripts",
1481
+ "Run post-install hooks without prompting for confirmation (required in non-interactive environments)",
1482
+ false
1308
1483
  ).action(async (modules, options) => {
1309
1484
  const addOpts = {
1310
1485
  moduleNames: modules ?? [],
1311
1486
  cwd: process.cwd(),
1312
- dryRun: options.dryRun
1487
+ dryRun: options.dryRun,
1488
+ allowScripts: options.allowScripts
1313
1489
  };
1314
1490
  if (options.local !== void 0) addOpts.localPath = options.local;
1315
1491
  if (options.with !== void 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impulselab/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "ImpulseLab CLI — install and manage modules for your projects",
5
5
  "private": false,
6
6
  "type": "module",