@impulselab/cli 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +616 -83
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -15,9 +15,6 @@ function configPath(cwd = process.cwd()) {
15
15
  return path.join(cwd, CONFIG_FILE);
16
16
  }
17
17
 
18
- // src/config/read-config.ts
19
- import fsExtra from "fs-extra";
20
-
21
18
  // src/schemas/impulse-config.ts
22
19
  import { z } from "zod";
23
20
  var ImpulseConfigSchema = z.object({
@@ -36,20 +33,44 @@ var ImpulseConfigSchema = z.object({
36
33
  ).default([])
37
34
  });
38
35
 
39
- // src/config/read-config.ts
40
- var { readJson, pathExists } = fsExtra;
41
- async function readConfig(cwd = process.cwd()) {
42
- const file = configPath(cwd);
43
- if (!await pathExists(file)) return null;
44
- const raw = await readJson(file);
45
- const parsed = ImpulseConfigSchema.safeParse(raw);
36
+ // src/shared/json-file.ts
37
+ import fsExtra from "fs-extra";
38
+ import { chmod } from "fs/promises";
39
+ var { readJson, pathExists, outputJson } = fsExtra;
40
+ async function readJsonFile(filePath, schema, opts) {
41
+ if (!await pathExists(filePath)) return null;
42
+ let raw;
43
+ try {
44
+ raw = await readJson(filePath);
45
+ } catch {
46
+ if (opts?.throwOnInvalid !== void 0) {
47
+ throw new Error(`${opts.throwOnInvalid}: failed to read file`);
48
+ }
49
+ return null;
50
+ }
51
+ const parsed = schema.safeParse(raw);
46
52
  if (!parsed.success) {
47
- throw new Error(
48
- `Invalid .impulse.json: ${parsed.error.message}`
49
- );
53
+ if (opts?.throwOnInvalid !== void 0) {
54
+ throw new Error(`${opts.throwOnInvalid}: ${parsed.error.message}`);
55
+ }
56
+ return null;
50
57
  }
51
58
  return parsed.data;
52
59
  }
60
+ async function writeJsonFile(filePath, data, opts) {
61
+ const { spaces = 2, mode } = opts ?? {};
62
+ await outputJson(filePath, data, { spaces, ...mode !== void 0 ? { mode } : {} });
63
+ if (mode !== void 0) {
64
+ await chmod(filePath, mode);
65
+ }
66
+ }
67
+
68
+ // src/config/read-config.ts
69
+ async function readConfig(cwd = process.cwd()) {
70
+ return readJsonFile(configPath(cwd), ImpulseConfigSchema, {
71
+ throwOnInvalid: "Invalid .impulse.json"
72
+ });
73
+ }
53
74
 
54
75
  // src/config/write-config.ts
55
76
  import fsExtra2 from "fs-extra";
@@ -146,7 +167,7 @@ async function isUltimateTemplate(cwd) {
146
167
  return foundDeps.size >= STRUCTURAL_MARKERS.workspaceDeps.length;
147
168
  }
148
169
  async function runInit(options) {
149
- const { cwd, force } = options;
170
+ const { cwd, force, skipDetection = false } = options;
150
171
  p.intro("impulse init");
151
172
  if (!force && await hasConfig(cwd)) {
152
173
  p.log.warn(
@@ -165,17 +186,21 @@ async function runInit(options) {
165
186
  );
166
187
  process.exit(1);
167
188
  }
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);
189
+ if (!skipDetection) {
190
+ const isUT = await isUltimateTemplate(cwd);
191
+ s.stop(isUT ? "Ultimate Template project detected." : "Project detected.");
192
+ if (!isUT) {
193
+ const proceed = await p.confirm({
194
+ message: "This project does not appear to be an ImpulseLab Ultimate Template. Continue anyway?",
195
+ initialValue: false
196
+ });
197
+ if (p.isCancel(proceed) || !proceed) {
198
+ p.cancel("Initialization cancelled.");
199
+ process.exit(0);
200
+ }
178
201
  }
202
+ } else {
203
+ s.stop("Project ready.");
179
204
  }
180
205
  const projectName = await detectProjectName(cwd);
181
206
  const srcExists = await pathExists3(path2.join(cwd, "src"));
@@ -197,6 +222,7 @@ async function runInit(options) {
197
222
  // src/commands/add.ts
198
223
  import { execSync as execSync2, execFileSync } from "child_process";
199
224
  import { existsSync } from "fs";
225
+ import { readFile as readFile7, writeFile, unlink } from "fs/promises";
200
226
  import path12 from "path";
201
227
  import * as p5 from "@clack/prompts";
202
228
 
@@ -271,7 +297,8 @@ var registryConstants = {
271
297
  githubBranch: "main",
272
298
  modulesDir: "modules",
273
299
  subModulesDirName: "sub-modules",
274
- manifestFileName: "module.json"
300
+ manifestFileName: "module.json",
301
+ presetsDir: "presets"
275
302
  };
276
303
 
277
304
  // src/registry/github-urls.ts
@@ -325,7 +352,7 @@ function moduleRegistryPath(moduleId) {
325
352
  }
326
353
 
327
354
  // src/registry/fetch-module-manifest.ts
328
- var { readJson: readJson3, pathExists: pathExists4 } = fsExtra5;
355
+ var { pathExists: pathExists4 } = fsExtra5;
329
356
  async function fetchModuleManifest(moduleId, localPath) {
330
357
  const registryPath = moduleRegistryPath(moduleId);
331
358
  if (localPath) {
@@ -333,12 +360,10 @@ async function fetchModuleManifest(moduleId, localPath) {
333
360
  if (!await pathExists4(file)) {
334
361
  throw new Error(`Local module not found: ${file}`);
335
362
  }
336
- const raw2 = await readJson3(file);
337
- const parsed2 = ModuleManifestSchema.safeParse(raw2);
338
- if (!parsed2.success) {
339
- throw new Error(`Invalid module.json for ${moduleId}: ${parsed2.error.message}`);
340
- }
341
- return parsed2.data;
363
+ const manifest = await readJsonFile(file, ModuleManifestSchema, {
364
+ throwOnInvalid: `Invalid module.json for ${moduleId}`
365
+ });
366
+ return manifest;
342
367
  }
343
368
  const url = githubUrls.rawFile(registryPath, registryConstants.manifestFileName);
344
369
  const res = await fetch(url, { headers: getGitHubHeaders() });
@@ -359,7 +384,7 @@ async function fetchModuleManifest(moduleId, localPath) {
359
384
  // src/registry/list-available-modules.ts
360
385
  import fsExtra6 from "fs-extra";
361
386
  import path4 from "path";
362
- var { readJson: readJson4, pathExists: pathExists5 } = fsExtra6;
387
+ var { readJson: readJson3, pathExists: pathExists5 } = fsExtra6;
363
388
  var _cachedModuleList = null;
364
389
  async function listAvailableModules(localPath) {
365
390
  if (localPath) {
@@ -375,7 +400,7 @@ async function listAvailableModules(localPath) {
375
400
  );
376
401
  if (!await pathExists5(manifestFile)) continue;
377
402
  try {
378
- const raw = await readJson4(manifestFile);
403
+ const raw = await readJson3(manifestFile);
379
404
  const parsed = ModuleManifestSchema.safeParse(raw);
380
405
  if (!parsed.success) continue;
381
406
  let subModules;
@@ -442,10 +467,10 @@ async function listAvailableModules(localPath) {
442
467
  // src/registry/fetch-module-file.ts
443
468
  async function fetchModuleFile(moduleName, fileSrc, localPath) {
444
469
  if (localPath) {
445
- const { readFile: readFile7 } = await import("fs/promises");
470
+ const { readFile: readFile9 } = await import("fs/promises");
446
471
  const { join } = await import("path");
447
472
  const file = join(localPath, moduleName, fileSrc);
448
- return readFile7(file, "utf-8");
473
+ return readFile9(file, "utf-8");
449
474
  }
450
475
  const url = githubUrls.rawFile(moduleName, fileSrc);
451
476
  const res = await fetch(url, { headers: getGitHubHeaders() });
@@ -536,15 +561,32 @@ ${value}
536
561
  // src/transforms/register-route.ts
537
562
  import fsExtra9 from "fs-extra";
538
563
  import path7 from "path";
564
+
565
+ // src/transforms/base-router-content.ts
566
+ var BASE_ROUTER_CONTENT = [
567
+ 'import type { RouterInput } from "@orpc/server";',
568
+ 'import { createRouter } from "@orpc/server";',
569
+ "",
570
+ "export const appRouter = createRouter({",
571
+ "} satisfies RouterInput);",
572
+ "",
573
+ "export type AppRouter = typeof appRouter;",
574
+ ""
575
+ ].join("\n");
576
+
577
+ // src/transforms/register-route.ts
539
578
  var { readFile: readFile3, outputFile: outputFile3, pathExists: pathExists8 } = fsExtra9;
540
579
  async function registerRoute(target, value, cwd, dryRun) {
541
580
  const file = path7.join(cwd, target);
581
+ let content;
542
582
  if (!await pathExists8(file)) {
543
- throw new Error(
544
- `register-route: target file not found: ${target}. Please ensure your router file exists.`
545
- );
583
+ content = BASE_ROUTER_CONTENT;
584
+ if (!dryRun) {
585
+ await outputFile3(file, content, "utf-8");
586
+ }
587
+ } else {
588
+ content = await readFile3(file, "utf-8");
546
589
  }
547
- const content = await readFile3(file, "utf-8");
548
590
  if (content.includes(value)) return;
549
591
  const routerPattern = /^([ \t]*\})[ \t]*(?:satisfies|as)\s/m;
550
592
  const match = routerPattern.exec(content);
@@ -691,9 +733,6 @@ async function runTransform(transform, cwd, dryRun) {
691
733
  // src/auth/require-auth.ts
692
734
  import * as p4 from "@clack/prompts";
693
735
 
694
- // src/auth/read-auth.ts
695
- import fsExtra13 from "fs-extra";
696
-
697
736
  // src/auth/auth-path.ts
698
737
  import path11 from "path";
699
738
  import os from "os";
@@ -710,19 +749,8 @@ var AuthCredentialsSchema = z6.object({
710
749
  });
711
750
 
712
751
  // src/auth/read-auth.ts
713
- var { readJson: readJson5, pathExists: pathExists12 } = fsExtra13;
714
752
  async function readAuth() {
715
- const file = authPath();
716
- if (!await pathExists12(file)) return null;
717
- let raw;
718
- try {
719
- raw = await readJson5(file);
720
- } catch {
721
- return null;
722
- }
723
- const parsed = AuthCredentialsSchema.safeParse(raw);
724
- if (!parsed.success) return null;
725
- return parsed.data;
753
+ return readJsonFile(authPath(), AuthCredentialsSchema);
726
754
  }
727
755
 
728
756
  // src/auth/require-auth.ts
@@ -782,6 +810,7 @@ function installNpmDeps(deps, cwd, dryRun) {
782
810
  async function installModule(moduleId, manifest, cwd, dryRun, installedModules, localPath) {
783
811
  p5.log.step(`Installing ${moduleId}@${manifest.version}...`);
784
812
  const installedDests = [];
813
+ const createdDests = [];
785
814
  if (manifest.files.length > 0) {
786
815
  const installed = await installFiles({
787
816
  moduleName: moduleId,
@@ -797,13 +826,34 @@ async function installModule(moduleId, manifest, cwd, dryRun, installedModules,
797
826
  continue;
798
827
  }
799
828
  installedDests.push(f.dest);
829
+ if (f.action === "created" || f.action === "would-create") {
830
+ createdDests.push(f.dest);
831
+ }
800
832
  const icon = f.action === "created" || f.action === "would-create" ? "+" : f.action === "overwritten" || f.action === "would-overwrite" ? "~" : "=";
801
833
  p5.log.message(` ${icon} ${f.dest}`);
802
834
  }
803
835
  }
804
836
  for (const transform of manifest.transforms) {
805
837
  p5.log.step(` transform: ${transform.type} \u2192 ${transform.target}`);
806
- await runTransform(transform, cwd, dryRun);
838
+ try {
839
+ await runTransform(transform, cwd, dryRun);
840
+ } catch (err) {
841
+ p5.log.warn(
842
+ `Transform "${transform.type} \u2192 ${transform.target}" failed for module "${moduleId}": ${err instanceof Error ? err.message : String(err)}`
843
+ );
844
+ if (!dryRun && createdDests.length > 0) {
845
+ p5.log.warn(`Rolling back ${createdDests.length} file(s) created by "${moduleId}"...`);
846
+ for (const dest of createdDests) {
847
+ try {
848
+ await unlink(path12.join(cwd, dest));
849
+ } catch {
850
+ }
851
+ }
852
+ }
853
+ throw new Error(
854
+ `Module "${moduleId}" install failed during transform "${transform.type} \u2192 ${transform.target}".`
855
+ );
856
+ }
807
857
  }
808
858
  return installedDests;
809
859
  }
@@ -829,7 +879,10 @@ async function pickModulesInteractively(localPath) {
829
879
  modules = await listAvailableModules(localPath);
830
880
  } catch (err) {
831
881
  s.stop("Failed to load modules.");
832
- throw err;
882
+ p5.cancel(
883
+ `Could not load module list: ${err instanceof Error ? err.message : String(err)}`
884
+ );
885
+ process.exit(1);
833
886
  }
834
887
  s.stop("Modules loaded.");
835
888
  if (modules.length === 0) {
@@ -847,7 +900,7 @@ async function pickModulesInteractively(localPath) {
847
900
  options.push({
848
901
  value: `${mod.name}/${sub}`,
849
902
  label: ` \u21B3 ${sub}`,
850
- hint: `sub-module of ${mod.name}`
903
+ hint: `automatically includes ${mod.name}`
851
904
  });
852
905
  }
853
906
  }
@@ -861,20 +914,25 @@ async function pickModulesInteractively(localPath) {
861
914
  process.exit(0);
862
915
  }
863
916
  const result = new Set(selected);
917
+ const autoIncluded = [];
864
918
  for (const id of selected) {
865
919
  const { parent, child } = parseModuleId(id);
866
- if (child !== null) {
920
+ if (child !== null && !selected.includes(parent)) {
867
921
  result.add(parent);
922
+ autoIncluded.push(parent);
868
923
  }
869
924
  }
925
+ if (autoIncluded.length > 0) {
926
+ p5.log.info(`Auto-included parent modules: ${autoIncluded.join(", ")}`);
927
+ }
870
928
  return [...result];
871
929
  }
872
930
  async function runAdd(options) {
873
- let { moduleNames, cwd, dryRun, localPath, withSubModules = [], allowScripts = false } = options;
931
+ let { moduleNames, cwd, dryRun, localPath, withSubModules = [], allowScripts = false, skipIntro = false, skipAuth = false } = options;
874
932
  let allTargets;
875
933
  if (moduleNames.length === 0) {
876
- p5.intro(`impulse add${dryRun ? " [dry-run]" : ""}`);
877
- await requireAuth();
934
+ if (!skipIntro) p5.intro(`impulse add${dryRun ? " [dry-run]" : ""}`);
935
+ if (!skipAuth) await requireAuth();
878
936
  if (withSubModules.length > 0) {
879
937
  p5.log.warn(`--with is ignored in interactive mode. Select sub-modules from the picker.`);
880
938
  withSubModules = [];
@@ -893,8 +951,8 @@ async function runAdd(options) {
893
951
  }
894
952
  const withIds = moduleNames.length === 1 && withSubModules.length > 0 ? withSubModules.map((sub) => `${moduleNames[0]}/${sub}`) : [];
895
953
  allTargets = [...moduleNames, ...withIds];
896
- p5.intro(`impulse add ${allTargets.join(", ")}${dryRun ? " [dry-run]" : ""}`);
897
- await requireAuth();
954
+ if (!skipIntro) p5.intro(`impulse add ${allTargets.join(", ")}${dryRun ? " [dry-run]" : ""}`);
955
+ if (!skipAuth) await requireAuth();
898
956
  }
899
957
  const config = await readConfig(cwd);
900
958
  if (!config) {
@@ -924,7 +982,14 @@ async function runAdd(options) {
924
982
  }
925
983
  }
926
984
  if (moduleNames.length === 1 && withSubModules.length > 0) {
927
- const parentManifest = await getManifest(moduleNames[0], localPath, manifests).catch(() => null);
985
+ let parentManifest = null;
986
+ try {
987
+ parentManifest = await getManifest(moduleNames[0], localPath, manifests);
988
+ } catch (err) {
989
+ p5.log.warn(
990
+ `Could not fetch manifest for "${moduleNames[0]}" to validate --with sub-modules: ${err instanceof Error ? err.message : String(err)}. Skipping validation.`
991
+ );
992
+ }
928
993
  if (parentManifest) {
929
994
  if (parentManifest.subModules.length === 0) {
930
995
  p5.cancel(`"${moduleNames[0]}" has no declared sub-modules.`);
@@ -1090,13 +1155,26 @@ Available: ${parentManifest.subModules.join(", ")}`
1090
1155
  runHooks = confirmed;
1091
1156
  }
1092
1157
  if (runHooks) {
1158
+ const hookFailures = [];
1093
1159
  for (const { name, hooks } of allPostInstallHooks) {
1094
1160
  p5.log.step(`Running post-install hooks for ${name}...`);
1095
1161
  for (const hook of hooks) {
1096
1162
  p5.log.message(` $ ${hook}`);
1097
- execSync2(hook, { cwd, stdio: "inherit" });
1163
+ try {
1164
+ execSync2(hook, { cwd, stdio: "inherit" });
1165
+ } catch (err) {
1166
+ const msg = `Post-install hook failed for "${name}" ($ ${hook}): ${err instanceof Error ? err.message : String(err)}`;
1167
+ p5.log.warn(`${msg}. Continuing.`);
1168
+ hookFailures.push(msg);
1169
+ }
1098
1170
  }
1099
1171
  }
1172
+ if (hookFailures.length > 0) {
1173
+ p5.log.warn(
1174
+ `${hookFailures.length} post-install hook(s) failed. See warnings above.`
1175
+ );
1176
+ process.exitCode = 1;
1177
+ }
1100
1178
  } else {
1101
1179
  p5.log.warn("Post-install hooks skipped.");
1102
1180
  }
@@ -1114,7 +1192,28 @@ Available: ${parentManifest.subModules.join(", ")}`
1114
1192
  const dests = installedFilesMap.get(targetId) ?? [];
1115
1193
  if (targetManifest) recordModule(config, targetId, targetManifest, dests, now);
1116
1194
  }
1117
- await writeConfig(config, cwd);
1195
+ const cfgPath = configPath(cwd);
1196
+ let backup = null;
1197
+ try {
1198
+ backup = await readFile7(cfgPath, "utf-8");
1199
+ } catch {
1200
+ }
1201
+ try {
1202
+ await writeConfig(config, cwd);
1203
+ } catch (err) {
1204
+ p5.log.warn(
1205
+ `Failed to write .impulse.json: ${err instanceof Error ? err.message : String(err)}`
1206
+ );
1207
+ if (backup !== null) {
1208
+ try {
1209
+ await writeFile(cfgPath, backup, "utf-8");
1210
+ p5.log.warn("Config restored to previous state.");
1211
+ } catch {
1212
+ p5.log.warn("Could not restore config backup \u2014 .impulse.json may be in a partial state.");
1213
+ }
1214
+ }
1215
+ throw err;
1216
+ }
1118
1217
  }
1119
1218
  const label = allTargets.length === 1 ? `"${allTargets[0]}"` : allTargets.map((t) => `"${t}"`).join(", ");
1120
1219
  p5.outro(
@@ -1214,11 +1313,20 @@ import { execFileSync as execFileSync2 } from "child_process";
1214
1313
  var IMPULSE_BASE_URL = process.env.IMPULSE_BASE_URL ?? "https://impulselab.ai";
1215
1314
  var CLIENT_ID = "impulse-cli";
1216
1315
  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
- });
1316
+ const url = `${IMPULSE_BASE_URL}/api/auth/device/code`;
1317
+ let res;
1318
+ try {
1319
+ res = await fetch(url, {
1320
+ method: "POST",
1321
+ headers: { "Content-Type": "application/json" },
1322
+ body: JSON.stringify({ client_id: CLIENT_ID })
1323
+ });
1324
+ } catch (err) {
1325
+ const reason = err instanceof Error ? err.message : String(err);
1326
+ throw new Error(
1327
+ `Network error calling ${url}: ${reason}. Check connection, VPN, proxy, and IMPULSE_BASE_URL (must match your deployed agency site).`
1328
+ );
1329
+ }
1222
1330
  if (!res.ok) {
1223
1331
  throw new Error(`Failed to initiate device flow: ${res.status} ${res.statusText}`);
1224
1332
  }
@@ -1275,6 +1383,25 @@ function openBrowser(url) {
1275
1383
  } catch {
1276
1384
  }
1277
1385
  }
1386
+ function copyToClipboard(text3) {
1387
+ try {
1388
+ const platform = process.platform;
1389
+ if (platform === "darwin") {
1390
+ execFileSync2("pbcopy", { input: text3 });
1391
+ } else if (platform === "win32") {
1392
+ execFileSync2("clip", { input: text3 });
1393
+ } else {
1394
+ try {
1395
+ execFileSync2("xclip", ["-selection", "clipboard"], { input: text3 });
1396
+ } catch {
1397
+ execFileSync2("xsel", ["--clipboard", "--input"], { input: text3 });
1398
+ }
1399
+ }
1400
+ return true;
1401
+ } catch {
1402
+ return false;
1403
+ }
1404
+ }
1278
1405
  function assertDeviceCodeResponse(data) {
1279
1406
  if (typeof data !== "object" || data === null || typeof data.device_code !== "string" || typeof data.user_code !== "string" || typeof data.verification_uri !== "string") {
1280
1407
  throw new Error("Invalid device code response from server");
@@ -1304,13 +1431,8 @@ function mapDeviceTokenResponse(raw) {
1304
1431
  }
1305
1432
 
1306
1433
  // src/auth/write-auth.ts
1307
- import fsExtra14 from "fs-extra";
1308
- import { chmod } from "fs/promises";
1309
- var { outputJson } = fsExtra14;
1310
1434
  async function writeAuth(credentials) {
1311
- const file = authPath();
1312
- await outputJson(file, credentials, { spaces: 2, mode: 384 });
1313
- await chmod(file, 384);
1435
+ await writeJsonFile(authPath(), credentials, { mode: 384 });
1314
1436
  }
1315
1437
 
1316
1438
  // src/commands/login.ts
@@ -1345,9 +1467,13 @@ async function runLogin() {
1345
1467
  Visit the following URL to authenticate:
1346
1468
  ${browserUrl}
1347
1469
  `);
1348
- p7.log.message(`Your one-time code: ${deviceCode.userCode}
1349
- `);
1470
+ const rawCode = deviceCode.userCode;
1471
+ const mid = Math.ceil(rawCode.length / 2);
1472
+ const formattedCode = rawCode.length >= 6 ? `${rawCode.slice(0, mid)}-${rawCode.slice(mid)}` : rawCode;
1350
1473
  openBrowser(browserUrl);
1474
+ const copied = copyToClipboard(rawCode);
1475
+ p7.log.message(`Your one-time code: ${formattedCode}${copied ? " (copied to clipboard)" : ""}
1476
+ `);
1351
1477
  p7.log.info(`If the browser did not open, copy the URL above.
1352
1478
  Base URL: ${IMPULSE_BASE_URL}`);
1353
1479
  const pollInterval = Math.max(
@@ -1406,11 +1532,11 @@ function sleep(ms) {
1406
1532
  import * as p8 from "@clack/prompts";
1407
1533
 
1408
1534
  // src/auth/clear-auth.ts
1409
- import fsExtra15 from "fs-extra";
1410
- var { remove, pathExists: pathExists13 } = fsExtra15;
1535
+ import fsExtra13 from "fs-extra";
1536
+ var { remove, pathExists: pathExists12 } = fsExtra13;
1411
1537
  async function clearAuth() {
1412
1538
  const file = authPath();
1413
- if (!await pathExists13(file)) return false;
1539
+ if (!await pathExists12(file)) return false;
1414
1540
  await remove(file);
1415
1541
  return true;
1416
1542
  }
@@ -1457,6 +1583,400 @@ async function runWhoami() {
1457
1583
  p9.outro("Done.");
1458
1584
  }
1459
1585
 
1586
+ // src/commands/create.ts
1587
+ import { execFileSync as execFileSync3 } from "child_process";
1588
+ import { existsSync as existsSync2 } from "fs";
1589
+ import path15 from "path";
1590
+ import * as p10 from "@clack/prompts";
1591
+ import fsExtra16 from "fs-extra";
1592
+
1593
+ // src/registry/fetch-template.ts
1594
+ import fsExtra14 from "fs-extra";
1595
+ import path13 from "path";
1596
+
1597
+ // src/schemas/template-manifest.ts
1598
+ import { z as z7 } from "zod";
1599
+ var TemplateFileSchema = z7.object({
1600
+ src: z7.string(),
1601
+ dest: z7.string()
1602
+ });
1603
+ var TemplateManifestSchema = z7.object({
1604
+ name: z7.string(),
1605
+ description: z7.string(),
1606
+ /** Human-readable labels describing the tech stack (e.g. ["Next.js 15", "Tailwind CSS v4"]). */
1607
+ stack: z7.array(z7.string()).default([]),
1608
+ /** Module IDs pre-selected in the interactive picker (e.g. ["db", "auth"]). */
1609
+ defaultModules: z7.array(z7.string()).default([]),
1610
+ /** Files to copy from the template directory into the new project. */
1611
+ files: z7.array(TemplateFileSchema).default([])
1612
+ });
1613
+
1614
+ // src/registry/fetch-template.ts
1615
+ var { readJson: readJson4, pathExists: pathExists13, readFile: readFile8 } = fsExtra14;
1616
+ var TEMPLATES_DIR = "templates";
1617
+ var TEMPLATE_MANIFEST_FILE = "template.json";
1618
+ function templateRawUrl(templateId, file) {
1619
+ return `https://raw.githubusercontent.com/${registryConstants.githubOrg}/${registryConstants.githubRepo}/${registryConstants.githubBranch}/${TEMPLATES_DIR}/${templateId}/${file}`;
1620
+ }
1621
+ async function fetchTemplateManifest(templateId, localPath) {
1622
+ if (localPath) {
1623
+ const file = path13.join(localPath, TEMPLATES_DIR, templateId, TEMPLATE_MANIFEST_FILE);
1624
+ if (!await pathExists13(file)) {
1625
+ throw new Error(`Local template not found: ${file}`);
1626
+ }
1627
+ const raw2 = await readJson4(file);
1628
+ const parsed2 = TemplateManifestSchema.safeParse(raw2);
1629
+ if (!parsed2.success) {
1630
+ throw new Error(`Invalid template.json for "${templateId}": ${parsed2.error.message}`);
1631
+ }
1632
+ return parsed2.data;
1633
+ }
1634
+ const url = templateRawUrl(templateId, TEMPLATE_MANIFEST_FILE);
1635
+ const res = await fetch(url, { headers: getGitHubHeaders() });
1636
+ if (!res.ok) {
1637
+ if (res.status === 404) {
1638
+ throw new Error(`Template not found in registry: ${templateId}`);
1639
+ }
1640
+ throw new Error(`Failed to fetch template manifest: ${res.status} ${res.statusText}`);
1641
+ }
1642
+ const raw = await res.json();
1643
+ const parsed = TemplateManifestSchema.safeParse(raw);
1644
+ if (!parsed.success) {
1645
+ throw new Error(`Invalid template.json for "${templateId}": ${parsed.error.message}`);
1646
+ }
1647
+ return parsed.data;
1648
+ }
1649
+ async function fetchTemplateFile(templateId, src, localPath) {
1650
+ if (localPath) {
1651
+ const file = path13.join(localPath, TEMPLATES_DIR, templateId, src);
1652
+ return readFile8(file, "utf-8");
1653
+ }
1654
+ const url = templateRawUrl(templateId, src);
1655
+ const res = await fetch(url, { headers: getGitHubHeaders() });
1656
+ if (!res.ok) {
1657
+ throw new Error(`Failed to fetch template file "${src}": ${res.status} ${res.statusText}`);
1658
+ }
1659
+ return res.text();
1660
+ }
1661
+
1662
+ // src/registry/fetch-preset.ts
1663
+ import fsExtra15 from "fs-extra";
1664
+ import path14 from "path";
1665
+
1666
+ // src/schemas/preset-manifest.ts
1667
+ import { z as z8 } from "zod";
1668
+ var PresetManifestSchema = z8.object({
1669
+ name: z8.string(),
1670
+ description: z8.string(),
1671
+ /** Template ID to scaffold from (references templates/<name>/). */
1672
+ template: z8.string(),
1673
+ /** Modules installed automatically — no user confirmation needed. */
1674
+ defaultModules: z8.array(z8.string()),
1675
+ /** Modules the user can optionally add during create. */
1676
+ optionalModules: z8.array(z8.string()).default([])
1677
+ });
1678
+
1679
+ // src/registry/fetch-preset.ts
1680
+ var { readJson: readJson5, pathExists: pathExists14 } = fsExtra15;
1681
+ var PRESETS_DIR = registryConstants.presetsDir;
1682
+ var PRESET_MANIFEST_FILE = "preset.json";
1683
+ function presetRawUrl(presetId, file) {
1684
+ return `https://raw.githubusercontent.com/${registryConstants.githubOrg}/${registryConstants.githubRepo}/${registryConstants.githubBranch}/${PRESETS_DIR}/${presetId}/${file}`;
1685
+ }
1686
+ async function fetchPresetManifest(presetId, localPath) {
1687
+ if (localPath) {
1688
+ const file = path14.join(localPath, PRESETS_DIR, presetId, PRESET_MANIFEST_FILE);
1689
+ if (!await pathExists14(file)) {
1690
+ throw new Error(`Local preset not found: ${file}`);
1691
+ }
1692
+ const raw2 = await readJson5(file);
1693
+ const parsed2 = PresetManifestSchema.safeParse(raw2);
1694
+ if (!parsed2.success) {
1695
+ throw new Error(`Invalid preset.json for "${presetId}": ${parsed2.error.message}`);
1696
+ }
1697
+ return parsed2.data;
1698
+ }
1699
+ const url = presetRawUrl(presetId, PRESET_MANIFEST_FILE);
1700
+ const res = await fetch(url, { headers: getGitHubHeaders() });
1701
+ if (!res.ok) {
1702
+ if (res.status === 404) {
1703
+ throw new Error(`Preset not found in registry: ${presetId}`);
1704
+ }
1705
+ throw new Error(`Failed to fetch preset manifest: ${res.status} ${res.statusText}`);
1706
+ }
1707
+ const raw = await res.json();
1708
+ const parsed = PresetManifestSchema.safeParse(raw);
1709
+ if (!parsed.success) {
1710
+ throw new Error(`Invalid preset.json for "${presetId}": ${parsed.error.message}`);
1711
+ }
1712
+ return parsed.data;
1713
+ }
1714
+ async function listAvailablePresets(localPath) {
1715
+ if (localPath) {
1716
+ const presetsDir = path14.join(localPath, PRESETS_DIR);
1717
+ if (!await pathExists14(presetsDir)) return [];
1718
+ const { readdir } = await import("fs/promises");
1719
+ const entries2 = await readdir(presetsDir, { withFileTypes: true });
1720
+ return entries2.filter((e) => e.isDirectory()).map((e) => e.name);
1721
+ }
1722
+ const url = `https://api.github.com/repos/${registryConstants.githubOrg}/${registryConstants.githubRepo}/contents/${PRESETS_DIR}`;
1723
+ const res = await fetch(url, { headers: getGitHubHeaders({ Accept: "application/vnd.github.v3+json" }) });
1724
+ if (!res.ok) return ["saas"];
1725
+ const entries = await res.json();
1726
+ if (!Array.isArray(entries)) return ["saas"];
1727
+ return entries.filter((e) => e.type === "dir").map((e) => e.name);
1728
+ }
1729
+
1730
+ // src/commands/create.ts
1731
+ var { outputFile: outputFile7, pathExists: pathExists15 } = fsExtra16;
1732
+ function slugify(name) {
1733
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
1734
+ }
1735
+ function isValidProjectName(name) {
1736
+ return /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(name);
1737
+ }
1738
+ async function runCreate(options) {
1739
+ const { cwd, localPath } = options;
1740
+ p10.intro("impulse create");
1741
+ await requireAuth();
1742
+ let projectName = options.projectName;
1743
+ if (!projectName) {
1744
+ const answer = await p10.text({
1745
+ message: "Project name:",
1746
+ placeholder: "my-app",
1747
+ validate(value) {
1748
+ if (!value?.trim()) return "Project name is required.";
1749
+ const slug = slugify(value.trim());
1750
+ if (!isValidProjectName(slug)) {
1751
+ return "Use lowercase letters, numbers, and hyphens only (e.g. my-app).";
1752
+ }
1753
+ return void 0;
1754
+ }
1755
+ });
1756
+ if (p10.isCancel(answer)) {
1757
+ p10.cancel("Cancelled.");
1758
+ process.exit(0);
1759
+ }
1760
+ projectName = slugify(answer.trim());
1761
+ } else {
1762
+ projectName = slugify(projectName.trim());
1763
+ }
1764
+ const targetDir = path15.resolve(cwd, projectName);
1765
+ if (existsSync2(targetDir)) {
1766
+ p10.cancel(`Directory "${projectName}" already exists.`);
1767
+ process.exit(1);
1768
+ }
1769
+ let templateId;
1770
+ let selectedModules = options.modules ?? [];
1771
+ if (options.template) {
1772
+ templateId = options.template;
1773
+ if (!options.modules) {
1774
+ const s = p10.spinner();
1775
+ s.start("Loading available modules...");
1776
+ let availableModules;
1777
+ try {
1778
+ availableModules = await listAvailableModules(localPath);
1779
+ } catch {
1780
+ availableModules = [];
1781
+ }
1782
+ s.stop("Modules loaded.");
1783
+ if (availableModules.length > 0) {
1784
+ const templateManifest = await fetchTemplateManifest(templateId, localPath).catch(() => null);
1785
+ const defaultValues = templateManifest ? templateManifest.defaultModules.filter((d) => availableModules.some((m) => m.name === d)) : [];
1786
+ const pickerOptions = availableModules.map((m) => ({
1787
+ value: m.name,
1788
+ label: m.name,
1789
+ ...m.description !== void 0 ? { hint: m.description } : {}
1790
+ }));
1791
+ const picked = await p10.multiselect({
1792
+ message: "Select modules to install (space to toggle, enter to confirm):",
1793
+ options: pickerOptions,
1794
+ initialValues: defaultValues,
1795
+ required: false
1796
+ });
1797
+ if (p10.isCancel(picked)) {
1798
+ p10.cancel("Cancelled.");
1799
+ process.exit(0);
1800
+ }
1801
+ selectedModules = picked;
1802
+ }
1803
+ }
1804
+ } else {
1805
+ let presetManifest;
1806
+ if (options.preset) {
1807
+ const s = p10.spinner();
1808
+ s.start(`Fetching preset "${options.preset}"...`);
1809
+ try {
1810
+ presetManifest = await fetchPresetManifest(options.preset, localPath);
1811
+ } catch (err) {
1812
+ s.stop("Failed to fetch preset.");
1813
+ p10.cancel(err instanceof Error ? err.message : String(err));
1814
+ process.exit(1);
1815
+ }
1816
+ s.stop(`Preset "${options.preset}" ready.`);
1817
+ } else {
1818
+ const s = p10.spinner();
1819
+ s.start("Loading available presets...");
1820
+ let presetIds;
1821
+ try {
1822
+ presetIds = await listAvailablePresets(localPath);
1823
+ } catch {
1824
+ presetIds = ["saas"];
1825
+ }
1826
+ s.stop("Presets loaded.");
1827
+ let selectedPresetId;
1828
+ if (presetIds.length > 1) {
1829
+ const presetDetails = await Promise.all(
1830
+ presetIds.map(async (id) => {
1831
+ try {
1832
+ const m = await fetchPresetManifest(id, localPath);
1833
+ return { id, description: m.description };
1834
+ } catch {
1835
+ return { id, description: "" };
1836
+ }
1837
+ })
1838
+ );
1839
+ const selected = await p10.select({
1840
+ message: "Select a preset:",
1841
+ options: presetDetails.map(({ id, description }) => ({
1842
+ value: id,
1843
+ label: id,
1844
+ ...description ? { hint: description } : {}
1845
+ })),
1846
+ initialValue: "saas"
1847
+ });
1848
+ if (p10.isCancel(selected)) {
1849
+ p10.cancel("Cancelled.");
1850
+ process.exit(0);
1851
+ }
1852
+ selectedPresetId = selected;
1853
+ } else {
1854
+ selectedPresetId = presetIds[0] ?? "saas";
1855
+ p10.log.info(`Using preset: ${selectedPresetId}`);
1856
+ }
1857
+ const s22 = p10.spinner();
1858
+ s22.start(`Fetching preset "${selectedPresetId}"...`);
1859
+ try {
1860
+ presetManifest = await fetchPresetManifest(selectedPresetId, localPath);
1861
+ } catch (err) {
1862
+ s22.stop("Failed to fetch preset.");
1863
+ p10.cancel(err instanceof Error ? err.message : String(err));
1864
+ process.exit(1);
1865
+ }
1866
+ s22.stop(`Preset "${selectedPresetId}" ready.`);
1867
+ if (!options.modules && presetManifest.optionalModules.length > 0) {
1868
+ const optionalSelected = await p10.multiselect({
1869
+ message: `Optional modules for "${selectedPresetId}" (space to toggle, enter to skip):`,
1870
+ options: presetManifest.optionalModules.map((id) => ({
1871
+ value: id,
1872
+ label: id
1873
+ })),
1874
+ required: false
1875
+ });
1876
+ if (p10.isCancel(optionalSelected)) {
1877
+ p10.cancel("Cancelled.");
1878
+ process.exit(0);
1879
+ }
1880
+ selectedModules = [
1881
+ ...presetManifest.defaultModules,
1882
+ ...optionalSelected
1883
+ ];
1884
+ } else if (!options.modules) {
1885
+ selectedModules = presetManifest.defaultModules;
1886
+ }
1887
+ }
1888
+ templateId = presetManifest.template;
1889
+ if (options.preset && !options.modules) {
1890
+ selectedModules = presetManifest.defaultModules;
1891
+ }
1892
+ }
1893
+ const s2 = p10.spinner();
1894
+ s2.start(`Fetching template "${templateId}"...`);
1895
+ let manifest;
1896
+ try {
1897
+ manifest = await fetchTemplateManifest(templateId, localPath);
1898
+ } catch (err) {
1899
+ s2.stop("Failed to fetch template.");
1900
+ p10.cancel(err instanceof Error ? err.message : String(err));
1901
+ process.exit(1);
1902
+ }
1903
+ s2.stop(`Template "${templateId}" ready.`);
1904
+ p10.log.message(`
1905
+ Stack: ${manifest.stack.join(", ")}`);
1906
+ const s4 = p10.spinner();
1907
+ s4.start(`Scaffolding "${projectName}"...`);
1908
+ try {
1909
+ for (const file of manifest.files) {
1910
+ const content = await fetchTemplateFile(templateId, file.src, localPath);
1911
+ const destPath = path15.join(targetDir, file.dest);
1912
+ await outputFile7(destPath, content, "utf-8");
1913
+ }
1914
+ const pkgPath = path15.join(targetDir, "package.json");
1915
+ if (existsSync2(pkgPath)) {
1916
+ const { readJson: readJson6, writeJson: writeJson2 } = fsExtra16;
1917
+ const pkg = await readJson6(pkgPath);
1918
+ if (pkg && typeof pkg === "object") {
1919
+ pkg["name"] = projectName;
1920
+ await writeJson2(pkgPath, pkg, { spaces: 2 });
1921
+ }
1922
+ }
1923
+ } catch (err) {
1924
+ s4.stop("Scaffold failed.");
1925
+ p10.cancel(err instanceof Error ? err.message : String(err));
1926
+ process.exit(1);
1927
+ }
1928
+ s4.stop(`Project scaffolded at ./${projectName}`);
1929
+ p10.log.step("Initializing impulse...");
1930
+ await runInit({ cwd: targetDir, force: false, skipDetection: true });
1931
+ if (selectedModules.length > 0) {
1932
+ p10.log.step(`Installing modules: ${selectedModules.join(", ")}`);
1933
+ await runAdd({
1934
+ moduleNames: selectedModules,
1935
+ cwd: targetDir,
1936
+ dryRun: false,
1937
+ ...localPath !== void 0 ? { localPath } : {},
1938
+ allowScripts: false,
1939
+ skipIntro: true,
1940
+ skipAuth: true
1941
+ });
1942
+ }
1943
+ const hasPnpmLock = await pathExists15(path15.join(targetDir, "pnpm-lock.yaml"));
1944
+ const pm = hasPnpmLock ? "pnpm" : existsSync2(path15.join(targetDir, "yarn.lock")) ? "yarn" : "pnpm";
1945
+ const s5 = p10.spinner();
1946
+ s5.start(`Running ${pm} install...`);
1947
+ try {
1948
+ execFileSync3(pm, ["install"], { cwd: targetDir, stdio: "pipe" });
1949
+ s5.stop("Dependencies installed.");
1950
+ } catch {
1951
+ s5.stop(`${pm} install failed \u2014 run it manually.`);
1952
+ p10.log.warn(`Run \`cd ${projectName} && ${pm} install\` to install dependencies.`);
1953
+ }
1954
+ const s6 = p10.spinner();
1955
+ s6.start("Initializing git repository...");
1956
+ try {
1957
+ execFileSync3("git", ["init"], { cwd: targetDir, stdio: "pipe" });
1958
+ execFileSync3("git", ["add", "-A"], { cwd: targetDir, stdio: "pipe" });
1959
+ execFileSync3(
1960
+ "git",
1961
+ ["commit", "-m", `feat: initial project from impulse create (${templateId} template)`],
1962
+ { cwd: targetDir, stdio: "pipe" }
1963
+ );
1964
+ s6.stop("Git repository initialized.");
1965
+ } catch {
1966
+ s6.stop("Git init failed \u2014 skipping.");
1967
+ p10.log.warn(`Run \`git init && git add -A && git commit -m "init"\` manually.`);
1968
+ }
1969
+ p10.outro(
1970
+ `
1971
+ Project "${projectName}" created successfully!
1972
+
1973
+ cd ${projectName}
1974
+ ${pm} dev
1975
+
1976
+ Add more modules anytime with \`impulse add\`.`
1977
+ );
1978
+ }
1979
+
1460
1980
  // src/cli-version.ts
1461
1981
  import { createRequire } from "module";
1462
1982
  var require2 = createRequire(import.meta.url);
@@ -1501,6 +2021,19 @@ program.command("list").description("List available and installed modules").opti
1501
2021
  if (options.local !== void 0) listOpts.localPath = options.local;
1502
2022
  await runList(listOpts);
1503
2023
  });
2024
+ program.command("create [name]").description(
2025
+ "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`"
2026
+ ).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) => {
2027
+ const moduleList = options.modules !== void 0 ? options.modules.split(",").map((s) => s.trim()).filter(Boolean) : void 0;
2028
+ await runCreate({
2029
+ cwd: process.cwd(),
2030
+ ...name !== void 0 ? { projectName: name } : {},
2031
+ ...options.preset !== void 0 ? { preset: options.preset } : {},
2032
+ ...options.template !== void 0 ? { template: options.template } : {},
2033
+ ...moduleList !== void 0 ? { modules: moduleList } : {},
2034
+ ...options.local !== void 0 ? { localPath: options.local } : {}
2035
+ });
2036
+ });
1504
2037
  program.command("login").description("Authenticate with ImpulseLab (opens browser for device flow)").action(async () => {
1505
2038
  await runLogin();
1506
2039
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@impulselab/cli",
3
- "version": "0.1.6",
3
+ "version": "0.2.1",
4
4
  "description": "ImpulseLab CLI — install and manage modules for your projects",
5
5
  "private": false,
6
6
  "type": "module",