@savvy-web/cli 3.2.8 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/cli/index.js +8 -5
  2. package/commands/changeset/commands/check.js +11 -7
  3. package/commands/changeset/commands/config-validate.js +6 -4
  4. package/commands/changeset/commands/deps-detect.js +4 -4
  5. package/commands/changeset/commands/deps-regen.js +11 -10
  6. package/commands/changeset/commands/init.js +13 -11
  7. package/commands/changeset/commands/lint.js +4 -3
  8. package/commands/changeset/commands/transform.js +7 -5
  9. package/commands/changeset/commands/validate-file.js +8 -6
  10. package/commands/changeset/commands/version.js +5 -4
  11. package/commands/changeset/utils/config-gate.js +3 -3
  12. package/commands/check.js +3 -2
  13. package/commands/clean.js +7 -8
  14. package/commands/commit/check.js +33 -35
  15. package/commands/commit/constants.js +5 -0
  16. package/commands/commit/init.js +8 -6
  17. package/commands/commit/lint.js +2 -1
  18. package/commands/lint/check.js +43 -47
  19. package/commands/lint/init.js +20 -22
  20. package/commands/repos/commands/add.js +8 -9
  21. package/commands/repos/commands/deregister.js +7 -7
  22. package/commands/repos/commands/note.js +7 -8
  23. package/commands/repos/commands/pin.js +11 -13
  24. package/commands/repos/commands/remove.js +13 -15
  25. package/commands/repos/commands/rename.js +10 -12
  26. package/commands/repos/commands/restore.js +12 -14
  27. package/commands/repos/commands/status.js +16 -8
  28. package/commands/repos/commands/sync.js +12 -13
  29. package/index.d.ts +16 -15
  30. package/internal/failure-line.js +39 -0
  31. package/internal/output.js +52 -0
  32. package/internal/version-line.js +19 -0
  33. package/main.d.ts +18 -2
  34. package/main.js +26 -16
  35. package/package.json +3 -1
  36. package/version.js +13 -0
@@ -1,3 +1,4 @@
1
+ import { Output } from "../../internal/output.js";
1
2
  import { HUSKY_HOOK_PATH, POST_CHECKOUT_HOOK_PATH, POST_COMMIT_HOOK_PATH, POST_MERGE_HOOK_PATH } from "./constants.js";
2
3
  import { dirname } from "node:path";
3
4
  import { SavvyBaseSection, SavvyHooksSection, SavvyToolchainSection, savvyBasePreamble, savvyHooksHygiene, savvyInstallBlock, savvyToolSection, savvyToolchainCheck } from "@savvy-web/silk-effects";
@@ -89,13 +90,13 @@ function runCommitInit(opts) {
89
90
  const fs = yield* FileSystem.FileSystem;
90
91
  const ms = yield* ManagedSection;
91
92
  if (config.startsWith("/")) yield* Effect.fail(/* @__PURE__ */ new Error("Config path must be relative to repository root, not absolute"));
92
- yield* Effect.log("Initializing commitlint configuration...\n");
93
+ yield* Output.heading("commitlint");
93
94
  yield* fs.makeDirectory(".husky", { recursive: true });
94
95
  if (force) yield* fs.writeFileString(HUSKY_HOOK_PATH, COMMIT_MSG_HEADER);
95
96
  else yield* ensureHookFile(HUSKY_HOOK_PATH, COMMIT_MSG_HEADER);
96
97
  const commitResults = yield* ms.syncAll(HUSKY_HOOK_PATH, [SavvyBaseSection.section(savvyBasePreamble()), savvyCommitBlock(config)]);
97
98
  yield* makeExecutable(HUSKY_HOOK_PATH);
98
- yield* Effect.log(`${"✓"} ${force ? "Replaced" : "Synced"} ${HUSKY_HOOK_PATH} (${commitResults.map((r) => r._tag).join(", ")})`);
99
+ yield* Output.ok(`${force ? "Replaced" : "Synced"} ${HUSKY_HOOK_PATH} (${commitResults.map((r) => r._tag).join(", ")})`);
99
100
  for (const [hookPath, installHook] of HYGIENE_HOOKS) {
100
101
  yield* ensureHookFile(hookPath, HYGIENE_HEADER);
101
102
  const sections = [SavvyHooksSection.section(savvyHooksHygiene())];
@@ -105,16 +106,17 @@ function runCommitInit(opts) {
105
106
  }
106
107
  yield* ms.syncAll(hookPath, sections);
107
108
  yield* makeExecutable(hookPath);
108
- yield* Effect.log(`${"✓"} Synced ${hookPath}`);
109
+ yield* Output.ok(`Synced ${hookPath}`);
109
110
  }
110
- if ((yield* fs.exists(config)) && !force) yield* Effect.log(`${"⚠"} ${config} already exists (use --force to overwrite)`);
111
+ if ((yield* fs.exists(config)) && !force) yield* Output.warn(`${config} already exists (use --force to overwrite)`);
111
112
  else {
112
113
  const configDir = dirname(config);
113
114
  if (configDir && configDir !== ".") yield* fs.makeDirectory(configDir, { recursive: true });
114
115
  yield* fs.writeFileString(config, CONFIG_CONTENT);
115
- yield* Effect.log(`${"✓"} Created ${config}`);
116
+ yield* Output.ok(`Created ${config}`);
116
117
  }
117
- yield* Effect.log("\nDone! Install @commitlint/cli if not already installed.");
118
+ yield* Output.line("");
119
+ yield* Output.ok("Install @commitlint/cli if it is not already installed");
118
120
  });
119
121
  }
120
122
 
