@impulselab/cli 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +549 -33
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -146,7 +146,7 @@ async function isUltimateTemplate(cwd) {
146
146
  return foundDeps.size >= STRUCTURAL_MARKERS.workspaceDeps.length;
147
147
  }
148
148
  async function runInit(options) {
149
- const { cwd, force } = options;
149
+ const { cwd, force, skipDetection = false } = options;
150
150
  p.intro("impulse init");
151
151
  if (!force && await hasConfig(cwd)) {
152
152
  p.log.warn(
@@ -165,17 +165,21 @@ async function runInit(options) {
165
165
  );
166
166
  process.exit(1);
167
167
  }
168
- const isUT = await isUltimateTemplate(cwd);
169
- s.stop(isUT ? "Ultimate Template project detected." : "Project detected.");
170
- if (!isUT) {
171
- const proceed = await p.confirm({
172
- message: "This project does not appear to be an ImpulseLab Ultimate Template. Continue anyway?",
173
- initialValue: false
174
- });
175
- if (p.isCancel(proceed) || !proceed) {
176
- p.cancel("Initialization cancelled.");
177
- process.exit(0);
168
+ if (!skipDetection) {
169
+ const isUT = await isUltimateTemplate(cwd);
170
+ s.stop(isUT ? "Ultimate Template project detected." : "Project detected.");
171
+ if (!isUT) {
172
+ const proceed = await p.confirm({
173
+ message: "This project does not appear to be an ImpulseLab Ultimate Template. Continue anyway?",
174
+ initialValue: false
175
+ });
176
+ if (p.isCancel(proceed) || !proceed) {
177
+ p.cancel("Initialization cancelled.");
178
+ process.exit(0);
179
+ }
178
180
  }
181
+ } else {
182
+ s.stop("Project ready.");
179
183
  }
180
184
  const projectName = await detectProjectName(cwd);
181
185
  const srcExists = await pathExists3(path2.join(cwd, "src"));
@@ -197,6 +201,7 @@ async function runInit(options) {
197
201
  // src/commands/add.ts
198
202
  import { execSync as execSync2, execFileSync } from "child_process";
199
203
  import { existsSync } from "fs";
204
+ import { readFile as readFile7, writeFile, unlink } from "fs/promises";
200
205
  import path12 from "path";
201
206
  import * as p5 from "@clack/prompts";
202
207
 
@@ -271,7 +276,8 @@ var registryConstants = {
271
276
  githubBranch: "main",
272
277
  modulesDir: "modules",
273
278
  subModulesDirName: "sub-modules",
274
- manifestFileName: "module.json"
279
+ manifestFileName: "module.json",
280
+ presetsDir: "presets"
275
281
  };
276
282
 
277
283
  // src/registry/github-urls.ts
@@ -442,10 +448,10 @@ async function listAvailableModules(localPath) {
442
448
  // src/registry/fetch-module-file.ts
443
449
  async function fetchModuleFile(moduleName, fileSrc, localPath) {
444
450
  if (localPath) {
445
- const { readFile: readFile7 } = await import("fs/promises");
451
+ const { readFile: readFile9 } = await import("fs/promises");
446
452
  const { join } = await import("path");
447
453
  const file = join(localPath, moduleName, fileSrc);
448
- return readFile7(file, "utf-8");
454
+ return readFile9(file, "utf-8");
449
455
  }
450
456
  const url = githubUrls.rawFile(moduleName, fileSrc);
451
457
  const res = await fetch(url, { headers: getGitHubHeaders() });
@@ -782,6 +788,7 @@ function installNpmDeps(deps, cwd, dryRun) {
782
788
  async function installModule(moduleId, manifest, cwd, dryRun, installedModules, localPath) {
783
789
  p5.log.step(`Installing ${moduleId}@${manifest.version}...`);
784
790
  const installedDests = [];
791
+ const createdDests = [];
785
792
  if (manifest.files.length > 0) {
786
793
  const installed = await installFiles({
787
794
  moduleName: moduleId,
@@ -797,13 +804,34 @@ async function installModule(moduleId, manifest, cwd, dryRun, installedModules,
797
804
  continue;
798
805
  }
799
806
  installedDests.push(f.dest);
807
+ if (f.action === "created" || f.action === "would-create") {
808
+ createdDests.push(f.dest);
809
+ }
800
810
  const icon = f.action === "created" || f.action === "would-create" ? "+" : f.action === "overwritten" || f.action === "would-overwrite" ? "~" : "=";
801
811
  p5.log.message(` ${icon} ${f.dest}`);
802
812
  }
803
813
  }
804
814
  for (const transform of manifest.transforms) {
805
815
  p5.log.step(` transform: ${transform.type} \u2192 ${transform.target}`);
806
- await runTransform(transform, cwd, dryRun);
816
+ try {
817
+ await runTransform(transform, cwd, dryRun);
818
+ } catch (err) {
819
+ p5.log.warn(
820
+ `Transform "${transform.type} \u2192 ${transform.target}" failed for module "${moduleId}": ${err instanceof Error ? err.message : String(err)}`
821
+ );
822
+ if (!dryRun && createdDests.length > 0) {
823
+ p5.log.warn(`Rolling back ${createdDests.length} file(s) created by "${moduleId}"...`);
824
+ for (const dest of createdDests) {
825
+ try {
826
+ await unlink(path12.join(cwd, dest));
827
+ } catch {
828
+ }
829
+ }
830
+ }
831
+ throw new Error(
832
+ `Module "${moduleId}" install failed during transform "${transform.type} \u2192 ${transform.target}".`
833
+ );
834
+ }
807
835
  }
808
836
  return installedDests;
809
837
  }
@@ -829,7 +857,10 @@ async function pickModulesInteractively(localPath) {
829
857
  modules = await listAvailableModules(localPath);
830
858
  } catch (err) {
831
859
  s.stop("Failed to load modules.");
832
- throw err;
860
+ p5.cancel(
861
+ `Could not load module list: ${err instanceof Error ? err.message : String(err)}`
862
+ );
863
+ process.exit(1);
833
864
  }
834
865
  s.stop("Modules loaded.");
835
866
  if (modules.length === 0) {
@@ -847,7 +878,7 @@ async function pickModulesInteractively(localPath) {
847
878
  options.push({
848
879
  value: `${mod.name}/${sub}`,
849
880
  label: ` \u21B3 ${sub}`,
850
- hint: `sub-module of ${mod.name}`
881
+ hint: `automatically includes ${mod.name}`
851
882
  });
852
883
  }
853
884
  }
@@ -861,20 +892,25 @@ async function pickModulesInteractively(localPath) {
861
892
  process.exit(0);
862
893
  }
863
894
  const result = new Set(selected);
895
+ const autoIncluded = [];
864
896
  for (const id of selected) {
865
897
  const { parent, child } = parseModuleId(id);
866
- if (child !== null) {
898
+ if (child !== null && !selected.includes(parent)) {
867
899
  result.add(parent);
900
+ autoIncluded.push(parent);
868
901
  }
869
902
  }
903
+ if (autoIncluded.length > 0) {
904
+ p5.log.info(`Auto-included parent modules: ${autoIncluded.join(", ")}`);
905
+ }
870
906
  return [...result];
871
907
  }
872
908
  async function runAdd(options) {
873
- let { moduleNames, cwd, dryRun, localPath, withSubModules = [], allowScripts = false } = options;
909
+ let { moduleNames, cwd, dryRun, localPath, withSubModules = [], allowScripts = false, skipIntro = false, skipAuth = false } = options;
874
910
  let allTargets;
875
911
  if (moduleNames.length === 0) {
876
- p5.intro(`impulse add${dryRun ? " [dry-run]" : ""}`);
877
- await requireAuth();
912
+ if (!skipIntro) p5.intro(`impulse add${dryRun ? " [dry-run]" : ""}`);
913
+ if (!skipAuth) await requireAuth();
878
914
  if (withSubModules.length > 0) {
879
915
  p5.log.warn(`--with is ignored in interactive mode. Select sub-modules from the picker.`);
880
916
  withSubModules = [];
@@ -893,8 +929,8 @@ async function runAdd(options) {
893
929
  }
894
930
  const withIds = moduleNames.length === 1 && withSubModules.length > 0 ? withSubModules.map((sub) => `${moduleNames[0]}/${sub}`) : [];
895
931
  allTargets = [...moduleNames, ...withIds];
896
- p5.intro(`impulse add ${allTargets.join(", ")}${dryRun ? " [dry-run]" : ""}`);
897
- await requireAuth();
932
+ if (!skipIntro) p5.intro(`impulse add ${allTargets.join(", ")}${dryRun ? " [dry-run]" : ""}`);
933
+ if (!skipAuth) await requireAuth();
898
934
  }
899
935
  const config = await readConfig(cwd);
900
936
  if (!config) {
@@ -924,7 +960,14 @@ async function runAdd(options) {
924
960
  }
925
961
  }
926
962
  if (moduleNames.length === 1 && withSubModules.length > 0) {
927
- const parentManifest = await getManifest(moduleNames[0], localPath, manifests).catch(() => null);
963
+ let parentManifest = null;
964
+ try {
965
+ parentManifest = await getManifest(moduleNames[0], localPath, manifests);
966
+ } catch (err) {
967
+ p5.log.warn(
968
+ `Could not fetch manifest for "${moduleNames[0]}" to validate --with sub-modules: ${err instanceof Error ? err.message : String(err)}. Skipping validation.`
969
+ );
970
+ }
928
971
  if (parentManifest) {
929
972
  if (parentManifest.subModules.length === 0) {
930
973
  p5.cancel(`"${moduleNames[0]}" has no declared sub-modules.`);
@@ -1090,13 +1133,26 @@ Available: ${parentManifest.subModules.join(", ")}`
1090
1133
  runHooks = confirmed;
1091
1134
  }
1092
1135
  if (runHooks) {
1136
+ const hookFailures = [];
1093
1137
  for (const { name, hooks } of allPostInstallHooks) {
1094
1138
  p5.log.step(`Running post-install hooks for ${name}...`);
1095
1139
  for (const hook of hooks) {
1096
1140
  p5.log.message(` $ ${hook}`);
1097
- execSync2(hook, { cwd, stdio: "inherit" });
1141
+ try {
1142
+ execSync2(hook, { cwd, stdio: "inherit" });
1143
+ } catch (err) {
1144
+ const msg = `Post-install hook failed for "${name}" ($ ${hook}): ${err instanceof Error ? err.message : String(err)}`;
1145
+ p5.log.warn(`${msg}. Continuing.`);
1146
+ hookFailures.push(msg);
1147
+ }
1098
1148
  }
1099
1149
  }
1150
+ if (hookFailures.length > 0) {
1151
+ p5.log.warn(
1152
+ `${hookFailures.length} post-install hook(s) failed. See warnings above.`
1153
+ );
1154
+ process.exitCode = 1;
1155
+ }
1100
1156
  } else {
1101
1157
  p5.log.warn("Post-install hooks skipped.");
1102
1158
  }
@@ -1114,7 +1170,28 @@ Available: ${parentManifest.subModules.join(", ")}`
1114
1170
  const dests = installedFilesMap.get(targetId) ?? [];
1115
1171
  if (targetManifest) recordModule(config, targetId, targetManifest, dests, now);
1116
1172
  }
1117
- await writeConfig(config, cwd);
1173
+ const cfgPath = configPath(cwd);
1174
+ let backup = null;
1175
+ try {
1176
+ backup = await readFile7(cfgPath, "utf-8");
1177
+ } catch {
1178
+ }
1179
+ try {
1180
+ await writeConfig(config, cwd);
1181
+ } catch (err) {
1182
+ p5.log.warn(
1183
+ `Failed to write .impulse.json: ${err instanceof Error ? err.message : String(err)}`
1184
+ );
1185
+ if (backup !== null) {
1186
+ try {
1187
+ await writeFile(cfgPath, backup, "utf-8");
1188
+ p5.log.warn("Config restored to previous state.");
1189
+ } catch {
1190
+ p5.log.warn("Could not restore config backup \u2014 .impulse.json may be in a partial state.");
1191
+ }
1192
+ }
1193
+ throw err;
1194
+ }
1118
1195
  }
1119
1196
  const label = allTargets.length === 1 ? `"${allTargets[0]}"` : allTargets.map((t) => `"${t}"`).join(", ");
1120
1197
  p5.outro(
@@ -1214,11 +1291,20 @@ import { execFileSync as execFileSync2 } from "child_process";
1214
1291
  var IMPULSE_BASE_URL = process.env.IMPULSE_BASE_URL ?? "https://impulselab.ai";
1215
1292
  var CLIENT_ID = "impulse-cli";
1216
1293
  async function requestDeviceCode() {
1217
- const res = await fetch(`${IMPULSE_BASE_URL}/api/auth/device/code`, {
1218
- method: "POST",
1219
- headers: { "Content-Type": "application/json" },
1220
- body: JSON.stringify({ client_id: CLIENT_ID })
1221
- });
1294
+ const url = `${IMPULSE_BASE_URL}/api/auth/device/code`;
1295
+ let res;
1296
+ try {
1297
+ res = await fetch(url, {
1298
+ method: "POST",
1299
+ headers: { "Content-Type": "application/json" },
1300
+ body: JSON.stringify({ client_id: CLIENT_ID })
1301
+ });
1302
+ } catch (err) {
1303
+ const reason = err instanceof Error ? err.message : String(err);
1304
+ throw new Error(
1305
+ `Network error calling ${url}: ${reason}. Check connection, VPN, proxy, and IMPULSE_BASE_URL (must match your deployed agency site).`
1306
+ );
1307
+ }
1222
1308
  if (!res.ok) {
1223
1309
  throw new Error(`Failed to initiate device flow: ${res.status} ${res.statusText}`);
1224
1310
  }
@@ -1275,6 +1361,25 @@ function openBrowser(url) {
1275
1361
  } catch {
1276
1362
  }
1277
1363
  }
1364
+ function copyToClipboard(text3) {
1365
+ try {
1366
+ const platform = process.platform;
1367
+ if (platform === "darwin") {
1368
+ execFileSync2("pbcopy", { input: text3 });
1369
+ } else if (platform === "win32") {
1370
+ execFileSync2("clip", { input: text3 });
1371
+ } else {
1372
+ try {
1373
+ execFileSync2("xclip", ["-selection", "clipboard"], { input: text3 });
1374
+ } catch {
1375
+ execFileSync2("xsel", ["--clipboard", "--input"], { input: text3 });
1376
+ }
1377
+ }
1378
+ return true;
1379
+ } catch {
1380
+ return false;
1381
+ }
1382
+ }
1278
1383
  function assertDeviceCodeResponse(data) {
1279
1384
  if (typeof data !== "object" || data === null || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string") {
1280
1385
  throw new Error("Invalid device code response from server");
@@ -1345,9 +1450,13 @@ async function runLogin() {
1345
1450
  Visit the following URL to authenticate:
1346
1451
  ${browserUrl}
1347
1452
  `);
1348
- p7.log.message(`Your one-time code: ${deviceCode.userCode}
1349
- `);
1453
+ const rawCode = deviceCode.userCode;
1454
+ const mid = Math.ceil(rawCode.length / 2);
1455
+ const formattedCode = rawCode.length >= 6 ? `${rawCode.slice(0, mid)}-${rawCode.slice(mid)}` : rawCode;
1350
1456
  openBrowser(browserUrl);
1457
+ const copied = copyToClipboard(rawCode);
1458
+ p7.log.message(`Your one-time code: ${formattedCode}${copied ? " (copied to clipboard)" : ""}
1459
+ `);
1351
1460
  p7.log.info(`If the browser did not open, copy the URL above.
1352
1461
  Base URL: ${IMPULSE_BASE_URL}`);
1353
1462
  const pollInterval = Math.max(
@@ -1457,6 +1566,400 @@ async function runWhoami() {
1457
1566
  p9.outro("Done.");
1458
1567
  }
1459
1568
 
1569
+ // src/commands/create.ts
1570
+ import { execFileSync as execFileSync3 } from "child_process";
1571
+ import { existsSync as existsSync2 } from "fs";
1572
+ import path15 from "path";
1573
+ import * as p10 from "@clack/prompts";
1574
+ import fsExtra18 from "fs-extra";
1575
+
1576
+ // src/registry/fetch-template.ts
1577
+ import fsExtra16 from "fs-extra";
1578
+ import path13 from "path";
1579
+
1580
+ // src/schemas/template-manifest.ts
1581
+ import { z as z7 } from "zod";
1582
+ var TemplateFileSchema = z7.object({
1583
+ src: z7.string(),
1584
+ dest: z7.string()
1585
+ });
1586
+ var TemplateManifestSchema = z7.object({
1587
+ name: z7.string(),
1588
+ description: z7.string(),
1589
+ /** Human-readable labels describing the tech stack (e.g. ["Next.js 15", "Tailwind CSS v4"]). */
1590
+ stack: z7.array(z7.string()).default([]),
1591
+ /** Module IDs pre-selected in the interactive picker (e.g. ["db", "auth"]). */
1592
+ defaultModules: z7.array(z7.string()).default([]),
1593
+ /** Files to copy from the template directory into the new project. */
1594
+ files: z7.array(TemplateFileSchema).default([])
1595
+ });
1596
+
1597
+ // src/registry/fetch-template.ts
1598
+ var { readJson: readJson6, pathExists: pathExists14, readFile: readFile8 } = fsExtra16;
1599
+ var TEMPLATES_DIR = "templates";
1600
+ var TEMPLATE_MANIFEST_FILE = "template.json";
1601
+ function templateRawUrl(templateId, file) {
1602
+ return `https://raw.githubusercontent.com/${registryConstants.githubOrg}/${registryConstants.githubRepo}/${registryConstants.githubBranch}/${TEMPLATES_DIR}/${templateId}/${file}`;
1603
+ }
1604
+ async function fetchTemplateManifest(templateId, localPath) {
1605
+ if (localPath) {
1606
+ const file = path13.join(localPath, TEMPLATES_DIR, templateId, TEMPLATE_MANIFEST_FILE);
1607
+ if (!await pathExists14(file)) {
1608
+ throw new Error(`Local template not found: ${file}`);
1609
+ }
1610
+ const raw2 = await readJson6(file);
1611
+ const parsed2 = TemplateManifestSchema.safeParse(raw2);
1612
+ if (!parsed2.success) {
1613
+ throw new Error(`Invalid template.json for "${templateId}": ${parsed2.error.message}`);
1614
+ }
1615
+ return parsed2.data;
1616
+ }
1617
+ const url = templateRawUrl(templateId, TEMPLATE_MANIFEST_FILE);
1618
+ const res = await fetch(url, { headers: getGitHubHeaders() });
1619
+ if (!res.ok) {
1620
+ if (res.status === 404) {
1621
+ throw new Error(`Template not found in registry: ${templateId}`);
1622
+ }
1623
+ throw new Error(`Failed to fetch template manifest: ${res.status} ${res.statusText}`);
1624
+ }
1625
+ const raw = await res.json();
1626
+ const parsed = TemplateManifestSchema.safeParse(raw);
1627
+ if (!parsed.success) {
1628
+ throw new Error(`Invalid template.json for "${templateId}": ${parsed.error.message}`);
1629
+ }
1630
+ return parsed.data;
1631
+ }
1632
+ async function fetchTemplateFile(templateId, src, localPath) {
1633
+ if (localPath) {
1634
+ const file = path13.join(localPath, TEMPLATES_DIR, templateId, src);
1635
+ return readFile8(file, "utf-8");
1636
+ }
1637
+ const url = templateRawUrl(templateId, src);
1638
+ const res = await fetch(url, { headers: getGitHubHeaders() });
1639
+ if (!res.ok) {
1640
+ throw new Error(`Failed to fetch template file "${src}": ${res.status} ${res.statusText}`);
1641
+ }
1642
+ return res.text();
1643
+ }
1644
+
1645
+ // src/registry/fetch-preset.ts
1646
+ import fsExtra17 from "fs-extra";
1647
+ import path14 from "path";
1648
+
1649
+ // src/schemas/preset-manifest.ts
1650
+ import { z as z8 } from "zod";
1651
+ var PresetManifestSchema = z8.object({
1652
+ name: z8.string(),
1653
+ description: z8.string(),
1654
+ /** Template ID to scaffold from (references templates/<name>/). */
1655
+ template: z8.string(),
1656
+ /** Modules installed automatically — no user confirmation needed. */
1657
+ defaultModules: z8.array(z8.string()),
1658
+ /** Modules the user can optionally add during create. */
1659
+ optionalModules: z8.array(z8.string()).default([])
1660
+ });
1661
+
1662
+ // src/registry/fetch-preset.ts
1663
+ var { readJson: readJson7, pathExists: pathExists15 } = fsExtra17;
1664
+ var PRESETS_DIR = registryConstants.presetsDir;
1665
+ var PRESET_MANIFEST_FILE = "preset.json";
1666
+ function presetRawUrl(presetId, file) {
1667
+ return `https://raw.githubusercontent.com/${registryConstants.githubOrg}/${registryConstants.githubRepo}/${registryConstants.githubBranch}/${PRESETS_DIR}/${presetId}/${file}`;
1668
+ }
1669
+ async function fetchPresetManifest(presetId, localPath) {
1670
+ if (localPath) {
1671
+ const file = path14.join(localPath, PRESETS_DIR, presetId, PRESET_MANIFEST_FILE);
1672
+ if (!await pathExists15(file)) {
1673
+ throw new Error(`Local preset not found: ${file}`);
1674
+ }
1675
+ const raw2 = await readJson7(file);
1676
+ const parsed2 = PresetManifestSchema.safeParse(raw2);
1677
+ if (!parsed2.success) {
1678
+ throw new Error(`Invalid preset.json for "${presetId}": ${parsed2.error.message}`);
1679
+ }
1680
+ return parsed2.data;
1681
+ }
1682
+ const url = presetRawUrl(presetId, PRESET_MANIFEST_FILE);
1683
+ const res = await fetch(url, { headers: getGitHubHeaders() });
1684
+ if (!res.ok) {
1685
+ if (res.status === 404) {
1686
+ throw new Error(`Preset not found in registry: ${presetId}`);
1687
+ }
1688
+ throw new Error(`Failed to fetch preset manifest: ${res.status} ${res.statusText}`);
1689
+ }
1690
+ const raw = await res.json();
1691
+ const parsed = PresetManifestSchema.safeParse(raw);
1692
+ if (!parsed.success) {
1693
+ throw new Error(`Invalid preset.json for "${presetId}": ${parsed.error.message}`);
1694
+ }
1695
+ return parsed.data;
1696
+ }
1697
+ async function listAvailablePresets(localPath) {
1698
+ if (localPath) {
1699
+ const presetsDir = path14.join(localPath, PRESETS_DIR);
1700
+ if (!await pathExists15(presetsDir)) return [];
1701
+ const { readdir } = await import("fs/promises");
1702
+ const entries2 = await readdir(presetsDir, { withFileTypes: true });
1703
+ return entries2.filter((e) => e.isDirectory()).map((e) => e.name);
1704
+ }
1705
+ const url = `https://api.github.com/repos/${registryConstants.githubOrg}/${registryConstants.githubRepo}/contents/${PRESETS_DIR}`;
1706
+ const res = await fetch(url, { headers: getGitHubHeaders({ Accept: "application/vnd.github.v3+json" }) });
1707
+ if (!res.ok) return ["saas"];
1708
+ const entries = await res.json();
1709
+ if (!Array.isArray(entries)) return ["saas"];
1710
+ return entries.filter((e) => e.type === "dir").map((e) => e.name);
1711
+ }
1712
+
1713
+ // src/commands/create.ts
1714
+ var { outputFile: outputFile7, pathExists: pathExists16 } = fsExtra18;
1715
+ function slugify(name) {
1716
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1717
+ }
1718
+ function isValidProjectName(name) {
1719
+ return /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(name);
1720
+ }
1721
+ async function runCreate(options) {
1722
+ const { cwd, localPath } = options;
1723
+ p10.intro("impulse create");
1724
+ await requireAuth();
1725
+ let projectName = options.projectName;
1726
+ if (!projectName) {
1727
+ const answer = await p10.text({
1728
+ message: "Project name:",
1729
+ placeholder: "my-app",
1730
+ validate(value) {
1731
+ if (!value?.trim()) return "Project name is required.";
1732
+ const slug = slugify(value.trim());
1733
+ if (!isValidProjectName(slug)) {
1734
+ return "Use lowercase letters, numbers, and hyphens only (e.g. my-app).";
1735
+ }
1736
+ return void 0;
1737
+ }
1738
+ });
1739
+ if (p10.isCancel(answer)) {
1740
+ p10.cancel("Cancelled.");
1741
+ process.exit(0);
1742
+ }
1743
+ projectName = slugify(answer.trim());
1744
+ } else {
1745
+ projectName = slugify(projectName.trim());
1746
+ }
1747
+ const targetDir = path15.resolve(cwd, projectName);
1748
+ if (existsSync2(targetDir)) {
1749
+ p10.cancel(`Directory "${projectName}" already exists.`);
1750
+ process.exit(1);
1751
+ }
1752
+ let templateId;
1753
+ let selectedModules = options.modules ?? [];
1754
+ if (options.template) {
1755
+ templateId = options.template;
1756
+ if (!options.modules) {
1757
+ const s = p10.spinner();
1758
+ s.start("Loading available modules...");
1759
+ let availableModules;
1760
+ try {
1761
+ availableModules = await listAvailableModules(localPath);
1762
+ } catch {
1763
+ availableModules = [];
1764
+ }
1765
+ s.stop("Modules loaded.");
1766
+ if (availableModules.length > 0) {
1767
+ const templateManifest = await fetchTemplateManifest(templateId, localPath).catch(() => null);
1768
+ const defaultValues = templateManifest ? templateManifest.defaultModules.filter((d) => availableModules.some((m) => m.name === d)) : [];
1769
+ const pickerOptions = availableModules.map((m) => ({
1770
+ value: m.name,
1771
+ label: m.name,
1772
+ ...m.description !== void 0 ? { hint: m.description } : {}
1773
+ }));
1774
+ const picked = await p10.multiselect({
1775
+ message: "Select modules to install (space to toggle, enter to confirm):",
1776
+ options: pickerOptions,
1777
+ initialValues: defaultValues,
1778
+ required: false
1779
+ });
1780
+ if (p10.isCancel(picked)) {
1781
+ p10.cancel("Cancelled.");
1782
+ process.exit(0);
1783
+ }
1784
+ selectedModules = picked;
1785
+ }
1786
+ }
1787
+ } else {
1788
+ let presetManifest;
1789
+ if (options.preset) {
1790
+ const s = p10.spinner();
1791
+ s.start(`Fetching preset "${options.preset}"...`);
1792
+ try {
1793
+ presetManifest = await fetchPresetManifest(options.preset, localPath);
1794
+ } catch (err) {
1795
+ s.stop("Failed to fetch preset.");
1796
+ p10.cancel(err instanceof Error ? err.message : String(err));
1797
+ process.exit(1);
1798
+ }
1799
+ s.stop(`Preset "${options.preset}" ready.`);
1800
+ } else {
1801
+ const s = p10.spinner();
1802
+ s.start("Loading available presets...");
1803
+ let presetIds;
1804
+ try {
1805
+ presetIds = await listAvailablePresets(localPath);
1806
+ } catch {
1807
+ presetIds = ["saas"];
1808
+ }
1809
+ s.stop("Presets loaded.");
1810
+ let selectedPresetId;
1811
+ if (presetIds.length > 1) {
1812
+ const presetDetails = await Promise.all(
1813
+ presetIds.map(async (id) => {
1814
+ try {
1815
+ const m = await fetchPresetManifest(id, localPath);
1816
+ return { id, description: m.description };
1817
+ } catch {
1818
+ return { id, description: "" };
1819
+ }
1820
+ })
1821
+ );
1822
+ const selected = await p10.select({
1823
+ message: "Select a preset:",
1824
+ options: presetDetails.map(({ id, description }) => ({
1825
+ value: id,
1826
+ label: id,
1827
+ ...description ? { hint: description } : {}
1828
+ })),
1829
+ initialValue: "saas"
1830
+ });
1831
+ if (p10.isCancel(selected)) {
1832
+ p10.cancel("Cancelled.");
1833
+ process.exit(0);
1834
+ }
1835
+ selectedPresetId = selected;
1836
+ } else {
1837
+ selectedPresetId = presetIds[0] ?? "saas";
1838
+ p10.log.info(`Using preset: ${selectedPresetId}`);
1839
+ }
1840
+ const s22 = p10.spinner();
1841
+ s22.start(`Fetching preset "${selectedPresetId}"...`);
1842
+ try {
1843
+ presetManifest = await fetchPresetManifest(selectedPresetId, localPath);
1844
+ } catch (err) {
1845
+ s22.stop("Failed to fetch preset.");
1846
+ p10.cancel(err instanceof Error ? err.message : String(err));
1847
+ process.exit(1);
1848
+ }
1849
+ s22.stop(`Preset "${selectedPresetId}" ready.`);
1850
+ if (!options.modules && presetManifest.optionalModules.length > 0) {
1851
+ const optionalSelected = await p10.multiselect({
1852
+ message: `Optional modules for "${selectedPresetId}" (space to toggle, enter to skip):`,
1853
+ options: presetManifest.optionalModules.map((id) => ({
1854
+ value: id,
1855
+ label: id
1856
+ })),
1857
+ required: false
1858
+ });
1859
+ if (p10.isCancel(optionalSelected)) {
1860
+ p10.cancel("Cancelled.");
1861
+ process.exit(0);
1862
+ }
1863
+ selectedModules = [
1864
+ ...presetManifest.defaultModules,
1865
+ ...optionalSelected
1866
+ ];
1867
+ } else if (!options.modules) {
1868
+ selectedModules = presetManifest.defaultModules;
1869
+ }
1870
+ }
1871
+ templateId = presetManifest.template;
1872
+ if (options.preset && !options.modules) {
1873
+ selectedModules = presetManifest.defaultModules;
1874
+ }
1875
+ }
1876
+ const s2 = p10.spinner();
1877
+ s2.start(`Fetching template "${templateId}"...`);
1878
+ let manifest;
1879
+ try {
1880
+ manifest = await fetchTemplateManifest(templateId, localPath);
1881
+ } catch (err) {
1882
+ s2.stop("Failed to fetch template.");
1883
+ p10.cancel(err instanceof Error ? err.message : String(err));
1884
+ process.exit(1);
1885
+ }
1886
+ s2.stop(`Template "${templateId}" ready.`);
1887
+ p10.log.message(`
1888
+ Stack: ${manifest.stack.join(", ")}`);
1889
+ const s4 = p10.spinner();
1890
+ s4.start(`Scaffolding "${projectName}"...`);
1891
+ try {
1892
+ for (const file of manifest.files) {
1893
+ const content = await fetchTemplateFile(templateId, file.src, localPath);
1894
+ const destPath = path15.join(targetDir, file.dest);
1895
+ await outputFile7(destPath, content, "utf-8");
1896
+ }
1897
+ const pkgPath = path15.join(targetDir, "package.json");
1898
+ if (existsSync2(pkgPath)) {
1899
+ const { readJson: readJson8, writeJson: writeJson2 } = fsExtra18;
1900
+ const pkg = await readJson8(pkgPath);
1901
+ if (pkg && typeof pkg === "object") {
1902
+ pkg["name"] = projectName;
1903
+ await writeJson2(pkgPath, pkg, { spaces: 2 });
1904
+ }
1905
+ }
1906
+ } catch (err) {
1907
+ s4.stop("Scaffold failed.");
1908
+ p10.cancel(err instanceof Error ? err.message : String(err));
1909
+ process.exit(1);
1910
+ }
1911
+ s4.stop(`Project scaffolded at ./${projectName}`);
1912
+ p10.log.step("Initializing impulse...");
1913
+ await runInit({ cwd: targetDir, force: false, skipDetection: true });
1914
+ if (selectedModules.length > 0) {
1915
+ p10.log.step(`Installing modules: ${selectedModules.join(", ")}`);
1916
+ await runAdd({
1917
+ moduleNames: selectedModules,
1918
+ cwd: targetDir,
1919
+ dryRun: false,
1920
+ ...localPath !== void 0 ? { localPath } : {},
1921
+ allowScripts: false,
1922
+ skipIntro: true,
1923
+ skipAuth: true
1924
+ });
1925
+ }
1926
+ const hasPnpmLock = await pathExists16(path15.join(targetDir, "pnpm-lock.yaml"));
1927
+ const pm = hasPnpmLock ? "pnpm" : existsSync2(path15.join(targetDir, "yarn.lock")) ? "yarn" : "pnpm";
1928
+ const s5 = p10.spinner();
1929
+ s5.start(`Running ${pm} install...`);
1930
+ try {
1931
+ execFileSync3(pm, ["install"], { cwd: targetDir, stdio: "pipe" });
1932
+ s5.stop("Dependencies installed.");
1933
+ } catch {
1934
+ s5.stop(`${pm} install failed \u2014 run it manually.`);
1935
+ p10.log.warn(`Run \`cd ${projectName} && ${pm} install\` to install dependencies.`);
1936
+ }
1937
+ const s6 = p10.spinner();
1938
+ s6.start("Initializing git repository...");
1939
+ try {
1940
+ execFileSync3("git", ["init"], { cwd: targetDir, stdio: "pipe" });
1941
+ execFileSync3("git", ["add", "-A"], { cwd: targetDir, stdio: "pipe" });
1942
+ execFileSync3(
1943
+ "git",
1944
+ ["commit", "-m", `feat: initial project from impulse create (${templateId} template)`],
1945
+ { cwd: targetDir, stdio: "pipe" }
1946
+ );
1947
+ s6.stop("Git repository initialized.");
1948
+ } catch {
1949
+ s6.stop("Git init failed \u2014 skipping.");
1950
+ p10.log.warn(`Run \`git init && git add -A && git commit -m "init"\` manually.`);
1951
+ }
1952
+ p10.outro(
1953
+ `
1954
+ Project "${projectName}" created successfully!
1955
+
1956
+ cd ${projectName}
1957
+ ${pm} dev
1958
+
1959
+ Add more modules anytime with \`impulse add\`.`
1960
+ );
1961
+ }
1962
+
1460
1963
  // src/cli-version.ts
1461
1964
  import { createRequire } from "module";
1462
1965
  var require2 = createRequire(import.meta.url);
@@ -1501,6 +2004,19 @@ program.command("list").description("List available and installed modules").opti
1501
2004
  if (options.local !== void 0) listOpts.localPath = options.local;
1502
2005
  await runList(listOpts);
1503
2006
  });
2007
+ program.command("create [name]").description(
2008
+ "Create a new project from a preset or template.\n No args: interactive prompts for name, preset, and optional modules\n With preset: `create my-app --preset saas`\n With template (advanced): `create my-app --template saas`\n With modules: `create my-app --preset minimal --modules auth,ui`"
2009
+ ).option("--preset <preset>", "Preset to use (omit for interactive selection)").option("--template <template>", "Template override \u2014 bypasses preset selection (advanced)").option("--modules <modules>", "Comma-separated modules to install after scaffolding").option("--local <path>", "Use a local presets/templates/modules directory (for development)").action(async (name, options) => {
2010
+ const moduleList = options.modules !== void 0 ? options.modules.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
2011
+ await runCreate({
2012
+ cwd: process.cwd(),
2013
+ ...name !== void 0 ? { projectName: name } : {},
2014
+ ...options.preset !== void 0 ? { preset: options.preset } : {},
2015
+ ...options.template !== void 0 ? { template: options.template } : {},
2016
+ ...moduleList !== void 0 ? { modules: moduleList } : {},
2017
+ ...options.local !== void 0 ? { localPath: options.local } : {}
2018
+ });
2019
+ });
1504
2020
  program.command("login").description("Authenticate with ImpulseLab (opens browser for device flow)").action(async () => {
1505
2021
  await runLogin();
1506
2022
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impulselab/cli",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "ImpulseLab CLI — install and manage modules for your projects",
5
5
  "private": false,
6
6
  "type": "module",