@salty-css/core 0.1.0-alpha.15 → 0.1.0-alpha.17

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.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Renders a single package spec for display. Translates the internal
3
+ * `'-D <pkg>@<ver>'` shorthand used by `npmInstall` into a `<pkg>@<ver> (dev)`
4
+ * suffix so the user-facing list reads naturally.
5
+ */
6
+ export declare const formatPackageForDisplay: (spec: string) => string;
7
+ export declare const renderPackageList: (packages: string[]) => string;
8
+ export interface ConfirmInstallOptions {
9
+ /** Streams used for the prompt — defaults to process.stdin/stdout. Allows tests to inject. */
10
+ input?: NodeJS.ReadableStream;
11
+ output?: NodeJS.WritableStream;
12
+ /** Whether the input is a TTY — defaults to process.stdin.isTTY. Allows tests to override. */
13
+ isTTY?: boolean;
14
+ }
15
+ /**
16
+ * Confirm a batched install. Resolves on success, throws on decline.
17
+ *
18
+ * - `yes=true` or empty `packages` → no prompt, resolves immediately.
19
+ * - Non-TTY without `yes` → throws, telling the user to pass `--yes`.
20
+ * - Otherwise prints the list and asks `Proceed? (y/N)`. Accepts y/yes
21
+ * (case-insensitive); anything else throws to abort the command.
22
+ */
23
+ export declare const confirmInstall: (packages: string[], yes: boolean, options?: ConfirmInstallOptions) => Promise<void>;
24
+ export interface ConfirmYesNoOptions extends ConfirmInstallOptions {
25
+ /** When true, resolves true without prompting. */
26
+ yes?: boolean;
27
+ /** When true, an empty answer counts as yes. Defaults to false (empty = no). */
28
+ defaultYes?: boolean;
29
+ }
30
+ /**
31
+ * Generic yes/no prompt. Unlike `confirmInstall`, non-TTY without `yes`
32
+ * resolves `false` instead of throwing — callers can choose policy.
33
+ */
34
+ export declare const confirmYesNo: (question: string, options?: ConfirmYesNoOptions) => Promise<boolean>;
package/bin/context.d.ts CHANGED
@@ -8,11 +8,14 @@ export interface ProjectContext {
8
8
  rcFile: RCFile;
9
9
  cliVersion: string;
10
10
  skipInstall: boolean;
11
+ yes: boolean;
11
12
  }
12
13
  export declare const resolveProjectDir: (dir: string, rootDir?: string) => string;
13
14
  export interface BuildContextOptions {
14
15
  dir: string;
15
16
  skipInstall?: boolean;
17
+ /** Skip the install confirmation prompt and install without asking. */
18
+ yes?: boolean;
16
19
  /** When false, build context even if package.json is missing (used by commands that should not require one). */
17
20
  requirePackageJson?: boolean;
18
21
  }
@@ -1,12 +1,20 @@
1
1
  import { ProjectContext } from '../context';
2
- import { BuildIntegrationAdapter } from './types';
2
+ import { BuildIntegrationAdapter, IntegrationPlan } from './types';
3
3
  export declare const buildIntegrationRegistry: BuildIntegrationAdapter[];
