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.
- package/- +3 -0
- package/.beads/README.md +85 -0
- package/.beads/config.yaml +67 -0
- package/.beads/interactions.jsonl +0 -0
- package/.beads/issues.jsonl +23 -0
- package/.beads/metadata.json +4 -0
- package/.git-blame-ignore-revs +2 -0
- package/.gitattributes +3 -0
- package/.prettierrc.json +5 -0
- package/AGENTS.md +40 -0
- package/README.md +122 -0
- package/block-in-file.ts +150 -0
- package/content +10 -0
- package/deno.json +14 -0
- package/deno.lock +1084 -0
- package/doc/PLAN-envsubst.md +200 -0
- package/doc/PLAN-restructure.md +114 -0
- package/package.json +44 -0
- package/src/attributes.ts +161 -0
- package/src/backup.ts +180 -0
- package/src/block-parser.ts +170 -0
- package/src/block-remover.ts +128 -0
- package/src/conflict-detection.ts +179 -0
- package/src/defaults.ts +23 -0
- package/src/envsubst.ts +59 -0
- package/src/file-processor.ts +378 -0
- package/src/index.ts +5 -0
- package/src/input.ts +69 -0
- package/src/mode-handler.ts +39 -0
- package/src/output.ts +107 -0
- package/src/plugins/.beads/.local_version +1 -0
- package/src/plugins/.beads/issues.jsonl +21 -0
- package/src/plugins/.beads/metadata.json +4 -0
- package/src/plugins/config.ts +282 -0
- package/src/plugins/diff.ts +109 -0
- package/src/plugins/io.ts +72 -0
- package/src/plugins/logger.ts +41 -0
- package/src/tags/tag-merger.ts +31 -0
- package/src/tags/tag-mode.ts +1 -0
- package/src/tags/tag.ts +36 -0
- package/src/tags/tags.ts +4 -0
- package/src/tags/types.ts +4 -0
- package/src/timestamp.ts +39 -0
- package/src/types.ts +32 -0
- package/src/validation.ts +11 -0
- package/test/additive-cli.test.ts +109 -0
- package/test/additive.test.ts +233 -0
- package/test/attributes-integration.test.ts +161 -0
- package/test/attributes.test.ts +100 -0
- package/test/backup.test.ts +386 -0
- package/test/block-in-file.test.ts +235 -0
- package/test/block-parser.test.ts +221 -0
- package/test/block-remover.test.ts +209 -0
- package/test/cli.test.ts +254 -0
- package/test/defaults.test.ts +38 -0
- package/test/envsubst-edge-cases.test.ts +116 -0
- package/test/envsubst-integration.test.ts +78 -0
- package/test/envsubst.test.ts +184 -0
- package/test/input.test.ts +86 -0
- package/test/mode.test.ts +193 -0
- package/test/output.test.ts +44 -0
- package/test/tag-merger.test.ts +176 -0
- package/test/tags.test.ts +116 -0
- package/test/timestamp-integration.test.ts +209 -0
- package/test/timestamp.test.ts +76 -0
- package/tsconfig.json +16 -0
- 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
|
+
}
|