stackpack-cli 0.3.1 → 0.3.2

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
@@ -137,6 +137,8 @@ StackPack requires **no sign-up, no login, no server, no telemetry**. Presets ar
137
137
 
138
138
  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
139
 
140
+ 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.
141
+
140
142
  ## Safety
141
143
 
142
144
  - 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-CqWM02UT.js");
5
5
  await runCli(process.argv);
6
6
  process.exit(process.exitCode ?? 0);
7
7
  //#endregion
@@ -3828,16 +3828,50 @@ function buildPresetFromSelection(params) {
3828
3828
  versionOverrides: selection.versionOverrides
3829
3829
  };
3830
3830
  }
3831
+ /**
3832
+ * True when the current selection still matches what the preset describes —
3833
+ * a save-and-load flow should not re-ask to save an unchanged setup.
3834
+ */
3835
+ function selectionMatchesPreset(preset, selection) {
3836
+ const dependencies = {};
3837
+ const devDependencies = {};
3838
+ for (const pkg of selection.customPackages) (pkg.dependencyType === "dependency" ? dependencies : devDependencies)[pkg.name] = pkg.version;
3839
+ const current = {
3840
+ integrations: selectedRecipes(selection).map(({ recipe, options }) => ({
3841
+ id: recipe.id,
3842
+ options
3843
+ })),
3844
+ dependencies,
3845
+ devDependencies,
3846
+ versionOverrides: selection.versionOverrides
3847
+ };
3848
+ const saved = {
3849
+ integrations: preset.integrations.map(({ id, options }) => ({
3850
+ id,
3851
+ options
3852
+ })),
3853
+ dependencies: preset.customPackages.dependencies,
3854
+ devDependencies: preset.customPackages.devDependencies,
3855
+ versionOverrides: preset.versionOverrides
3856
+ };
3857
+ return JSON.stringify(current) === JSON.stringify(saved);
3858
+ }
3831
3859
  /** Post-install offer to save the applied setup as a preset. */
