block-in-file 1.0.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 (67) hide show
  1. package/- +3 -0
  2. package/.beads/README.md +85 -0
  3. package/.beads/config.yaml +67 -0
  4. package/.beads/interactions.jsonl +0 -0
  5. package/.beads/issues.jsonl +23 -0
  6. package/.beads/metadata.json +4 -0
  7. package/.git-blame-ignore-revs +2 -0
  8. package/.gitattributes +3 -0
  9. package/.prettierrc.json +5 -0
  10. package/AGENTS.md +40 -0
  11. package/README.md +122 -0
  12. package/block-in-file.ts +150 -0
  13. package/content +10 -0
  14. package/deno.json +14 -0
  15. package/deno.lock +1084 -0
  16. package/doc/PLAN-envsubst.md +200 -0
  17. package/doc/PLAN-restructure.md +114 -0
  18. package/package.json +44 -0
  19. package/src/attributes.ts +161 -0
  20. package/src/backup.ts +180 -0
  21. package/src/block-parser.ts +170 -0
  22. package/src/block-remover.ts +128 -0
  23. package/src/conflict-detection.ts +179 -0
  24. package/src/defaults.ts +23 -0
  25. package/src/envsubst.ts +59 -0
  26. package/src/file-processor.ts +378 -0
  27. package/src/index.ts +5 -0
  28. package/src/input.ts +69 -0
  29. package/src/mode-handler.ts +39 -0
  30. package/src/output.ts +107 -0
  31. package/src/plugins/.beads/.local_version +1 -0
  32. package/src/plugins/.beads/issues.jsonl +21 -0
  33. package/src/plugins/.beads/metadata.json +4 -0
  34. package/src/plugins/config.ts +282 -0
  35. package/src/plugins/diff.ts +109 -0
  36. package/src/plugins/io.ts +72 -0
  37. package/src/plugins/logger.ts +41 -0
  38. package/src/tags/tag-merger.ts +31 -0
  39. package/src/tags/tag-mode.ts +1 -0
  40. package/src/tags/tag.ts +36 -0
  41. package/src/tags/tags.ts +4 -0
  42. package/src/tags/types.ts +4 -0
  43. package/src/timestamp.ts +39 -0
  44. package/src/types.ts +32 -0
  45. package/src/validation.ts +11 -0
  46. package/test/additive-cli.test.ts +109 -0
  47. package/test/additive.test.ts +233 -0
  48. package/test/attributes-integration.test.ts +161 -0
  49. package/test/attributes.test.ts +100 -0
  50. package/test/backup.test.ts +386 -0
  51. package/test/block-in-file.test.ts +235 -0
  52. package/test/block-parser.test.ts +221 -0
  53. package/test/block-remover.test.ts +209 -0
  54. package/test/cli.test.ts +254 -0
  55. package/test/defaults.test.ts +38 -0
  56. package/test/envsubst-edge-cases.test.ts +116 -0
  57. package/test/envsubst-integration.test.ts +78 -0
  58. package/test/envsubst.test.ts +184 -0
  59. package/test/input.test.ts +86 -0
  60. package/test/mode.test.ts +193 -0
  61. package/test/output.test.ts +44 -0
  62. package/test/tag-merger.test.ts +176 -0
  63. package/test/tags.test.ts +116 -0
  64. package/test/timestamp-integration.test.ts +209 -0
  65. package/test/timestamp.test.ts +76 -0
  66. package/tsconfig.json +16 -0
  67. package/vitest.config.ts +8 -0
