snap-scaffold 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 (4) hide show
  1. package/README.md +103 -0
  2. package/bin/snap.js +112 -0
  3. package/lib/index.js +347 -0
  4. package/package.json +22 -0
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # snap-scaffold
2
+
3
+ A CLI to **snapshot** your directory structure into a plain-text file and **restore** it anywhere — including from pasted text.
4
+
5
+ ---
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g snap-scaffold
11
+ ```
12
+
13
+ ---
14
+
15
+ ## Commands
16
+
17
+ ### `snap export`
18
+
19
+ Scans current directory and **appends** a snapshot block to `.snapshot`.
20
+ Never truncates or deletes previous snapshots.
21
+
22
+ ```bash
23
+ snap export
24
+ snap export -o my-project.snapshot
25
+ snap export --ignore dist,build,coverage
26
+ ```
27
+
28
+ ### `snap restore`
29
+
30
+ Recreates the directory/file structure. **Never overwrites** existing files.
31
+
32
+ ```bash
33
+ snap restore
34
+ snap restore -i my-project.snapshot -d ./new-folder
35
+ snap restore --dry-run # preview only
36
+ snap restore --text "d src f src/index.js" # restore from pasted text
37
+ ```
38
+
39
+ ### `snap tree`
40
+
41
+ Prints a pretty visual tree of your current directory (or a snapshot).
42
+
43
+ ```bash
44
+ snap tree
45
+
46
+ # Output:
47
+ my-project/
48
+ ├── .env
49
+ ├── apps/
50
+ │ └── web/
51
+ ├── infra/
52
+ │ └── docker-compose.yml
53
+ └── package.json
54
+ ```
55
+
56
+ ```bash
57
+ snap tree -i my-project.snapshot # tree from a saved snapshot
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Snapshot format
63
+
64
+ The `.snapshot` file is plain text:
65
+
66
+ ```
67
+ # snap snapshot
68
+ # created: 2025-04-01T10:00:00.000Z
69
+ # root: /Users/you/my-project
70
+ #
71
+ # d = directory f = file
72
+ #
73
+ d src
74
+ d src/components
75
+ f src/components/Button.js
76
+ f src/index.js
77
+ f package.json
78
+ ```
79
+
80
+ Each `export` appends a new timestamped block — you keep a full history.
81
+
82
+ ---
83
+
84
+ ## Safety
85
+
86
+ | Situation | Behaviour |
87
+ | ------------------------------ | ---------------------------- |
88
+ | Export on existing `.snapshot` | Appends — old data untouched |
89
+ | Restore on existing file/dir | Skips it — never overwrites |
90
+ | `node_modules`, `.git` | Always excluded from export |
91
+
92
+ ---
93
+
94
+ ## Options
95
+
96
+ | Flag | Command | Description |
97
+ | --------------------- | ------------ | ---------------------------------- |
98
+ | `-o, --output <file>` | export | Output file (default: `.snapshot`) |
99
+ | `--ignore <a,b>` | export/tree | Comma-separated names to skip |
100
+ | `-i, --input <file>` | restore/tree | Snapshot file to read |
101
+ | `-d, --dest <dir>` | restore | Where to restore (default: cwd) |
102
+ | `-n, --dry-run` | restore | Preview without writing |
103
+ | `--text "<text>"` | restore | Restore from raw pasted text |
package/bin/snap.js ADDED
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const minimist = require("minimist");
5
+ const { exportSnap, restoreSnap, printTree } = require("../lib/index");
6
+
7
+ const argv = minimist(process.argv.slice(2), {
8
+ string: ["output", "input", "dest", "ignore", "text"],
9
+ boolean: ["dry-run", "help", "version"],
10
+ alias: {
11
+ o: "output",
12
+ i: "input",
13
+ d: "dest",
14
+ n: "dry-run",
15
+ h: "help",
16
+ v: "version",
17
+ },
18
+ });
19
+
20
+ const command = argv._[0];
21
+
22
+ // ── Version ──────────────────────────────────────────────────────────────────
23
+ if (argv.version) {
24
+ const pkg = require("../package.json");
25
+ console.log(`snap-scaffold v${pkg.version}`);
26
+ process.exit(0);
27
+ }
28
+
29
+ // ── Help ─────────────────────────────────────────────────────────────────────
30
+ const HELP = `
31
+ snap — snapshot and scaffold directory structures
32
+
33
+ COMMANDS
34
+ snap export Scan current dir → write .snapshot file
35
+ snap restore Read .snapshot → recreate files & folders
36
+ snap tree Print a visual ├── tree of current dir
37
+
38
+ EXPORT
39
+ -o, --output <file> Output file (default: .snapshot)
40
+ --ignore <a,b,c> Extra names to skip (comma-separated)
41
+
42
+ RESTORE
43
+ -i, --input <file> Snapshot file (default: .snapshot)
44
+ -d, --dest <dir> Where to restore (default: cwd)
45
+ -n, --dry-run Preview without writing anything
46
+ --text "<d f ...>" Restore directly from pasted snapshot text
47
+
48
+ TREE
49
+ -i, --input <file> Show tree from a snapshot file instead of live fs
50
+ --ignore <a,b,c> Extra names to skip
51
+
52
+ NOTES
53
+ • export NEVER deletes or truncates — it always appends a new block
54
+ • restore NEVER overwrites — only creates what is missing
55
+ • node_modules, .git, .snapshot are always excluded from export
56
+
57
+ EXAMPLES
58
+ snap export
59
+ snap export -o project.snapshot --ignore dist,build
60
+ snap restore
61
+ snap restore -i project.snapshot -d ./new-project --dry-run
62
+ snap restore --text "d src f src/index.js f README.md"
63
+ snap tree
64
+ snap tree -i project.snapshot
65
+ `.trimStart();
66
+
67
+ if (argv.help || !command) {
68
+ process.stdout.write(HELP);
69
+ process.exit(0);
70
+ }
71
+
72
+ // ── Commands ──────────────────────────────────────────────────────────────────
73
+
74
+ try {
75
+ if (command === "export") {
76
+ const extra = argv.ignore
77
+ ? String(argv.ignore)
78
+ .split(",")
79
+ .map((s) => s.trim())
80
+ .filter(Boolean)
81
+ : [];
82
+ exportSnap({ rootDir: process.cwd(), output: argv.output, ignore: extra });
83
+ } else if (command === "restore") {
84
+ if (argv.text) {
85
+ restoreSnap({
86
+ rawText: String(argv.text),
87
+ destDir: argv.dest,
88
+ dryRun: argv["dry-run"],
89
+ });
90
+ } else {
91
+ restoreSnap({
92
+ input: argv.input,
93
+ destDir: argv.dest,
94
+ dryRun: argv["dry-run"],
95
+ });
96
+ }
97
+ } else if (command === "tree") {
98
+ const extra = argv.ignore
99
+ ? String(argv.ignore)
100
+ .split(",")
101
+ .map((s) => s.trim())
102
+ .filter(Boolean)
103
+ : [];
104
+ printTree({ rootDir: process.cwd(), input: argv.input, ignore: extra });
105
+ } else {
106
+ console.error(`❌ Unknown command: "${command}"\nRun: snap --help`);
107
+ process.exit(1);
108
+ }
109
+ } catch (err) {
110
+ console.error(`❌ ${err.message}`);
111
+ process.exit(1);
112
+ }
package/lib/index.js ADDED
@@ -0,0 +1,347 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+
6
+ // ── Always-excluded names ────────────────────────────────────────────────────
7
+ const DEFAULT_IGNORE = new Set([
8
+ "node_modules",
9
+ ".git",
10
+ ".DS_Store",
11
+ "Thumbs.db",
12
+ ".snapshot",
13
+ ]);
14
+
15
+ // ── Helpers ──────────────────────────────────────────────────────────────────
16
+
17
+ function readDir(dir) {
18
+ try {
19
+ return fs.readdirSync(dir, { withFileTypes: true });
20
+ } catch {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ function sorted(entries) {
26
+ return [...entries].sort((a, b) => {
27
+ if (a.isDirectory() === b.isDirectory())
28
+ return a.name.localeCompare(b.name);
29
+ return a.isDirectory() ? -1 : 1; // dirs first
30
+ });
31
+ }
32
+
33
+ // ── WALK (shared by export + tree) ──────────────────────────────────────────
34
+
35
+ /**
36
+ * Returns flat list: [{ type:'dir'|'file', rel:string, depth:number }]
37
+ */
38
+ function walk(rootDir, ignoreSet) {
39
+ const results = [];
40
+
41
+ function recurse(current, depth) {
42
+ for (const entry of sorted(readDir(current))) {
43
+ if (ignoreSet.has(entry.name)) continue;
44
+
45
+ const abs = path.join(current, entry.name);
46
+ const rel = path.relative(rootDir, abs).replace(/\\/g, "/"); // normalise to forward slashes
47
+
48
+ if (entry.isDirectory()) {
49
+ results.push({ type: "dir", rel, depth });
50
+ recurse(abs, depth + 1);
51
+ } else {
52
+ results.push({ type: "file", rel, depth });
53
+ }
54
+ }
55
+ }
56
+
57
+ recurse(rootDir, 0);
58
+ return results;
59
+ }
60
+
61
+ // ═══════════════════════════════════════════════════════════════════════════
62
+ // EXPORT
63
+ // ═══════════════════════════════════════════════════════════════════════════
64
+
65
+ /**
66
+ * Snapshot the directory into a .snapshot file.
67
+ * ALWAYS appends — never truncates.
68
+ *
69
+ * File format:
70
+ * # snap snapshot
71
+ * # created: <ISO>
72
+ * # root: <abs path>
73
+ * #
74
+ * d src/components
75
+ * f src/components/Button.js
76
+ */
77
+ function exportSnap(opts = {}) {
78
+ const rootDir = path.resolve(opts.rootDir || process.cwd());
79
+ const output = path.resolve(opts.output || path.join(rootDir, ".snapshot"));
80
+ const ignore = new Set([...DEFAULT_IGNORE, ...(opts.ignore || [])]);
81
+
82
+ console.log(`📂 Scanning ${rootDir}`);
83
+ const entries = walk(rootDir, ignore);
84
+
85
+ const lines = [
86
+ "# snap snapshot",
87
+ `# created: ${new Date().toISOString()}`,
88
+ `# root: ${rootDir}`,
89
+ "#",
90
+ "# d = directory f = file",
91
+ "#",
92
+ ...entries.map((e) => `${e.type === "dir" ? "d" : "f"} ${e.rel}`),
93
+ "",
94
+ ];
95
+
96
+ const block = lines.join("\n");
97
+ const sep = "\n# ──────────────────────────────────────────────\n\n";
98
+ const prev = fs.existsSync(output) ? fs.readFileSync(output, "utf8") : "";
99
+
100
+ // Flag "w" re-writes the file but we prepend ALL previous content — safe.
101
+ fs.writeFileSync(output, prev ? prev + sep + block : block, {
102
+ encoding: "utf8",
103
+ });
104
+
105
+ const dirs = entries.filter((e) => e.type === "dir").length;
106
+ const files = entries.filter((e) => e.type === "file").length;
107
+ console.log(`✅ Snapshot → ${output}`);
108
+ console.log(` ${dirs} dirs, ${files} files`);
109
+ }
110
+
111
+ // ═══════════════════════════════════════════════════════════════════════════
112
+ // RESTORE
113
+ // ═══════════════════════════════════════════════════════════════════════════
114
+
115
+ /**
116
+ * Parse the LAST snapshot block in a file.
117
+ */
118
+ function parseSnapshot(filePath) {
119
+ const raw = fs.readFileSync(filePath, "utf8");
120
+ const blocks = raw.split(/\n#\s─{10,}.*\n/);
121
+ const last = blocks[blocks.length - 1];
122
+
123
+ let snapshotRoot = null;
124
+ const entries = [];
125
+
126
+ for (const line of last.split("\n")) {
127
+ const t = line.trim();
128
+ if (!t || t.startsWith("#")) {
129
+ const m = t.match(/^#\s*root:\s*(.+)$/);
130
+ if (m) snapshotRoot = m[1].trim();
131
+ continue;
132
+ }
133
+ const m = t.match(/^([df])\s+(.+)$/);
134
+ if (m) entries.push({ type: m[1] === "d" ? "dir" : "file", rel: m[2] });
135
+ }
136
+
137
+ return { entries, snapshotRoot };
138
+ }
139
+
140
+ /**
141
+ * Parse a RAW text (not a file) — used when user pastes the content directly.
142
+ * Handles both forward and back slashes.
143
+ */
144
+ function parseRawText(text) {
145
+ const entries = [];
146
+
147
+ // Tokenise: split on whitespace but keep "d"/"f" markers + the path that follows.
148
+ // This handles both:
149
+ // multi-line: "d bin\nf bin/snap.js"
150
+ // single-line: "d bin f bin/snap.js d lib f lib/index.js"
151
+ const tokens = text.trim().split(/\s+/);
152
+
153
+ for (let i = 0; i < tokens.length; i++) {
154
+ const tok = tokens[i];
155
+ if (tok === "d" || tok === "f") {
156
+ const relPath = tokens[i + 1];
157
+ if (relPath && relPath !== "d" && relPath !== "f") {
158
+ entries.push({
159
+ type: tok === "d" ? "dir" : "file",
160
+ rel: relPath.replace(/\\/g, "/"), // normalise backslashes
161
+ });
162
+ i++; // consume the path token
163
+ }
164
+ }
165
+ }
166
+
167
+ return { entries, snapshotRoot: null };
168
+ }
169
+
170
+ /**
171
+ * Restore from snapshot.
172
+ * NEVER overwrites existing files or directories.
173
+ */
174
+ function restoreSnap(opts = {}) {
175
+ const destDir = path.resolve(opts.destDir || process.cwd());
176
+ const dryRun = !!opts.dryRun;
177
+
178
+ let entries, snapshotRoot;
179
+
180
+ if (opts.rawText) {
181
+ // ── restore from pasted text ──────────────────────────────────────────
182
+ ({ entries, snapshotRoot } = parseRawText(opts.rawText));
183
+ console.log("📋 Reading from provided text");
184
+ } else {
185
+ const input = path.resolve(
186
+ opts.input || path.join(process.cwd(), ".snapshot"),
187
+ );
188
+ if (!fs.existsSync(input))
189
+ throw new Error(`Snapshot file not found: ${input}`);
190
+ ({ entries, snapshotRoot } = parseSnapshot(input));
191
+ console.log(`📄 Reading ${input}`);
192
+ }
193
+
194
+ if (snapshotRoot) console.log(` Original root ${snapshotRoot}`);
195
+ console.log(` Restoring into ${destDir}`);
196
+ if (dryRun) console.log(" (dry-run — nothing written)\n");
197
+
198
+ let created = 0,
199
+ skipped = 0;
200
+
201
+ for (const e of entries) {
202
+ const target = path.join(destDir, e.rel);
203
+
204
+ if (fs.existsSync(target)) {
205
+ console.log(` ⏭ exists ${e.rel}${e.type === "dir" ? "/" : ""}`);
206
+ skipped++;
207
+ continue;
208
+ }
209
+
210
+ if (e.type === "dir") {
211
+ console.log(` 📁 mkdir ${e.rel}/`);
212
+ if (!dryRun) fs.mkdirSync(target, { recursive: true });
213
+ } else {
214
+ console.log(` 📄 touch ${e.rel}`);
215
+ if (!dryRun) {
216
+ fs.mkdirSync(path.dirname(target), { recursive: true });
217
+ fs.writeFileSync(target, "", { encoding: "utf8", flag: "wx" });
218
+ }
219
+ }
220
+ created++;
221
+ }
222
+
223
+ console.log("");
224
+ console.log(
225
+ dryRun
226
+ ? `✅ Dry-run done. Would create ${created}, skip ${skipped}.`
227
+ : `✅ Restore done. Created ${created}, skipped ${skipped} (already existed).`,
228
+ );
229
+ }
230
+
231
+ // ═══════════════════════════════════════════════════════════════════════════
232
+ // TREE (pretty ├── display)
233
+ // ═══════════════════════════════════════════════════════════════════════════
234
+
235
+ /**
236
+ * Print a visual tree, either from the live filesystem or from a snapshot file.
237
+ *
238
+ * jarvis/
239
+ * ├── .env
240
+ * ├── infra/
241
+ * │ └── docker-compose.yml
242
+ * └── apps/
243
+ */
244
+ function printTree(opts = {}) {
245
+ let entries;
246
+ let label;
247
+
248
+ if (opts.input) {
249
+ // ── from snapshot file ────────────────────────────────────────────────
250
+ const input = path.resolve(opts.input);
251
+ if (!fs.existsSync(input))
252
+ throw new Error(`Snapshot file not found: ${input}`);
253
+ const parsed = parseSnapshot(input);
254
+ entries = parsed.entries.map((e) => ({
255
+ ...e,
256
+ depth: e.rel.split("/").length - 1,
257
+ }));
258
+ label = parsed.snapshotRoot
259
+ ? path.basename(parsed.snapshotRoot)
260
+ : "(snapshot)";
261
+ } else {
262
+ // ── from live filesystem ──────────────────────────────────────────────
263
+ const rootDir = path.resolve(opts.rootDir || process.cwd());
264
+ const ignore = new Set([...DEFAULT_IGNORE, ...(opts.ignore || [])]);
265
+ entries = walk(rootDir, ignore);
266
+ label = path.basename(rootDir);
267
+ }
268
+
269
+ // Build tree lines
270
+ // We need to know, for each depth, whether there are more siblings coming.
271
+ // Strategy: track the last-child status per depth level.
272
+
273
+ const lines = [`${label}/`];
274
+
275
+ // Convert flat list to a nested structure for proper └── vs ├── detection
276
+ // We'll do it by scanning ahead.
277
+ for (let i = 0; i < entries.length; i++) {
278
+ const e = entries[i];
279
+ const depth = e.depth;
280
+ const name = path.basename(e.rel);
281
+
282
+ // Is this the last entry at this depth under the same parent?
283
+ const parentRel = e.rel.includes("/")
284
+ ? e.rel.slice(0, e.rel.lastIndexOf("/"))
285
+ : "";
286
+ let isLast = true;
287
+ for (let j = i + 1; j < entries.length; j++) {
288
+ const next = entries[j];
289
+ const nextParent = next.rel.includes("/")
290
+ ? next.rel.slice(0, next.rel.lastIndexOf("/"))
291
+ : "";
292
+ if (next.depth < depth) break; // went up — we were last
293
+ if (next.depth === depth && nextParent === parentRel) {
294
+ isLast = false;
295
+ break;
296
+ }
297
+ }
298
+
299
+ // Build the prefix: for each ancestor depth, check if that ancestor was last
300
+ let prefix = "";
301
+ for (let d = 0; d < depth; d++) {
302
+ // Find the ancestor at depth d that contains this entry
303
+ let ancestorIsLast = true;
304
+ // Look at what comes after the ancestor
305
+ // Find ancestor rel
306
+ const parts = e.rel.split("/");
307
+ const ancestorRel = parts.slice(0, d + 1).join("/");
308
+ const ancestorParent = d > 0 ? parts.slice(0, d).join("/") : "";
309
+
310
+ for (let j = 0; j < entries.length; j++) {
311
+ const candidate = entries[j];
312
+ const candidateParent = candidate.rel.includes("/")
313
+ ? candidate.rel.slice(0, candidate.rel.lastIndexOf("/"))
314
+ : "";
315
+ if (candidate.depth === d && candidateParent === ancestorParent) {
316
+ if (candidate.rel === ancestorRel) {
317
+ // Now scan forward to see if there are more siblings after this ancestor
318
+ ancestorIsLast = true;
319
+ for (let k = j + 1; k < entries.length; k++) {
320
+ const sib = entries[k];
321
+ const sibParent = sib.rel.includes("/")
322
+ ? sib.rel.slice(0, sib.rel.lastIndexOf("/"))
323
+ : "";
324
+ if (sib.depth < d) break;
325
+ if (sib.depth === d && sibParent === ancestorParent) {
326
+ ancestorIsLast = false;
327
+ break;
328
+ }
329
+ }
330
+ break;
331
+ }
332
+ }
333
+ }
334
+ prefix += ancestorIsLast ? " " : "│ ";
335
+ }
336
+
337
+ const branch = isLast ? "└── " : "├── ";
338
+ const suffix = e.type === "dir" ? "/" : "";
339
+ lines.push(`${prefix}${branch}${name}${suffix}`);
340
+ }
341
+
342
+ const output = lines.join("\n");
343
+ console.log(output);
344
+ return output;
345
+ }
346
+
347
+ module.exports = { exportSnap, restoreSnap, printTree, parseRawText };
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "snap-scaffold",
3
+ "version": "1.0.0",
4
+ "description": "Snapshot your directory structure and restore it anywhere",
5
+ "main": "lib/index.js",
6
+ "bin": {
7
+ "snap": "bin/snap.js"
8
+ },
9
+ "keywords": [
10
+ "cli",
11
+ "scaffold",
12
+ "snapshot",
13
+ "directory",
14
+ "file-structure",
15
+ "tree"
16
+ ],
17
+ "author": "mrxgroot",
18
+ "license": "MIT",
19
+ "dependencies": {
20
+ "minimist": "^1.2.8"
21
+ }
22
+ }