touch-all 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -19
- package/dist/lib/cli.d.ts +2 -0
- package/dist/lib/{src/_commonErrors.d.ts → errors.d.ts} +0 -9
- package/dist/lib/fsGenerator.d.ts +7 -0
- package/dist/lib/fsNormalizator.d.ts +20 -0
- package/dist/lib/index.d.ts +5 -0
- package/dist/lib/index.js +17 -22
- package/dist/lib/{src/parser.d.ts → parser.d.ts} +1 -1
- package/dist/lib/stdin.d.ts +2 -0
- package/dist/lib/symlinkGuard.d.ts +4 -0
- package/dist/slim/touch-all.js +126 -123
- package/package.json +1 -2
- package/dist/lib/src/cli.d.ts +0 -2
- package/dist/lib/src/fsGenerator.d.ts +0 -4
- package/dist/lib/src/fsNormalizator.d.ts +0 -11
- package/dist/lib/src/index.d.ts +0 -5
- /package/dist/lib/{src/touch-all.d.ts → touch-all.d.ts} +0 -0
- /package/dist/lib/{src/_commonTypes.d.ts → types.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -10,12 +10,13 @@ It behaves like `mkdir -p` and `touch` combined, creating directories and files
|
|
|
10
10
|
|
|
11
11
|
- Accepts tree strings in **box-drawing** (`├──`, `└──`, `│`) or **indentation** (spaces) format
|
|
12
12
|
- Trailing slash `/` marks a directory; no trailing `/` marks a file
|
|
13
|
-
- Inline comments stripped automatically (`# ...`, `// ...`, `<- ...`, `← ...`)
|
|
14
13
|
- Symlink creation with `link-name -> target` syntax
|
|
15
|
-
-
|
|
16
|
-
- `--
|
|
17
|
-
-
|
|
14
|
+
- Inline comments stripped automatically (`# ...`, `// ...`, `<- ...`, `← ...`)
|
|
15
|
+
- `--dry-run` (`-n`) parses and validates without touching the file system
|
|
16
|
+
- Prints a summary line on success (`✓ Done. N items created.`); `--verbose` adds per-item detail
|
|
17
|
+
- Path traversal protection — no file, folder, or symlink target can escape the target directory
|
|
18
18
|
- Importable as a Node.js library with full TypeScript types
|
|
19
|
+
- Pipable interface to run as `echo tree | touch-all` in CI or scripts
|
|
19
20
|
|
|
20
21
|
## Installation
|
|
21
22
|
|
|
@@ -60,7 +61,7 @@ touch-all "..." --dry-run
|
|
|
60
61
|
touch-all "..." -n
|
|
61
62
|
```
|
|
62
63
|
|
|
63
|
-
- `--verbose` , `-v` – prints every created path to the console. Useful for seeing exactly what will be created, especially with complex structures.
|
|
64
|
+
- `--verbose` , `-v` – prints every created path to the console. Useful for seeing exactly what will be created, especially with complex structures. By default only a summary line is printed on success (`✓ Done. N items created.`). `--verbose` adds per-item detail.
|
|
64
65
|
|
|
65
66
|
```bash
|
|
66
67
|
touch-all "..." --verbose
|
|
@@ -120,10 +121,10 @@ Both formats produce identical results.
|
|
|
120
121
|
| `name` | file |
|
|
121
122
|
| `dir/sub/` | directory at an explicit subpath |
|
|
122
123
|
| `dir/sub/file.ts` | file at an explicit subpath |
|
|
123
|
-
| `... #
|
|
124
|
+
| `... # comment` | ignored (stripped) |
|
|
124
125
|
| `... // comment` | ignored (stripped) |
|
|
125
126
|
| `... <- comment` | ignored (stripped) |
|
|
126
|
-
| `... ←
|
|
127
|
+
| `... ← comment` | ignored (stripped) |
|
|
127
128
|
| `name -> target` | symlink pointing to `target` |
|
|
128
129
|
|
|
129
130
|
Items at the root level (no indentation / no parent) are created directly inside the target directory.
|
|
@@ -134,17 +135,19 @@ Use `link-name -> target` to create a symlink. The target is passed as-is to the
|
|
|
134
135
|
|
|
135
136
|
```
|
|
136
137
|
my-project/
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
├─ src/
|
|
139
|
+
│ ├─ index.ts
|
|
140
|
+
│ └─ utils -> ../shared/utils.ts # symlink to a sibling directory
|
|
141
|
+
└─ shared/
|
|
142
|
+
└─ utils.ts
|
|
142
143
|
```
|
|
143
144
|
|
|
144
145
|
If `target` ends with `/`, the symlink is created as a directory symlink (relevant on Windows). The link name's suffix is ignored.
|
|
145
146
|
|
|
146
147
|
> [!WARNING]
|
|
147
148
|
> If any symlink target resolves outside the project root (`--path`), `touch-all` will prompt for confirmation before proceeding. Use `--yes` to skip the prompt in scripts or CI.
|
|
149
|
+
>
|
|
150
|
+
> When using `fileStructureCreator` directly as a library, outside-root symlinks are rejected by default with a `PathTraversalError`. Pass `{ allowOutsideSymlinks: true }` as the third argument to allow them.
|
|
148
151
|
|
|
149
152
|
## Library API
|
|
150
153
|
|
|
@@ -157,8 +160,8 @@ import {
|
|
|
157
160
|
parserFolderStructure,
|
|
158
161
|
fileStructureCreator,
|
|
159
162
|
resolveProjectPathToBase,
|
|
163
|
+
isSymlinkOutsideRoot,
|
|
160
164
|
PathTraversalError,
|
|
161
|
-
FsError,
|
|
162
165
|
} from 'touch-all'
|
|
163
166
|
import type { ParserResult, ParserResultLineItem } from 'touch-all'
|
|
164
167
|
```
|
|
@@ -189,10 +192,12 @@ const items = parserFolderStructure(`
|
|
|
189
192
|
// ]
|
|
190
193
|
```
|
|
191
194
|
|
|
192
|
-
### `fileStructureCreator(items
|
|
195
|
+
### `fileStructureCreator(items, basePath, options?): Effect<void, PathTraversalError>`
|
|
193
196
|
|
|
194
197
|
Creates the parsed structure on disk under `basePath`. Returns an [Effect](https://effect.website/).
|
|
195
198
|
|
|
199
|
+
By default, symlinks whose targets resolve outside `basePath` are rejected with `PathTraversalError`. Pass `{ allowOutsideSymlinks: true }` to allow them.
|
|
200
|
+
|
|
196
201
|
```ts
|
|
197
202
|
import { Effect } from 'effect'
|
|
198
203
|
import { NodeContext, NodeRuntime } from '@effect/platform-node'
|
|
@@ -200,22 +205,45 @@ import { NodeContext, NodeRuntime } from '@effect/platform-node'
|
|
|
200
205
|
const projectDirectory = '/absolute/target/path'
|
|
201
206
|
const items = parserFolderStructure(tree)
|
|
202
207
|
|
|
208
|
+
// Default: outside-root symlinks are rejected
|
|
203
209
|
fileStructureCreator(items, projectDirectory).pipe(Effect.provide(NodeContext.layer), NodeRuntime.runMain)
|
|
210
|
+
|
|
211
|
+
// Opt out of symlink containment check
|
|
212
|
+
fileStructureCreator(items, projectDirectory, { allowOutsideSymlinks: true }).pipe(
|
|
213
|
+
Effect.provide(NodeContext.layer),
|
|
214
|
+
NodeRuntime.runMain
|
|
215
|
+
)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### `isSymlinkOutsideRoot(linkPath, target, basePath, path): boolean`
|
|
219
|
+
|
|
220
|
+
Pure function that returns `true` if a symlink target resolves outside `basePath`. Useful for pre-validating items before passing them to `fileStructureCreator`.
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
import { Path } from '@effect/platform'
|
|
224
|
+
import { Effect } from 'effect'
|
|
225
|
+
import { NodeContext } from '@effect/platform-node'
|
|
226
|
+
import { isSymlinkOutsideRoot } from 'touch-all'
|
|
227
|
+
|
|
228
|
+
Effect.gen(function* () {
|
|
229
|
+
const path = yield* Path.Path
|
|
230
|
+
const outside = isSymlinkOutsideRoot('src/link', '../../etc/passwd', '/project', path)
|
|
231
|
+
// true — target escapes /project
|
|
232
|
+
}).pipe(Effect.provide(NodeContext.layer))
|
|
204
233
|
```
|
|
205
234
|
|
|
206
235
|
### `resolveProjectPathToBase(projectPath: string, basePath: string): Effect<string, PathTraversalError>`
|
|
207
236
|
|
|
208
237
|
Resolves `projectPath` relative to `basePath` and rejects any path that would escape `basePath` (path traversal protection).
|
|
209
238
|
|
|
210
|
-
> [!
|
|
239
|
+
> [!WARNING]
|
|
211
240
|
> `projectPath` cannot traverse outside of `basePath`. If `projectPath` is absolute, it treated as relative to `basePath`. If `projectPath` is relative, it is resolved against `basePath`. In either case, if the resulting path is outside of `basePath`, a `PathTraversalError` is thrown.
|
|
212
241
|
|
|
213
242
|
### Error types
|
|
214
243
|
|
|
215
|
-
| Class | `_tag` | When thrown
|
|
216
|
-
| -------------------- | ---------------------- |
|
|
217
|
-
| `PathTraversalError` | `'PathTraversalError'` |
|
|
218
|
-
| `FsError` | `'FsError'` | `mkdirSync`, `writeFileSync`, or `symlinkSync` fails |
|
|
244
|
+
| Class | `_tag` | When thrown |
|
|
245
|
+
| -------------------- | ---------------------- | -------------------------------------------------------------------------- |
|
|
246
|
+
| `PathTraversalError` | `'PathTraversalError'` | A file/folder path or symlink target escapes `basePath` (unless opted out) |
|
|
219
247
|
|
|
220
248
|
## License
|
|
221
249
|
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
export declare const cli: (args: ReadonlyArray<string>) => Effect.Effect<void, import("./errors").PathTraversalError | import("@effect/platform/Error").PlatformError | Error | import("@effect/cli/ValidationError").ValidationError, import("@effect/cli/CliApp").CliApp.Environment>;
|
|
@@ -7,12 +7,3 @@ export declare class PathTraversalError {
|
|
|
7
7
|
constructor(path: string);
|
|
8
8
|
toString(): string;
|
|
9
9
|
}
|
|
10
|
-
/**
|
|
11
|
-
* Error type for file system operation failures
|
|
12
|
-
*/
|
|
13
|
-
export declare class FsError {
|
|
14
|
-
readonly message: string;
|
|
15
|
-
readonly _tag = "FsError";
|
|
16
|
-
constructor(message: string);
|
|
17
|
-
toString(): string;
|
|
18
|
-
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { FileSystem, Path } from '@effect/platform';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { type ParserResult } from './types';
|
|
4
|
+
import { PathTraversalError } from './errors';
|
|
5
|
+
export declare const fileStructureCreator: (items: ParserResult, basePath: string, options?: {
|
|
6
|
+
allowOutsideSymlinks?: boolean;
|
|
7
|
+
}) => Effect.Effect<void, PathTraversalError | import("@effect/platform/Error").PlatformError, Path.Path | FileSystem.FileSystem>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Path } from '@effect/platform';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { PathTraversalError } from './errors';
|
|
4
|
+
/**
|
|
5
|
+
* Returns true if the symlink target escapes the base directory.
|
|
6
|
+
*
|
|
7
|
+
* @param linkPath string - Path of the symlink relative to project root.
|
|
8
|
+
* @param target string - The symlink target value.
|
|
9
|
+
* @param basePath string - Project root (absolute or relative).
|
|
10
|
+
* @param path Path.Path - Platform path service instance.
|
|
11
|
+
*/
|
|
12
|
+
export declare const isSymlinkOutsideRoot: (linkPath: string, target: string, basePath: string, path: Path.Path) => boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Safely normalize a user-supplied path against a base directory.
|
|
15
|
+
* Fails with PathTraversalError if the resolved path escapes the base.
|
|
16
|
+
*
|
|
17
|
+
* @param projectPath string - Path relative to project.
|
|
18
|
+
* @param basePath string - Must be absolute.
|
|
19
|
+
*/
|
|
20
|
+
export declare const resolveProjectPathToBase: (projectPath: string, basePath: string) => Effect.Effect<string, PathTraversalError, Path.Path>;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { fileStructureCreator } from './fsGenerator';
|
|
2
|
+
export { isSymlinkOutsideRoot, resolveProjectPathToBase } from './fsNormalizator';
|
|
3
|
+
export { PathTraversalError } from './errors';
|
|
4
|
+
export type { ParserResult, ParserResultLineItem } from './types';
|
|
5
|
+
export { parserFolderStructure } from './parser';
|
package/dist/lib/index.js
CHANGED
|
@@ -768,12 +768,9 @@ var TypeId3 = TypeId2;
|
|
|
768
768
|
var Path2 = Path;
|
|
769
769
|
var layer2 = layer;
|
|
770
770
|
// src/fsGenerator.ts
|
|
771
|
-
import { Effect as Effect4 } from "effect";
|
|
771
|
+
import { Console, Effect as Effect4 } from "effect";
|
|
772
772
|
|
|
773
|
-
// src/
|
|
774
|
-
import { Effect as Effect3 } from "effect";
|
|
775
|
-
|
|
776
|
-
// src/_commonErrors.ts
|
|
773
|
+
// src/errors.ts
|
|
777
774
|
class PathTraversalError {
|
|
778
775
|
path;
|
|
779
776
|
_tag = "PathTraversalError";
|
|
@@ -781,22 +778,18 @@ class PathTraversalError {
|
|
|
781
778
|
this.path = path;
|
|
782
779
|
}
|
|
783
780
|
toString() {
|
|
784
|
-
return `${this._tag}:
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
class FsError {
|
|
789
|
-
message;
|
|
790
|
-
_tag = "FsError";
|
|
791
|
-
constructor(message) {
|
|
792
|
-
this.message = message;
|
|
793
|
-
}
|
|
794
|
-
toString() {
|
|
795
|
-
return `${this._tag}: ${this.message}`;
|
|
781
|
+
return `${this._tag}: path escapes root — ${this.path}`;
|
|
796
782
|
}
|
|
797
783
|
}
|
|
798
784
|
|
|
799
785
|
// src/fsNormalizator.ts
|
|
786
|
+
import { Effect as Effect3 } from "effect";
|
|
787
|
+
var isSymlinkOutsideRoot = (linkPath, target, basePath, path) => {
|
|
788
|
+
const resolvedBase = path.resolve(basePath);
|
|
789
|
+
const symlinkDir = path.resolve(resolvedBase, path.dirname(linkPath));
|
|
790
|
+
const resolvedTarget = path.resolve(symlinkDir, target);
|
|
791
|
+
return path.relative(resolvedBase, resolvedTarget).startsWith("..");
|
|
792
|
+
};
|
|
800
793
|
var resolveProjectPathToBase = (projectPath, basePath) => Effect3.gen(function* () {
|
|
801
794
|
const path = yield* exports_Path.Path;
|
|
802
795
|
const filePath = projectPath.startsWith(path.sep) ? `.${projectPath}` : projectPath;
|
|
@@ -809,7 +802,7 @@ var resolveProjectPathToBase = (projectPath, basePath) => Effect3.gen(function*
|
|
|
809
802
|
});
|
|
810
803
|
|
|
811
804
|
// src/fsGenerator.ts
|
|
812
|
-
var fileStructureCreator = (items, basePath) => Effect4.gen(function* () {
|
|
805
|
+
var fileStructureCreator = (items, basePath, options) => Effect4.gen(function* () {
|
|
813
806
|
const fs = yield* exports_FileSystem.FileSystem;
|
|
814
807
|
const path = yield* exports_Path.Path;
|
|
815
808
|
yield* Effect4.logInfo(`Creating structure in: ${basePath}`);
|
|
@@ -823,6 +816,9 @@ var fileStructureCreator = (items, basePath) => Effect4.gen(function* () {
|
|
|
823
816
|
yield* Effect4.logInfo(` Created file: ${item.path}`);
|
|
824
817
|
break;
|
|
825
818
|
case "symlink":
|
|
819
|
+
if (!options?.allowOutsideSymlinks && isSymlinkOutsideRoot(item.path, item.target, basePath, path)) {
|
|
820
|
+
yield* Effect4.fail(new PathTraversalError(`symlink target escapes root: ${item.path} -> ${item.target} (base: ${basePath})`));
|
|
821
|
+
}
|
|
826
822
|
yield* fs.makeDirectory(dir, { recursive: true });
|
|
827
823
|
yield* fs.symlink(item.target, fullPath);
|
|
828
824
|
yield* Effect4.logInfo(` Created symlink: ${item.path} -> ${item.target}`);
|
|
@@ -833,8 +829,7 @@ var fileStructureCreator = (items, basePath) => Effect4.gen(function* () {
|
|
|
833
829
|
break;
|
|
834
830
|
}
|
|
835
831
|
}
|
|
836
|
-
yield*
|
|
837
|
-
✓ Structure created successfully!`);
|
|
832
|
+
yield* Console.log(`✓ Done. ${items.length} items created.`);
|
|
838
833
|
});
|
|
839
834
|
// src/parser.ts
|
|
840
835
|
var parserFolderStructure = (treeString) => {
|
|
@@ -895,7 +890,7 @@ function countLeadingSpaces(str) {
|
|
|
895
890
|
export {
|
|
896
891
|
resolveProjectPathToBase,
|
|
897
892
|
parserFolderStructure,
|
|
893
|
+
isSymlinkOutsideRoot,
|
|
898
894
|
fileStructureCreator,
|
|
899
|
-
PathTraversalError
|
|
900
|
-
FsError
|
|
895
|
+
PathTraversalError
|
|
901
896
|
};
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type { ParserResult } from './
|
|
1
|
+
import type { ParserResult } from './types';
|
|
2
2
|
export declare const parserFolderStructure: (treeString: string) => ParserResult;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { Path, Terminal } from '@effect/platform';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { type ParserResult } from './types';
|
|
4
|
+
export declare const parseAndGuardSymlinks: (treeString: string, projectRoot: string, yes: boolean) => Effect.Effect<ParserResult, Error, Path.Path | Terminal.Terminal>;
|
package/dist/slim/touch-all.js
CHANGED
|
@@ -15,12 +15,12 @@ var __export = (target, all) => {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
// src/touch-all.ts
|
|
18
|
-
import { Effect as
|
|
18
|
+
import { Effect as Effect8, Logger as Logger2, LogLevel as LogLevel2 } from "effect";
|
|
19
19
|
import { NodeContext, NodeRuntime } from "@effect/platform-node";
|
|
20
20
|
|
|
21
21
|
// src/cli.ts
|
|
22
|
-
import { createInterface } from "node:readline";
|
|
23
22
|
import { Args, Command as Command2, Options } from "@effect/cli";
|
|
23
|
+
import { Console as Console3, Effect as Effect7, Logger, LogLevel, Option as Option2 } from "effect";
|
|
24
24
|
// node_modules/@effect/platform/dist/esm/FileSystem.js
|
|
25
25
|
var exports_FileSystem = {};
|
|
26
26
|
__export(exports_FileSystem, {
|
|
@@ -794,8 +794,73 @@ class QuitException extends (/* @__PURE__ */ TaggedError2("QuitException")) {
|
|
|
794
794
|
}
|
|
795
795
|
var isQuitException = (u) => typeof u === "object" && u != null && ("_tag" in u) && u._tag === "QuitException";
|
|
796
796
|
var Terminal = tag2;
|
|
797
|
-
// src/
|
|
798
|
-
import { Console, Effect as
|
|
797
|
+
// src/fsGenerator.ts
|
|
798
|
+
import { Console, Effect as Effect4 } from "effect";
|
|
799
|
+
|
|
800
|
+
// src/errors.ts
|
|
801
|
+
class PathTraversalError {
|
|
802
|
+
path;
|
|
803
|
+
_tag = "PathTraversalError";
|
|
804
|
+
constructor(path) {
|
|
805
|
+
this.path = path;
|
|
806
|
+
}
|
|
807
|
+
toString() {
|
|
808
|
+
return `${this._tag}: path escapes root — ${this.path}`;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// src/fsNormalizator.ts
|
|
813
|
+
import { Effect as Effect3 } from "effect";
|
|
814
|
+
var isSymlinkOutsideRoot = (linkPath, target, basePath, path) => {
|
|
815
|
+
const resolvedBase = path.resolve(basePath);
|
|
816
|
+
const symlinkDir = path.resolve(resolvedBase, path.dirname(linkPath));
|
|
817
|
+
const resolvedTarget = path.resolve(symlinkDir, target);
|
|
818
|
+
return path.relative(resolvedBase, resolvedTarget).startsWith("..");
|
|
819
|
+
};
|
|
820
|
+
var resolveProjectPathToBase = (projectPath, basePath) => Effect3.gen(function* () {
|
|
821
|
+
const path = yield* exports_Path.Path;
|
|
822
|
+
const filePath = projectPath.startsWith(path.sep) ? `.${projectPath}` : projectPath;
|
|
823
|
+
const absolute = path.resolve(basePath, filePath);
|
|
824
|
+
const rel = path.relative(basePath, absolute);
|
|
825
|
+
if (rel.startsWith("..")) {
|
|
826
|
+
return yield* Effect3.fail(new PathTraversalError(["project:", projectPath, "base:", basePath].join(" ")));
|
|
827
|
+
}
|
|
828
|
+
return absolute;
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
// src/fsGenerator.ts
|
|
832
|
+
var fileStructureCreator = (items, basePath, options) => Effect4.gen(function* () {
|
|
833
|
+
const fs = yield* exports_FileSystem.FileSystem;
|
|
834
|
+
const path = yield* exports_Path.Path;
|
|
835
|
+
yield* Effect4.logInfo(`Creating structure in: ${basePath}`);
|
|
836
|
+
for (const item of items) {
|
|
837
|
+
const fullPath = yield* resolveProjectPathToBase(item.path, basePath);
|
|
838
|
+
const dir = path.dirname(fullPath);
|
|
839
|
+
switch (item.type) {
|
|
840
|
+
case "file":
|
|
841
|
+
yield* fs.makeDirectory(dir, { recursive: true });
|
|
842
|
+
yield* fs.writeFile(fullPath, new Uint8Array);
|
|
843
|
+
yield* Effect4.logInfo(` Created file: ${item.path}`);
|
|
844
|
+
break;
|
|
845
|
+
case "symlink":
|
|
846
|
+
if (!options?.allowOutsideSymlinks && isSymlinkOutsideRoot(item.path, item.target, basePath, path)) {
|
|
847
|
+
yield* Effect4.fail(new PathTraversalError(`symlink target escapes root: ${item.path} -> ${item.target} (base: ${basePath})`));
|
|
848
|
+
}
|
|
849
|
+
yield* fs.makeDirectory(dir, { recursive: true });
|
|
850
|
+
yield* fs.symlink(item.target, fullPath);
|
|
851
|
+
yield* Effect4.logInfo(` Created symlink: ${item.path} -> ${item.target}`);
|
|
852
|
+
break;
|
|
853
|
+
case "folder":
|
|
854
|
+
yield* fs.makeDirectory(fullPath, { recursive: true });
|
|
855
|
+
yield* Effect4.logInfo(` Created directory: ${item.path}`);
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
yield* Console.log(`✓ Done. ${items.length} items created.`);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
// src/symlinkGuard.ts
|
|
863
|
+
import { Console as Console2, Effect as Effect5 } from "effect";
|
|
799
864
|
|
|
800
865
|
// src/parser.ts
|
|
801
866
|
var parserFolderStructure = (treeString) => {
|
|
@@ -854,68 +919,52 @@ function countLeadingSpaces(str) {
|
|
|
854
919
|
return match2 ? match2[0].length : 0;
|
|
855
920
|
}
|
|
856
921
|
|
|
857
|
-
// src/
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
const filePath = projectPath.startsWith(path.sep) ? `.${projectPath}` : projectPath;
|
|
879
|
-
const absolute = path.resolve(basePath, filePath);
|
|
880
|
-
const rel = path.relative(basePath, absolute);
|
|
881
|
-
if (rel.startsWith("..")) {
|
|
882
|
-
return yield* Effect3.fail(new PathTraversalError(["project:", projectPath, "base:", basePath].join(" ")));
|
|
922
|
+
// src/symlinkGuard.ts
|
|
923
|
+
var parseAndGuardSymlinks = (treeString, projectRoot, yes) => Effect5.gen(function* () {
|
|
924
|
+
const nodePath = yield* exports_Path.Path;
|
|
925
|
+
const items = parserFolderStructure(treeString);
|
|
926
|
+
const resolvedRoot = nodePath.resolve(projectRoot);
|
|
927
|
+
const symlinks = items.filter((item) => item.type === "symlink");
|
|
928
|
+
const outsideSymlinks = symlinks.filter((item) => isSymlinkOutsideRoot(item.path, item.target, resolvedRoot, nodePath));
|
|
929
|
+
if (outsideSymlinks.length === 0)
|
|
930
|
+
return items;
|
|
931
|
+
if (yes)
|
|
932
|
+
return items;
|
|
933
|
+
const listing = outsideSymlinks.map((item) => ` ${item.path} -> ${item.target}`).join(`
|
|
934
|
+
`);
|
|
935
|
+
yield* Console2.error(`Warning: the following symlinks point outside PROJECT_ROOT (${resolvedRoot}):
|
|
936
|
+
${listing}`);
|
|
937
|
+
const terminal = yield* exports_Terminal.Terminal;
|
|
938
|
+
yield* terminal.display("Proceed? (y/N) ");
|
|
939
|
+
const answer = yield* terminal.readLine.pipe(Effect5.catchTag("QuitException", () => Effect5.fail(new Error(`Non-interactive mode: use --yes to allow symlinks outside project root`))));
|
|
940
|
+
if (answer.trim().toLowerCase() !== "y") {
|
|
941
|
+
yield* Console2.error("Aborted.");
|
|
942
|
+
return yield* Effect5.fail(new Error("Aborted by user"));
|
|
883
943
|
}
|
|
884
|
-
return
|
|
944
|
+
return items;
|
|
885
945
|
});
|
|
886
946
|
|
|
887
|
-
// src/
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
break;
|
|
901
|
-
case "symlink":
|
|
902
|
-
yield* fs.makeDirectory(dir, { recursive: true });
|
|
903
|
-
yield* fs.symlink(item.target, fullPath);
|
|
904
|
-
yield* Effect4.logInfo(` Created symlink: ${item.path} -> ${item.target}`);
|
|
905
|
-
break;
|
|
906
|
-
case "folder":
|
|
907
|
-
yield* fs.makeDirectory(fullPath, { recursive: true });
|
|
908
|
-
yield* Effect4.logInfo(` Created directory: ${item.path}`);
|
|
909
|
-
break;
|
|
947
|
+
// src/stdin.ts
|
|
948
|
+
import { createInterface } from "node:readline";
|
|
949
|
+
import { Effect as Effect6 } from "effect";
|
|
950
|
+
var readStdin = Effect6.tryPromise({
|
|
951
|
+
try: () => new Promise((resolve3, reject) => {
|
|
952
|
+
try {
|
|
953
|
+
const rl = createInterface({ input: process.stdin });
|
|
954
|
+
const lines = [];
|
|
955
|
+
rl.on("line", (line) => lines.push(line));
|
|
956
|
+
rl.on("close", () => resolve3(lines.join(`
|
|
957
|
+
`)));
|
|
958
|
+
} catch (e) {
|
|
959
|
+
reject(e);
|
|
910
960
|
}
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
✓ Structure created successfully!`);
|
|
961
|
+
}),
|
|
962
|
+
catch: (e) => new Error(`Failed to read stdin: ${String(e)}`)
|
|
914
963
|
});
|
|
915
964
|
// package.json
|
|
916
965
|
var package_default = {
|
|
917
966
|
name: "touch-all",
|
|
918
|
-
version: "2.
|
|
967
|
+
version: "2.1.1",
|
|
919
968
|
description: "CLI tool to create folder structures from markdown tree representations",
|
|
920
969
|
keywords: [
|
|
921
970
|
"cli",
|
|
@@ -964,7 +1013,6 @@ var package_default = {
|
|
|
964
1013
|
effect: "^3.21.0"
|
|
965
1014
|
},
|
|
966
1015
|
devDependencies: {
|
|
967
|
-
"@total-typescript/tsconfig": "1.0.3",
|
|
968
1016
|
"@types/bun": "1.3.11",
|
|
969
1017
|
"@types/node": "25.5.0",
|
|
970
1018
|
oxfmt: "0.41.0",
|
|
@@ -980,88 +1028,43 @@ var package_default = {
|
|
|
980
1028
|
};
|
|
981
1029
|
|
|
982
1030
|
// src/cli.ts
|
|
1031
|
+
var { name, version } = package_default;
|
|
983
1032
|
var treeArg = Args.text({ name: "tree" }).pipe(Args.withDescription("Multiline string representing the directory tree structure"), Args.optional);
|
|
984
1033
|
var pathOption = Options.directory("path").pipe(Options.withAlias("p"), Options.withDefault("."), Options.withDescription("Target folder path (defaults to current directory)"));
|
|
985
1034
|
var dryRunOption = Options.boolean("dry-run").pipe(Options.withAlias("n"), Options.withDefault(false), Options.withDescription("Parse and validate the tree without writing to the file system"));
|
|
986
1035
|
var verboseOption = Options.boolean("verbose").pipe(Options.withAlias("v"), Options.withDefault(false), Options.withDescription("Log to console extra information about creating a directory tree"));
|
|
987
1036
|
var yesOption = Options.boolean("yes").pipe(Options.withAlias("y"), Options.withDefault(false), Options.withDescription("Skip confirmation prompt when symlinks point outside the project root"));
|
|
988
|
-
var command = Command2.make(
|
|
1037
|
+
var command = Command2.make(name, {
|
|
989
1038
|
tree: treeArg,
|
|
990
1039
|
path: pathOption,
|
|
991
1040
|
dryRun: dryRunOption,
|
|
992
1041
|
verbose: verboseOption,
|
|
993
1042
|
yes: yesOption
|
|
994
|
-
}).pipe(Command2.withDescription("Create directory structure from a tree representation"), Command2.withHandler(({ tree, path: targetPath, dryRun
|
|
995
|
-
const
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
return yield* Effect5.tryPromise({
|
|
1000
|
-
try: () => new Promise((resolve3) => {
|
|
1001
|
-
const rl = createInterface({ input: process.stdin });
|
|
1002
|
-
const lines = [];
|
|
1003
|
-
rl.on("line", (line) => lines.push(line));
|
|
1004
|
-
rl.on("close", () => resolve3(lines.join(`
|
|
1005
|
-
`)));
|
|
1006
|
-
}),
|
|
1007
|
-
catch: (e) => new Error(`Failed to read stdin: ${String(e)}`)
|
|
1008
|
-
});
|
|
1009
|
-
});
|
|
1010
|
-
const checkOutsideSymlinks = (treeString, projectRoot) => Effect5.gen(function* () {
|
|
1011
|
-
const nodePath = yield* exports_Path.Path;
|
|
1012
|
-
const items = parserFolderStructure(treeString);
|
|
1013
|
-
const resolvedRoot = nodePath.resolve(projectRoot);
|
|
1014
|
-
const outsideSymlinks = items.filter((item) => item.type === "symlink").filter((item) => {
|
|
1015
|
-
const symlinkDir = nodePath.resolve(resolvedRoot, nodePath.dirname(item.path));
|
|
1016
|
-
const resolvedTarget = nodePath.resolve(symlinkDir, item.target);
|
|
1017
|
-
return nodePath.relative(resolvedRoot, resolvedTarget).startsWith("..");
|
|
1018
|
-
});
|
|
1019
|
-
if (outsideSymlinks.length === 0)
|
|
1020
|
-
return items;
|
|
1021
|
-
if (yes)
|
|
1022
|
-
return items;
|
|
1023
|
-
if (!process.stdin.isTTY) {
|
|
1024
|
-
yield* Console.error(`Cannot prompt in non-interactive mode: tree contains symlinks outside PROJECT_ROOT (${resolvedRoot}). Use --yes to proceed.`);
|
|
1025
|
-
return yield* Effect5.fail(new Error("Non-interactive mode with outside symlinks"));
|
|
1026
|
-
}
|
|
1027
|
-
const listing = outsideSymlinks.map((item) => ` ${item.path} -> ${item.type === "symlink" ? item.target : ""}`).join(`
|
|
1028
|
-
`);
|
|
1029
|
-
yield* Console.log(`Warning: the following symlinks point outside PROJECT_ROOT (${resolvedRoot}):
|
|
1030
|
-
${listing}`);
|
|
1031
|
-
const terminal = yield* exports_Terminal.Terminal;
|
|
1032
|
-
yield* terminal.display("Proceed? (y/N) ");
|
|
1033
|
-
const answer = yield* terminal.readLine.pipe(Effect5.catchTag("QuitException", () => Effect5.succeed("")));
|
|
1034
|
-
if (answer.trim().toLowerCase() !== "y") {
|
|
1035
|
-
yield* Console.log("Aborted.");
|
|
1036
|
-
return yield* Effect5.fail(new Error("Aborted by user"));
|
|
1037
|
-
}
|
|
1038
|
-
return items;
|
|
1039
|
-
});
|
|
1040
|
-
const program = Effect5.gen(function* () {
|
|
1041
|
-
const treeString = Option2.isSome(tree) ? tree.value : yield* readStdin;
|
|
1043
|
+
}).pipe(Command2.withDescription("Create directory structure from a tree representation"), Command2.withHandler(({ tree, path: targetPath, dryRun, verbose, yes }) => {
|
|
1044
|
+
const program = Effect7.gen(function* () {
|
|
1045
|
+
const rawTree = Option2.isSome(tree) ? tree.value : yield* readStdin;
|
|
1046
|
+
const treeString = Option2.isSome(tree) ? rawTree.replace(/\\(\\|n)/g, (_, c) => c === "n" ? `
|
|
1047
|
+
` : "\\") : rawTree;
|
|
1042
1048
|
if (dryRun) {
|
|
1043
|
-
yield*
|
|
1049
|
+
yield* Effect7.logInfo("Dry run: no changes will be made to the file system.");
|
|
1044
1050
|
}
|
|
1045
|
-
yield*
|
|
1046
|
-
const items = yield*
|
|
1051
|
+
yield* Effect7.logInfo("Parsing tree structure...");
|
|
1052
|
+
const items = yield* parseAndGuardSymlinks(treeString, targetPath, yes);
|
|
1047
1053
|
if (items.length === 0) {
|
|
1048
|
-
yield*
|
|
1049
|
-
return yield*
|
|
1054
|
+
yield* Console3.error("No valid items found in the tree structure");
|
|
1055
|
+
return yield* Effect7.fail(new Error("Invalid tree structure"));
|
|
1050
1056
|
}
|
|
1051
|
-
yield*
|
|
1052
|
-
yield*
|
|
1053
|
-
${items.map((i) =>
|
|
1054
|
-
`)
|
|
1057
|
+
yield* Effect7.logInfo(`Found ${items.length} items to create`);
|
|
1058
|
+
yield* Effect7.logInfo(`Found:
|
|
1059
|
+
${items.map((i) => i.path).join(`
|
|
1060
|
+
`)}`);
|
|
1055
1061
|
if (!dryRun) {
|
|
1056
|
-
yield* fileStructureCreator(items, targetPath);
|
|
1062
|
+
yield* fileStructureCreator(items, targetPath, { allowOutsideSymlinks: yes });
|
|
1057
1063
|
}
|
|
1058
1064
|
});
|
|
1059
1065
|
return verbose ? program.pipe(Logger.withMinimumLogLevel(LogLevel.Info)) : program;
|
|
1060
1066
|
}));
|
|
1061
|
-
var cli = Command2.run(command, {
|
|
1062
|
-
name: package_default.name,
|
|
1063
|
-
version: package_default.version
|
|
1064
|
-
});
|
|
1067
|
+
var cli = Command2.run(command, { name, version });
|
|
1065
1068
|
|
|
1066
1069
|
// src/touch-all.ts
|
|
1067
|
-
|
|
1070
|
+
Effect8.suspend(() => cli(process.argv)).pipe(Logger2.withMinimumLogLevel(LogLevel2.Warning), Effect8.provide(NodeContext.layer), NodeRuntime.runMain);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "touch-all",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "CLI tool to create folder structures from markdown tree representations",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -49,7 +49,6 @@
|
|
|
49
49
|
"effect": "^3.21.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@total-typescript/tsconfig": "1.0.3",
|
|
53
52
|
"@types/bun": "1.3.11",
|
|
54
53
|
"@types/node": "25.5.0",
|
|
55
54
|
"oxfmt": "0.41.0",
|
package/dist/lib/src/cli.d.ts
DELETED
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
import { Effect } from 'effect';
|
|
2
|
-
export declare const cli: (args: ReadonlyArray<string>) => Effect.Effect<void, import("./_commonErrors").PathTraversalError | import("@effect/platform/Error").PlatformError | Error | import("@effect/cli/ValidationError").ValidationError, import("@effect/cli/CliApp").CliApp.Environment>;
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
import { FileSystem, Path } from '@effect/platform';
|
|
2
|
-
import { Effect } from 'effect';
|
|
3
|
-
import { type ParserResult } from './_commonTypes';
|
|
4
|
-
export declare const fileStructureCreator: (items: ParserResult, basePath: string) => Effect.Effect<void, import("./_commonErrors").PathTraversalError | import("@effect/platform/Error").PlatformError, Path.Path | FileSystem.FileSystem>;
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { Path } from '@effect/platform';
|
|
2
|
-
import { Effect } from 'effect';
|
|
3
|
-
import { PathTraversalError } from './_commonErrors';
|
|
4
|
-
/**
|
|
5
|
-
* Safely normalize a user-supplied path against a base directory.
|
|
6
|
-
* Fails with PathTraversalError if the resolved path escapes the base.
|
|
7
|
-
*
|
|
8
|
-
* @param projectPath string - Path relative to project.
|
|
9
|
-
* @param basePath string - Must be absolute.
|
|
10
|
-
*/
|
|
11
|
-
export declare const resolveProjectPathToBase: (projectPath: string, basePath: string) => Effect.Effect<string, PathTraversalError, Path.Path>;
|
package/dist/lib/src/index.d.ts
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
export { fileStructureCreator } from './fsGenerator';
|
|
2
|
-
export { resolveProjectPathToBase } from './fsNormalizator';
|
|
3
|
-
export { PathTraversalError, FsError } from './_commonErrors';
|
|
4
|
-
export type { ParserResult, ParserResultLineItem } from './_commonTypes';
|
|
5
|
-
export { parserFolderStructure } from './parser';
|
|
File without changes
|
|
File without changes
|