@@ -0,0 +1,200 @@
1
+ # Envsubst Feature Plan
2
+
3
+ ## Overview
4
+
5
+ Add environment variable interpolation to block content with support for recursive and non-recursive substitution modes.
6
+
7
+ ## Requirements
8
+
9
+ 1. **Envsubst Option**: Add a new `--envsubst` flag that enables environment variable substitution in the input block
10
+ 2. **Recursive Mode**: Substitution continues until the result stabilizes (handles nested variables like `${VAR1}` where VAR1 contains `${VAR2}`)
11
+ 3. **Non-recursive Mode**: Single pass substitution only (matches standard envsubst behavior)
12
+ 4. **Variable Syntax**: Support `${VAR}` and `$VAR` syntax for environment variables
13
+
14
+ ## Implementation Plan
15
+
16
+ ### Phase 1: Core Envsubst Module
17
+
18
+ Create `src/envsubst.ts` with:
19
+
20
+ - `substitute(text: string, options: EnvsubstOptions): string` - Main substitution function
21
+ - Support for `${VAR}` syntax (priority)
22
+ - Support for `$VAR` syntax (fallback)
23
+ - Recursive mode: loop until stable or max iterations reached
24
+ - Non-recursive mode: single pass substitution
25
+
26
+ ### Phase 2: Configuration Updates
27
+
28
+ Update `src/plugins/config.ts`:
29
+
30
+ - Add `envsubst` option to ConfigExtension interface
31
+ - Add CLI option `--envsubst` with values: `true`, `false`, `recursive`, `non-recursive`
32
+ - Default: `false` (disabled)
33
+ - Map values: `true` → `recursive`, `non-recursive` → single pass
34
+
35
+ Update `src/types.ts`:
36
+
37
+ - Add `envsubst` field to `BlockInFileOptions` interface
38
+ - Add `envsubst` field to default options
39
+
40
+ ### Phase 3: Integration
41
+
42
+ Update `src/file-processor.ts`:
43
+
44
+ - Import envsubst module
45
+ - Apply envsubst to `inputBlock` before processing
46
+ - Pass envsubst option through ProcessContext
47
+
48
+ Update `block-in-file.ts`:
49
+
50
+ - Pass envsubst from config to processFile context
51
+
52
+ ### Phase 4: Testing
53
+
54
+ Create test files:
55
+
56
+ - `test/envsubst.test.ts` - Unit tests for substitution logic
57
+ - `test/envsubst-integration.test.ts` - End-to-end CLI tests
58
+ - `test/envsubst-edge-cases.test.ts` - Edge case tests
59
+
60
+ ## Design Decisions
61
+
62
+ ### Variable Syntax Priority
63
+
64
+ 1. `${VAR}` - Preferred, explicit syntax
65
+ 2. `$VAR` - Supported for compatibility with shell syntax
66
+
67
+ ### Undefined Variables
68
+
69
+ - **Decision**: Replace with empty string (not "undefined" or leave as-is)
70
+ - Matches standard envsubst behavior
71
+ - Allows optional variables without errors
72
+
73
+ ### Recursive Mode Safety
74
+
75
+ - Maximum iterations: 100 (prevent infinite loops)
76
+ - Stop when: `previous === current` or max iterations reached
77
+ - Handles nested variables: `${VAR1}` where VAR1="${VAR2}" expands fully
78
+
79
+ ### Non-recursive Mode
80
+
81
+ - Single pass only (matches standard envsubst)
82
+ - Substitute variables once with current environment values
83
+ - No re-evaluation of substituted values
84
+ - Nested braces like `${VAR${NESTED}}` will expand inner but not result
85
+
86
+ ### Escaping Behavior
87
+
88
+ - **Decision**: Backslash escaping NOT supported (matches envsubst quirky behavior)
89
+ - `\${VAR}` becomes `\value` (backslash is literal, substitution still happens)
90
+ - This is consistent with standard envsubst, not traditional escaping
91
+
92
+ ## Edge Cases Handled
93
+
94
+ 1. **Empty variable names**: `${}` - NOT matched by regex pattern (requires at least one character after `$` and before `{`), remains as-is
95
+ 2. **Lone dollar sign**: `$` - NOT matched by regex pattern (requires variable name), remains as-is
96
+ 3. **Nested braces**: `${VAR${NESTED}}`
97
+ - Recursive mode: expands fully (NESTED → inner, then VARinner → final)
98
+ - Non-recursive mode: expands inner only (NESTED → inner, result is `${VARinner}`)
99
+ 4. **Invalid variable names**: `${VAR-INVALID}`, `${1VAR}` - NOT matched (regex requires `[a-zA-Z_][a-zA-Z0-9_]*`), remain as-is
100
+ 5. **Variables with underscores**: `${MY_LONG_VAR_NAME_123}` - Matched and substituted correctly
101
+ 6. **Malformed syntax**: `${` or `$` at end of string - NOT matched by patterns, remain as-is
102
+
103
+ ## API Changes
104
+
105
+ ### CLI Options
106
+
107
+ ```bash
108
+ --envsubst # Enable recursive substitution
109
+ --envsubst=recursive # Explicit recursive mode
110
+ --envsubst=non-recursive # Single pass substitution (like envsubst)
111
+ --envsubst=false # Disable (default)
112
+ ```
113
+
114
+ ### Programmatic API
115
+
116
+ ```typescript
117
+ const options: BlockInFileOptions = {
118
+ // ... other options
119
+ envsubst: true, // recursive (expand until stable)
120
+ envsubst: "recursive", // explicit recursive mode
121
+ envsubst: "non-recursive", // single pass (like envsubst)
122
+ envsubst: false, // disabled
123
+ };
124
+ ```
125
+
126
+ ## Example Usage
127
+
128
+ ### Basic Substitution
129
+
130
+ ```bash
131
+ export MY_VAR="hello world"
132
+ echo "content: \${MY_VAR}" | block-in-file -i - -o output.txt --envsubst
133
+ # Results in: content: hello world
134
+ ```
135
+
136
+ ### Recursive Substitution (handles nested variables)
137
+
138
+ ```bash
139
+ export VAR1="value1"
140
+ export VAR2="prefix \${VAR1}"
141
+ echo "result: \${VAR2}" | block-in-file -i - -o output.txt --envsubst
142
+ # Results in: result: prefix value1
143
+ ```
144
+
145
+ ### Non-recursive (single pass, like envsubst)
146
+
147
+ ```bash
148
+ export VAR1="value1"
149
+ export VAR2="prefix \${VAR1}"
150
+ echo "result: \${VAR2}" | block-in-file -i - -o output.txt --envsubst=non-recursive
151
+ # Results in: result: prefix ${VAR1} (VAR1 not expanded in single pass)
152
+ ```
153
+
154
+ ### Undefined Variables
155
+
156
+ ```bash
157
+ export DEFINED_VAR="value"
158
+ echo "\${DEFINED_VAR} and \${UNDEFINED_VAR}" | block-in-file -i - -o output.txt --envsubst
159
+ # Results in: value and (undefined becomes empty string)
160
+ ```
161
+
162
+ ### Empty Variable Names
163
+
164
+ ```bash
165
+ echo "value: \${}" | block-in-file -i - -o output.txt --envsubst
166
+ # Results in: value: ${} (pattern doesn't match, stays as-is)
167
+ ```
168
+
169
+ ## Testing Strategy
170
+
171
+ 1. **Unit Tests** (envsubst.ts):
172
+ - Basic substitution with `${VAR}`
173
+ - Basic substitution with `$VAR`
174
+ - Multiple variables in one string
175
+ - Recursive substitution with nesting
176
+ - Non-recursive single pass
177
+ - Undefined variable handling (becomes empty string)
178
+ - Malformed syntax handling
179
+ - Edge cases (empty string, no variables, invalid names)
180
+
181
+ 2. **Integration Tests**:
182
+ - End-to-end with block-in-file command
183
+ - Combination with other options (mode, validate, etc.)
184
+ - File input and stdin input
185
+
186
+ 3. **Edge Case Tests**:
187
+ - Empty variable names `${}`
188
+ - Lone dollar sign `$`
189
+ - Nested braces in both modes
190
+ - Invalid variable names
191
+ - Variables with underscores and numbers
192
+ - Backslash escaping behavior
193
+
194
+ ## Implementation Order
195
+
196
+ 1. ✅ Create envsubst module with substitution logic
197
+ 2. ✅ Add configuration options
198
+ 3. ✅ Integrate into file processor
199
+ 4. ✅ Add tests (unit, integration, edge cases)
200
+ 5. ✅ Update documentation (PLAN-envsubst.md)
@@ -0,0 +1,114 @@
1
+ # Restructure Plan
2
+
3
+ ## Current Issues
4
+
5
+ 1. **Single file complexity** - `block-in-file.ts` contains 215 lines mixing multiple responsibilities
6
+ 2. **Non-testable code** - No way to unit test individual components
7
+ 3. **Global mutable state** - `defaults` and `setDefaults()` create coupling issues
8
+ 4. **Poor separation of concerns** - File I/O, parsing, and logic intermingled
9
+ 5. **Dead code** - Commented-out blocks that should be removed
10
+
11
+ ## Target Structure
12
+
13
+ ```
14
+ src/
15
+ ├── types.ts # All type definitions, interfaces
16
+ ├── input.ts # Input handling (_input, stream reading)
17
+ ├── block-parser.ts # Block finding, parsing, insertion logic
18
+ ├── output.ts # Output writing, formatting
19
+ ├── defaults.ts # Default configuration (immutable pattern)
20
+ ├── block-in-file.ts # Main class orchestrating components
21
+ ├── cli.ts # CLI interface (renamed from cliffy.ts)
22
+ └── index.ts # Public API exports
23
+ ```
24
+
25
+ ## Component Breakdown
26
+
27
+ ### `types.ts`
28
+
29
+ - `BlockInFileOptions` interface
30
+ - `CreateArg` type
31
+ - `InputOptions` interface
32
+ - All shared types
33
+
34
+ ### `input.ts`
35
+
36
+ - `get<T>()` utility function
37
+ - `createOpt()` function
38
+ - `_input()` function
39
+ - Stream handling utilities
40
+
41
+ ### `block-parser.ts`
42
+
43
+ - Block detection logic (finding opener/closer)
44
+ - Insertion position calculation (before/after matching)
45
+ - Line-by-line processing state machine
46
+ - Returns structured result: `{ outputs: string[], matched: number, opened?: number }`
47
+
48
+ ### `output.ts`
49
+
50
+ - `formatOutputs()` - Join lines with appropriate line endings
51
+ - `writeOutput()` - Handle different output modes (file, stdout, none)
52
+ - Diff generation (when implemented)
53
+
54
+ ### `defaults.ts`
55
+
56
+ - Immutable default configuration object
57
+ - `getDefaultOptions()` function
58
+ - No mutable exports
59
+
60
+ ### `block-in-file.ts`
61
+
62
+ - Main `BlockInFile` class
63
+ - `run()` method that orchestrates components
64
+ - Minimal logic, delegates to specialized modules
65
+
66
+ ### `cli.ts`
67
+
68
+ - All Cliffy/CLI logic
69
+ - Argument parsing
70
+ - Environment variable handling
71
+ - Renamed from `cliffy.ts` for clarity
72
+
73
+ ### `index.ts`
74
+
75
+ - Public API exports
76
+ - Re-exports for convenience
77
+
78
+ ## Migration Steps
79
+
80
+ 1. **Create new files** with empty structure
81
+ 2. **Extract types** to `types.ts`
82
+ 3. **Move input functions** to `input.ts`
83
+ 4. **Extract parsing logic** to `block-parser.ts` (core complexity)
84
+ 5. **Extract output logic** to `output.ts`
85
+ 6. **Create defaults module** with immutable pattern
86
+ 7. **Refactor main class** to use new components
87
+ 8. **Update CLI imports**
88
+ 9. **Verify functionality** with existing examples
89
+ 10. **Add tests** for individual components
90
+
91
+ ## Testing Strategy
92
+
93
+ After restructuring:
94
+
95
+ - Unit test `block-parser.ts` with mock inputs
96
+ - Unit test `output.ts` with various output modes
97
+ - Unit test `input.ts` file opening scenarios
98
+ - Integration test full `BlockInFile.run()` flow
99
+ - CLI tests for argument parsing
100
+
101
+ ## Benefits
102
+
103
+ - **Maintainability** - Clear file boundaries make changes easier
104
+ - **Testability** - Components can be unit tested independently
105
+ - **Reusability** - Individual modules can be used separately
106
+ - **Readability** - Smaller files are easier to understand
107
+ - **Onboarding** - New contributors can navigate code faster
108
+
109
+ ## Backward Compatibility
110
+
111
+ - Public API remains unchanged
112
+ - CLI interface unchanged
113
+ - Existing functionality preserved
114
+ - Only internal structure changes
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "block-in-file",
3
+ "version": "1.0.0",
4
+ "description": "Insert & update blocks of text in files",
5
+ "keywords": [
6
+ "block",
7
+ "file",
8
+ "insert",
9
+ "text"
10
+ ],
11
+ "license": "ISC",
12
+ "author": "",
13
+ "bin": {
14
+ "block-in-file": "./block-in-file.ts"
15
+ },
16
+ "type": "module",
17
+ "dependencies": {
18
+ "@gunshi/plugin": "^0.27.5",
19
+ "args-tokenizer": "^0.3.0",
20
+ "gunshi": "^0.28.0",
21
+ "tinyexec": "^1.0.2"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^25.2.0",
25
+ "@typescript/native-preview": "^7.0.0-dev.20260201.1",
26
+ "@vitest/coverage-v8": "^4.0.18",
27
+ "concurrently": "^9.2.1",
28
+ "oxfmt": "^0.27.0",
29
+ "oxlint": "^1.42.0",
30
+ "tsx": "^4.21.0",
31
+ "vitest": "^4.0.18"
32
+ },
33
+ "scripts": {
34
+ "check:ts": "tsgo --noEmit",
35
+ "check:lint": "oxlint",
36
+ "check:fmt": "oxfmt --check",
37
+ "fix:lint": "oxlint --fix",
38
+ "fix:fmt": "oxfmt",
39
+ "check": "concurrently \"pnpm:check:*\"",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest",
42
+ "test:coverage": "vitest run --coverage"
43
+ }
44
+ }
@@ -0,0 +1,161 @@
1
+ import { x } from "tinyexec";
2
+ import type { LoggerExtension } from "./plugins/logger.ts";
3
+ import type { IOExtension } from "./plugins/io.ts";
4
+
5
+ export interface AttributeChange {
6
+ mode: "+" | "-" | "=";
7
+ attribute: string;
8
+ }
9
+
10
+ export interface AttributeOptions {
11
+ attributes?: string;
12
+ debug: boolean;
13
+ logger: LoggerExtension;
14
+ io: IOExtension;
15
+ }
16
+
17
+ export function parseAttributes(attrString: string): AttributeChange[] {
18
+ const changes: AttributeChange[] = [];
19
+
20
+ if (!attrString || attrString.trim().length === 0) {
21
+ return changes;
22
+ }
23
+
24
+ const tokens = attrString.trim().split(/\s+/);
25
+
26
+ for (const token of tokens) {
27
+ if (token.length === 0) continue;
28
+
29
+ const mode = token[0] as "+" | "-" | "=";
30
+ const attr = token.slice(1);
31
+
32
+ if (!/^[+\-=]/.test(mode) || !/^[a-zA-Z]+$/.test(attr)) {
33
+ throw new Error(`Invalid attribute syntax: ${token}`);
34
+ }
35
+
36
+ changes.push({ mode, attribute: attr });
37
+ }
38
+
39
+ return changes;
40
+ }
41
+
42
+ export async function supportsChattr(): Promise<boolean> {
43
+ if (process.platform !== "linux") {
44
+ return false;
45
+ }
46
+
47
+ try {
48
+ await x("which", ["chattr"], { throwOnError: true });
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ async function runChattr(args: string[]): Promise<void> {
56
+ try {
57
+ await x("chattr", args, { throwOnError: true });
58
+ } catch (err) {
59
+ const error = err as Error & { stderr?: string; exitCode?: number };
60
+ const stderr = error.stderr?.trim() || "";
61
+ const code = error.exitCode;
62
+ throw new Error(
63
+ `chattr failed${code !== undefined ? ` with code ${code}` : ""}${stderr ? `: ${stderr}` : ""}`,
64
+ );
65
+ }
66
+ }
67
+
68
+ async function runLsattr(filePath: string): Promise<string> {
69
+ try {
70
+ const result = await x("lsattr", [filePath], { throwOnError: true });
71
+ return result.stdout.trim();
72
+ } catch (err) {
73
+ const error = err as Error & { stderr?: string; exitCode?: number };
74
+ const stderr = error.stderr?.trim() || "";
75
+ const code = error.exitCode;
76
+ throw new Error(
77
+ `lsattr failed${code !== undefined ? ` with code ${code}` : ""}${stderr ? `: ${stderr}` : ""}`,
78
+ );
79
+ }
80
+ }
81
+
82
+ export async function applyAttributes(
83
+ filePath: string,
84
+ changes: AttributeChange[],
85
+ debug: boolean,
86
+ logger: LoggerExtension,
87
+ ): Promise<void> {
88
+ if (changes.length === 0) {
89
+ return;
90
+ }
91
+
92
+ if (!(await supportsChattr())) {
93
+ throw new Error(
94
+ "chattr is not available on this system. File attributes require Linux and the chattr command.",
95
+ );
96
+ }
97
+
98
+ if (debug) {
99
+ logger.debug(
100
+ `Applying attributes to ${filePath}: ${changes.map((c) => `${c.mode}${c.attribute}`).join(" ")}`,
101
+ );
102
+ }
103
+
104
+ for (const change of changes) {
105
+ await runChattr([`${change.mode}${change.attribute}`, filePath]);
106
+ }
107
+
108
+ if (debug) {
109
+ try {
110
+ const attrs = await runLsattr(filePath);
111
+ logger.debug(`File attributes after chattr: ${attrs}`);
112
+ } catch (err) {
113
+ logger.debug(`Could not verify attributes with lsattr: ${(err as Error).message}`);
114
+ }
115
+ }
116
+ }
117
+
118
+ export async function applyAttributesSafe(
119
+ filePath: string,
120
+ changes: AttributeChange[],
121
+ opts: AttributeOptions,
122
+ ): Promise<void> {
123
+ const { debug, logger } = opts;
124
+
125
+ if (changes.length === 0) {
126
+ return;
127
+ }
128
+
129
+ if (!(await supportsChattr())) {
130
+ if (debug) {
131
+ logger.debug("chattr not available on this system, skipping attribute setting");
132
+ }
133
+ return;
134
+ }
135
+
136
+ if (process.platform !== "linux") {
137
+ if (debug) {
138
+ logger.debug(`File attributes are only supported on Linux, skipping for ${process.platform}`);
139
+ }
140
+ return;
141
+ }
142
+
143
+ if (debug) {
144
+ logger.debug(
145
+ `Attempting to set attributes on ${filePath}: ${changes.map((c) => `${c.mode}${c.attribute}`).join(" ")}`,
146
+ );
147
+ }
148
+
149
+ try {
150
+ await applyAttributes(filePath, changes, debug, logger);
151
+ } catch (err) {
152
+ const errorMsg = (err as Error).message;
153
+ if (errorMsg.includes("Operation not permitted") || errorMsg.includes("Permission denied")) {
154
+ logger.warn(
155
+ `Insufficient privileges to set file attributes. Root or CAP_LINUX_IMMUTABLE capability required.`,
156
+ );
157
+ } else {
158
+ logger.warn(`Failed to set file attributes: ${errorMsg}`);
159
+ }
160
+ }
161
+ }
package/src/backup.ts ADDED
@@ -0,0 +1,180 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as path from "node:path";
3
+ import * as fs from "node:fs/promises";
4
+
5
+ export type BackupMode = "iterate" | "fail" | "overwrite";
6
+
7
+ export interface BackupOptions {
8
+ enabled: boolean;
9
+ suffix: string;
10
+ backupDir?: string;
11
+ stateOnFail?: BackupMode;
12
+ }
13
+
14
+ export interface TemplateVariables {
15
+ date: string;
16
+ time: string;
17
+ iso: string;
18
+ epoch: string;
19
+ md5?: string;
20
+ sha256?: string;
21
+ }
22
+
23
+ export function generateTemplateVariables(content?: string): TemplateVariables {
24
+ const now = new Date();
25
+
26
+ const variables: TemplateVariables = {
27
+ date: now.toISOString().split("T")[0],
28
+ time: now.toTimeString().split(" ")[0].replace(/:/g, ""),
29
+ iso: now.toISOString(),
30
+ epoch: Math.floor(now.getTime() / 1000).toString(),
31
+ };
32
+
33
+ if (content) {
34
+ const md5 = crypto.createHash("md5").update(content).digest("hex");
35
+ const sha256 = crypto.createHash("sha256").update(content).digest("hex");
36
+ variables.md5 = md5.substring(0, 8);
37
+ variables.sha256 = sha256.substring(0, 8);
38
+ }
39
+
40
+ return variables;
41
+ }
42
+
43
+ export function replaceTemplateVariables(template: string, variables: TemplateVariables): string {
44
+ let result = template;
45
+
46
+ for (const [key, value] of Object.entries(variables)) {
47
+ result = result.replaceAll(`{${key}}`, value);
48
+ }
49
+
50
+ return result;
51
+ }
52
+
53
+ export function generateBackupPaths(
54
+ originalPath: string,
55
+ suffix: string,
56
+ variables: TemplateVariables,
57
+ backupDir?: string,
58
+ ): string {
59
+ const basePath = backupDir ? path.join(backupDir, path.basename(originalPath)) : originalPath;
60
+ const processedSuffix = replaceTemplateVariables(suffix, variables);
61
+ return `${basePath}${processedSuffix}`;
62
+ }
63
+
64
+ export async function detectGitRepo(dir: string): Promise<boolean> {
65
+ try {
66
+ const gitPath = path.join(dir, ".git");
67
+ await fs.access(gitPath);
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
73
+
74
+ export async function isPathInGitRepo(filePath: string): Promise<boolean> {
75
+ const dir = path.dirname(filePath);
76
+ return detectGitRepo(dir);
77
+ }
78
+
79
+ export async function createBackup(originalPath: string, backupPath: string): Promise<void> {
80
+ await fs.copyFile(originalPath, backupPath);
81
+
82
+ const isGitRepo = await isPathInGitRepo(backupPath);
83
+ if (isGitRepo) {
84
+ const gitignorePath = path.join(path.dirname(backupPath), ".gitignore");
85
+
86
+ try {
87
+ const gitignore = await fs.readFile(gitignorePath, "utf-8");
88
+ const backupName = path.basename(backupPath);
89
+
90
+ if (!gitignore.includes(backupName)) {
91
+ await fs.appendFile(gitignorePath, `\n${backupName}\n`);
92
+ }
93
+ } catch {
94
+ await fs.writeFile(gitignorePath, `${path.basename(backupPath)}\n`);
95
+ }
96
+ }
97
+ }
98
+
99
+ export async function findAvailableBackupPath(
100
+ baseBackupPath: string,
101
+ mode: BackupMode = "iterate",
102
+ ): Promise<string | null> {
103
+ try {
104
+ await fs.access(baseBackupPath);
105
+
106
+ if (mode === "iterate") {
107
+ let counter = 1;
108
+ while (true) {
109
+ const backupPath = `${baseBackupPath}.${counter}`;
110
+ try {
111
+ await fs.access(backupPath);
112
+ counter++;
113
+ } catch {
114
+ return backupPath;
115
+ }
116
+ }
117
+ } else if (mode === "overwrite") {
118
+ return baseBackupPath;
119
+ } else {
120
+ return null;
121
+ }
122
+ } catch {
123
+ return baseBackupPath;
124
+ }
125
+ }
126
+
127
+ export async function performBackup(
128
+ originalPath: string,
129
+ options: BackupOptions,
130
+ content?: string,
131
+ ): Promise<string | null> {
132
+ if (!options.enabled) {
133
+ return null;
134
+ }
135
+
136
+ const variables = generateTemplateVariables(content);
137
+ const backupPath = generateBackupPaths(
138
+ originalPath,
139
+ options.suffix,
140
+ variables,
141
+ options.backupDir,
142
+ );
143
+
144
+ try {
145
+ await fs.access(originalPath);
146
+
147
+ const finalBackupPath = await findAvailableBackupPath(backupPath, options.stateOnFail);
148
+
149
+ if (finalBackupPath === null && options.stateOnFail === "fail") {
150
+ throw new Error(
151
+ `Backup failed: backup file already exists and state-on-fail is set to 'fail'`,
152
+ );
153
+ }
154
+
155
+ if (finalBackupPath) {
156
+ await createBackup(originalPath, finalBackupPath);
157
+ return finalBackupPath;
158
+ }
159
+ } catch (err) {
160
+ if (options.stateOnFail === "fail") {
161
+ throw err;
162
+ }
163
+ }
164
+
165
+ return null;
166
+ }
167
+
168
+ export function parseBackupOption(backupArgs: string[] | undefined): BackupOptions {
169
+ if (!backupArgs || backupArgs.length === 0) {
170
+ return { enabled: false, suffix: "" };
171
+ }
172
+
173
+ const joined = backupArgs.join(".");
174
+ const suffix = joined.startsWith(".") ? joined : `.${joined}`;
175
+
176
+ return {
177
+ enabled: true,
178
+ suffix,
179
+ };
180
+ }