3832
- async function offerToSavePreset(context, selection) {
3860
+ async function offerToSavePreset(context, selection, options = {}) {
3833
3861
  if (context.framework === "unknown" || context.language === "unknown") return;
3862
+ const source = options.sourcePreset;
3863
+ if (source && selectionMatchesPreset(source, selection)) {
3864
+ p.log.success(`Installed from preset "${source.displayName ?? source.name}" — nothing new to save. Good to go!`);
3865
+ return;
3866
+ }
3834
3867
  if (!guard(await p.confirm({
3835
- message: "Save this setup as a preset?",
3868
+ 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
3869
  initialValue: false
3837
3870
  }))) return;
3838
3871
  const name = guard(await p.text({
3839
3872
  message: "Preset name",
3840
3873
  placeholder: "my-react-stack",
3874
+ initialValue: source?.name,
3841
3875
  validate(value) {
3842
3876
  const result = validatePresetName(value ?? "");
3843
3877
  return result.ok ? void 0 : result.reason;
@@ -3902,7 +3936,7 @@ async function dashboardInstallLoop(params) {
3902
3936
  continue;
3903
3937
  }
3904
3938
  if (result === "installed") {
3905
- await offerToSavePreset(params.context, params.selection);
3939
+ await offerToSavePreset(params.context, params.selection, { sourcePreset: params.sourcePreset });
3906
3940
  return "installed";
3907
3941
  }
3908
3942
  return result === "dry-run" ? "dry-run" : "cancelled";
@@ -3968,6 +4002,25 @@ async function directoryState(target) {
3968
4002
  return "missing";
3969
4003
  }
3970
4004
  }
4005
+ /**
4006
+ * Creating a project inside an existing project is allowed but easy to mix up
4007
+ * later (two nested package.json trees), so it needs an explicit confirmation.
4008
+ */
4009
+ async function confirmIfInsideExistingProject() {
4010
+ if (!await fs.access(path.join(process.cwd(), "package.json")).then(() => true).catch(() => false)) return true;
4011
+ 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.`);
4012
+ return guard(await p.select({
4013
+ message: "Create the new project inside this existing project anyway?",
4014
+ options: [{
4015
+ value: "cancel",
4016
+ label: "No, cancel",
4017
+ hint: "cd to another folder first, then run stackpack again"
4018
+ }, {
4019
+ value: "continue",
4020
+ label: "Yes, create it nested inside this project"
4021
+ }]
4022
+ })) === "continue";
4023
+ }
3971
4024
  async function askProjectName(initial) {
3972
4025
  let candidate = initial;
3973
4026
  for (;;) {
@@ -4081,6 +4134,7 @@ async function ensureDependenciesInstalled(destination, packageManager) {
4081
4134
  async function runNew(projectNameArg, options) {
4082
4135
  p.intro("StackPack — new project");
4083
4136
  p.log.message(pc.dim("Official project tooling with real-world integrations."));
4137
+ if (!await confirmIfInsideExistingProject()) throw new CancelledError();
4084
4138
  let preset;
4085
4139
  if (options.preset) preset = (await loadPreset(options.preset, { projectRoot: process.cwd() })).preset;
4086
4140
  else {
@@ -4179,14 +4233,16 @@ async function runNew(projectNameArg, options) {
4179
4233
  context,
4180
4234
  selection: preset ? presetToSelection(preset) : createEmptySelection(),
4181
4235
  dryRun: false,
4182
- startAtReview: preset !== void 0
4236
+ startAtReview: preset !== void 0,
4237
+ sourcePreset: preset
4183
4238
  }) === "cancelled") {
4184
4239
  await ensureDependenciesInstalled(destination, packageManager);
4185
4240
  p.outro(`Base project created at ${destination}. No integrations were installed.`);
4186
4241
  return;
4187
4242
  }
4188
4243
  await ensureDependenciesInstalled(destination, packageManager);
4189
- p.outro(`Project ready at ${destination}`);
4244
+ p.note(`cd ${name}\n${packageManager} run dev`, "Next steps");
4245
+ p.outro(`Project ready at ${destination} — good to go!`);
4190
4246
  }
4191
4247
  //#endregion
4192
4248
  //#region src/project/git-status.ts
@@ -4436,7 +4492,8 @@ async function runApply(name, options) {
4436
4492
  context,
4437
4493
  selection: presetToSelection(preset),
4438
4494
  dryRun: options.dryRun === true,
4439
- startAtReview: true
4495
+ startAtReview: true,
4496
+ sourcePreset: preset
4440
4497
  });
4441
4498
  if (outcome === "cancelled") throw new CancelledError();
4442
4499
  p.outro(outcome === "dry-run" ? "Dry run finished." : `Preset "${name}" applied.`);
@@ -4581,8 +4638,62 @@ async function runPresetsDelete(name, options = {}) {
4581
4638
  p.outro(`Deleted ${location.scope} preset: ${location.filePath}`);
4582
4639
  }
4583
4640
  //#endregion
4641
+ //#region src/utils/update-check.ts
4642
+ const PACKAGE_NAME = "stackpack-cli";
4643
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
4644
+ const CHECK_INTERVAL_MS = 1440 * 60 * 1e3;
4645
+ const FETCH_TIMEOUT_MS = 2e3;
4646
+ function cacheFilePath() {
4647
+ return path.join(getStoragePaths().cacheDir, "update-check.json");
4648
+ }
4649
+ async function readCachedLatest() {
4650
+ try {
4651
+ const raw = JSON.parse(await fs.readFile(cacheFilePath(), "utf8"));
4652
+ return Date.now() - Date.parse(raw.checkedAt) < CHECK_INTERVAL_MS && semver.valid(raw.latest) ? raw.latest : null;
4653
+ } catch {
4654
+ return null;
4655
+ }
4656
+ }
4657
+ async function fetchLatestFromRegistry() {
4658
+ const controller = new AbortController();
4659
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
4660
+ try {
4661
+ const response = await fetch(REGISTRY_URL, { signal: controller.signal });
4662
+ if (!response.ok) return null;
4663
+ const data = await response.json();
4664
+ if (typeof data.version !== "string" || !semver.valid(data.version)) return null;
4665
+ const cache = {
4666
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
4667
+ latest: data.version
4668
+ };
4669
+ await fs.mkdir(path.dirname(cacheFilePath()), { recursive: true });
4670
+ await fs.writeFile(cacheFilePath(), JSON.stringify(cache), "utf8");
4671
+ return data.version;
4672
+ } catch {
4673
+ return null;
4674
+ } finally {
4675
+ clearTimeout(timer);
4676
+ }
4677
+ }
4678
+ /**
4679
+ * Anonymous, best-effort check for a newer published version. Never blocks
4680
+ * for more than ~2s, runs at most once per day (cached in ~/.stackpack/cache),
4681
+ * fails silently offline, and is disabled entirely by STACKPACK_NO_UPDATE_CHECK.
4682
+ * It never updates anything by itself — it only returns a notice to display.
4683
+ */
4684
+ async function getUpdateNotice(currentVersion) {
4685
+ if (process.env.STACKPACK_NO_UPDATE_CHECK) return null;
4686
+ try {
4687
+ const latest = await readCachedLatest() ?? await fetchLatestFromRegistry();
4688
+ if (!latest || !semver.valid(currentVersion) || !semver.gt(latest, currentVersion)) return null;
4689
+ return `Update available: ${currentVersion} → ${latest}\nRun: npm install -g ${PACKAGE_NAME}`;
4690
+ } catch {
4691
+ return null;
4692
+ }
4693
+ }
4694
+ //#endregion
4584
4695
  //#region src/version.ts
4585
- const VERSION = "0.3.0";
4696
+ const VERSION = "0.3.2";
4586
4697
  //#endregion
4587
4698
  //#region src/program.ts
4588
4699
  const packageManagerOption = new Option("--package-manager <manager>", "package manager to use").choices([
@@ -4596,6 +4707,8 @@ async function runMainMenu() {
4596
4707
  p.log.message("Official project tooling with real-world integrations.");
4597
4708
  p.log.message("Presets stay on this device.");
4598
4709
  p.log.message(pc.dim("Ctrl+C on any question brings you back to this menu instead of exiting."));
4710
+ const updateNotice = await getUpdateNotice(VERSION);
4711
+ if (updateNotice) p.log.warn(pc.yellow(updateNotice));
4599
4712
  for (;;) {
4600
4713
  const choice = await p.select({
4601
4714
  message: "What would you like to do?",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stackpack-cli",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",