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 +5 -0
- package/dist/cli.js +1 -1
- package/dist/{program-D9mgsLIa.js → program-Bq8-8HvQ.js} +269 -15
- package/package.json +2 -2
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-
|
|
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
|
-
/**
|
|
2481
|
-
|
|
2482
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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.
|
|
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.
|
|
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": ">=
|
|
23
|
+
"node": ">=22"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
26
|
"dev": "tsx src/cli.ts",
|