@@ -1,3 +1,4 @@
1
+ import { Output } from "../../internal/output.js";
1
2
  import { buildCommitlintInvocation } from "./commitlint-invocation.js";
2
3
  import { Argument, CliError, Command } from "effect/unstable/cli";
3
4
  import { Commitlint } from "@savvy-web/silk-effects";
@@ -75,7 +76,7 @@ function runCommitLint(file) {
75
76
  if (stdout.length > 0) yield* Effect.sync(() => process.stdout.write(stdout));
76
77
  if (stderr.length > 0) yield* Effect.sync(() => process.stderr.write(stderr));
77
78
  if (!passed) return yield* Effect.fail(new CliError.UserError({ cause: `Commit message at ${file} failed commitlint validation` }));
78
- yield* Effect.log("Commit message passes the Silk commitlint preset.");
79
+ yield* Output.ok("Commit message passes the Silk commitlint preset");
79
80
  });
80
81
  }
81
82
  /* v8 ignore next */
@@ -1,3 +1,4 @@
1
+ import { Output } from "../../internal/output.js";
1
2
  import { BIOME_VERSION } from "./biome-version.js";
2
3
  import { ConfigDiscovery, Lint, SavvyBaseSection, SavvyHooksSection, SavvyToolchainSection, savvyBasePreamble, savvyHooksHygiene, savvyInstallBlock, savvyOkfBlock, savvyToolchainCheck } from "@savvy-web/silk-effects";
3
4
  import { Effect, FileSystem, Option } from "effect";
@@ -12,14 +13,8 @@ import { Tool, ToolDiscovery } from "@effected/commands";
12
13
  *
13
14
  * @internal
14
15
  */
15
- /** Unicode checkmark symbol. */
16
- const CHECK_MARK = "✓";
17
- /** Unicode cross symbol. */
18
- const CROSS_MARK = "✗";
19
- /** Unicode warning symbol. */
16
+ /** Unicode warning symbol, for the quiet-mode warning lines. */
20
17
  const WARNING = "⚠";
21
- /** Unicode bullet symbol. */
22
- const BULLET = "•";
23
18
  /** Paths to search for config files. */