4
- export declare const detectAndApplyIntegrations: (ctx: ProjectContext) => Promise<{
4
+ export interface PlannedIntegration {
5
+ name: string;
6
+ configPath: string;
7
+ plan: IntegrationPlan;
8
+ }
9
+ /** Detect every integration that has work to do and compute its plan. */
10
+ export declare const planIntegrations: (ctx: ProjectContext) => Promise<PlannedIntegration[]>;
11
+ /** Execute each previously-planned integration (writes config files). */
12
+ export declare const applyIntegrationPlans: (planned: PlannedIntegration[]) => Promise<{
5
13
  name: string;
6
14
  configPath: string;
7
15
  changed: boolean;
8
16
  }[]>;
9
- export type { BuildIntegrationAdapter, ConfigEdit } from './types';
17
+ export type { BuildIntegrationAdapter, ConfigEdit, IntegrationPlan } from './types';
10
18
  export { viteIntegration, editViteConfig, vitePackage } from './vite';
11
19
  export { nextIntegration, editNextConfig, nextPackage, nextConfigFiles } from './next';
12
20
  export { astroIntegration, editAstroConfig, astroPackage } from './astro';
@@ -3,12 +3,24 @@ export interface IntegrationApplyResult {
3
3
  /** Whether any file was edited or any install was performed. */
4
4
  changed: boolean;
5
5
  }
6
+ /**
7
+ * Pending integration work — the packages it would like installed and a
8
+ * closure that writes the config edits once the install (if any) has run.
9
+ */
10
+ export interface IntegrationPlan {
11
+ /** Packages to install, using the same `npmInstall` shorthand (e.g. `'-D @salty-css/vite@1.2.3'`). */
12
+ packages: string[];
13
+ execute(): Promise<IntegrationApplyResult>;
14
+ }
6
15
  export interface BuildIntegrationAdapter {
7
16
  name: string;
8
17
  /** Returns the config file path this integration targets, or null when not applicable. */
9
18
  detect(ctx: ProjectContext): Promise<string | null> | string | null;
10
- /** Idempotently wire the integration into the project (edit configs, install dev deps). */
11
- apply(ctx: ProjectContext, configPath: string): Promise<IntegrationApplyResult>;
19
+ /**
20
+ * Idempotently produce the work needed to wire the integration in. Returns
21
+ * null when the integration is already wired (nothing to do).
22
+ */
23
+ plan(ctx: ProjectContext, configPath: string): Promise<IntegrationPlan | null>;
12
24
  }
13
25
  /** Pure transform — returned by an integration when it knows how to rewrite a config file. */
14
26
  export interface ConfigEdit {
package/bin/main.cjs CHANGED
@@ -11,6 +11,7 @@ const child_process = require("child_process");
11
11
  const ora = require("ora");
12
12
  const pascalCase = require("../pascal-case-By_l58S-.cjs");
13
13
  const ejs = require("ejs");
14
+ const promises$1 = require("readline/promises");
14
15
  var _documentCurrentScript = typeof document !== "undefined" ? document.currentScript : null;
15
16
  const defaultPackageJsonPath = path.join(process.cwd(), "package.json");
16
17
  const readPackageJson = async (filePath = defaultPackageJsonPath) => {
@@ -141,7 +142,8 @@ const buildContext = async (opts) => {
141
142
  packageJson,
142
143
  rcFile,
143
144
  cliVersion: cliPackageJson.version || "0.0.0",
144
- skipInstall: !!opts.skipInstall
145
+ skipInstall: !!opts.skipInstall,
146
+ yes: !!opts.yes
145
147
  };
146
148
  };
147
149
  const registerBuildCommand = (program, defaultProject) => {
@@ -296,6 +298,52 @@ const registerGenerateCommand = (program, defaultProject) => {
296
298
  await formatWithPrettier(formattedStyledFilePath);
297
299
  });
298
300
  };
301
+ const formatPackageForDisplay = (spec) => {
302
+ const trimmed = spec.trim();
303
+ if (trimmed.startsWith("-D ")) return `${trimmed.slice(3).trim()} (dev)`;
304
+ return trimmed;
305
+ };
306
+ const renderPackageList = (packages) => {
307
+ return packages.map((p) => ` + ${formatPackageForDisplay(p)}`).join("\n");
308
+ };
309
+ const confirmInstall = async (packages, yes, options = {}) => {
310
+ if (yes) return;
311
+ if (packages.length === 0) return;
312
+ const input = options.input ?? process.stdin;
313
+ const output = options.output ?? process.stdout;
314
+ const isTTY = options.isTTY ?? process.stdin.isTTY ?? false;
315
+ if (!isTTY) {
316
+ throw new Error("Cannot prompt for install confirmation: stdin is not a TTY. Re-run with --yes to install the listed packages without prompting.");
317
+ }
318
+ output.write(`The following packages will be installed:
319
+ ${renderPackageList(packages)}
320
+ `);
321
+ const rl = promises$1.createInterface({ input, output, terminal: false });
322
+ try {
323
+ const answer = (await rl.question("Proceed? (y/N) ")).trim().toLowerCase();
324
+ if (answer !== "y" && answer !== "yes") {
325
+ throw new Error("Install cancelled by user.");
326
+ }
327
+ } finally {
328
+ rl.close();
329
+ }
330
+ };
331
+ const confirmYesNo = async (question, options = {}) => {
332
+ if (options.yes) return true;
333
+ const input = options.input ?? process.stdin;
334
+ const output = options.output ?? process.stdout;
335
+ const isTTY = options.isTTY ?? process.stdin.isTTY ?? false;
336
+ if (!isTTY) return false;
337
+ const suffix = options.defaultYes ? "(Y/n)" : "(y/N)";
338
+ const rl = promises$1.createInterface({ input, output, terminal: false });
339
+ try {
340
+ const answer = (await rl.question(`${question} ${suffix} `)).trim().toLowerCase();
341
+ if (answer === "") return !!options.defaultYes;
342
+ return answer === "y" || answer === "yes";
343
+ } finally {
344
+ rl.close();
345
+ }
346
+ };
299
347
  const CSS_FILE_FOLDERS = ["src", "public", "assets", "styles", "css", "app"];
300
348
  const CSS_SECOND_LEVEL_FOLDERS = ["styles", "css", "app", "pages"];
301
349
  const CSS_FILE_NAMES = ["index", "styles", "main", "app", "global", "globals"];
@@ -342,17 +390,22 @@ const editAstroConfig = (existing) => {
342
390
  const astroIntegration = {
343
391
  name: "astro",
344
392
  detect: (ctx) => findAstroConfig(ctx.projectDir),
345
- apply: async (ctx, configPath) => {
393
+ plan: async (ctx, configPath) => {
346
394
  const existing = await promises.readFile(configPath, "utf-8").catch(() => void 0);
347
- if (existing === void 0) return { changed: false };
395
+ if (existing === void 0) return null;
348
396
  const result = editAstroConfig(existing);
349
397
  if (result.warning) compiler_saltyCompiler.logger.warn(result.warning);
350
- if (result.content === null) return { changed: false };
351
- if (!ctx.skipInstall) await npmInstall(`-D ${astroPackage(ctx.cliVersion)}`);
352
- compiler_saltyCompiler.logger.info("Adding Salty-CSS integration to Astro config: " + configPath);
353
- await promises.writeFile(configPath, result.content);
354
- await formatWithPrettier(configPath);
355
- return { changed: true };
398
+ if (result.content === null) return null;
399
+ const newContent = result.content;
400
+ return {
401
+ packages: [`-D ${astroPackage(ctx.cliVersion)}`],
402
+ execute: async () => {
403
+ compiler_saltyCompiler.logger.info("Adding Salty-CSS integration to Astro config: " + configPath);
404
+ await promises.writeFile(configPath, newContent);
405
+ await formatWithPrettier(configPath);
406
+ return { changed: true };
407
+ }
408
+ };
356
409
  }
357
410
  };
358
411
  const ESLINT_CONFIG_CANDIDATES = [
@@ -411,20 +464,25 @@ const eslintIntegration = {
411
464
  const candidates = eslintConfigCandidates(ctx.projectDir, ctx.cwd);
412
465
  return candidates.find((p) => fs.existsSync(p)) ?? null;
413
466
  },
414
- apply: async (ctx, configPath) => {
467
+ plan: async (ctx, configPath) => {
415
468
  const existing = await promises.readFile(configPath, "utf-8").catch(() => void 0);
416
469
  if (existing === void 0) {
417
470
  compiler_saltyCompiler.logger.error("Could not read ESLint config file.");
418
- return { changed: false };
471
+ return null;
419
472
  }
420
- if (!ctx.skipInstall) await npmInstall(corePackages.eslintConfigCore(ctx.cliVersion));
421
473
  const result = editEslintConfig(existing, configPath.endsWith("js"));
422
474
  if (result.warning) compiler_saltyCompiler.logger.warn(result.warning);
423
- if (result.content === null) return { changed: false };
424
- compiler_saltyCompiler.logger.info("Edit file: " + configPath);
425
- await promises.writeFile(configPath, result.content);
426
- await formatWithPrettier(configPath);
427
- return { changed: true };
475
+ if (result.content === null) return null;
476
+ const newContent = result.content;
477
+ return {
478
+ packages: [corePackages.eslintConfigCore(ctx.cliVersion)],
479
+ execute: async () => {
480
+ compiler_saltyCompiler.logger.info("Edit file: " + configPath);
481
+ await promises.writeFile(configPath, newContent);
482
+ await formatWithPrettier(configPath);
483
+ return { changed: true };
484
+ }
485
+ };
428
486
  }
429
487
  };
430
488
  const nextConfigFiles = ["next.config.js", "next.config.cjs", "next.config.ts", "next.config.mjs"];
@@ -454,16 +512,20 @@ const nextIntegration = {
454
512
  const found = nextConfigFiles.map((file) => path.join(ctx.projectDir, file)).find((p) => fs.existsSync(p));
455
513
  return found ?? null;
456
514
  },
457
- apply: async (ctx, configPath) => {
515
+ plan: async (ctx, configPath) => {
458
516
  const existing = await promises.readFile(configPath, "utf-8").catch(() => void 0);
459
- if (existing === void 0) return { changed: false };
517
+ if (existing === void 0) return null;
460
518
  const { content } = editNextConfig(existing);
461
- if (content === null) return { changed: false };
462
- if (!ctx.skipInstall) await npmInstall(`-D ${nextPackage(ctx.cliVersion)}`);
463
- compiler_saltyCompiler.logger.info("Adding Salty-CSS plugin to Next.js config...");
464
- await promises.writeFile(configPath, content);
465
- await formatWithPrettier(configPath);
466
- return { changed: true };
519
+ if (content === null) return null;
520
+ return {
521
+ packages: [`-D ${nextPackage(ctx.cliVersion)}`],
522
+ execute: async () => {
523
+ compiler_saltyCompiler.logger.info("Adding Salty-CSS plugin to Next.js config...");
524
+ await promises.writeFile(configPath, content);
525
+ await formatWithPrettier(configPath);
526
+ return { changed: true };
527
+ }
528
+ };
467
529
  }
468
530
  };
469
531
  const vitePackage = (version) => `@salty-css/vite@${version}`;
@@ -481,27 +543,40 @@ const viteIntegration = {
481
543
  const path$1 = path.join(ctx.projectDir, "vite.config.ts");
482
544
  return fs.existsSync(path$1) ? path$1 : null;
483
545
  },
484
- apply: async (ctx, configPath) => {
546
+ plan: async (ctx, configPath) => {
485
547
  const existing = await promises.readFile(configPath, "utf-8").catch(() => void 0);
486
- if (existing === void 0) return { changed: false };
548
+ if (existing === void 0) return null;
487
549
  const { content } = editViteConfig(existing);
488
- if (content === null) return { changed: false };
489
- compiler_saltyCompiler.logger.info("Edit file: " + configPath);
490
- if (!ctx.skipInstall) await npmInstall(`-D ${vitePackage(ctx.cliVersion)}`);
491
- compiler_saltyCompiler.logger.info("Adding Salty-CSS plugin to Vite config...");
492
- await promises.writeFile(configPath, content);
493
- await formatWithPrettier(configPath);
494
- return { changed: true };
550
+ if (content === null) return null;
551
+ return {
552
+ packages: [`-D ${vitePackage(ctx.cliVersion)}`],
553
+ execute: async () => {
554
+ compiler_saltyCompiler.logger.info("Edit file: " + configPath);
555
+ compiler_saltyCompiler.logger.info("Adding Salty-CSS plugin to Vite config...");
556
+ await promises.writeFile(configPath, content);
557
+ await formatWithPrettier(configPath);
558
+ return { changed: true };
559
+ }
560
+ };
495
561
  }
496
562
  };
497
563
  const buildIntegrationRegistry = [eslintIntegration, viteIntegration, nextIntegration, astroIntegration];
498
- const detectAndApplyIntegrations = async (ctx) => {
499
- const results = [];
564
+ const planIntegrations = async (ctx) => {
565
+ const planned = [];
500
566
  for (const integration of buildIntegrationRegistry) {
501
567
  const configPath = await integration.detect(ctx);
502
568
  if (!configPath) continue;
503
- const result = await integration.apply(ctx, configPath);
504
- results.push({ name: integration.name, configPath, changed: result.changed });
569
+ const plan = await integration.plan(ctx, configPath);
570
+ if (!plan) continue;
571
+ planned.push({ name: integration.name, configPath, plan });
572
+ }
573
+ return planned;
574
+ };
575
+ const applyIntegrationPlans = async (planned) => {
576
+ const results = [];
577
+ for (const { name, configPath, plan } of planned) {
578
+ const result = await plan.execute();
579
+ results.push({ name, configPath, changed: result.changed });
505
580
  }
506
581
  return results;
507
582
  };
@@ -552,17 +627,24 @@ const wirePrepareScript = async () => {
552
627
  await updatePackageJson(next);
553
628
  };
554
629
  const registerInitCommand = (program) => {
555
- program.command("init [directory]").description("Initialize a new Salty-CSS project.").option("-d, --dir <dir>", "Project directory to initialize the project in.").option("--css-file <css-file>", "Existing CSS file where to import the generated CSS. Path must be relative to the given project directory.").option("--skip-install", "Skip installing dependencies.").action(async function(_dir = ".") {
630
+ program.command("init [directory]").description("Initialize a new Salty-CSS project.").option("-d, --dir <dir>", "Project directory to initialize the project in.").option("--css-file <css-file>", "Existing CSS file where to import the generated CSS. Path must be relative to the given project directory.").option("--skip-install", "Skip installing dependencies.").option("-y, --yes", "Skip the install confirmation prompt.").action(async function(_dir = ".") {
556
631
  try {
557
632
  const opts = this.opts();
558
633
  const dir = opts.dir ?? _dir;
559
634
  if (!dir) return compiler_saltyCompiler.logError("Project directory must be provided. Add it as the first argument after init command or use the --dir option.");
560
- const ctx = await buildContext({ dir, skipInstall: opts.skipInstall });
635
+ const ctx = await buildContext({ dir, skipInstall: opts.skipInstall, yes: opts.yes });
561
636
  compiler_saltyCompiler.logger.info("Initializing a new Salty-CSS project!");
562
637
  const framework = await detectFramework(ctx);
563
638
  compiler_saltyCompiler.logger.info(`Detected framework: ${framework.name}`);
639
+ const plannedIntegrations = await planIntegrations(ctx);
564
640
  if (!ctx.skipInstall) {
565
- await npmInstall(corePackages.core(ctx.cliVersion), framework.runtimePackage(ctx.cliVersion));
641
+ const packages = [
642
+ corePackages.core(ctx.cliVersion),
643
+ framework.runtimePackage(ctx.cliVersion),
644
+ ...plannedIntegrations.flatMap((p) => p.plan.packages)
645
+ ];
646
+ await confirmInstall(packages, ctx.yes);
647
+ await npmInstall(...packages);
566
648
  }
567
649
  const projectFiles = await Promise.all([readTemplate("salty.config.ts"), readTemplate("saltygen/index.css")]);
568
650
  await promises.mkdir(ctx.projectDir, { recursive: true });
@@ -570,7 +652,7 @@ const registerInitCommand = (program) => {
570
652
  await writeProjectToRc(ctx.cwd, ctx.relativeProjectPath, framework);
571
653
  await ensureGitignoreSaltygen(ctx.cwd);
572
654
  await importSaltygenIntoCss(ctx.projectDir, opts.cssFile);
573
- await detectAndApplyIntegrations(ctx);
655
+ await applyIntegrationPlans(plannedIntegrations);
574
656
  await wirePrepareScript();
575
657
  compiler_saltyCompiler.logger.info("Running the build to generate initial CSS...");
576
658
  const compiler = new compiler_saltyCompiler.SaltyCompiler(ctx.projectDir);
@@ -601,8 +683,8 @@ const getSaltyCssPackages = async () => {
601
683
  return saltyCssPackages;
602
684
  };
603
685
  const registerUpdateCommand = (program) => {
604
- program.command("update [version]").alias("up").description("Update Salty-CSS packages to the latest or specified version.").option("-v, --version <version>", "Version to update to.").option("--legacy-peer-deps <legacyPeerDeps>", "Use legacy peer dependencies (not recommended).", false).action(async function(_version = "latest") {
605
- const { legacyPeerDeps, version = _version } = this.opts();
686
+ program.command("update [version]").alias("up").description("Update Salty-CSS packages to the latest or specified version.").option("-v, --version <version>", "Version to update to.").option("--legacy-peer-deps <legacyPeerDeps>", "Use legacy peer dependencies (not recommended).", false).option("-y, --yes", "Skip confirmation prompts (install and rebuild).").option("-d, --dir <dir>", "Project directory to rebuild after updating.").action(async function(_version = "latest") {
687
+ const { legacyPeerDeps, version = _version, yes = false, dir } = this.opts();
606
688
  const saltyCssPackages = await getSaltyCssPackages();
607
689
  if (!saltyCssPackages) return compiler_saltyCompiler.logError("Could not update Salty-CSS packages as any were found in package.json.");
608
690
  const cli = await readThisPackageJson();
@@ -610,6 +692,11 @@ const registerUpdateCommand = (program) => {
610
692
  if (version === "@") return `${name}@${cli.version}`;
611
693
  return `${name}@${version.replace(/^@/, "")}`;
612
694
  });
695
+ try {
696
+ await confirmInstall(packagesToUpdate, yes);
697
+ } catch (err) {
698
+ return compiler_saltyCompiler.logError(err instanceof Error ? err.message : String(err));
699
+ }
613
700
  if (legacyPeerDeps) {
614
701
  compiler_saltyCompiler.logger.warn("Using legacy peer dependencies to update packages.");
615
702
  await npmInstall(...packagesToUpdate, "--legacy-peer-deps");
@@ -632,6 +719,17 @@ const registerUpdateCommand = (program) => {
632
719
  compiler_saltyCompiler.logger.info(`Updated to ${v.replace(/^\^/, "")}: ${names.join(", ")}`);
633
720
  }
634
721
  }
722
+ const project = dir ?? await getDefaultProject();
723
+ if (!project) {
724
+ compiler_saltyCompiler.logger.warn("Skipping rebuild: no project directory configured. Run `salty-css build [dir]` manually.");
725
+ return;
726
+ }
727
+ const shouldRebuild = await confirmYesNo("Rebuild Salty CSS now?", { yes });
728
+ if (!shouldRebuild) return;
729
+ const projectDir = resolveProjectDir(project);
730
+ compiler_saltyCompiler.logger.info("Rebuilding Salty-CSS project...");
731
+ await new compiler_saltyCompiler.SaltyCompiler(projectDir).generateCss();
732
+ compiler_saltyCompiler.logger.info("Rebuild complete.");
635
733
  });
636
734
  };
637
735
  const registerVersionOption = (program) => {
package/bin/main.js CHANGED
@@ -9,6 +9,7 @@ import { exec } from "child_process";
9
9
  import ora from "ora";
10
10
  import { p as pascalCase } from "../pascal-case-F3Usi5Wf.js";
11
11
  import ejs from "ejs";
12
+ import { createInterface } from "readline/promises";
12
13
  const defaultPackageJsonPath = join(process.cwd(), "package.json");
13
14
  const readPackageJson = async (filePath = defaultPackageJsonPath) => {
14
15
  const content = await readFile(filePath, "utf-8").then(JSON.parse).catch(() => void 0);
@@ -138,7 +139,8 @@ const buildContext = async (opts) => {
138
139
  packageJson,
139
140
  rcFile,
140
141
  cliVersion: cliPackageJson.version || "0.0.0",
141
- skipInstall: !!opts.skipInstall
142
+ skipInstall: !!opts.skipInstall,
143
+ yes: !!opts.yes
142
144
  };
143
145
  };
144
146
  const registerBuildCommand = (program, defaultProject) => {
@@ -293,6 +295,52 @@ const registerGenerateCommand = (program, defaultProject) => {
293
295
  await formatWithPrettier(formattedStyledFilePath);
294
296
  });
295
297
  };
298
+ const formatPackageForDisplay = (spec) => {
299
+ const trimmed = spec.trim();
300
+ if (trimmed.startsWith("-D ")) return `${trimmed.slice(3).trim()} (dev)`;
301
+ return trimmed;
302
+ };
303
+ const renderPackageList = (packages) => {
304
+ return packages.map((p) => ` + ${formatPackageForDisplay(p)}`).join("\n");
305
+ };
306
+ const confirmInstall = async (packages, yes, options = {}) => {
307
+ if (yes) return;
308
+ if (packages.length === 0) return;
309
+ const input = options.input ?? process.stdin;
310
+ const output = options.output ?? process.stdout;
311
+ const isTTY = options.isTTY ?? process.stdin.isTTY ?? false;
312
+ if (!isTTY) {
313
+ throw new Error("Cannot prompt for install confirmation: stdin is not a TTY. Re-run with --yes to install the listed packages without prompting.");
314
+ }
315
+ output.write(`The following packages will be installed:
316
+ ${renderPackageList(packages)}
317
+ `);
318
+ const rl = createInterface({ input, output, terminal: false });
319
+ try {
320
+ const answer = (await rl.question("Proceed? (y/N) ")).trim().toLowerCase();
321
+ if (answer !== "y" && answer !== "yes") {
322
+ throw new Error("Install cancelled by user.");
323
+ }
324
+ } finally {
325
+ rl.close();
326
+ }
327
+ };
328
+ const confirmYesNo = async (question, options = {}) => {
329
+ if (options.yes) return true;
330
+ const input = options.input ?? process.stdin;
331
+ const output = options.output ?? process.stdout;
332
+ const isTTY = options.isTTY ?? process.stdin.isTTY ?? false;
333
+ if (!isTTY) return false;
334
+ const suffix = options.defaultYes ? "(Y/n)" : "(y/N)";
335
+ const rl = createInterface({ input, output, terminal: false });
336
+ try {
337
+ const answer = (await rl.question(`${question} ${suffix} `)).trim().toLowerCase();
338
+ if (answer === "") return !!options.defaultYes;
339
+ return answer === "y" || answer === "yes";
340
+ } finally {
341
+ rl.close();
342
+ }
343
+ };
296
344
  const CSS_FILE_FOLDERS = ["src", "public", "assets", "styles", "css", "app"];
297
345
  const CSS_SECOND_LEVEL_FOLDERS = ["styles", "css", "app", "pages"];
298
346
  const CSS_FILE_NAMES = ["index", "styles", "main", "app", "global", "globals"];
@@ -339,17 +387,22 @@ const editAstroConfig = (existing) => {
339
387
  const astroIntegration = {
340
388
  name: "astro",
341
389
  detect: (ctx) => findAstroConfig(ctx.projectDir),
342
- apply: async (ctx, configPath) => {
390
+ plan: async (ctx, configPath) => {
343
391
  const existing = await readFile(configPath, "utf-8").catch(() => void 0);
344
- if (existing === void 0) return { changed: false };
392
+ if (existing === void 0) return null;
345
393
  const result = editAstroConfig(existing);
346
394
  if (result.warning) logger.warn(result.warning);
347
- if (result.content === null) return { changed: false };
348
- if (!ctx.skipInstall) await npmInstall(`-D ${astroPackage(ctx.cliVersion)}`);
349
- logger.info("Adding Salty-CSS integration to Astro config: " + configPath);
350
- await writeFile(configPath, result.content);
351
- await formatWithPrettier(configPath);
352
- return { changed: true };
395
+ if (result.content === null) return null;
396
+ const newContent = result.content;
397
+ return {
398
+ packages: [`-D ${astroPackage(ctx.cliVersion)}`],
399
+ execute: async () => {
400
+ logger.info("Adding Salty-CSS integration to Astro config: " + configPath);
401
+ await writeFile(configPath, newContent);
402
+ await formatWithPrettier(configPath);
403
+ return { changed: true };
404
+ }
405
+ };
353
406
  }
354
407
  };
355
408
  const ESLINT_CONFIG_CANDIDATES = [
@@ -408,20 +461,25 @@ const eslintIntegration = {
408
461
  const candidates = eslintConfigCandidates(ctx.projectDir, ctx.cwd);
409
462
  return candidates.find((p) => existsSync(p)) ?? null;
410
463
  },
411
- apply: async (ctx, configPath) => {
464
+ plan: async (ctx, configPath) => {
412
465
  const existing = await readFile(configPath, "utf-8").catch(() => void 0);
413
466
  if (existing === void 0) {
414
467
  logger.error("Could not read ESLint config file.");
415
- return { changed: false };
468
+ return null;
416
469
  }
417
- if (!ctx.skipInstall) await npmInstall(corePackages.eslintConfigCore(ctx.cliVersion));
418
470
  const result = editEslintConfig(existing, configPath.endsWith("js"));
419
471
  if (result.warning) logger.warn(result.warning);
420
- if (result.content === null) return { changed: false };
421
- logger.info("Edit file: " + configPath);
422
- await writeFile(configPath, result.content);
423
- await formatWithPrettier(configPath);
424
- return { changed: true };
472
+ if (result.content === null) return null;
473
+ const newContent = result.content;
474
+ return {
475
+ packages: [corePackages.eslintConfigCore(ctx.cliVersion)],
476
+ execute: async () => {
477
+ logger.info("Edit file: " + configPath);
478
+ await writeFile(configPath, newContent);
479
+ await formatWithPrettier(configPath);
480
+ return { changed: true };
481
+ }
482
+ };
425
483
  }
426
484
  };
427
485
  const nextConfigFiles = ["next.config.js", "next.config.cjs", "next.config.ts", "next.config.mjs"];
@@ -451,16 +509,20 @@ const nextIntegration = {
451
509
  const found = nextConfigFiles.map((file) => join(ctx.projectDir, file)).find((p) => existsSync(p));
452
510
  return found ?? null;
453
511
  },
454
- apply: async (ctx, configPath) => {
512
+ plan: async (ctx, configPath) => {
455
513
  const existing = await readFile(configPath, "utf-8").catch(() => void 0);
456
- if (existing === void 0) return { changed: false };
514
+ if (existing === void 0) return null;
457
515
  const { content } = editNextConfig(existing);
458
- if (content === null) return { changed: false };
459
- if (!ctx.skipInstall) await npmInstall(`-D ${nextPackage(ctx.cliVersion)}`);
460
- logger.info("Adding Salty-CSS plugin to Next.js config...");
461
- await writeFile(configPath, content);
462
- await formatWithPrettier(configPath);
463
- return { changed: true };
516
+ if (content === null) return null;
517
+ return {
518
+ packages: [`-D ${nextPackage(ctx.cliVersion)}`],
519
+ execute: async () => {
520
+ logger.info("Adding Salty-CSS plugin to Next.js config...");
521
+ await writeFile(configPath, content);
522
+ await formatWithPrettier(configPath);
523
+ return { changed: true };
524
+ }
525
+ };
464
526
  }
465
527
  };
466
528
  const vitePackage = (version) => `@salty-css/vite@${version}`;
@@ -478,27 +540,40 @@ const viteIntegration = {
478
540
  const path = join(ctx.projectDir, "vite.config.ts");
479
541
  return existsSync(path) ? path : null;
480
542
  },
481
- apply: async (ctx, configPath) => {
543
+ plan: async (ctx, configPath) => {
482
544
  const existing = await readFile(configPath, "utf-8").catch(() => void 0);
483
- if (existing === void 0) return { changed: false };
545
+ if (existing === void 0) return null;
484
546
  const { content } = editViteConfig(existing);
485
- if (content === null) return { changed: false };
486
- logger.info("Edit file: " + configPath);
487
- if (!ctx.skipInstall) await npmInstall(`-D ${vitePackage(ctx.cliVersion)}`);
488
- logger.info("Adding Salty-CSS plugin to Vite config...");
489
- await writeFile(configPath, content);
490
- await formatWithPrettier(configPath);
491
- return { changed: true };
547
+ if (content === null) return null;
548
+ return {
549
+ packages: [`-D ${vitePackage(ctx.cliVersion)}`],
550
+ execute: async () => {
551
+ logger.info("Edit file: " + configPath);
552
+ logger.info("Adding Salty-CSS plugin to Vite config...");
553
+ await writeFile(configPath, content);
554
+ await formatWithPrettier(configPath);
555
+ return { changed: true };
556
+ }
557
+ };
492
558
  }
493
559
  };
494
560
  const buildIntegrationRegistry = [eslintIntegration, viteIntegration, nextIntegration, astroIntegration];
495
- const detectAndApplyIntegrations = async (ctx) => {
496
- const results = [];
561
+ const planIntegrations = async (ctx) => {
562
+ const planned = [];
497
563
  for (const integration of buildIntegrationRegistry) {
498
564
  const configPath = await integration.detect(ctx);
499
565
  if (!configPath) continue;
500
- const result = await integration.apply(ctx, configPath);
501
- results.push({ name: integration.name, configPath, changed: result.changed });
566
+ const plan = await integration.plan(ctx, configPath);
567
+ if (!plan) continue;
568
+ planned.push({ name: integration.name, configPath, plan });
569
+ }
570
+ return planned;
571
+ };
572
+ const applyIntegrationPlans = async (planned) => {
573
+ const results = [];
574
+ for (const { name, configPath, plan } of planned) {
575
+ const result = await plan.execute();
576
+ results.push({ name, configPath, changed: result.changed });
502
577
  }
503
578
  return results;
504
579
  };
@@ -549,17 +624,24 @@ const wirePrepareScript = async () => {
549
624
  await updatePackageJson(next);
550
625
  };
551
626
  const registerInitCommand = (program) => {
552
- program.command("init [directory]").description("Initialize a new Salty-CSS project.").option("-d, --dir <dir>", "Project directory to initialize the project in.").option("--css-file <css-file>", "Existing CSS file where to import the generated CSS. Path must be relative to the given project directory.").option("--skip-install", "Skip installing dependencies.").action(async function(_dir = ".") {
627
+ program.command("init [directory]").description("Initialize a new Salty-CSS project.").option("-d, --dir <dir>", "Project directory to initialize the project in.").option("--css-file <css-file>", "Existing CSS file where to import the generated CSS. Path must be relative to the given project directory.").option("--skip-install", "Skip installing dependencies.").option("-y, --yes", "Skip the install confirmation prompt.").action(async function(_dir = ".") {
553
628
  try {
554
629
  const opts = this.opts();
555
630
  const dir = opts.dir ?? _dir;
556
631
  if (!dir) return logError("Project directory must be provided. Add it as the first argument after init command or use the --dir option.");
557
- const ctx = await buildContext({ dir, skipInstall: opts.skipInstall });
632
+ const ctx = await buildContext({ dir, skipInstall: opts.skipInstall, yes: opts.yes });
558
633
  logger.info("Initializing a new Salty-CSS project!");
559
634
  const framework = await detectFramework(ctx);
560
635
  logger.info(`Detected framework: ${framework.name}`);
636
+ const plannedIntegrations = await planIntegrations(ctx);
561
637
  if (!ctx.skipInstall) {
562
- await npmInstall(corePackages.core(ctx.cliVersion), framework.runtimePackage(ctx.cliVersion));
638
+ const packages = [
639
+ corePackages.core(ctx.cliVersion),
640
+ framework.runtimePackage(ctx.cliVersion),
641
+ ...plannedIntegrations.flatMap((p) => p.plan.packages)
642
+ ];
643
+ await confirmInstall(packages, ctx.yes);
644
+ await npmInstall(...packages);
563
645
  }
564
646
  const projectFiles = await Promise.all([readTemplate("salty.config.ts"), readTemplate("saltygen/index.css")]);
565
647
  await mkdir(ctx.projectDir, { recursive: true });
@@ -567,7 +649,7 @@ const registerInitCommand = (program) => {
567
649
  await writeProjectToRc(ctx.cwd, ctx.relativeProjectPath, framework);
568
650
  await ensureGitignoreSaltygen(ctx.cwd);
569
651
  await importSaltygenIntoCss(ctx.projectDir, opts.cssFile);
570
- await detectAndApplyIntegrations(ctx);
652
+ await applyIntegrationPlans(plannedIntegrations);
571
653
  await wirePrepareScript();
572
654
  logger.info("Running the build to generate initial CSS...");
573
655
  const compiler = new SaltyCompiler(ctx.projectDir);
@@ -598,8 +680,8 @@ const getSaltyCssPackages = async () => {
598
680
  return saltyCssPackages;
599
681
  };
600
682
  const registerUpdateCommand = (program) => {
601
- program.command("update [version]").alias("up").description("Update Salty-CSS packages to the latest or specified version.").option("-v, --version <version>", "Version to update to.").option("--legacy-peer-deps <legacyPeerDeps>", "Use legacy peer dependencies (not recommended).", false).action(async function(_version = "latest") {
602
- const { legacyPeerDeps, version = _version } = this.opts();
683
+ program.command("update [version]").alias("up").description("Update Salty-CSS packages to the latest or specified version.").option("-v, --version <version>", "Version to update to.").option("--legacy-peer-deps <legacyPeerDeps>", "Use legacy peer dependencies (not recommended).", false).option("-y, --yes", "Skip confirmation prompts (install and rebuild).").option("-d, --dir <dir>", "Project directory to rebuild after updating.").action(async function(_version = "latest") {
684
+ const { legacyPeerDeps, version = _version, yes = false, dir } = this.opts();
603
685
  const saltyCssPackages = await getSaltyCssPackages();
604
686
  if (!saltyCssPackages) return logError("Could not update Salty-CSS packages as any were found in package.json.");
605
687
  const cli = await readThisPackageJson();
@@ -607,6 +689,11 @@ const registerUpdateCommand = (program) => {
607
689
  if (version === "@") return `${name}@${cli.version}`;
608
690
  return `${name}@${version.replace(/^@/, "")}`;
609
691
  });
692
+ try {
693
+ await confirmInstall(packagesToUpdate, yes);
694
+ } catch (err) {
695
+ return logError(err instanceof Error ? err.message : String(err));
696
+ }
610
697
  if (legacyPeerDeps) {
611
698
  logger.warn("Using legacy peer dependencies to update packages.");
612
699
  await npmInstall(...packagesToUpdate, "--legacy-peer-deps");
@@ -629,6 +716,17 @@ const registerUpdateCommand = (program) => {
629
716
  logger.info(`Updated to ${v.replace(/^\^/, "")}: ${names.join(", ")}`);
630
717
  }
631
718
  }
719
+ const project = dir ?? await getDefaultProject();
720
+ if (!project) {
721
+ logger.warn("Skipping rebuild: no project directory configured. Run `salty-css build [dir]` manually.");
722
+ return;
723
+ }
724
+ const shouldRebuild = await confirmYesNo("Rebuild Salty CSS now?", { yes });
725
+ if (!shouldRebuild) return;
726
+ const projectDir = resolveProjectDir(project);
727
+ logger.info("Rebuilding Salty-CSS project...");
728
+ await new SaltyCompiler(projectDir).generateCss();
729
+ logger.info("Rebuild complete.");
632
730
  });
633
731
  };
634
732
  const registerVersionOption = (program) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salty-css/core",
3
- "version": "0.1.0-alpha.15",
3
+ "version": "0.1.0-alpha.17",
4
4
  "main": "./dist/index.js",
5
5
  "module": "./dist/index.mjs",
6
6
  "typings": "./dist/index.d.ts",