@timeax/scaffold 0.0.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/.gitattributes +2 -0
- package/dist/cli.cjs +1081 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.mjs +1070 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/index.cjs +785 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +439 -0
- package/dist/index.d.ts +439 -0
- package/dist/index.mjs +773 -0
- package/dist/index.mjs.map +1 -0
- package/docs/structure.txt +25 -0
- package/package.json +49 -0
- package/readme.md +424 -0
- package/src/cli/main.ts +244 -0
- package/src/core/apply-structure.ts +255 -0
- package/src/core/cache-manager.ts +99 -0
- package/src/core/config-loader.ts +184 -0
- package/src/core/hook-runner.ts +73 -0
- package/src/core/init-scaffold.ts +162 -0
- package/src/core/resolve-structure.ts +64 -0
- package/src/core/runner.ts +94 -0
- package/src/core/scan-structure.ts +214 -0
- package/src/core/structure-txt.ts +203 -0
- package/src/core/watcher.ts +106 -0
- package/src/index.ts +5 -0
- package/src/schema/config.ts +180 -0
- package/src/schema/hooks.ts +139 -0
- package/src/schema/index.ts +4 -0
- package/src/schema/structure.ts +77 -0
- package/src/util/fs-utils.ts +126 -0
- package/src/util/logger.ts +144 -0
- package/tsconfig.json +24 -0
- package/tsup.config.ts +48 -0
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { runOnce, RunOptions } from '../core/runner';
|
|
6
|
+
import { watchScaffold } from '../core/watcher';
|
|
7
|
+
import {
|
|
8
|
+
scanDirectoryToStructureText,
|
|
9
|
+
writeScannedStructuresFromConfig,
|
|
10
|
+
} from '../core/scan-structure';
|
|
11
|
+
import { initScaffold } from '../core/init-scaffold';
|
|
12
|
+
import {
|
|
13
|
+
defaultLogger,
|
|
14
|
+
Logger,
|
|
15
|
+
type LogLevel,
|
|
16
|
+
} from '../util/logger';
|
|
17
|
+
import { ensureDirSync } from '../util/fs-utils';
|
|
18
|
+
|
|
19
|
+
interface BaseCliOptions {
|
|
20
|
+
config?: string;
|
|
21
|
+
dir?: string;
|
|
22
|
+
watch?: boolean;
|
|
23
|
+
quiet?: boolean;
|
|
24
|
+
debug?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ScanCliOptions {
|
|
28
|
+
root?: string;
|
|
29
|
+
out?: string;
|
|
30
|
+
ignore?: string[];
|
|
31
|
+
fromConfig?: boolean;
|
|
32
|
+
groups?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface InitCliOptions {
|
|
36
|
+
force?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create a logger with the appropriate level from CLI flags.
|
|
41
|
+
*/
|
|
42
|
+
function createCliLogger(opts: { quiet?: boolean; debug?: boolean }): Logger {
|
|
43
|
+
if (opts.quiet) {
|
|
44
|
+
defaultLogger.setLevel('silent');
|
|
45
|
+
} else if (opts.debug) {
|
|
46
|
+
defaultLogger.setLevel('debug');
|
|
47
|
+
}
|
|
48
|
+
return defaultLogger.child('[cli]');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function askYesNo(question: string): Promise<'delete' | 'keep'> {
|
|
52
|
+
const rl = readline.createInterface({
|
|
53
|
+
input: process.stdin,
|
|
54
|
+
output: process.stdout,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
rl.question(`${question} [y/N] `, (answer) => {
|
|
59
|
+
rl.close();
|
|
60
|
+
const val = answer.trim().toLowerCase();
|
|
61
|
+
if (val === 'y' || val === 'yes') {
|
|
62
|
+
resolve('delete');
|
|
63
|
+
} else {
|
|
64
|
+
resolve('keep');
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function handleRunCommand(cwd: string, baseOpts: BaseCliOptions) {
|
|
71
|
+
const logger = createCliLogger(baseOpts);
|
|
72
|
+
|
|
73
|
+
const configPath = baseOpts.config
|
|
74
|
+
? path.resolve(cwd, baseOpts.config)
|
|
75
|
+
: undefined;
|
|
76
|
+
const scaffoldDir = baseOpts.dir
|
|
77
|
+
? path.resolve(cwd, baseOpts.dir)
|
|
78
|
+
: undefined;
|
|
79
|
+
|
|
80
|
+
logger.debug(
|
|
81
|
+
`Starting scaffold (cwd=${cwd}, config=${configPath ?? 'auto'}, dir=${scaffoldDir ?? 'scaffold/'
|
|
82
|
+
}, watch=${baseOpts.watch ? 'yes' : 'no'})`,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const runnerOptions: RunOptions = {
|
|
86
|
+
configPath,
|
|
87
|
+
scaffoldDir,
|
|
88
|
+
logger,
|
|
89
|
+
interactiveDelete: async ({
|
|
90
|
+
relativePath,
|
|
91
|
+
size,
|
|
92
|
+
createdByStub,
|
|
93
|
+
groupName,
|
|
94
|
+
}) => {
|
|
95
|
+
const sizeKb = (size / 1024).toFixed(1);
|
|
96
|
+
const stubInfo = createdByStub ? ` (stub: ${createdByStub})` : '';
|
|
97
|
+
const groupInfo = groupName ? ` [group: ${groupName}]` : '';
|
|
98
|
+
const question =
|
|
99
|
+
`File "${relativePath}"${groupInfo} is ~${sizeKb}KB and no longer in structure${stubInfo}. Delete it?`;
|
|
100
|
+
|
|
101
|
+
return askYesNo(question);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (baseOpts.watch) {
|
|
106
|
+
// Watch mode – this will not return
|
|
107
|
+
watchScaffold(cwd, runnerOptions);
|
|
108
|
+
} else {
|
|
109
|
+
await runOnce(cwd, runnerOptions);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function handleScanCommand(
|
|
114
|
+
cwd: string,
|
|
115
|
+
scanOpts: ScanCliOptions,
|
|
116
|
+
baseOpts: BaseCliOptions,
|
|
117
|
+
) {
|
|
118
|
+
const logger = createCliLogger(baseOpts);
|
|
119
|
+
|
|
120
|
+
const useConfigMode =
|
|
121
|
+
scanOpts.fromConfig || (!scanOpts.root && !scanOpts.out);
|
|
122
|
+
|
|
123
|
+
if (useConfigMode) {
|
|
124
|
+
logger.info('Scanning project using scaffold config/groups...');
|
|
125
|
+
await writeScannedStructuresFromConfig(cwd, {
|
|
126
|
+
ignore: scanOpts.ignore,
|
|
127
|
+
groups: scanOpts.groups,
|
|
128
|
+
});
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Manual single-root mode
|
|
133
|
+
const rootDir = path.resolve(cwd, scanOpts.root ?? '.');
|
|
134
|
+
const ignore = scanOpts.ignore ?? [];
|
|
135
|
+
|
|
136
|
+
logger.info(`Scanning directory for structure: ${rootDir}`);
|
|
137
|
+
const text = scanDirectoryToStructureText(rootDir, {
|
|
138
|
+
ignore,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (scanOpts.out) {
|
|
142
|
+
const outPath = path.resolve(cwd, scanOpts.out);
|
|
143
|
+
const dir = path.dirname(outPath);
|
|
144
|
+
ensureDirSync(dir);
|
|
145
|
+
fs.writeFileSync(outPath, text, 'utf8');
|
|
146
|
+
logger.info(`Wrote structure to ${outPath}`);
|
|
147
|
+
} else {
|
|
148
|
+
process.stdout.write(text + '\n');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function handleInitCommand(
|
|
153
|
+
cwd: string,
|
|
154
|
+
initOpts: InitCliOptions,
|
|
155
|
+
baseOpts: BaseCliOptions,
|
|
156
|
+
) {
|
|
157
|
+
const logger = createCliLogger(baseOpts);
|
|
158
|
+
|
|
159
|
+
const scaffoldDirRel = baseOpts.dir ?? 'scaffold';
|
|
160
|
+
|
|
161
|
+
logger.info(`Initializing scaffold directory at "${scaffoldDirRel}"...`);
|
|
162
|
+
|
|
163
|
+
const result = await initScaffold(cwd, {
|
|
164
|
+
scaffoldDir: scaffoldDirRel,
|
|
165
|
+
force: initOpts.force,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
logger.info(
|
|
169
|
+
`Done. Config: ${result.configPath}, Structure: ${result.structurePath}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function main() {
|
|
174
|
+
const cwd = process.cwd();
|
|
175
|
+
|
|
176
|
+
const program = new Command();
|
|
177
|
+
|
|
178
|
+
program
|
|
179
|
+
.name('scaffold')
|
|
180
|
+
.description('@timeax/scaffold – structure-based project scaffolding')
|
|
181
|
+
// global-ish options used by base + scan + init
|
|
182
|
+
.option('-c, --config <path>', 'Path to scaffold config file')
|
|
183
|
+
.option('-d, --dir <path>', 'Path to scaffold directory (default: ./scaffold)')
|
|
184
|
+
.option('-w, --watch', 'Watch scaffold directory for changes')
|
|
185
|
+
.option('--quiet', 'Silence logs')
|
|
186
|
+
.option('--debug', 'Enable debug logging');
|
|
187
|
+
|
|
188
|
+
// scan subcommand
|
|
189
|
+
program
|
|
190
|
+
.command('scan')
|
|
191
|
+
.description(
|
|
192
|
+
'Generate structure.txt-style output (config-aware by default, or manual root/out)',
|
|
193
|
+
)
|
|
194
|
+
.option(
|
|
195
|
+
'--from-config',
|
|
196
|
+
'Scan based on scaffold config/groups and write structure files into scaffold/ (default if no root/out specified)',
|
|
197
|
+
)
|
|
198
|
+
.option(
|
|
199
|
+
'-r, --root <path>',
|
|
200
|
+
'Root directory to scan (manual mode)',
|
|
201
|
+
)
|
|
202
|
+
.option(
|
|
203
|
+
'-o, --out <path>',
|
|
204
|
+
'Output file path (manual mode)',
|
|
205
|
+
)
|
|
206
|
+
.option(
|
|
207
|
+
'--ignore <patterns...>',
|
|
208
|
+
'Additional glob patterns to ignore (relative to root)',
|
|
209
|
+
)
|
|
210
|
+
.option(
|
|
211
|
+
'--groups <names...>',
|
|
212
|
+
'Limit config-based scanning to specific groups (by name)',
|
|
213
|
+
)
|
|
214
|
+
.action(async (scanOpts: ScanCliOptions, cmd: Command) => {
|
|
215
|
+
const baseOpts = cmd.parent?.opts<BaseCliOptions>() ?? {};
|
|
216
|
+
await handleScanCommand(cwd, scanOpts, baseOpts);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// init subcommand
|
|
220
|
+
program
|
|
221
|
+
.command('init')
|
|
222
|
+
.description('Initialize scaffold folder and config/structure files')
|
|
223
|
+
.option(
|
|
224
|
+
'--force',
|
|
225
|
+
'Overwrite existing config/structure files if they already exist',
|
|
226
|
+
)
|
|
227
|
+
.action(async (initOpts: InitCliOptions, cmd: Command) => {
|
|
228
|
+
const baseOpts = cmd.parent?.opts<BaseCliOptions>() ?? {};
|
|
229
|
+
await handleInitCommand(cwd, initOpts, baseOpts);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Base command: run scaffold once or in watch mode
|
|
233
|
+
program.action(async (opts: BaseCliOptions) => {
|
|
234
|
+
await handleRunCommand(cwd, opts);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await program.parseAsync(process.argv);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Run and handle errors
|
|
241
|
+
main().catch((err) => {
|
|
242
|
+
defaultLogger.error(err);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// src/core/apply-structure.ts
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import type {
|
|
6
|
+
ScaffoldConfig,
|
|
7
|
+
StructureEntry,
|
|
8
|
+
FileEntry,
|
|
9
|
+
DirEntry,
|
|
10
|
+
HookContext,
|
|
11
|
+
} from '../schema';
|
|
12
|
+
import { CacheManager } from './cache-manager';
|
|
13
|
+
import { HookRunner } from './hook-runner';
|
|
14
|
+
import {
|
|
15
|
+
ensureDirSync,
|
|
16
|
+
statSafeSync,
|
|
17
|
+
toProjectRelativePath,
|
|
18
|
+
toPosixPath,
|
|
19
|
+
} from '../util/fs-utils';
|
|
20
|
+
import type { Logger } from '../util/logger';
|
|
21
|
+
import { defaultLogger } from '../util/logger';
|
|
22
|
+
|
|
23
|
+
export interface InteractiveDeleteParams {
|
|
24
|
+
absolutePath: string;
|
|
25
|
+
relativePath: string; // project-root relative, POSIX
|
|
26
|
+
size: number;
|
|
27
|
+
createdByStub?: string;
|
|
28
|
+
groupName?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ApplyOptions {
|
|
32
|
+
config: ScaffoldConfig;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Global project root for this run.
|
|
36
|
+
*/
|
|
37
|
+
projectRoot: string;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Absolute directory where this structure group should be applied.
|
|
41
|
+
* For grouped mode, this is projectRoot + group.root.
|
|
42
|
+
* For single mode, this will simply be projectRoot.
|
|
43
|
+
*/
|
|
44
|
+
baseDir: string;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Which structure entries to apply (already resolved from txt or inline).
|
|
48
|
+
*/
|
|
49
|
+
structure: StructureEntry[];
|
|
50
|
+
|
|
51
|
+
cache: CacheManager;
|
|
52
|
+
hooks: HookRunner;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Optional group metadata (only set for groups).
|
|
56
|
+
*/
|
|
57
|
+
groupName?: string;
|
|
58
|
+
groupRoot?: string;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Optional override for deletion threshold.
|
|
62
|
+
* Falls back to config.sizePromptThreshold or internal default.
|
|
63
|
+
*/
|
|
64
|
+
sizePromptThreshold?: number;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Optional interactive delete callback.
|
|
68
|
+
* Should ask the user and return 'delete' or 'keep'.
|
|
69
|
+
*/
|
|
70
|
+
interactiveDelete?: (
|
|
71
|
+
params: InteractiveDeleteParams,
|
|
72
|
+
) => Promise<'delete' | 'keep'>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Optional logger; defaults to defaultLogger.child('[apply]').
|
|
76
|
+
*/
|
|
77
|
+
logger?: Logger;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function applyStructure(opts: ApplyOptions): Promise<void> {
|
|
81
|
+
const {
|
|
82
|
+
config,
|
|
83
|
+
projectRoot,
|
|
84
|
+
baseDir,
|
|
85
|
+
structure,
|
|
86
|
+
cache,
|
|
87
|
+
hooks,
|
|
88
|
+
groupName,
|
|
89
|
+
groupRoot,
|
|
90
|
+
sizePromptThreshold,
|
|
91
|
+
interactiveDelete,
|
|
92
|
+
} = opts;
|
|
93
|
+
|
|
94
|
+
const logger =
|
|
95
|
+
opts.logger ?? defaultLogger.child(groupName ? `[apply:${groupName}]` : '[apply]');
|
|
96
|
+
|
|
97
|
+
const desiredPaths = new Set<string>(); // project-root relative, POSIX
|
|
98
|
+
|
|
99
|
+
const threshold = sizePromptThreshold ?? config.sizePromptThreshold ?? 128 * 1024;
|
|
100
|
+
|
|
101
|
+
async function walk(entry: StructureEntry, inheritedStub?: string): Promise<void> {
|
|
102
|
+
const effectiveStub = entry.stub ?? inheritedStub;
|
|
103
|
+
if (entry.type === 'dir') {
|
|
104
|
+
await handleDir(entry as DirEntry, effectiveStub);
|
|
105
|
+
} else {
|
|
106
|
+
await handleFile(entry as FileEntry, effectiveStub);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function handleDir(entry: DirEntry, inheritedStub?: string): Promise<void> {
|
|
111
|
+
const relFromBase = entry.path.replace(/^[./]+/, '');
|
|
112
|
+
const absDir = path.resolve(baseDir, relFromBase);
|
|
113
|
+
const relFromRoot = toPosixPath(
|
|
114
|
+
toProjectRelativePath(projectRoot, absDir),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
desiredPaths.add(relFromRoot);
|
|
118
|
+
|
|
119
|
+
ensureDirSync(absDir);
|
|
120
|
+
|
|
121
|
+
const nextStub = entry.stub ?? inheritedStub;
|
|
122
|
+
|
|
123
|
+
if (entry.children) {
|
|
124
|
+
for (const child of entry.children) {
|
|
125
|
+
// eslint-disable-next-line no-await-in-loop
|
|
126
|
+
await walk(child, nextStub);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function handleFile(entry: FileEntry, inheritedStub?: string): Promise<void> {
|
|
132
|
+
const relFromBase = entry.path.replace(/^[./]+/, '');
|
|
133
|
+
const absFile = path.resolve(baseDir, relFromBase);
|
|
134
|
+
const relFromRoot = toPosixPath(
|
|
135
|
+
toProjectRelativePath(projectRoot, absFile),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
desiredPaths.add(relFromRoot);
|
|
139
|
+
|
|
140
|
+
const stubName = entry.stub ?? inheritedStub;
|
|
141
|
+
|
|
142
|
+
const ctx: HookContext = {
|
|
143
|
+
projectRoot,
|
|
144
|
+
targetPath: relFromRoot,
|
|
145
|
+
absolutePath: absFile,
|
|
146
|
+
isDirectory: false,
|
|
147
|
+
stubName,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// If file already exists, do not overwrite; just ensure hooks
|
|
151
|
+
if (fs.existsSync(absFile)) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await hooks.runRegular('preCreateFile', ctx);
|
|
156
|
+
|
|
157
|
+
const dir = path.dirname(absFile);
|
|
158
|
+
ensureDirSync(dir);
|
|
159
|
+
|
|
160
|
+
if (stubName) {
|
|
161
|
+
await hooks.runStub('preStub', ctx);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let content = '';
|
|
165
|
+
const stubContent = await hooks.renderStubContent(ctx);
|
|
166
|
+
if (typeof stubContent === 'string') {
|
|
167
|
+
content = stubContent;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
fs.writeFileSync(absFile, content, 'utf8');
|
|
171
|
+
const stats = fs.statSync(absFile);
|
|
172
|
+
|
|
173
|
+
cache.set({
|
|
174
|
+
path: relFromRoot,
|
|
175
|
+
createdAt: new Date().toISOString(),
|
|
176
|
+
sizeAtCreate: stats.size,
|
|
177
|
+
createdByStub: stubName,
|
|
178
|
+
groupName,
|
|
179
|
+
groupRoot,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
logger.info(`created ${relFromRoot}`);
|
|
183
|
+
|
|
184
|
+
if (stubName) {
|
|
185
|
+
await hooks.runStub('postStub', ctx);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await hooks.runRegular('postCreateFile', ctx);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 1) Create/update from structure
|
|
192
|
+
for (const entry of structure) {
|
|
193
|
+
// eslint-disable-next-line no-await-in-loop
|
|
194
|
+
await walk(entry);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 2) Handle deletions: any cached path not in desiredPaths
|
|
198
|
+
for (const cachedPath of cache.allPaths()) {
|
|
199
|
+
if (desiredPaths.has(cachedPath)) continue;
|
|
200
|
+
|
|
201
|
+
const abs = path.resolve(projectRoot, cachedPath);
|
|
202
|
+
const stats = statSafeSync(abs);
|
|
203
|
+
|
|
204
|
+
if (!stats) {
|
|
205
|
+
cache.delete(cachedPath);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Only handle files here; dirs are not tracked in cache.
|
|
210
|
+
if (!stats.isFile()) {
|
|
211
|
+
cache.delete(cachedPath);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const entry = cache.get(cachedPath);
|
|
216
|
+
const ctx: HookContext = {
|
|
217
|
+
projectRoot,
|
|
218
|
+
targetPath: cachedPath,
|
|
219
|
+
absolutePath: abs,
|
|
220
|
+
isDirectory: false,
|
|
221
|
+
stubName: entry?.createdByStub,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
await hooks.runRegular('preDeleteFile', ctx);
|
|
225
|
+
|
|
226
|
+
let shouldDelete = true;
|
|
227
|
+
if (stats.size > threshold && interactiveDelete) {
|
|
228
|
+
const res = await interactiveDelete({
|
|
229
|
+
absolutePath: abs,
|
|
230
|
+
relativePath: cachedPath,
|
|
231
|
+
size: stats.size,
|
|
232
|
+
createdByStub: entry?.createdByStub,
|
|
233
|
+
groupName: entry?.groupName,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (res === 'keep') {
|
|
237
|
+
shouldDelete = false;
|
|
238
|
+
cache.delete(cachedPath); // user takes ownership
|
|
239
|
+
logger.info(`keeping ${cachedPath} (removed from cache)`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (shouldDelete) {
|
|
244
|
+
try {
|
|
245
|
+
fs.unlinkSync(abs);
|
|
246
|
+
logger.info(`deleted ${cachedPath}`);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
logger.warn(`failed to delete ${cachedPath}`, err);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
cache.delete(cachedPath);
|
|
252
|
+
await hooks.runRegular('postDeleteFile', ctx);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// src/core/cache-manager.ts
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { ensureDirSync, toPosixPath } from '../util/fs-utils';
|
|
6
|
+
import { defaultLogger } from '../util/logger';
|
|
7
|
+
|
|
8
|
+
const logger = defaultLogger.child('[cache]');
|
|
9
|
+
|
|
10
|
+
export interface CacheEntry {
|
|
11
|
+
/**
|
|
12
|
+
* Path relative to the *project root* (global root), POSIX style.
|
|
13
|
+
*/
|
|
14
|
+
path: string;
|
|
15
|
+
|
|
16
|
+
createdAt: string;
|
|
17
|
+
sizeAtCreate: number;
|
|
18
|
+
createdByStub?: string;
|
|
19
|
+
groupName?: string;
|
|
20
|
+
groupRoot?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CacheFile {
|
|
24
|
+
version: 1;
|
|
25
|
+
entries: Record<string, CacheEntry>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_CACHE: CacheFile = {
|
|
29
|
+
version: 1,
|
|
30
|
+
entries: {},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export class CacheManager {
|
|
34
|
+
private cache: CacheFile = DEFAULT_CACHE;
|
|
35
|
+
|
|
36
|
+
constructor(
|
|
37
|
+
private readonly projectRoot: string,
|
|
38
|
+
private readonly cacheFileRelPath: string,
|
|
39
|
+
) { }
|
|
40
|
+
|
|
41
|
+
private get cachePathAbs(): string {
|
|
42
|
+
return path.resolve(this.projectRoot, this.cacheFileRelPath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
load(): void {
|
|
46
|
+
const cachePath = this.cachePathAbs;
|
|
47
|
+
if (!fs.existsSync(cachePath)) {
|
|
48
|
+
this.cache = { ...DEFAULT_CACHE, entries: {} };
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const raw = fs.readFileSync(cachePath, 'utf8');
|
|
54
|
+
const parsed = JSON.parse(raw) as CacheFile;
|
|
55
|
+
if (parsed.version === 1 && parsed.entries) {
|
|
56
|
+
this.cache = parsed;
|
|
57
|
+
} else {
|
|
58
|
+
logger.warn('Cache file version mismatch or invalid, resetting cache.');
|
|
59
|
+
this.cache = { ...DEFAULT_CACHE, entries: {} };
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
logger.warn('Failed to read cache file, resetting cache.', err);
|
|
63
|
+
this.cache = { ...DEFAULT_CACHE, entries: {} };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
save(): void {
|
|
68
|
+
const cachePath = this.cachePathAbs;
|
|
69
|
+
const dir = path.dirname(cachePath);
|
|
70
|
+
ensureDirSync(dir);
|
|
71
|
+
fs.writeFileSync(cachePath, JSON.stringify(this.cache, null, 2), 'utf8');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get(relPath: string): CacheEntry | undefined {
|
|
75
|
+
const key = toPosixPath(relPath);
|
|
76
|
+
return this.cache.entries[key];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
set(entry: CacheEntry): void {
|
|
80
|
+
const key = toPosixPath(entry.path);
|
|
81
|
+
this.cache.entries[key] = {
|
|
82
|
+
...entry,
|
|
83
|
+
path: key,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
delete(relPath: string): void {
|
|
88
|
+
const key = toPosixPath(relPath);
|
|
89
|
+
delete this.cache.entries[key];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
allPaths(): string[] {
|
|
93
|
+
return Object.keys(this.cache.entries);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
allEntries(): CacheEntry[] {
|
|
97
|
+
return Object.values(this.cache.entries);
|
|
98
|
+
}
|
|
99
|
+
}
|