24
19
  const CONFIG_SEARCH_PATHS = [
25
20
  "lib/configs/lint-staged.config.ts",
@@ -285,42 +280,43 @@ function runLintCheck(opts) {
285
280
  if (!markdownlintStatus.configMatches) warnings.push(`${WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: config rules differ from template.\n Run 'savvy init --force' to overwrite.`);
286
281
  }
287
282
  if (quiet) {
288
- if (warnings.length > 0) for (const warning of warnings) yield* Effect.log(warning);
283
+ if (warnings.length > 0) for (const warning of warnings) yield* Output.line(warning);
289
284
  return;
290
285
  }
291
- yield* Effect.log("Checking lint-staged configuration...\n");
292
- if (foundConfig) yield* Effect.log(`${CHECK_MARK} Config file: ${foundConfig}`);
293
- else yield* Effect.log(`${CROSS_MARK} No lint-staged config file found`);
294
- if (hasHuskyHook) yield* Effect.log(`${CHECK_MARK} Husky hook: ${Lint.HUSKY_HOOK_PATH}`);
295
- else yield* Effect.log(`${CROSS_MARK} No husky pre-commit hook found`);
286
+ yield* Output.heading("lint-staged configuration");
287
+ if (foundConfig) yield* Output.ok(`Config file: ${foundConfig}`);
288
+ else yield* Output.fail(`No lint-staged config file found`);
289
+ if (hasHuskyHook) yield* Output.ok(`Husky hook: ${Lint.HUSKY_HOOK_PATH}`);
290
+ else yield* Output.fail(`No husky pre-commit hook found`);
296
291
  if (hasHuskyHook) {
297
- if (baseStatusLabel === "up-to-date") yield* Effect.log(`${CHECK_MARK} Base section: up-to-date`);
298
- else if (baseStatusLabel === "outdated") yield* Effect.log(`${WARNING} Base section: outdated (run 'savvy init' to update)`);
299
- else yield* Effect.log(`${BULLET} Base section: not found (run 'savvy init' to add)`);
292
+ if (baseStatusLabel === "up-to-date") yield* Output.ok(`Base section: up-to-date`);
293
+ else if (baseStatusLabel === "outdated") yield* Output.warn(`Base section: outdated (run 'savvy init' to update)`);
294
+ else yield* Output.skip(`Base section: not found (run 'savvy init' to add)`);
300
295
  const lintLabel = detectedConfigPath ? ` (config: ${detectedConfigPath})` : "";
301
- if (lintStatusLabel === "up-to-date") yield* Effect.log(`${CHECK_MARK} Lint section: up-to-date${lintLabel}`);
302
- else if (lintStatusLabel === "outdated") yield* Effect.log(`${WARNING} Lint section: outdated (run 'savvy init' to update)`);
303
- else yield* Effect.log(`${BULLET} Lint section: not found (run 'savvy init' to add)`);
304
- if (okfStatusLabel === "up-to-date") yield* Effect.log(`${CHECK_MARK} OKF section: up-to-date`);
305
- else if (okfStatusLabel === "outdated") yield* Effect.log(`${WARNING} OKF section: outdated (run 'savvy init' to update)`);
306
- else yield* Effect.log(`${BULLET} OKF section: not found (run 'savvy init' to add)`);
296
+ if (lintStatusLabel === "up-to-date") yield* Output.ok(`Lint section: up-to-date${lintLabel}`);
297
+ else if (lintStatusLabel === "outdated") yield* Output.warn(`Lint section: outdated (run 'savvy init' to update)`);
298
+ else yield* Output.skip(`Lint section: not found (run 'savvy init' to add)`);
299
+ if (okfStatusLabel === "up-to-date") yield* Output.ok(`OKF section: up-to-date`);
300
+ else if (okfStatusLabel === "outdated") yield* Output.warn(`OKF section: outdated (run 'savvy init' to update)`);
301
+ else yield* Output.skip(`OKF section: not found (run 'savvy init' to add)`);
307
302
  }
308
- for (const status of shellHookStatuses) if (!status.found) yield* Effect.log(`${BULLET} ${status.path}: savvy-hooks section not found`);
309
- else if (status.isUpToDate) yield* Effect.log(`${CHECK_MARK} ${status.path}: up-to-date`);
310
- else yield* Effect.log(`${WARNING} ${status.path}: outdated (run 'savvy init' to update)`);
311
- for (const status of installStatuses) if (!status.found) yield* Effect.log(`${BULLET} ${status.path}: savvy-install section not found`);
312
- else if (status.isUpToDate) yield* Effect.log(`${CHECK_MARK} ${status.path}: savvy-install up-to-date`);
313
- else yield* Effect.log(`${WARNING} ${status.path}: savvy-install outdated (run 'savvy init' to update)`);
314
- for (const status of toolchainStatuses) if (!status.found) yield* Effect.log(`${BULLET} ${status.path}: savvy-toolchain section not found`);
315
- else if (status.isUpToDate) yield* Effect.log(`${CHECK_MARK} ${status.path}: savvy-toolchain up-to-date`);
316
- else yield* Effect.log(`${WARNING} ${status.path}: savvy-toolchain outdated (run 'savvy init' to update)`);
317
- yield* Effect.log("\nTool availability:");
303
+ for (const status of shellHookStatuses) if (!status.found) yield* Output.skip(`${status.path}: savvy-hooks section not found`);
304
+ else if (status.isUpToDate) yield* Output.ok(`${status.path}: up-to-date`);
305
+ else yield* Output.warn(`${status.path}: outdated (run 'savvy init' to update)`);
306
+ for (const status of installStatuses) if (!status.found) yield* Output.skip(`${status.path}: savvy-install section not found`);
307
+ else if (status.isUpToDate) yield* Output.ok(`${status.path}: savvy-install up-to-date`);
308
+ else yield* Output.warn(`${status.path}: savvy-install outdated (run 'savvy init' to update)`);
309
+ for (const status of toolchainStatuses) if (!status.found) yield* Output.skip(`${status.path}: savvy-toolchain section not found`);
310
+ else if (status.isUpToDate) yield* Output.ok(`${status.path}: savvy-toolchain up-to-date`);
311
+ else yield* Output.warn(`${status.path}: savvy-toolchain outdated (run 'savvy init' to update)`);
312
+ yield* Output.line("");
313
+ yield* Output.heading("Tool availability");
318
314
  const biomeAvailable = yield* td.isAvailable(Tool.named("biome"));
319
315
  const biomeConfig = yield* findConfig(discovery, ["biome.jsonc", "biome.json"]);
320
316
  if (biomeAvailable) {
321
317
  const configInfo = biomeConfig ? ` (config: ${biomeConfig})` : "";
322
- yield* Effect.log(` ${CHECK_MARK} Biome${configInfo}`);
323
- } else yield* Effect.log(` ${BULLET} Biome: not installed`);
318
+ yield* Output.ok(`Biome${configInfo}`);
319
+ } else yield* Output.skip(`Biome: not installed`);
324
320
  const markdownAvailable = yield* td.isAvailable(Tool.named("markdownlint-cli2"));
325
321
  const markdownConfig = yield* findConfig(discovery, [
326
322
  ".markdownlint-cli2.jsonc",
@@ -333,29 +329,29 @@ function runLintCheck(opts) {
333
329
  ]);
334
330
  if (markdownAvailable) {
335
331
  const configInfo = markdownConfig ? ` (config: ${markdownConfig})` : "";
336
- yield* Effect.log(` ${CHECK_MARK} markdownlint-cli2${configInfo}`);
337
- } else yield* Effect.log(` ${BULLET} markdownlint-cli2: not installed`);
332
+ yield* Output.ok(`markdownlint-cli2${configInfo}`);
333
+ } else yield* Output.skip(`markdownlint-cli2: not installed`);
338
334
  const tscAvailable = yield* td.isAvailable(Tool.named("tsc"));
339
335
  const tsgoAvailable = yield* td.isAvailable(Tool.named("tsgo"));
