@impulselab/cli 0.1.2 → 0.1.4

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 +534 -89
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -197,8 +197,8 @@ async function runInit(options) {
197
197
  // src/commands/add.ts
198
198
  import { execSync as execSync2, execFileSync } from "child_process";
199
199
  import { existsSync } from "fs";
200
- import path11 from "path";
201
- import * as p4 from "@clack/prompts";
200
+ import path12 from "path";
201
+ import * as p5 from "@clack/prompts";
202
202
 
203
203
  // src/registry/fetch-module-manifest.ts
204
204
  import fsExtra5 from "fs-extra";
@@ -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
@@ -243,11 +247,21 @@ var ModuleManifestSchema = z5.object({
243
247
  subModules: z5.array(z5.string()).default([]),
244
248
  dependencies: z5.array(ModuleDependencySchema).default([]),
245
249
  moduleDependencies: z5.array(z5.string()).default([]),
250
+ /** Optional module dependencies that enhance this module but are not required. */
251
+ optionalModuleDependencies: z5.array(z5.string()).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
 
253
267
  // src/registry/github-urls.ts
@@ -364,7 +378,8 @@ async function listAvailableModules(localPath) {
364
378
  result.push({
365
379
  name: parsed.data.name,
366
380
  description: parsed.data.description,
367
- ...subModules ? { subModules } : {}
381
+ ...subModules ? { subModules } : {},
382
+ ...parsed.data.category ? { category: parsed.data.category } : {}
368
383
  });
369
384
  } catch {
370
385
  }
@@ -432,9 +447,17 @@ import path5 from "path";
432
447
  import * as p2 from "@clack/prompts";
433
448
  var { outputFile, pathExists: pathExists6 } = fsExtra7;
434
449
  async function installFiles(options) {
435
- const { moduleName, files, cwd, dryRun, localPath } = options;
450
+ const { moduleName, files, cwd, dryRun, localPath, installedModules = /* @__PURE__ */ new Set() } = options;
436
451
  const results = [];
437
452
  for (const file of files) {
453
+ if (file.when?.moduleInstalled !== void 0 && !installedModules.has(file.when.moduleInstalled)) {
454
+ results.push({
455
+ dest: file.dest,
456
+ action: "conditional-skip",
457
+ reason: `requires ${file.when.moduleInstalled}`
458
+ });
459
+ continue;
460
+ }
438
461
  const destAbs = path5.join(cwd, file.dest);
439
462
  const exists = await pathExists6(destAbs);
440
463
  if (exists) {
@@ -652,6 +675,54 @@ async function runTransform(transform, cwd, dryRun) {
652
675
  }
653
676
  }
654
677
 
678
+ // src/auth/require-auth.ts
679
+ import * as p4 from "@clack/prompts";
680
+
681
+ // src/auth/read-auth.ts
682
+ import fsExtra13 from "fs-extra";
683
+
684
+ // src/auth/auth-path.ts
685
+ import path11 from "path";
686
+ import os from "os";
687
+ function authPath() {
688
+ return path11.join(os.homedir(), ".impulselab", "auth.json");
689
+ }
690
+
691
+ // src/schemas/auth-credentials.ts
692
+ import { z as z6 } from "zod";
693
+ var AuthCredentialsSchema = z6.object({
694
+ token: z6.string(),
695
+ refreshToken: z6.string().optional(),
696
+ expiresAt: z6.string().optional(),
697
+ email: z6.string()
698
+ });
699
+
700
+ // src/auth/read-auth.ts
701
+ var { readJson: readJson5, pathExists: pathExists12 } = fsExtra13;
702
+ async function readAuth() {
703
+ const file = authPath();
704
+ if (!await pathExists12(file)) return null;
705
+ let raw;
706
+ try {
707
+ raw = await readJson5(file);
708
+ } catch {
709
+ return null;
710
+ }
711
+ const parsed = AuthCredentialsSchema.safeParse(raw);
712
+ if (!parsed.success) return null;
713
+ return parsed.data;
714
+ }
715
+
716
+ // src/auth/require-auth.ts
717
+ async function requireAuth() {
718
+ const credentials = await readAuth();
719
+ if (!credentials) {
720
+ p4.cancel("Not authenticated. Run `impulse login` first.");
721
+ process.exit(1);
722
+ }
723
+ return credentials;
724
+ }
725
+
655
726
  // src/commands/add.ts
656
727
  async function resolveModuleDeps(moduleId, localPath, resolved, orderedModules) {
657
728
  if (resolved.has(moduleId)) return;
@@ -670,48 +741,62 @@ async function resolveWithParent(moduleId, localPath, installedNames, resolved,
670
741
  await resolveModuleDeps(moduleId, localPath, resolved, orderedModules);
671
742
  }
672
743
  function detectPackageManager(cwd) {
673
- if (existsSync(path11.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
674
- if (existsSync(path11.join(cwd, "yarn.lock"))) return "yarn";
744
+ if (existsSync(path12.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
745
+ if (existsSync(path12.join(cwd, "yarn.lock"))) return "yarn";
675
746
  return "npm";
676
747
  }
748
+ function isPnpmMonorepo(cwd) {
749
+ return existsSync(path12.join(cwd, "pnpm-workspace.yaml"));
750
+ }
677
751
  function installNpmDeps(deps, cwd, dryRun) {
678
752
  if (deps.length === 0) return;
679
753
  const pm = detectPackageManager(cwd);
680
- const args = ["add", ...deps];
754
+ const isMonorepo = pm === "pnpm" && isPnpmMonorepo(cwd);
755
+ const args = isMonorepo ? ["-w", "add", ...deps] : ["add", ...deps];
681
756
  if (dryRun) {
682
- p4.log.info(`[dry-run] Would run: ${pm} ${args.join(" ")}`);
757
+ p5.log.info(`[dry-run] Would run: ${pm} ${args.join(" ")}`);
683
758
  return;
684
759
  }
685
- p4.log.step(`Installing dependencies: ${deps.join(", ")}`);
760
+ p5.log.step(`Installing dependencies: ${deps.join(", ")}`);
686
761
  execFileSync(pm, args, { cwd, stdio: "inherit" });
687
762
  }
688
- async function installModule(moduleId, manifest, cwd, dryRun, localPath) {
689
- p4.log.step(`Installing ${moduleId}@${manifest.version}...`);
763
+ async function installModule(moduleId, manifest, cwd, dryRun, installedModules, localPath) {
764
+ p5.log.step(`Installing ${moduleId}@${manifest.version}...`);
765
+ const installedDests = [];
690
766
  if (manifest.files.length > 0) {
691
767
  const installed = await installFiles({
692
768
  moduleName: moduleId,
693
769
  files: manifest.files,
694
770
  cwd,
695
771
  dryRun,
696
- localPath
772
+ localPath,
773
+ installedModules
697
774
  });
698
775
  for (const f of installed) {
776
+ if (f.action === "conditional-skip") {
777
+ if (dryRun) {
778
+ p5.log.message(` \u25CB ${f.dest} [skipped: ${f.reason}]`);
779
+ }
780
+ continue;
781
+ }
782
+ installedDests.push(f.dest);
699
783
  const icon = f.action === "created" || f.action === "would-create" ? "+" : f.action === "overwritten" || f.action === "would-overwrite" ? "~" : "=";
700
- p4.log.message(` ${icon} ${f.dest}`);
784
+ p5.log.message(` ${icon} ${f.dest}`);
701
785
  }
702
786
  }
703
787
  for (const transform of manifest.transforms) {
704
- p4.log.step(` transform: ${transform.type} \u2192 ${transform.target}`);
788
+ p5.log.step(` transform: ${transform.type} \u2192 ${transform.target}`);
705
789
  await runTransform(transform, cwd, dryRun);
706
790
  }
791
+ return installedDests;
707
792
  }
708
- function recordModule(config, moduleId, manifest, now) {
793
+ function recordModule(config, moduleId, manifest, installedFiles, now) {
709
794
  const existing = config.installedModules.findIndex((m) => m.name === moduleId);
710
795
  const record = {
711
796
  name: moduleId,
712
797
  version: manifest.version,
713
798
  installedAt: now,
714
- files: manifest.files.map((f) => f.dest)
799
+ files: installedFiles
715
800
  };
716
801
  if (existing >= 0) {
717
802
  config.installedModules[existing] = record;
@@ -719,48 +804,125 @@ function recordModule(config, moduleId, manifest, now) {
719
804
  config.installedModules.push(record);
720
805
  }
721
806
  }
807
+ async function pickModulesInteractively(localPath) {
808
+ const s = p5.spinner();
809
+ s.start("Loading available modules...");
810
+ let modules;
811
+ try {
812
+ modules = await listAvailableModules(localPath);
813
+ } catch (err) {
814
+ s.stop("Failed to load modules.");
815
+ throw err;
816
+ }
817
+ s.stop("Modules loaded.");
818
+ if (modules.length === 0) {
819
+ p5.log.warn("No modules available.");
820
+ return [];
821
+ }
822
+ const options = [];
823
+ for (const mod of modules) {
824
+ options.push({
825
+ value: mod.name,
826
+ label: mod.name,
827
+ ...mod.description !== void 0 ? { hint: mod.description } : {}
828
+ });
829
+ for (const sub of mod.subModules ?? []) {
830
+ options.push({
831
+ value: `${mod.name}/${sub}`,
832
+ label: ` \u21B3 ${sub}`,
833
+ hint: `sub-module of ${mod.name}`
834
+ });
835
+ }
836
+ }
837
+ const selected = await p5.multiselect({
838
+ message: "Select modules to install:",
839
+ options,
840
+ required: true
841
+ });
842
+ if (p5.isCancel(selected)) {
843
+ p5.outro("Cancelled.");
844
+ process.exit(0);
845
+ }
846
+ const result = new Set(selected);
847
+ for (const id of selected) {
848
+ const { parent, child } = parseModuleId(id);
849
+ if (child !== null) {
850
+ result.add(parent);
851
+ }
852
+ }
853
+ return [...result];
854
+ }
722
855
  async function runAdd(options) {
723
- const { moduleName, cwd, dryRun, localPath, withSubModules = [] } = options;
724
- const withIds = withSubModules.map((sub) => `${moduleName}/${sub}`);
725
- const allTargets = [moduleName, ...withIds];
726
- p4.intro(`impulse add ${allTargets.join(", ")}${dryRun ? " [dry-run]" : ""}`);
856
+ let { moduleNames, cwd, dryRun, localPath, withSubModules = [] } = options;
857
+ let allTargets;
858
+ if (moduleNames.length === 0) {
859
+ p5.intro(`impulse add${dryRun ? " [dry-run]" : ""}`);
860
+ await requireAuth();
861
+ if (withSubModules.length > 0) {
862
+ p5.log.warn(`--with is ignored in interactive mode. Select sub-modules from the picker.`);
863
+ withSubModules = [];
864
+ }
865
+ const picked = await pickModulesInteractively(localPath);
866
+ if (picked.length === 0) {
867
+ p5.outro("Nothing selected.");
868
+ return;
869
+ }
870
+ allTargets = picked;
871
+ } else {
872
+ if (moduleNames.length > 1 && withSubModules.length > 0) {
873
+ p5.log.warn(
874
+ `--with is ignored when multiple modules are provided. Specify a single module to use --with.`
875
+ );
876
+ }
877
+ const withIds = moduleNames.length === 1 && withSubModules.length > 0 ? withSubModules.map((sub) => `${moduleNames[0]}/${sub}`) : [];
878
+ allTargets = [...moduleNames, ...withIds];
879
+ p5.intro(`impulse add ${allTargets.join(", ")}${dryRun ? " [dry-run]" : ""}`);
880
+ await requireAuth();
881
+ }
727
882
  const config = await readConfig(cwd);
728
883
  if (!config) {
729
- p4.cancel("No .impulse.json found. Run `impulse init` first.");
884
+ p5.cancel("No .impulse.json found. Run `impulse init` first.");
730
885
  process.exit(1);
731
886
  }
732
887
  const installedNames = new Set(config.installedModules.map((m) => m.name));
733
- const { child: mainChild } = parseModuleId(moduleName);
734
- if (mainChild === null && withSubModules.length === 0 && installedNames.has(moduleName)) {
735
- const existing = config.installedModules.find((m) => m.name === moduleName);
736
- p4.log.warn(`Module "${moduleName}" is already installed (v${existing?.version ?? "?"}).`);
737
- const reinstall = await p4.confirm({
738
- message: "Reinstall?",
739
- initialValue: false
888
+ if (withSubModules.length === 0) {
889
+ const alreadyInstalled = allTargets.filter((id) => {
890
+ const { child } = parseModuleId(id);
891
+ return child === null && installedNames.has(id);
740
892
  });
741
- if (p4.isCancel(reinstall) || !reinstall) {
742
- p4.outro("Cancelled.");
743
- return;
893
+ if (alreadyInstalled.length > 0) {
894
+ for (const id of alreadyInstalled) {
895
+ const existing = config.installedModules.find((m) => m.name === id);
896
+ p5.log.warn(`Module "${id}" is already installed (v${existing?.version ?? "?"}).`);
897
+ }
898
+ const reinstall = await p5.confirm({
899
+ message: alreadyInstalled.length === 1 ? "Reinstall?" : "Reinstall all?",
900
+ initialValue: false
901
+ });
902
+ if (p5.isCancel(reinstall) || !reinstall) {
903
+ p5.outro("Cancelled.");
904
+ return;
905
+ }
744
906
  }
745
907
  }
746
- if (withSubModules.length > 0) {
747
- const parentManifest = await fetchModuleManifest(moduleName, localPath).catch(() => null);
908
+ if (moduleNames.length === 1 && withSubModules.length > 0) {
909
+ const parentManifest = await fetchModuleManifest(moduleNames[0], localPath).catch(() => null);
748
910
  if (parentManifest) {
749
911
  if (parentManifest.subModules.length === 0) {
750
- p4.cancel(`"${moduleName}" has no declared sub-modules.`);
912
+ p5.cancel(`"${moduleNames[0]}" has no declared sub-modules.`);
751
913
  process.exit(1);
752
914
  }
753
915
  const invalid = withSubModules.filter((sub) => !parentManifest.subModules.includes(sub));
754
916
  if (invalid.length > 0) {
755
- p4.cancel(
756
- `Unknown sub-module(s) for "${moduleName}": ${invalid.join(", ")}.
917
+ p5.cancel(
918
+ `Unknown sub-module(s) for "${moduleNames[0]}": ${invalid.join(", ")}.
757
919
  Available: ${parentManifest.subModules.join(", ")}`
758
920
  );
759
921
  process.exit(1);
760
922
  }
761
923
  }
762
924
  }
763
- const s = p4.spinner();
925
+ const s = p5.spinner();
764
926
  s.start("Resolving dependencies...");
765
927
  const resolved = /* @__PURE__ */ new Set();
766
928
  const orderedModules = [];
@@ -770,7 +932,7 @@ Available: ${parentManifest.subModules.join(", ")}`
770
932
  }
771
933
  } catch (err) {
772
934
  s.stop("Dependency resolution failed.");
773
- p4.cancel(err instanceof Error ? err.message : String(err));
935
+ p5.cancel(err instanceof Error ? err.message : String(err));
774
936
  process.exit(1);
775
937
  }
776
938
  s.stop(`Resolved: ${orderedModules.join(" \u2192 ")}`);
@@ -778,21 +940,34 @@ Available: ${parentManifest.subModules.join(", ")}`
778
940
  for (const id of orderedModules) {
779
941
  manifests.set(id, await fetchModuleManifest(id, localPath));
780
942
  }
943
+ for (const [id, manifest] of manifests) {
944
+ if (installedNames.has(id)) continue;
945
+ for (const incompatible of manifest.incompatibleWith) {
946
+ if (installedNames.has(incompatible)) {
947
+ p5.cancel(`Module "${id}" is incompatible with installed module "${incompatible}".`);
948
+ process.exit(1);
949
+ }
950
+ if (manifests.has(incompatible) && !installedNames.has(incompatible)) {
951
+ p5.cancel(`Module "${id}" is incompatible with module "${incompatible}" (also being installed).`);
952
+ process.exit(1);
953
+ }
954
+ }
955
+ }
781
956
  const allDeps = /* @__PURE__ */ new Set();
782
957
  for (const manifest of manifests.values()) {
783
958
  for (const dep of manifest.dependencies) {
784
959
  allDeps.add(dep);
785
960
  }
786
961
  }
787
- p4.log.message("\nSummary of changes:");
962
+ p5.log.message("\nSummary of changes:");
788
963
  for (const [id, manifest] of manifests) {
789
- p4.log.message(`
964
+ p5.log.message(`
790
965
  Module: ${id}@${manifest.version}`);
791
966
  for (const file of manifest.files) {
792
- p4.log.message(` + ${file.dest}`);
967
+ p5.log.message(` + ${file.dest}`);
793
968
  }
794
969
  for (const transform of manifest.transforms) {
795
- p4.log.message(` ~ ${transform.type} \u2192 ${transform.target}`);
970
+ p5.log.message(` ~ ${transform.type} \u2192 ${transform.target}`);
796
971
  }
797
972
  }
798
973
  const allEnvVars = /* @__PURE__ */ new Set();
@@ -802,33 +977,36 @@ Available: ${parentManifest.subModules.join(", ")}`
802
977
  }
803
978
  }
804
979
  if (allDeps.size > 0) {
805
- p4.log.message(`
980
+ p5.log.message(`
806
981
  npm deps: ${[...allDeps].join(", ")}`);
807
982
  }
808
983
  if (allEnvVars.size > 0) {
809
- p4.log.message(`
984
+ p5.log.message(`
810
985
  env vars required: ${[...allEnvVars].join(", ")}`);
811
986
  }
812
987
  if (!dryRun) {
813
- const confirm4 = await p4.confirm({
988
+ const confirm6 = await p5.confirm({
814
989
  message: "Proceed?",
815
990
  initialValue: true
816
991
  });
817
- if (p4.isCancel(confirm4) || !confirm4) {
818
- p4.outro("Cancelled.");
992
+ if (p5.isCancel(confirm6) || !confirm6) {
993
+ p5.outro("Cancelled.");
819
994
  return;
820
995
  }
821
996
  }
822
997
  const primaryTargetSet = new Set(allTargets);
823
998
  const depModules = orderedModules.filter((id) => !primaryTargetSet.has(id) && !installedNames.has(id));
824
999
  const targetModules = orderedModules.filter((id) => primaryTargetSet.has(id));
1000
+ const installedFilesMap = /* @__PURE__ */ new Map();
825
1001
  const depPostInstallHooks = [];
826
1002
  if (depModules.length > 0) {
827
- p4.log.step(`Installing module dependencies: ${depModules.join(", ")}`);
1003
+ p5.log.step(`Installing module dependencies: ${depModules.join(", ")}`);
828
1004
  for (const dep of depModules) {
829
1005
  const depManifest = manifests.get(dep);
830
1006
  if (!depManifest) continue;
831
- await installModule(dep, depManifest, cwd, dryRun, localPath);
1007
+ const dests = await installModule(dep, depManifest, cwd, dryRun, installedNames, localPath);
1008
+ installedFilesMap.set(dep, dests);
1009
+ installedNames.add(dep);
832
1010
  if (depManifest.postInstall && depManifest.postInstall.length > 0) {
833
1011
  depPostInstallHooks.push({ name: dep, hooks: depManifest.postInstall });
834
1012
  }
@@ -837,14 +1015,16 @@ Available: ${parentManifest.subModules.join(", ")}`
837
1015
  for (const targetId of targetModules) {
838
1016
  const targetManifest = manifests.get(targetId);
839
1017
  if (!targetManifest) continue;
840
- await installModule(targetId, targetManifest, cwd, dryRun, localPath);
1018
+ const dests = await installModule(targetId, targetManifest, cwd, dryRun, installedNames, localPath);
1019
+ installedFilesMap.set(targetId, dests);
1020
+ installedNames.add(targetId);
841
1021
  }
842
1022
  installNpmDeps([...allDeps], cwd, dryRun);
843
1023
  if (!dryRun) {
844
1024
  for (const { name, hooks } of depPostInstallHooks) {
845
- p4.log.step(`Running post-install hooks for ${name}...`);
1025
+ p5.log.step(`Running post-install hooks for ${name}...`);
846
1026
  for (const hook of hooks) {
847
- p4.log.message(` $ ${hook}`);
1027
+ p5.log.message(` $ ${hook}`);
848
1028
  execSync2(hook, { cwd, stdio: "inherit" });
849
1029
  }
850
1030
  }
@@ -853,9 +1033,9 @@ Available: ${parentManifest.subModules.join(", ")}`
853
1033
  for (const targetId of targetModules) {
854
1034
  const targetManifest = manifests.get(targetId);
855
1035
  if (!targetManifest?.postInstall?.length) continue;
856
- p4.log.step(`Running post-install hooks for ${targetId}...`);
1036
+ p5.log.step(`Running post-install hooks for ${targetId}...`);
857
1037
  for (const hook of targetManifest.postInstall) {
858
- p4.log.message(` $ ${hook}`);
1038
+ p5.log.message(` $ ${hook}`);
859
1039
  execSync2(hook, { cwd, stdio: "inherit" });
860
1040
  }
861
1041
  }
@@ -864,72 +1044,328 @@ Available: ${parentManifest.subModules.join(", ")}`
864
1044
  const now = (/* @__PURE__ */ new Date()).toISOString();
865
1045
  for (const dep of depModules) {
866
1046
  const depManifest = manifests.get(dep);
867
- if (depManifest) recordModule(config, dep, depManifest, now);
1047
+ const dests = installedFilesMap.get(dep) ?? [];
1048
+ if (depManifest) recordModule(config, dep, depManifest, dests, now);
868
1049
  }
869
1050
  for (const targetId of targetModules) {
870
1051
  const targetManifest = manifests.get(targetId);
871
- if (targetManifest) recordModule(config, targetId, targetManifest, now);
1052
+ const dests = installedFilesMap.get(targetId) ?? [];
1053
+ if (targetManifest) recordModule(config, targetId, targetManifest, dests, now);
872
1054
  }
873
1055
  await writeConfig(config, cwd);
874
1056
  }
875
1057
  const label = allTargets.length === 1 ? `"${allTargets[0]}"` : allTargets.map((t) => `"${t}"`).join(", ");
876
- p4.outro(
1058
+ p5.outro(
877
1059
  dryRun ? "Dry run complete \u2014 no files were modified." : `Module(s) ${label} installed successfully!`
878
1060
  );
879
1061
  }
880
1062
 
881
1063
  // src/commands/list.ts
882
- import * as p5 from "@clack/prompts";
1064
+ import * as p6 from "@clack/prompts";
1065
+ function printModule(mod, installedNames, installedModules) {
1066
+ const installed = installedNames.has(mod.name);
1067
+ const installedInfo = installed ? installedModules?.find((m) => m.name === mod.name) : null;
1068
+ const status = installed ? `[installed v${installedInfo?.version ?? "?"}]` : "[available]";
1069
+ const desc = mod.description ? ` \u2014 ${mod.description}` : "";
1070
+ p6.log.message(` ${installed ? "\u2713" : "\u25CB"} ${mod.name} ${status}${desc}`);
1071
+ if (mod.subModules && mod.subModules.length > 0) {
1072
+ const last = mod.subModules.length - 1;
1073
+ mod.subModules.forEach((sub, i) => {
1074
+ const subId = `${mod.name}/${sub}`;
1075
+ const subInstalled = installedNames.has(subId);
1076
+ const subInfo = subInstalled ? installedModules?.find((m) => m.name === subId) : null;
1077
+ const subStatus = subInstalled ? `[installed v${subInfo?.version ?? "?"}]` : "[not installed]";
1078
+ const connector = i === last ? "\u2514\u2500" : "\u251C\u2500";
1079
+ p6.log.message(` ${connector} ${sub} ${subStatus}`);
1080
+ });
1081
+ }
1082
+ }
883
1083
  async function runList(options) {
884
1084
  const { cwd, localPath } = options;
885
- p5.intro("impulse list");
1085
+ p6.intro("impulse list");
1086
+ await requireAuth();
886
1087
  const config = await readConfig(cwd);
887
1088
  const installedNames = new Set(
888
1089
  config?.installedModules.map((m) => m.name) ?? []
889
1090
  );
890
- const s = p5.spinner();
1091
+ const s = p6.spinner();
891
1092
  s.start("Fetching available modules...");
892
1093
  let available;
893
1094
  try {
894
1095
  available = await listAvailableModules(localPath);
895
1096
  } catch (err) {
896
1097
  s.stop("Failed to fetch module list.");
897
- p5.cancel(err instanceof Error ? err.message : String(err));
1098
+ p6.cancel(err instanceof Error ? err.message : String(err));
898
1099
  process.exit(1);
899
1100
  }
900
1101
  s.stop(`Found ${available.length} module(s).`);
901
1102
  if (available.length === 0) {
902
- p5.log.message("No modules available yet.");
903
- p5.outro("Done.");
1103
+ p6.log.message("No modules available yet.");
1104
+ p6.outro("Done.");
904
1105
  return;
905
1106
  }
906
- p5.log.message("\nAvailable modules:\n");
907
- for (const mod of available) {
908
- const installed = installedNames.has(mod.name);
909
- const installedInfo = installed ? config?.installedModules.find((m) => m.name === mod.name) : null;
910
- const status = installed ? `[installed v${installedInfo?.version ?? "?"}]` : "[available]";
911
- const desc = mod.description ? ` \u2014 ${mod.description}` : "";
912
- p5.log.message(` ${installed ? "\u2713" : "\u25CB"} ${mod.name} ${status}${desc}`);
913
- if (mod.subModules && mod.subModules.length > 0) {
914
- const last = mod.subModules.length - 1;
915
- mod.subModules.forEach((sub, i) => {
916
- const subId = `${mod.name}/${sub}`;
917
- const subInstalled = installedNames.has(subId);
918
- const subInfo = subInstalled ? config?.installedModules.find((m) => m.name === subId) : null;
919
- const subStatus = subInstalled ? `[installed v${subInfo?.version ?? "?"}]` : "[not installed]";
920
- const connector = i === last ? "\u2514\u2500" : "\u251C\u2500";
921
- p5.log.message(` ${connector} ${sub} ${subStatus}`);
922
- });
1107
+ p6.log.message("\nAvailable modules:\n");
1108
+ const hasCategories = available.some((m) => m.category !== void 0);
1109
+ if (hasCategories) {
1110
+ const groups = /* @__PURE__ */ new Map();
1111
+ for (const mod of available) {
1112
+ const key = mod.category ?? "other";
1113
+ const existing = groups.get(key);
1114
+ if (existing) {
1115
+ existing.push(mod);
1116
+ } else {
1117
+ groups.set(key, [mod]);
1118
+ }
1119
+ }
1120
+ const categoryOrder = ["core", "feature", "integration", "dx", "other"];
1121
+ const sortedKeys = [
1122
+ ...categoryOrder.filter((k) => groups.has(k)),
1123
+ ...[...groups.keys()].filter((k) => !categoryOrder.includes(k))
1124
+ ];
1125
+ for (const key of sortedKeys) {
1126
+ p6.log.message(`${key.toUpperCase()}`);
1127
+ for (const mod of groups.get(key)) {
1128
+ printModule(mod, installedNames, config?.installedModules);
1129
+ }
1130
+ p6.log.message("");
1131
+ }
1132
+ } else {
1133
+ for (const mod of available) {
1134
+ printModule(mod, installedNames, config?.installedModules);
923
1135
  }
924
1136
  }
925
- p5.log.message(
1137
+ p6.log.message(
926
1138
  `
927
1139
  Run \`impulse add <module>\` to install a module.`
928
1140
  );
929
- p5.log.message(
1141
+ p6.log.message(
930
1142
  `Run \`impulse add <parent>/<sub>\` or \`impulse add <parent> --with <sub1>,<sub2>\` for sub-modules.`
931
1143
  );
932
- p5.outro("Done.");
1144
+ p6.outro("Done.");
1145
+ }
1146
+
1147
+ // src/commands/login.ts
1148
+ import * as p7 from "@clack/prompts";
1149
+
1150
+ // src/auth/device-flow.ts
1151
+ import { execFileSync as execFileSync2 } from "child_process";
1152
+ var IMPULSE_BASE_URL = process.env.IMPULSE_BASE_URL ?? "https://impulse.studio";
1153
+ async function requestDeviceCode() {
1154
+ const res = await fetch(`${IMPULSE_BASE_URL}/api/auth/device/code`, {
1155
+ method: "POST",
1156
+ headers: { "Content-Type": "application/json" },
1157
+ body: JSON.stringify({ client: "impulse-cli" })
1158
+ });
1159
+ if (!res.ok) {
1160
+ throw new Error(`Failed to initiate device flow: ${res.status} ${res.statusText}`);
1161
+ }
1162
+ const data = await res.json();
1163
+ assertDeviceCodeResponse(data);
1164
+ return data;
1165
+ }
1166
+ async function pollDeviceToken(deviceCode) {
1167
+ let res;
1168
+ try {
1169
+ res = await fetch(`${IMPULSE_BASE_URL}/api/auth/device/token`, {
1170
+ method: "POST",
1171
+ headers: { "Content-Type": "application/json" },
1172
+ body: JSON.stringify({ deviceCode })
1173
+ });
1174
+ } catch {
1175
+ return { status: "error", message: "Network error while polling for token" };
1176
+ }
1177
+ if (res.status === 200) {
1178
+ const data = await res.json();
1179
+ assertDeviceTokenResponse(data);
1180
+ return { status: "authorized", data };
1181
+ }
1182
+ if (res.status === 202) {
1183
+ return { status: "pending" };
1184
+ }
1185
+ if (res.status === 400) {
1186
+ let body;
1187
+ try {
1188
+ body = await res.json();
1189
+ } catch {
1190
+ body = {};
1191
+ }
1192
+ const error = typeof body === "object" && body !== null && "error" in body ? String(body.error) : "";
1193
+ if (error === "slow_down") return { status: "slow_down" };
1194
+ if (error === "expired" || error === "authorization_expired") return { status: "expired" };
1195
+ return { status: "error", message: error || "Device code error" };
1196
+ }
1197
+ return { status: "error", message: `Unexpected response: ${res.status}` };
1198
+ }
1199
+ function openBrowser(url) {
1200
+ try {
1201
+ const platform = process.platform;
1202
+ if (platform === "darwin") {
1203
+ execFileSync2("open", [url]);
1204
+ } else if (platform === "win32") {
1205
+ execFileSync2("cmd", ["/c", "start", url]);
1206
+ } else {
1207
+ execFileSync2("xdg-open", [url]);
1208
+ }
1209
+ } catch {
1210
+ }
1211
+ }
1212
+ function assertDeviceCodeResponse(data) {
1213
+ if (typeof data !== "object" || data === null || typeof data.deviceCode !== "string" || typeof data.userCode !== "string" || typeof data.verificationUri !== "string") {
1214
+ throw new Error("Invalid device code response from server");
1215
+ }
1216
+ }
1217
+ function assertDeviceTokenResponse(data) {
1218
+ if (typeof data !== "object" || data === null || typeof data.token !== "string" || typeof data.email !== "string") {
1219
+ throw new Error("Invalid token response from server");
1220
+ }
1221
+ }
1222
+
1223
+ // src/auth/write-auth.ts
1224
+ import fsExtra14 from "fs-extra";
1225
+ import { chmod } from "fs/promises";
1226
+ var { outputJson } = fsExtra14;
1227
+ async function writeAuth(credentials) {
1228
+ const file = authPath();
1229
+ await outputJson(file, credentials, { spaces: 2, mode: 384 });
1230
+ await chmod(file, 384);
1231
+ }
1232
+
1233
+ // src/commands/login.ts
1234
+ var DEFAULT_POLL_INTERVAL_MS = 5e3;
1235
+ var MAX_POLL_DURATION_MS = 5 * 60 * 1e3;
1236
+ async function runLogin() {
1237
+ p7.intro("impulse login");
1238
+ const existing = await readAuth();
1239
+ if (existing) {
1240
+ const reauth = await p7.confirm({
1241
+ message: `Already logged in as ${existing.email}. Log in again?`,
1242
+ initialValue: false
1243
+ });
1244
+ if (p7.isCancel(reauth) || !reauth) {
1245
+ p7.outro("Cancelled.");
1246
+ return;
1247
+ }
1248
+ }
1249
+ const s = p7.spinner();
1250
+ s.start("Initiating login...");
1251
+ let deviceCode;
1252
+ try {
1253
+ deviceCode = await requestDeviceCode();
1254
+ } catch (err) {
1255
+ s.stop("Failed to initiate login.");
1256
+ p7.cancel(err instanceof Error ? err.message : String(err));
1257
+ process.exit(1);
1258
+ }
1259
+ s.stop("Opening browser for authentication...");
1260
+ const browserUrl = deviceCode.verificationUri;
1261
+ p7.log.message(`
1262
+ Visit the following URL to authenticate:
1263
+ ${browserUrl}
1264
+ `);
1265
+ p7.log.message(`Your one-time code: ${deviceCode.userCode}
1266
+ `);
1267
+ openBrowser(browserUrl);
1268
+ p7.log.info(`If the browser did not open, copy the URL above.
1269
+ Base URL: ${IMPULSE_BASE_URL}`);
1270
+ const pollInterval = Math.max(
1271
+ (deviceCode.interval ?? 5) * 1e3,
1272
+ DEFAULT_POLL_INTERVAL_MS
1273
+ );
1274
+ const expiresAt = Date.now() + (deviceCode.expiresIn ?? 300) * 1e3;
1275
+ const deadline = Math.min(expiresAt, Date.now() + MAX_POLL_DURATION_MS);
1276
+ const pollSpinner = p7.spinner();
1277
+ pollSpinner.start("Waiting for authentication...");
1278
+ let currentInterval = pollInterval;
1279
+ while (Date.now() < deadline) {
1280
+ await sleep(currentInterval);
1281
+ const result = await pollDeviceToken(deviceCode.deviceCode);
1282
+ if (result.status === "authorized") {
1283
+ pollSpinner.stop("Authentication successful!");
1284
+ await writeAuth({
1285
+ token: result.data.token,
1286
+ refreshToken: result.data.refreshToken,
1287
+ expiresAt: result.data.expiresAt,
1288
+ email: result.data.email
1289
+ });
1290
+ p7.outro(`Logged in as ${result.data.email}`);
1291
+ return;
1292
+ }
1293
+ if (result.status === "slow_down") {
1294
+ currentInterval += 5e3;
1295
+ continue;
1296
+ }
1297
+ if (result.status === "expired") {
1298
+ pollSpinner.stop("Device code expired.");
1299
+ p7.cancel("Authentication timed out. Run `impulse login` again.");
1300
+ process.exit(1);
1301
+ }
1302
+ if (result.status === "error") {
1303
+ pollSpinner.stop("Authentication failed.");
1304
+ p7.cancel(result.message);
1305
+ process.exit(1);
1306
+ }
1307
+ }
1308
+ pollSpinner.stop("Authentication timed out.");
1309
+ p7.cancel("Authentication timed out. Run `impulse login` again.");
1310
+ process.exit(1);
1311
+ }
1312
+ function sleep(ms) {
1313
+ return new Promise((resolve) => setTimeout(resolve, ms));
1314
+ }
1315
+
1316
+ // src/commands/logout.ts
1317
+ import * as p8 from "@clack/prompts";
1318
+
1319
+ // src/auth/clear-auth.ts
1320
+ import fsExtra15 from "fs-extra";
1321
+ var { remove, pathExists: pathExists13 } = fsExtra15;
1322
+ async function clearAuth() {
1323
+ const file = authPath();
1324
+ if (!await pathExists13(file)) return false;
1325
+ await remove(file);
1326
+ return true;
1327
+ }
1328
+
1329
+ // src/commands/logout.ts
1330
+ async function runLogout() {
1331
+ p8.intro("impulse logout");
1332
+ const credentials = await readAuth();
1333
+ if (!credentials) {
1334
+ p8.outro("Not currently logged in.");
1335
+ return;
1336
+ }
1337
+ const confirm6 = await p8.confirm({
1338
+ message: `Log out ${credentials.email}?`,
1339
+ initialValue: true
1340
+ });
1341
+ if (p8.isCancel(confirm6) || !confirm6) {
1342
+ p8.outro("Cancelled.");
1343
+ return;
1344
+ }
1345
+ await clearAuth();
1346
+ p8.outro("Logged out successfully.");
1347
+ }
1348
+
1349
+ // src/commands/whoami.ts
1350
+ import * as p9 from "@clack/prompts";
1351
+ async function runWhoami() {
1352
+ p9.intro("impulse whoami");
1353
+ const credentials = await readAuth();
1354
+ if (!credentials) {
1355
+ p9.cancel("Not authenticated. Run `impulse login` first.");
1356
+ process.exit(1);
1357
+ }
1358
+ p9.log.message(`Logged in as: ${credentials.email}`);
1359
+ if (credentials.expiresAt) {
1360
+ const expires = new Date(credentials.expiresAt);
1361
+ const now = /* @__PURE__ */ new Date();
1362
+ if (expires < now) {
1363
+ p9.log.warn("Your session has expired. Run `impulse login` to re-authenticate.");
1364
+ } else {
1365
+ p9.log.message(`Session expires: ${expires.toLocaleString()}`);
1366
+ }
1367
+ }
1368
+ p9.outro("Done.");
933
1369
  }
934
1370
 
935
1371
  // src/cli-version.ts
@@ -943,17 +1379,17 @@ program.name("impulse").description("ImpulseLab CLI \u2014 install and manage mo
943
1379
  program.command("init").description("Initialize impulse in the current project").option("--force", "Reinitialize even if .impulse.json already exists", false).action(async (options) => {
944
1380
  await runInit({ cwd: process.cwd(), force: options.force });
945
1381
  });
946
- program.command("add <module>").description(
947
- "Add a module to the current project.\n Supports sub-module syntax: `add attio/quote-to-cash`\n Use --with to install multiple sub-modules at once: `add attio --with quote-to-cash,gocardless`"
1382
+ program.command("add [modules...]").description(
1383
+ "Add module(s) to the current project.\n No args: opens interactive multi-select picker\n Single module: `add auth`\n Multiple modules: `add auth attio/gocardless`\n Sub-module syntax: `add attio/quote-to-cash`\n Use --with to install multiple sub-modules at once: `add attio --with quote-to-cash,gocardless`"
948
1384
  ).option("--dry-run", "Preview changes without writing files", false).option(
949
1385
  "--local <path>",
950
1386
  "Use a local modules directory (for development)"
951
1387
  ).option(
952
1388
  "--with <submodules>",
953
1389
  "Comma-separated sub-modules to install alongside the parent (e.g. --with quote-to-cash,gocardless)"
954
- ).action(async (moduleName, options) => {
1390
+ ).action(async (modules, options) => {
955
1391
  const addOpts = {
956
- moduleName,
1392
+ moduleNames: modules ?? [],
957
1393
  cwd: process.cwd(),
958
1394
  dryRun: options.dryRun
959
1395
  };
@@ -971,4 +1407,13 @@ program.command("list").description("List available and installed modules").opti
971
1407
  if (options.local !== void 0) listOpts.localPath = options.local;
972
1408
  await runList(listOpts);
973
1409
  });
1410
+ program.command("login").description("Authenticate with ImpulseLab (opens browser for device flow)").action(async () => {
1411
+ await runLogin();
1412
+ });
1413
+ program.command("logout").description("Log out and clear stored credentials").action(async () => {
1414
+ await runLogout();
1415
+ });
1416
+ program.command("whoami").description("Show the currently authenticated user").action(async () => {
1417
+ await runWhoami();
1418
+ });
974
1419
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impulselab/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "ImpulseLab CLI — install and manage modules for your projects",
5
5
  "private": false,
6
6
  "type": "module",
@@ -39,6 +39,7 @@
39
39
  "dev": "tsup src/index.ts --format esm --target es2022 --watch",
40
40
  "lint": "oxlint .",
41
41
  "test": "vitest run",
42
+ "release:publish": "pnpm run test && pnpm run build && pnpm publish --no-git-checks --access public",
42
43
  "release": "bumpp"
43
44
  }
44
45
  }