stackpack-cli 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -112,6 +112,8 @@ Framework-specific filtering applies automatically — e.g. React Router is hidd
112
112
  stackpack # interactive main menu
113
113
  stackpack new <project-name> # create a project with an official creator
114
114
  stackpack new my-app --preset jc-react-stack
115
+ stackpack install <preset> [name] # express mode: whole stack in one shot
116
+ stackpack i <preset> [name] # same, shorter
115
117
  stackpack add # add integrations to the current project
116
118
  stackpack add --dry-run # full plan, zero changes
117
119
  stackpack add --package-manager pnpm
@@ -120,6 +122,7 @@ stackpack save <name> [--local|--global]
120
122
  stackpack apply <name> [--dry-run] # apply a preset to the current project
121
123
  stackpack presets list
122
124
  stackpack presets show <name>
125
+ stackpack presets edit <name> # swap integrations, save back — no project touched
123
126
  stackpack presets delete <name>
124
127
  stackpack --no-color
125
128
  ```
@@ -137,6 +140,8 @@ StackPack requires **no sign-up, no login, no server, no telemetry**. Presets ar
137
140
 
138
141
  Presets never contain shell commands, executable code, absolute paths, credentials, or `.env` values — the schema rejects anything unexpected. Internet access is only needed to run official creators/initializers and install packages.
139
142
 
143
+ One small exception, in the open: the interactive menu checks the npm registry (at most once per day, cached locally) to tell you when a newer StackPack version exists. It is an anonymous read-only request that sends nothing about you or your projects, never updates anything by itself, fails silently offline, and can be disabled completely by setting the `STACKPACK_NO_UPDATE_CHECK` environment variable.
144
+
140
145
  ## Safety
141
146
 
142
147
  - Nothing is installed before you confirm the reviewed plan.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  //#region src/cli.ts
3
3
  if (process.argv.includes("--no-color")) process.env.NO_COLOR = "1";
4
- const { runCli } = await import("./program-D9mgsLIa.js");
4
+ const { runCli } = await import("./program-Bq8-8HvQ.js");
5
5
  await runCli(process.argv);
6
6
  process.exit(process.exitCode ?? 0);
7
7
  //#endregion
@@ -2477,16 +2477,45 @@ function testingLabel(selection) {
2477
2477
  if (selection.testing.length === 0) return void 0;
2478
2478
  return selection.testing.map((entry) => getRecipe(entry.id)?.name ?? entry.id).join(" + ");
2479
2479
  }
2480
- /** Category rows turn green with a check once something is selected. */
2481
- function categoryOption(value, label, selected) {
2482
- return selected ? {
2480
+ /**
2481
+ * Project inventory per category: what is already installed in the project
2482
+ * (detected from real files/packages), independent of this session's picks.
2483
+ */
2484
+ function installedSummary(availabilities, category) {
2485
+ const entries = availabilities.filter((a) => a.recipe.category === category && (a.compatibility === "already-installed" || a.compatibility === "partially-configured"));
2486
+ if (entries.length === 0) return void 0;
2487
+ return {
2488
+ label: entries.map((a) => {
2489
+ const version = a.detection.installedVersion;
2490
+ const base = version ? `${a.recipe.name} ${version}` : a.recipe.name;
2491
+ return a.compatibility === "partially-configured" ? `${base} — partial setup` : base;
2492
+ }).join(" + "),
2493
+ partial: entries.some((a) => a.compatibility === "partially-configured")
2494
+ };
2495
+ }
2496
+ /**
2497
+ * Category rows show this session's picks first (green check), otherwise what
2498
+ * the project already has installed, otherwise that the slot is open.
2499
+ */
2500
+ function categoryOption(value, label, selected, installed) {
2501
+ if (selected) return {
2483
2502
  value,
2484
2503
  label: `${label} ${pc.green("✓")}`,
2485
2504
  hint: pc.green(selected)
2505
+ };
2506
+ if (installed) return installed.partial ? {
2507
+ value,
2508
+ label: `${label} ${pc.yellow("⚠")}`,
2509
+ hint: pc.yellow(`installed: ${installed.label}`)
2486
2510
  } : {
2511
+ value,
2512
+ label: `${label} ${pc.green("●")}`,
2513
+ hint: pc.dim(`installed: ${installed.label}`)
2514
+ };
2515
+ return {
2487
2516
  value,
2488
2517
  label,
2489
- hint: "Not selected"
2518
+ hint: "Not installed"
2490
2519
  };
2491
2520
  }
2492
2521
  function categoryHasVisibleRecipes(availabilities, category) {
@@ -2496,7 +2525,8 @@ function categoryHasVisibleRecipes(availabilities, category) {
2496
2525
  * The jumpable category dashboard. Selections persist in memory while the
2497
2526
  * user moves between categories; nothing is installed here.
2498
2527
  */
2499
- async function runDashboard(context, selection) {
2528
+ async function runDashboard(context, selection, options = {}) {
2529
+ const reviewLabel = options.reviewLabel ?? "Review and Install";
2500
2530
  for (;;) {
2501
2531
  const availabilities = filterIntegrations(context, allRecipes);
2502
2532
  const count = selectedIntegrationCount(selection);
@@ -2504,13 +2534,13 @@ async function runDashboard(context, selection) {
2504
2534
  const overrideCount = Object.keys(selection.versionOverrides).length;
2505
2535
  p.log.info(`Selected integrations: ${count} Additional packages: ${customCount}${overrideCount > 0 ? ` Version overrides: ${overrideCount}` : ""}`);
2506
2536
  const options = [];
2507
- if (categoryHasVisibleRecipes(availabilities, "routing")) options.push(categoryOption("routing", "Routing", selectionLabel(selection.routing)));
2508
- options.push(categoryOption("state-management", "State Management", selectionLabel(selection.stateManagement)), categoryOption("data-fetching", "Data Fetching and API", selectionLabel(selection.dataFetching)), categoryOption("forms-validation", "Forms and Validation", selectionLabel(selection.formsAndValidation)), categoryOption("ui", "UI Components", selectionLabel(selection.ui)), categoryOption("orm", "Database / ORM", selectionLabel(selection.orm)), categoryOption("testing", "Testing", testingLabel(selection)), categoryOption("custom-packages", "Custom Packages", customCount > 0 ? `${customCount} package${customCount === 1 ? "" : "s"}` : void 0), {
2537
+ if (categoryHasVisibleRecipes(availabilities, "routing")) options.push(categoryOption("routing", "Routing", selectionLabel(selection.routing), installedSummary(availabilities, "routing")));
2538
+ options.push(categoryOption("state-management", "State Management", selectionLabel(selection.stateManagement), installedSummary(availabilities, "state-management")), categoryOption("data-fetching", "Data Fetching and API", selectionLabel(selection.dataFetching), installedSummary(availabilities, "data-fetching")), categoryOption("forms-validation", "Forms and Validation", selectionLabel(selection.formsAndValidation), installedSummary(availabilities, "forms-validation")), categoryOption("ui", "UI Components", selectionLabel(selection.ui), installedSummary(availabilities, "ui")), categoryOption("orm", "Database / ORM", selectionLabel(selection.orm), installedSummary(availabilities, "orm")), categoryOption("testing", "Testing", testingLabel(selection), installedSummary(availabilities, "testing")), categoryOption("custom-packages", "Custom Packages", customCount > 0 ? `${customCount} package${customCount === 1 ? "" : "s"}` : void 0), {
2509
2539
  value: "versions",
2510
2540
  label: "Edit Package Versions"
2511
2541
  }, {
2512
2542
  value: "review",
2513
- label: "Review and Install"
2543
+ label: reviewLabel
2514
2544
  });
2515
2545
  switch (guard(await p.select({
2516
2546
  message: "Choose a category",
@@ -3828,16 +3858,50 @@ function buildPresetFromSelection(params) {
3828
3858
  versionOverrides: selection.versionOverrides
3829
3859
  };
3830
3860
  }
3861
+ /**
3862
+ * True when the current selection still matches what the preset describes —
3863
+ * a save-and-load flow should not re-ask to save an unchanged setup.
3864
+ */
3865
+ function selectionMatchesPreset(preset, selection) {
3866
+ const dependencies = {};
3867
+ const devDependencies = {};
3868
+ for (const pkg of selection.customPackages) (pkg.dependencyType === "dependency" ? dependencies : devDependencies)[pkg.name] = pkg.version;
3869
+ const current = {
3870
+ integrations: selectedRecipes(selection).map(({ recipe, options }) => ({
3871
+ id: recipe.id,
3872
+ options
3873
+ })),
3874
+ dependencies,
3875
+ devDependencies,
3876
+ versionOverrides: selection.versionOverrides
3877
+ };
3878
+ const saved = {
3879
+ integrations: preset.integrations.map(({ id, options }) => ({
3880
+ id,
3881
+ options
3882
+ })),
3883
+ dependencies: preset.customPackages.dependencies,
3884
+ devDependencies: preset.customPackages.devDependencies,
3885
+ versionOverrides: preset.versionOverrides
3886
+ };
3887
+ return JSON.stringify(current) === JSON.stringify(saved);
3888
+ }
3831
3889
  /** Post-install offer to save the applied setup as a preset. */
3832
- async function offerToSavePreset(context, selection) {
3890
+ async function offerToSavePreset(context, selection, options = {}) {
3833
3891
  if (context.framework === "unknown" || context.language === "unknown") return;
3892
+ const source = options.sourcePreset;
3893
+ if (source && selectionMatchesPreset(source, selection)) {
3894
+ p.log.success(`Installed from preset "${source.displayName ?? source.name}" — nothing new to save. Good to go!`);
3895
+ return;
3896
+ }
3834
3897
  if (!guard(await p.confirm({
3835
- message: "Save this setup as a preset?",
3898
+ message: source ? `This setup was changed after loading preset "${source.name}". Save the updated setup as a preset?` : "Save this setup as a preset?",
3836
3899
  initialValue: false
3837
3900
  }))) return;
3838
3901
  const name = guard(await p.text({
3839
3902
  message: "Preset name",
3840
3903
  placeholder: "my-react-stack",
3904
+ initialValue: source?.name,
3841
3905
  validate(value) {
3842
3906
  const result = validatePresetName(value ?? "");
3843
3907
  return result.ok ? void 0 : result.reason;
@@ -3902,7 +3966,7 @@ async function dashboardInstallLoop(params) {
3902
3966
  continue;
3903
3967
  }
3904
3968
  if (result === "installed") {
3905
- await offerToSavePreset(params.context, params.selection);
3969
+ await offerToSavePreset(params.context, params.selection, { sourcePreset: params.sourcePreset });
3906
3970
  return "installed";
3907
3971
  }
3908
3972
  return result === "dry-run" ? "dry-run" : "cancelled";
@@ -3968,6 +4032,25 @@ async function directoryState(target) {
3968
4032
  return "missing";
3969
4033
  }
3970
4034
  }
4035
+ /**
4036
+ * Creating a project inside an existing project is allowed but easy to mix up
4037
+ * later (two nested package.json trees), so it needs an explicit confirmation.
4038
+ */
4039
+ async function confirmIfInsideExistingProject() {
4040
+ if (!await fs.access(path.join(process.cwd(), "package.json")).then(() => true).catch(() => false)) return true;
4041
+ p.log.warn(`This folder is already a project (package.json found):\n ${process.cwd()}\nThe new project would be created inside it — nested projects are easy to confuse later.`);
4042
+ return guard(await p.select({
4043
+ message: "Create the new project inside this existing project anyway?",
4044
+ options: [{
4045
+ value: "cancel",
4046
+ label: "No, cancel",
4047
+ hint: "cd to another folder first, then run stackpack again"
4048
+ }, {
4049
+ value: "continue",
4050
+ label: "Yes, create it nested inside this project"
4051
+ }]
4052
+ })) === "continue";
4053
+ }
3971
4054
  async function askProjectName(initial) {
3972
4055
  let candidate = initial;
3973
4056
  for (;;) {
@@ -4078,9 +4161,47 @@ async function ensureDependenciesInstalled(destination, packageManager) {
4078
4161
  const result = await realCommandRunner(baseInstallCommand(packageManager, destination));
4079
4162
  if (result.exitCode !== 0) p.log.warn(`Dependency installation failed (exit code ${result.exitCode}). Run "${packageManager} install" inside the project to finish setup.`);
4080
4163
  }
4164
+ /**
4165
+ * Express install: create a project from a saved preset in one shot, like the
4166
+ * official create-* tools but with the whole stack. Skips the intermediate
4167
+ * confirmations of runNew; the review screen is the single decision point.
4168
+ */
4169
+ async function runExpressInstall(presetName, projectNameArg, options = {}) {
4170
+ p.intro("StackPack — express install");
4171
+ const { preset, location } = await loadPreset(presetName, { projectRoot: process.cwd() });
4172
+ p.log.info(`Preset: ${preset.displayName ?? preset.name} ${pc.dim(`(${location.scope} — ${preset.integrations.length} integrations)`)}`);
4173
+ if (!await confirmIfInsideExistingProject()) throw new CancelledError();
4174
+ const { name, destination } = await askProjectName(projectNameArg);
4175
+ const creator = getCreator(preset.base.creator);
4176
+ const config = await loadConfig();
4177
+ const packageManager = options.packageManager ?? config.defaultPackageManager ?? "npm";
4178
+ await runOfficialCreator(creator, name, { language: preset.base.language }, packageManager);
4179
+ const context = await detectProject(destination, { packageManagerOverride: packageManager });
4180
+ if (context.detection === "unsupported") {
4181
+ p.log.warn("The generated project is not a supported React or Next.js project, so the preset's integrations cannot be applied.");
4182
+ await ensureDependenciesInstalled(destination, packageManager);
4183
+ p.outro(`Base project created at ${destination}. No integrations were installed.`);
4184
+ return;
4185
+ }
4186
+ if (await dashboardInstallLoop({
4187
+ context,
4188
+ selection: presetToSelection(preset),
4189
+ dryRun: false,
4190
+ startAtReview: true,
4191
+ sourcePreset: preset
4192
+ }) === "cancelled") {
4193
+ await ensureDependenciesInstalled(destination, packageManager);
4194
+ p.outro(`Base project created at ${destination}. No integrations were installed.`);
4195
+ return;
4196
+ }
4197
+ await ensureDependenciesInstalled(destination, packageManager);
4198
+ p.note(`cd ${name}\n${packageManager} run dev`, "Next steps");
4199
+ p.outro(`Project ready at ${destination} — good to go!`);
4200
+ }
4081
4201
  async function runNew(projectNameArg, options) {
4082
4202
  p.intro("StackPack — new project");
4083
4203
  p.log.message(pc.dim("Official project tooling with real-world integrations."));
4204
+ if (!await confirmIfInsideExistingProject()) throw new CancelledError();
4084
4205
  let preset;
4085
4206
  if (options.preset) preset = (await loadPreset(options.preset, { projectRoot: process.cwd() })).preset;
4086
4207
  else {
@@ -4179,14 +4300,16 @@ async function runNew(projectNameArg, options) {
4179
4300
  context,
4180
4301
  selection: preset ? presetToSelection(preset) : createEmptySelection(),
4181
4302
  dryRun: false,
4182
- startAtReview: preset !== void 0
4303
+ startAtReview: preset !== void 0,
4304
+ sourcePreset: preset
4183
4305
  }) === "cancelled") {
4184
4306
  await ensureDependenciesInstalled(destination, packageManager);
4185
4307
  p.outro(`Base project created at ${destination}. No integrations were installed.`);
4186
4308
  return;
4187
4309
  }
4188
4310
  await ensureDependenciesInstalled(destination, packageManager);
4189
- p.outro(`Project ready at ${destination}`);
4311
+ p.note(`cd ${name}\n${packageManager} run dev`, "Next steps");
4312
+ p.outro(`Project ready at ${destination} — good to go!`);
4190
4313
  }
4191
4314
  //#endregion
4192
4315
  //#region src/project/git-status.ts
@@ -4436,7 +4559,8 @@ async function runApply(name, options) {
4436
4559
  context,
4437
4560
  selection: presetToSelection(preset),
4438
4561
  dryRun: options.dryRun === true,
4439
- startAtReview: true
4562
+ startAtReview: true,
4563
+ sourcePreset: preset
4440
4564
  });
4441
4565
  if (outcome === "cancelled") throw new CancelledError();
4442
4566
  p.outro(outcome === "dry-run" ? "Dry run finished." : `Preset "${name}" applied.`);
@@ -4545,6 +4669,11 @@ async function runPresetsBrowser(options = {}) {
4545
4669
  value: "list",
4546
4670
  label: "Back to preset list"
4547
4671
  },
4672
+ {
4673
+ value: "edit",
4674
+ label: "Edit this preset",
4675
+ hint: "swap integrations, save back"
4676
+ },
4548
4677
  {
4549
4678
  value: "delete",
4550
4679
  label: "Delete this preset"
@@ -4556,6 +4685,10 @@ async function runPresetsBrowser(options = {}) {
4556
4685
  ]
4557
4686
  }));
4558
4687
  if (action === "menu") return;
4688
+ if (action === "edit") {
4689
+ await runPresetsEdit(selected.name, { cwd: projectRoot });
4690
+ continue;
4691
+ }
4559
4692
  if (action === "delete") {
4560
4693
  if (guard(await p.confirm({
4561
4694
  message: `Delete preset "${selected.name}"?`,
@@ -4567,6 +4700,65 @@ async function runPresetsBrowser(options = {}) {
4567
4700
  }
4568
4701
  }
4569
4702
  }
4703
+ /**
4704
+ * Editing a preset never touches a real project, so the dashboard runs
4705
+ * against a synthetic context built from the preset's own project shape.
4706
+ */
4707
+ function contextForPresetEditing(preset) {
4708
+ return {
4709
+ rootDirectory: process.cwd(),
4710
+ framework: preset.project.framework,
4711
+ buildTool: preset.project.buildTool,
4712
+ language: preset.project.language,
4713
+ packageManager: "npm",
4714
+ packageManagerCandidates: [],
4715
+ detection: "detected",
4716
+ packageJson: {},
4717
+ detectedFiles: [],
4718
+ installedPackages: {}
4719
+ };
4720
+ }
4721
+ /**
4722
+ * Save-and-load editing: open a preset in the dashboard, change integrations,
4723
+ * and write it back to the same file. No project is created or modified.
4724
+ */
4725
+ async function runPresetsEdit(name, options = {}) {
4726
+ const projectRoot = options.cwd ?? process.cwd();
4727
+ const { preset, location } = await loadPreset(name, { projectRoot });
4728
+ p.log.info(`Editing ${location.scope} preset: ${location.filePath}`);
4729
+ p.log.message(pc.dim("Changes apply to the preset file only — no project is created or modified."));
4730
+ const context = contextForPresetEditing(preset);
4731
+ const selection = presetToSelection(preset);
4732
+ if (await runDashboard(context, selection, { reviewLabel: "Finish editing" }) !== "review") return;
4733
+ if (selectionMatchesPreset(preset, selection)) {
4734
+ p.log.info(`No changes made — preset "${preset.name}" is untouched.`);
4735
+ return;
4736
+ }
4737
+ const rebuilt = buildPresetFromSelection({
4738
+ name: preset.name,
4739
+ scope: location.scope,
4740
+ context,
4741
+ selection
4742
+ });
4743
+ if (!rebuilt) {
4744
+ p.log.warn("This preset cannot be rebuilt (unknown framework or language). Nothing saved.");
4745
+ return;
4746
+ }
4747
+ const updated = {
4748
+ ...rebuilt,
4749
+ displayName: preset.displayName ?? preset.name,
4750
+ createdAt: preset.createdAt
4751
+ };
4752
+ if (!guard(await p.confirm({
4753
+ message: `Save changes to preset "${preset.name}"?`,
4754
+ initialValue: true
4755
+ }))) {
4756
+ p.log.info("Changes discarded — the preset file was not modified.");
4757
+ return;
4758
+ }
4759
+ const saved = await savePreset(updated, location.scope, { projectRoot });
4760
+ p.log.success(`Preset updated: ${saved.filePath}`);
4761
+ }
4570
4762
  async function runPresetsDelete(name, options = {}) {
4571
4763
  const storeOptions = { projectRoot: options.cwd ?? process.cwd() };
4572
4764
  p.intro("StackPack — delete preset");
@@ -4581,8 +4773,62 @@ async function runPresetsDelete(name, options = {}) {
4581
4773
  p.outro(`Deleted ${location.scope} preset: ${location.filePath}`);
4582
4774
  }
4583
4775
  //#endregion
4776
+ //#region src/utils/update-check.ts
4777
+ const PACKAGE_NAME = "stackpack-cli";
4778
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
4779
+ const CHECK_INTERVAL_MS = 1440 * 60 * 1e3;
4780
+ const FETCH_TIMEOUT_MS = 2e3;
4781
+ function cacheFilePath() {
4782
+ return path.join(getStoragePaths().cacheDir, "update-check.json");
4783
+ }
4784
+ async function readCachedLatest() {
4785
+ try {
4786
+ const raw = JSON.parse(await fs.readFile(cacheFilePath(), "utf8"));
4787
+ return Date.now() - Date.parse(raw.checkedAt) < CHECK_INTERVAL_MS && semver.valid(raw.latest) ? raw.latest : null;
4788
+ } catch {
4789
+ return null;
4790
+ }
4791
+ }
4792
+ async function fetchLatestFromRegistry() {
4793
+ const controller = new AbortController();
4794
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
4795
+ try {
4796
+ const response = await fetch(REGISTRY_URL, { signal: controller.signal });
4797
+ if (!response.ok) return null;
4798
+ const data = await response.json();
4799
+ if (typeof data.version !== "string" || !semver.valid(data.version)) return null;
4800
+ const cache = {
4801
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
4802
+ latest: data.version
4803
+ };
4804
+ await fs.mkdir(path.dirname(cacheFilePath()), { recursive: true });
4805
+ await fs.writeFile(cacheFilePath(), JSON.stringify(cache), "utf8");
4806
+ return data.version;
4807
+ } catch {
4808
+ return null;
4809
+ } finally {
4810
+ clearTimeout(timer);
4811
+ }
4812
+ }
4813
+ /**
4814
+ * Anonymous, best-effort check for a newer published version. Never blocks
4815
+ * for more than ~2s, runs at most once per day (cached in ~/.stackpack/cache),
4816
+ * fails silently offline, and is disabled entirely by STACKPACK_NO_UPDATE_CHECK.
4817
+ * It never updates anything by itself — it only returns a notice to display.
4818
+ */
4819
+ async function getUpdateNotice(currentVersion) {
4820
+ if (process.env.STACKPACK_NO_UPDATE_CHECK) return null;
4821
+ try {
4822
+ const latest = await readCachedLatest() ?? await fetchLatestFromRegistry();
4823
+ if (!latest || !semver.valid(currentVersion) || !semver.gt(latest, currentVersion)) return null;
4824
+ return `Update available: ${currentVersion} → ${latest}\nRun: npm install -g ${PACKAGE_NAME}`;
4825
+ } catch {
4826
+ return null;
4827
+ }
4828
+ }
4829
+ //#endregion
4584
4830
  //#region src/version.ts
4585
- const VERSION = "0.3.0";
4831
+ const VERSION = "0.3.3";
4586
4832
  //#endregion
4587
4833
  //#region src/program.ts
4588
4834
  const packageManagerOption = new Option("--package-manager <manager>", "package manager to use").choices([
@@ -4596,6 +4842,8 @@ async function runMainMenu() {
4596
4842
  p.log.message("Official project tooling with real-world integrations.");
4597
4843
  p.log.message("Presets stay on this device.");
4598
4844
  p.log.message(pc.dim("Ctrl+C on any question brings you back to this menu instead of exiting."));
4845
+ const updateNotice = await getUpdateNotice(VERSION);
4846
+ if (updateNotice) p.log.warn(pc.yellow(updateNotice));
4599
4847
  for (;;) {
4600
4848
  const choice = await p.select({
4601
4849
  message: "What would you like to do?",
@@ -4676,6 +4924,9 @@ async function runCli(argv) {
4676
4924
  program.command("apply").description("Apply a saved preset's integrations to the current project").argument("<preset-name>", "preset to apply").option("--dry-run", "show the full plan without changing anything").action(async (name, options) => {
4677
4925
  await runApply(name, options);
4678
4926
  });
4927
+ program.command("install").alias("i").description("Create a project from a saved preset in one shot (express mode)").argument("<preset-name>", "saved preset to install from").argument("[project-name]", "project folder to create (asked interactively if omitted)").addOption(packageManagerOption).action(async (presetName, projectName, options) => {
4928
+ await runExpressInstall(presetName, projectName, options);
4929
+ });
4679
4930
  const presets = program.command("presets").description("Manage saved presets");
4680
4931
  presets.command("list").description("List saved presets (global and project-local)").action(async () => {
4681
4932
  await runPresetsList();
@@ -4683,6 +4934,9 @@ async function runCli(argv) {
4683
4934
  presets.command("show").description("Show a preset's contents").argument("<preset-name>").action(async (name) => {
4684
4935
  await runPresetsShow(name);
4685
4936
  });
4937
+ presets.command("edit").description("Edit a saved preset's integrations (no project is touched)").argument("<preset-name>").action(async (name) => {
4938
+ await runPresetsEdit(name);
4939
+ });
4686
4940
  presets.command("delete").description("Delete a saved preset").argument("<preset-name>").action(async (name) => {
4687
4941
  await runPresetsDelete(name);
4688
4942
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stackpack-cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Local-first, privacy-focused terminal integration builder for JavaScript and TypeScript projects. Official tooling first, presets stay on your device.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,7 @@
20
20
  "dist"
21
21
  ],
22
22
  "engines": {
23
- "node": ">=18.17"
23
+ "node": ">=22"
24
24
  },
25
25
  "scripts": {
26
26
  "dev": "tsx src/cli.ts",