340
- if (tscAvailable) yield* Effect.log(` ${CHECK_MARK} TypeScript (tsc)`);
341
- else if (tsgoAvailable) yield* Effect.log(` ${CHECK_MARK} TypeScript (tsgo)`);
342
- else yield* Effect.log(` ${BULLET} TypeScript: not installed`);
336
+ if (tscAvailable) yield* Output.ok(`TypeScript (tsc)`);
337
+ else if (tsgoAvailable) yield* Output.ok(`TypeScript (tsgo)`);
338
+ else yield* Output.skip(`TypeScript: not installed`);
343
339
  if (hasMarkdownlintConfig) {
344
- if (markdownlintStatus.isUpToDate) yield* Effect.log(` ${CHECK_MARK} ${Lint.MARKDOWNLINT_CONFIG_PATH}: up-to-date`);
340
+ if (markdownlintStatus.isUpToDate) yield* Output.ok(`${Lint.MARKDOWNLINT_CONFIG_PATH}: up-to-date`);
345
341
  else {
346
342
  const issues = [];
347
343
  if (!markdownlintStatus.schemaMatches) issues.push("$schema");
348
344
  if (!markdownlintStatus.configMatches) issues.push("config");
349
- yield* Effect.log(` ${WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: ${issues.join(", ")} differ from template`);
345
+ yield* Output.warn(`${Lint.MARKDOWNLINT_CONFIG_PATH}: ${issues.join(", ")} differ from template`);
350
346
  }
351
- } else yield* Effect.log(` ${BULLET} ${Lint.MARKDOWNLINT_CONFIG_PATH}: not found`);
352
- for (const status of biomeSchemaStatus.statuses) if (status.matches) yield* Effect.log(` ${CHECK_MARK} ${status.path}: biome $schema up-to-date`);
353
- else yield* Effect.log(` ${WARNING} ${status.path}: biome $schema outdated (run 'savvy init' to update)`);
354
- yield* Effect.log("");
347
+ } else yield* Output.skip(`${Lint.MARKDOWNLINT_CONFIG_PATH}: not found`);
348
+ for (const status of biomeSchemaStatus.statuses) if (status.matches) yield* Output.ok(`${status.path}: biome $schema up-to-date`);
349
+ else yield* Output.warn(`${status.path}: biome $schema outdated (run 'savvy init' to update)`);
350
+ yield* Output.line("");
355
351
  const hasMarkdownlintIssues = hasMarkdownlintConfig && !markdownlintStatus.isUpToDate;
356
352
  const hasBiomeSchemaIssues = biomeSchemaStatus.statuses.some((s) => !s.matches);
357
- if (!foundConfig || !hasHuskyHook || !sectionsHealthy || hasMarkdownlintIssues || hasBiomeSchemaIssues) yield* Effect.log(`${WARNING} Some issues found. Run 'savvy init' to fix.`);
358
- else yield* Effect.log(`${CHECK_MARK} Lint-staged is configured correctly.`);
353
+ if (!foundConfig || !hasHuskyHook || !sectionsHealthy || hasMarkdownlintIssues || hasBiomeSchemaIssues) yield* Output.warn(`Some issues found. Run 'savvy init' to fix.`);
354
+ else yield* Output.ok(`Lint-staged is configured correctly.`);
359
355
  });
360
356
  }
361
357
 
@@ -1,3 +1,4 @@
1
+ import { Output } from "../../internal/output.js";
1
2
  import { BIOME_VERSION } from "./biome-version.js";
2
3
  import { dirname, join } from "node:path";
3
4
  import { BiomeSchemaSync, LIFECYCLE_SCRIPTS_CONFIG_KEY, Lint, SavvyBaseSection, SavvyHooksSection, SavvyToolchainSection, publishesBuiltLinkDirectory, savvyBasePreamble, savvyHooksHygiene, savvyInstallBlock, savvyOkfBlock, savvyToolchainCheck } from "@savvy-web/silk-effects";
@@ -14,10 +15,6 @@ import { isDeepStrictEqual } from "node:util";
14
15
  *
15
16
  * @internal
16
17
  */
17
- /** Unicode checkmark symbol. */
18
- const CHECK_MARK = "✓";
19
- /** Unicode warning symbol. */
20
- const WARNING = "⚠";
21
18
  /** Executable file permission mode. */
22
19
  const EXECUTABLE_MODE = 493;
23
20
  /** Formatting options for jsonc-effect surgical edits. */
@@ -85,16 +82,16 @@ function writeMarkdownlintConfig(fs, preset, force) {
85
82
  if (!configExists) {
86
83
  yield* fs.makeDirectory("lib/configs", { recursive: true });
87
84
  yield* fs.writeFileString(Lint.MARKDOWNLINT_CONFIG_PATH, `${fullTemplate}\n`);
88
- yield* Effect.log(`${CHECK_MARK} Created ${Lint.MARKDOWNLINT_CONFIG_PATH}`);
85
+ yield* Output.ok(`Created ${Lint.MARKDOWNLINT_CONFIG_PATH}`);
89
86
  return;
90
87
  }
91
88
  if (preset !== "silk") {
92
- yield* Effect.log(`${CHECK_MARK} ${Lint.MARKDOWNLINT_CONFIG_PATH}: exists (not managed by ${preset} preset)`);
89
+ yield* Output.ok(`${Lint.MARKDOWNLINT_CONFIG_PATH}: exists (not managed by ${preset} preset)`);
93
90
  return;
94
91
  }
95
92
  if (force) {
96
93
  yield* fs.writeFileString(Lint.MARKDOWNLINT_CONFIG_PATH, `${fullTemplate}\n`);
97
- yield* Effect.log(`${CHECK_MARK} Replaced ${Lint.MARKDOWNLINT_CONFIG_PATH} (--force)`);
94
+ yield* Output.ok(`Replaced ${Lint.MARKDOWNLINT_CONFIG_PATH} (--force)`);
98
95
  return;
99
96
  }
100
97
  const existingText = yield* fs.readFileString(Lint.MARKDOWNLINT_CONFIG_PATH);
@@ -116,11 +113,11 @@ function writeMarkdownlintConfig(fs, preset, force) {
116
113
  }
117
114
  const existingConfig = existingParsed.config;
118
115
  const configMatches = existingConfig !== void 0 && isDeepStrictEqual(existingConfig, Lint.MARKDOWNLINT_CONFIG);
119
- if (!configMatches) yield* Effect.log(`${WARNING} ${Lint.MARKDOWNLINT_CONFIG_PATH}: config rules differ from template (use --force to overwrite)`);
116
+ if (!configMatches) yield* Output.warn(`${Lint.MARKDOWNLINT_CONFIG_PATH}: config rules differ from template (use --force to overwrite)`);
120
117
  if (applied.length > 0) {
121
118
  yield* fs.writeFileString(Lint.MARKDOWNLINT_CONFIG_PATH, updatedText);
122
- yield* Effect.log(`${CHECK_MARK} Updated ${applied.join(", ")} in ${Lint.MARKDOWNLINT_CONFIG_PATH}`);
123
- } else if (configMatches) yield* Effect.log(`${CHECK_MARK} ${Lint.MARKDOWNLINT_CONFIG_PATH}: up-to-date`);
119
+ yield* Output.ok(`Updated ${applied.join(", ")} in ${Lint.MARKDOWNLINT_CONFIG_PATH}`);
120
+ } else if (configMatches) yield* Output.ok(`${Lint.MARKDOWNLINT_CONFIG_PATH}: up-to-date`);
124
121
  });
125
122
  }
126
123
  /**
@@ -151,7 +148,7 @@ function writeMarkdownlintConfig(fs, preset, force) {
151
148
  */
152
149
  function biomeConfigRoots() {
153
150
  return Effect.gen(function* () {
154
- return yield* (yield* WorkspaceDiscovery).listPackages().pipe(Effect.map((packages) => packages.map((pkg) => pkg.path)), Effect.catchTag("WorkspaceRootNotFoundError", () => Effect.succeed([])), Effect.catch((e) => Effect.as(Effect.log(`${WARNING} Only syncing biome $schema in the current directory: ${e.message}`), [])));
151
+ return yield* (yield* WorkspaceDiscovery).listPackages().pipe(Effect.map((packages) => packages.map((pkg) => pkg.path)), Effect.catchTag("WorkspaceRootNotFoundError", () => Effect.succeed([])), Effect.catch((e) => Effect.as(Effect.logWarning(`Only syncing biome $schema in the current directory: ${e.message}`), [])));
155
152
  });
156
153
  }
157
154
  /**
@@ -179,8 +176,8 @@ function noticeLifecycleScripts() {
179
176
  for (const root of roots) {
180
177
  const manifest = yield* fs.readFileString(join(root, "package.json")).pipe(Effect.map((raw) => JSON.parse(raw)), Effect.catch(() => Effect.succeed(void 0)));
181
178
  if (publishesBuiltLinkDirectory(manifest)) {
182
- yield* Effect.log(`${WARNING} This workspace publishes through built link directories, so a hook-time install must run lifecycle scripts.`);
183
- yield* Effect.log(` Allow them with: git config --local ${LIFECYCLE_SCRIPTS_CONFIG_KEY} true`);
179
+ yield* Output.warn("This workspace publishes through built link directories, so a hook-time install must run lifecycle scripts.");
180
+ yield* Output.detail(`Allow them with: git config --local ${LIFECYCLE_SCRIPTS_CONFIG_KEY} true`);
184
181
  return;
185
182
  }
186
183
  }
@@ -210,9 +207,9 @@ function syncBiomeSchemas() {
210
207
  const roots = yield* biomeConfigRoots();
211
208
  const passes = roots.length > 0 ? roots.map((cwd) => ({ cwd })) : [{ cwd: process.cwd() }];
212
209
  for (const options of passes) yield* syncer.sync(BIOME_VERSION, options).pipe(Effect.flatMap((result) => Effect.gen(function* () {
213
- for (const configPath of result.current) yield* Effect.log(`${CHECK_MARK} ${configPath}: biome $schema up-to-date`);
214
- for (const configPath of result.updated) yield* Effect.log(`${CHECK_MARK} Updated $schema in ${configPath}`);
215
- })), Effect.catchTag("BiomeSyncError", (e) => Effect.log(`${WARNING} Could not sync biome $schema: ${e.message}`)));
210
+ for (const configPath of result.current) yield* Output.ok(`${configPath}: biome $schema up-to-date`);
211
+ for (const configPath of result.updated) yield* Output.ok(`Updated $schema in ${configPath}`);
212
+ })), Effect.catchTag("BiomeSyncError", (e) => Output.warn(`Could not sync biome $schema: ${e.message}`)));
216
213
  });
217
214
  }
218
215
  /** Make a file executable. */
@@ -246,7 +243,7 @@ function runLintInit(opts) {
246
243
  const fs = yield* FileSystem.FileSystem;
247
244
  const ms = yield* ManagedSection;
248
245
  if (config.startsWith("/")) yield* Effect.fail(/* @__PURE__ */ new Error("Config path must be relative to repository root, not absolute"));
249
- yield* Effect.log("Initializing lint-staged configuration...\n");
246
+ yield* Output.heading("lint-staged");
250
247
  yield* fs.makeDirectory(".husky", { recursive: true });
251
248
  if (force) yield* fs.writeFileString(Lint.HUSKY_HOOK_PATH, PRE_COMMIT_HEADER);
252
249
  else yield* ensureHookFile(Lint.HUSKY_HOOK_PATH, PRE_COMMIT_HEADER);
@@ -256,7 +253,7 @@ function runLintInit(opts) {
256
253
  savvyOkfBlock()
257
254
  ]);
258
255
  yield* makeExecutable(Lint.HUSKY_HOOK_PATH);
259
- yield* Effect.log(`${CHECK_MARK} ${force ? "Replaced" : "Synced"} ${Lint.HUSKY_HOOK_PATH} (${preCommitResults.map((r) => r._tag).join(", ")})`);
256
+ yield* Output.ok(`${force ? "Replaced" : "Synced"} ${Lint.HUSKY_HOOK_PATH} (${preCommitResults.map((r) => r._tag).join(", ")})`);
260
257
  if (presetIncludesShellScripts(preset)) for (const [hookPath, installHook] of HYGIENE_HOOKS) {
261
258
  yield* ensureHookFile(hookPath, HYGIENE_HEADER);
262
259
  yield* ms.remove(hookPath, Lint.LegacySavvyLintHygieneDef);
@@ -267,19 +264,20 @@ function runLintInit(opts) {
267
264
  }
268
265
  yield* ms.syncAll(hookPath, sections);
269
266
  yield* makeExecutable(hookPath);
270
- yield* Effect.log(`${CHECK_MARK} Synced ${hookPath}`);
267
+ yield* Output.ok(`Synced ${hookPath}`);
271
268
  }
272
269
  if (presetIncludesShellScripts(preset)) yield* noticeLifecycleScripts();
273
270
  if (presetIncludesMarkdown(preset)) yield* writeMarkdownlintConfig(fs, preset, force);
274
271
  yield* syncBiomeSchemas();
275
- if ((yield* fs.exists(config)) && !force) yield* Effect.log(`${WARNING} ${config} already exists (use --force to overwrite)`);
272
+ if ((yield* fs.exists(config)) && !force) yield* Output.warn(`${config} already exists (use --force to overwrite)`);
276
273
  else {
277
274
  const configDir = dirname(config);
278
275
  if (configDir && configDir !== ".") yield* fs.makeDirectory(configDir, { recursive: true });
279
276
  yield* fs.writeFileString(config, generateConfigContent(preset));
280
- yield* Effect.log(`${CHECK_MARK} Created ${config} (preset: ${preset})`);
277
+ yield* Output.ok(`Created ${config} (preset: ${preset})`);
281
278
  }
282
- yield* Effect.log("\nDone! Lint-staged is ready to use.");
279
+ yield* Output.line("");
280
+ yield* Output.ok("lint-staged is ready to use");
283
281
  });
284
282
  }
285
283
 
@@ -1,4 +1,6 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { Argument, Command, Flag } from "effect/unstable/cli";
3
+ import { CliExit } from "@effected/cli";
2
4
  import { Repos } from "@savvy-web/silk-effects";
3
5
  import { Effect, Option } from "effect";
4
6
 
@@ -43,18 +45,15 @@ const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to
43
45
  */
44
46
  const runReposAdd = (cwd, opts) => Effect.gen(function* () {
45
47
  const result = yield* (yield* Repos.ReposManager).add(cwd, opts);
46
- yield* Effect.log(`${result.name} @ ${result.ref} -> ${result.path}`);
47
- yield* Effect.log("staged — review and commit");
48
+ yield* Output.ok(`${result.name} @ ${result.ref} -> ${result.path}`);
49
+ yield* Output.detail("staged — review and commit");
48
50
  }).pipe(Effect.catchTag("ReposConfigError", (error) => {
49
- if (error.kind === "missing") return Effect.log("no .repos/config.json — nothing vendored");
50
- process.exitCode = 1;
51
- return Effect.log(error.message);
51
+ if (error.kind === "missing") return Output.skip("no .repos/config.json — nothing vendored");
52
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
52
53
  }), Effect.catchTag("GitSubmoduleError", (error) => {
53
- process.exitCode = 1;
54
- return Effect.log(error.message);
54
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
55
55
  }), Effect.catchTag("ReposLockdownError", (error) => {
56
- process.exitCode = 1;
57
- return Effect.log(error.message);
56
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
58
57
  }));
59
58
  /* v8 ignore start -- CLI registration; handler tested via runReposAdd */
60
59
  const addCommand = Command.make("add", {
@@ -1,4 +1,6 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { Argument, Command, Flag } from "effect/unstable/cli";
3
+ import { CliExit } from "@effected/cli";
2
4
  import { Repos } from "@savvy-web/silk-effects";
3
5
  import { Effect } from "effect";
4
6
 
@@ -46,15 +48,13 @@ const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to
46
48
  */
47
49
  const runReposDeregister = (cwd, section) => Effect.gen(function* () {
48
50
  const result = yield* (yield* Repos.ReposManager).deregister(cwd, section);
49
- yield* Effect.log(`${result.section}: deregistered (${result.removedKeys.length} config keys removed)`);
50
- for (const key of result.removedKeys) yield* Effect.log(` removed ${key}`);
51
- yield* Effect.log("local git config only — nothing to commit");
51
+ yield* Output.ok(`${result.section}: deregistered (${result.removedKeys.length} config keys removed)`);
52
+ for (const key of result.removedKeys) yield* Output.detail(`removed ${key}`);
53
+ yield* Output.detail("local git config only — nothing to commit");
52
54
  }).pipe(Effect.catchTag("ReposConfigError", (error) => {
53
- process.exitCode = 1;
54
- return Effect.log(error.message);
55
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
55
56
  }), Effect.catchTag("GitSubmoduleError", (error) => {
56
- process.exitCode = 1;
57
- return Effect.log(error.message);
57
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
58
58
  }));
59
59
  /* v8 ignore start -- CLI registration; handler tested via runReposDeregister */
60
60
  const deregisterCommand = Command.make("deregister", {
@@ -1,4 +1,6 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { Argument, Command, Flag } from "effect/unstable/cli";
3
+ import { CliExit } from "@effected/cli";
2
4
  import { Repos } from "@savvy-web/silk-effects";
3
5
  import { Effect } from "effect";
4
6
 
@@ -39,17 +41,14 @@ import { Effect } from "effect";
39
41
  */
40
42
  const runReposNote = (cwd, name, op) => Effect.gen(function* () {
41
43
  const result = yield* (yield* Repos.ReposManager).note(cwd, name, op);
42
- yield* Effect.log(`${result.name}: ${result.op} note ${result.id} (${result.noteCount} notes)`);
44
+ yield* Output.ok(`${result.name}: ${result.op} note ${result.id} (${result.noteCount} notes)`);
43
45
  }).pipe(Effect.catchTag("ReposConfigError", (error) => {
44
- if (error.kind === "missing") return Effect.log("no .repos/config.json — nothing vendored");
45
- process.exitCode = 1;
46
- return Effect.log(error.message);
46
+ if (error.kind === "missing") return Output.skip("no .repos/config.json — nothing vendored");
47
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
47
48
  }), Effect.catchTag("RepoNotFoundError", (error) => {
48
- process.exitCode = 1;
49
- return Effect.log(error.message);
49
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
50
50
  }), Effect.catchTag("NoteNotFoundError", (error) => {
51
- process.exitCode = 1;
52
- return Effect.log(error.message);
51
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
53
52
  }));
54
53
  /* v8 ignore start -- CLI registration; handler tested via runReposNote */
55
54
  const nameArg = Argument.String("name");
@@ -1,4 +1,6 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { Argument, Command, Flag } from "effect/unstable/cli";
3
+ import { CliExit } from "@effected/cli";
2
4
  import { Repos } from "@savvy-web/silk-effects";
3
5
  import { Effect } from "effect";
4
6
 
@@ -39,23 +41,19 @@ const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to
39
41
  */
40
42
  const runReposPin = (cwd, name, ref) => Effect.gen(function* () {
41
43
  const result = yield* (yield* Repos.ReposManager).pin(cwd, name, ref);
42
- yield* Effect.log(`${result.name}: ${result.oldCommit ?? "unknown"} -> ${result.newCommit}`);
43
- yield* Effect.log(result.commitMessage);
44
- yield* Effect.log("staged — review and commit");
45
- for (const staleId of result.staleNoteIds) yield* Effect.log(`warning: note ${staleId} is now stale against ${ref}`);
44
+ yield* Output.ok(`${result.name}: ${result.oldCommit ?? "unknown"} -> ${result.newCommit}`);
45
+ yield* Output.detail(result.commitMessage);
46
+ yield* Output.detail("staged — review and commit");
47
+ for (const staleId of result.staleNoteIds) yield* Output.warn(`note ${staleId} is now stale against ${ref}`);
46
48
  }).pipe(Effect.catchTag("ReposConfigError", (error) => {
47
- if (error.kind === "missing") return Effect.log("no .repos/config.json — nothing vendored");
48
- process.exitCode = 1;
49
- return Effect.log(error.message);
49
+ if (error.kind === "missing") return Output.skip("no .repos/config.json — nothing vendored");
50
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
50
51
  }), Effect.catchTag("GitSubmoduleError", (error) => {
51
- process.exitCode = 1;
52
- return Effect.log(error.message);
52
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
53
53
  }), Effect.catchTag("RepoNotFoundError", (error) => {
54
- process.exitCode = 1;
55
- return Effect.log(error.message);
54
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
56
55
  }), Effect.catchTag("ReposLockdownError", (error) => {
57
- process.exitCode = 1;
58
- return Effect.log(error.message);
56
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
59
57
  }));
60
58
  /* v8 ignore start -- CLI registration; handler tested via runReposPin */
61
59
  const pinCommand = Command.make("pin", {
@@ -1,4 +1,6 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { Argument, Command, Flag } from "effect/unstable/cli";
3
+ import { CliExit } from "@effected/cli";
2
4
  import { Repos } from "@savvy-web/silk-effects";
3
5
  import { Effect } from "effect";
4
6
 
@@ -43,27 +45,23 @@ const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to
43
45
  */
44
46
  const runReposRemove = (cwd, name) => Effect.gen(function* () {
45
47
  const result = yield* (yield* Repos.ReposManager).remove(cwd, name);
46
- yield* Effect.log(`${result.name}: removed (${result.path})`);
47
- yield* Effect.log(result.commitMessage);
48
- yield* Effect.log("staged — review and commit");
49
- for (const note of result.removedNotes) yield* Effect.log(`warning: note ${note.id} (${note.ref}) was removed with the entry — promote first if durable`);
48
+ yield* Output.ok(`${result.name}: removed (${result.path})`);
49
+ yield* Output.detail(result.commitMessage);
50
+ yield* Output.detail("staged — review and commit");
51
+ for (const note of result.removedNotes) yield* Output.warn(`note ${note.id} (${note.ref}) was removed with the entry — promote first if durable`);
50
52
  if (result.removedEntry.orientation) {
51
- yield* Effect.log(`warning: the orientation block for ${result.name} was removed with the entry and add will NOT restore it — re-vendoring? capture it now:`);
52
- yield* Effect.log(JSON.stringify(result.removedEntry.orientation, null, 2));
53
+ yield* Output.warn(`the orientation block for ${result.name} was removed with the entry and add will NOT restore it — re-vendoring? capture it now:`);
54
+ yield* Output.line(JSON.stringify(result.removedEntry.orientation, null, 2));
53
55
  }
54
56
  }).pipe(Effect.catchTag("ReposConfigError", (error) => {
55
- if (error.kind === "missing") return Effect.log("no .repos/config.json — nothing vendored");
56
- process.exitCode = 1;
57
- return Effect.log(error.message);
57
+ if (error.kind === "missing") return Output.skip("no .repos/config.json — nothing vendored");
58
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
58
59
  }), Effect.catchTag("RepoNotFoundError", (error) => {
59
- process.exitCode = 1;
60
- return Effect.log(error.message);
60
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
61
61
  }), Effect.catchTag("GitSubmoduleError", (error) => {
62
- process.exitCode = 1;
63
- return Effect.log(error.message);
62
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
64
63
  }), Effect.catchTag("ReposLockdownError", (error) => {
65
- process.exitCode = 1;
66
- return Effect.log(error.message);
64
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
67
65
  }));
68
66
  /* v8 ignore start -- CLI registration; handler tested via runReposRemove */
69
67
  const removeCommand = Command.make("remove", {
@@ -1,4 +1,6 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { Argument, Command, Flag } from "effect/unstable/cli";
3
+ import { CliExit } from "@effected/cli";
2
4
  import { Repos } from "@savvy-web/silk-effects";
3
5
  import { Effect } from "effect";
4
6
 
@@ -45,22 +47,18 @@ const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to
45
47
  */
46
48
  const runReposRename = (cwd, oldName, newName) => Effect.gen(function* () {
47
49
  const result = yield* (yield* Repos.ReposManager).rename(cwd, oldName, newName);
48
- yield* Effect.log(`${result.oldName}: renamed to ${result.newName} (${result.path})`);
49
- yield* Effect.log(result.commitMessage);
50
- yield* Effect.log("staged — review and commit");
50
+ yield* Output.ok(`${result.oldName}: renamed to ${result.newName} (${result.path})`);
51
+ yield* Output.detail(result.commitMessage);
52
+ yield* Output.detail("staged — review and commit");
51
53
  }).pipe(Effect.catchTag("ReposConfigError", (error) => {
52
- if (error.kind === "missing") return Effect.log("no .repos/config.json — nothing vendored");
53
- process.exitCode = 1;
54
- return Effect.log(error.message);
54
+ if (error.kind === "missing") return Output.skip("no .repos/config.json — nothing vendored");
55
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
55
56
  }), Effect.catchTag("RepoNotFoundError", (error) => {
56
- process.exitCode = 1;
57
- return Effect.log(error.message);
57
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
58
58
  }), Effect.catchTag("GitSubmoduleError", (error) => {
59
- process.exitCode = 1;
60
- return Effect.log(error.message);
59
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
61
60
  }), Effect.catchTag("ReposLockdownError", (error) => {
62
- process.exitCode = 1;
63
- return Effect.log(error.message);
61
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
64
62
  }));
65
63
  /* v8 ignore start -- CLI registration; handler tested via runReposRename */
66
64
  const renameCommand = Command.make("rename", {
@@ -1,4 +1,6 @@
1
+ import { Output } from "../../../internal/output.js";
1
2
  import { Argument, Command, Flag } from "effect/unstable/cli";
3
+ import { CliExit } from "@effected/cli";
2
4
  import { Repos } from "@savvy-web/silk-effects";
3
5
  import { Effect } from "effect";
4
6
 
@@ -50,24 +52,20 @@ const cwdOption = Flag.Directory("cwd").pipe(Flag.withDescription("Repo root to
50
52
  */
51
53
  const runReposRestore = (cwd, names) => Effect.gen(function* () {
52
54
  const result = yield* (yield* Repos.ReposManager).restore(cwd, names.length > 0 ? names : void 0);
53
- for (const entry of result.restored) yield* Effect.log(`${entry.name}: restored to ${entry.commit}`);
54
- for (const name of result.skippedClean) yield* Effect.log(`${name}: clean — skipped`);
55
- for (const name of result.stillDirty) yield* Effect.log(`${name}: WARNING — reset ran but the worktree is STILL dirty; run \`savvy repos status --drift\``);
56
- if (result.stillDirty.length > 0) process.exitCode = 1;
57
- if (result.restored.length === 0 && result.skippedClean.length === 0) yield* Effect.log("nothing to restore");
55
+ for (const entry of result.restored) yield* Output.ok(`${entry.name}: restored to ${entry.commit}`);
56
+ for (const name of result.skippedClean) yield* Output.skip(`${name}: clean — skipped`);
57
+ for (const name of result.stillDirty) yield* Output.fail(`${name}: reset ran but the worktree is STILL dirty; run \`savvy repos status --drift\``);
58
+ if (result.stillDirty.length > 0) yield* CliExit.set(1);
59
+ if (result.restored.length === 0 && result.skippedClean.length === 0) yield* Output.ok("nothing to restore");
58
60
  }).pipe(Effect.catchTag("ReposConfigError", (error) => {
59
- if (error.kind === "missing") return Effect.log("no .repos/config.json — nothing vendored");
60
- process.exitCode = 1;
61
- return Effect.log(error.message);
61
+ if (error.kind === "missing") return Output.skip("no .repos/config.json — nothing vendored");
62
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
62
63
  }), Effect.catchTag("RepoNotFoundError", (error) => {
63
- process.exitCode = 1;
64
- return Effect.log(error.message);
64
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
65
65
  }), Effect.catchTag("GitSubmoduleError", (error) => {
66
- process.exitCode = 1;
67
- return Effect.log(error.message);
66
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
68
67
  }), Effect.catchTag("ReposLockdownError", (error) => {
69
- process.exitCode = 1;
70
- return Effect.log(error.message);
68
+ return CliExit.set(1).pipe(Effect.andThen(Effect.logError(error.message)));
71
69
  }));
72
70
  /* v8 ignore start -- CLI registration; handler tested via runReposRestore */
73
71
  const restoreCommand = Command.make("restore", {