@h1v35/hivex 0.2.2 → 0.3.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/README.md +44 -12
- package/docs/CONTEXT.md +6 -0
- package/docs/README.md +3 -1
- package/docs/adr/0012-project-foundation-and-workflow.md +21 -0
- package/docs/guidelines/engineering.md +10 -4
- package/docs/guidelines/triage-labels.md +29 -0
- package/docs/procedures/issue-tracker.md +21 -0
- package/package.json +4 -1
- package/skills/hivex/SKILL.md +22 -46
- package/skills/hivex/assets/project/AGENTS.md +11 -0
- package/skills/hivex/assets/project/docs/CONTEXT.md +8 -0
- package/skills/hivex/assets/project/docs/PRD.md +16 -0
- package/skills/hivex/assets/project/docs/README.md +18 -0
- package/skills/hivex/assets/project/docs/adr/README.md +5 -0
- package/skills/hivex/assets/project/docs/guidelines/engineering.md +37 -0
- package/skills/hivex/assets/project/docs/guidelines/triage-labels.md +29 -0
- package/skills/hivex/assets/project/docs/procedures/issue-tracker.md +21 -0
- package/skills/hivex/assets/project/hivex.json +8 -0
- package/skills/hivex/references/markdown.md +13 -7
- package/skills/hivex-design/SKILL.md +26 -0
- package/skills/hivex-document/SKILL.md +36 -0
- package/skills/hivex-git/SKILL.md +42 -0
- package/skills/hivex-git/assets/labels.json +152 -0
- package/skills/hivex-implement/SKILL.md +28 -0
- package/skills/hivex-review/SKILL.md +26 -0
- package/src/cli.ts +5 -0
- package/src/project-initialization.ts +319 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { HivexError } from './errors.ts';
|
|
5
|
+
|
|
6
|
+
interface TemplateFile {
|
|
7
|
+
bytes: Buffer;
|
|
8
|
+
path: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface FileOperation {
|
|
12
|
+
absolutePath: string;
|
|
13
|
+
bytes: Buffer;
|
|
14
|
+
path: string;
|
|
15
|
+
state: 'created' | 'preserved' | 'updated';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface InitReport {
|
|
19
|
+
command: 'init';
|
|
20
|
+
created: string[];
|
|
21
|
+
modelCalls: 0;
|
|
22
|
+
preserved: string[];
|
|
23
|
+
updated: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ignoreRules = ['!/.hivex/', '/.hivex/*', '!/.hivex/graph.json'];
|
|
27
|
+
const assetsPath = path.join(import.meta.dirname, '../skills/hivex/assets/project');
|
|
28
|
+
const assetsUnavailableCode = 'INIT_ASSETS_UNAVAILABLE';
|
|
29
|
+
const assetsInvalidCode = 'INIT_ASSETS_INVALID';
|
|
30
|
+
|
|
31
|
+
const fail = function fail(code: string, message: string): never {
|
|
32
|
+
throw new HivexError({ code, message });
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const safeRelativePath = function safeRelativePath(relativePath: string) {
|
|
36
|
+
const normalized = relativePath.replaceAll('\\', '/');
|
|
37
|
+
const parts = normalized.split('/');
|
|
38
|
+
const hasInvalidPath = [
|
|
39
|
+
!relativePath,
|
|
40
|
+
path.isAbsolute(relativePath),
|
|
41
|
+
normalized.startsWith('/'),
|
|
42
|
+
normalized.includes('\u{0}'),
|
|
43
|
+
relativePath.includes('\\'),
|
|
44
|
+
].includes(true);
|
|
45
|
+
if (hasInvalidPath) {
|
|
46
|
+
return fail('INVALID_DESTINATION', 'Initialization paths must be relative project files');
|
|
47
|
+
}
|
|
48
|
+
if (parts.some((part) => ['', '.', '..'].includes(part))) {
|
|
49
|
+
return fail('INVALID_DESTINATION', 'Initialization paths must be relative project files');
|
|
50
|
+
}
|
|
51
|
+
return normalized;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const errorMessage = function errorMessage(error: unknown) {
|
|
55
|
+
return Error.isError(error) ? error.message : 'unknown error';
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const templateEntries = function templateEntries(directory: string) {
|
|
59
|
+
try {
|
|
60
|
+
return readdirSync(directory, { withFileTypes: true }).toSorted((left, right) =>
|
|
61
|
+
left.name.localeCompare(right.name)
|
|
62
|
+
);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
return fail(assetsUnavailableCode, `Unable to read project templates: ${errorMessage(error)}`);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const readTemplateFile = function readTemplateFile(
|
|
69
|
+
absolutePath: string,
|
|
70
|
+
relativePath: string
|
|
71
|
+
): TemplateFile {
|
|
72
|
+
try {
|
|
73
|
+
return { bytes: readFileSync(absolutePath), path: relativePath };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return fail(
|
|
76
|
+
assetsUnavailableCode,
|
|
77
|
+
`Unable to read project template ${relativePath}: ${errorMessage(error)}`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const readTemplateFiles = function readTemplateFiles(
|
|
83
|
+
directory: string,
|
|
84
|
+
relativeDirectory = ''
|
|
85
|
+
): TemplateFile[] {
|
|
86
|
+
const files: TemplateFile[] = [];
|
|
87
|
+
const visit = function visit(current: string, currentRelativeDirectory: string) {
|
|
88
|
+
for (const entry of templateEntries(current)) {
|
|
89
|
+
const relativePath = safeRelativePath(
|
|
90
|
+
currentRelativeDirectory ? `${currentRelativeDirectory}/${entry.name}` : entry.name
|
|
91
|
+
);
|
|
92
|
+
const absolutePath = path.join(current, entry.name);
|
|
93
|
+
if (entry.isSymbolicLink()) {
|
|
94
|
+
fail(assetsInvalidCode, `Project template must not be a symlink: ${relativePath}`);
|
|
95
|
+
} else if (entry.isDirectory()) {
|
|
96
|
+
visit(absolutePath, relativePath);
|
|
97
|
+
} else if (entry.isFile()) {
|
|
98
|
+
files.push(readTemplateFile(absolutePath, relativePath));
|
|
99
|
+
} else {
|
|
100
|
+
fail(assetsInvalidCode, `Project template is not a regular file: ${relativePath}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
visit(directory, relativeDirectory);
|
|
105
|
+
return files;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const projectRoot = function projectRoot(requested: string) {
|
|
109
|
+
if (!requested.trim()) {
|
|
110
|
+
return fail('INVALID_ROOT', 'Project root must be a non-empty path');
|
|
111
|
+
}
|
|
112
|
+
const root = path.resolve(requested);
|
|
113
|
+
let stat;
|
|
114
|
+
try {
|
|
115
|
+
stat = lstatSync(root);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return fail('INVALID_ROOT', `Project root is not readable: ${errorMessage(error)}`);
|
|
118
|
+
}
|
|
119
|
+
if (stat.isSymbolicLink()) {
|
|
120
|
+
return fail('INVALID_ROOT', 'Project root must not be a symlink');
|
|
121
|
+
}
|
|
122
|
+
if (!stat.isDirectory()) {
|
|
123
|
+
return fail('INVALID_ROOT', 'Project root must be a directory');
|
|
124
|
+
}
|
|
125
|
+
return root;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const destination = function destination(root: string, relativePath: string) {
|
|
129
|
+
const absolutePath = path.resolve(root, relativePath);
|
|
130
|
+
const relative = path.relative(root, absolutePath);
|
|
131
|
+
if (
|
|
132
|
+
!relative ||
|
|
133
|
+
relative === '..' ||
|
|
134
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
135
|
+
path.isAbsolute(relative)
|
|
136
|
+
) {
|
|
137
|
+
return fail(
|
|
138
|
+
'INVALID_DESTINATION',
|
|
139
|
+
`Initialization path escapes the project root: ${relativePath}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let current = root;
|
|
144
|
+
const parts = relative.split(path.sep);
|
|
145
|
+
for (const [index, part] of parts.entries()) {
|
|
146
|
+
current = path.join(current, part);
|
|
147
|
+
let stat;
|
|
148
|
+
try {
|
|
149
|
+
stat = lstatSync(current, { throwIfNoEntry: false });
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return fail(
|
|
152
|
+
'INVALID_DESTINATION',
|
|
153
|
+
`Unable to inspect initialization path ${relativePath}: ${errorMessage(error)}`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (stat === undefined) {
|
|
157
|
+
return { absolutePath, exists: false };
|
|
158
|
+
}
|
|
159
|
+
if (stat.isSymbolicLink()) {
|
|
160
|
+
return fail(
|
|
161
|
+
'INVALID_DESTINATION',
|
|
162
|
+
`Initialization path must not use symlinks: ${relativePath}`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const isFinal = index === parts.length - 1;
|
|
166
|
+
if ((!isFinal && !stat.isDirectory()) || (isFinal && !stat.isFile())) {
|
|
167
|
+
return fail(
|
|
168
|
+
'INVALID_DESTINATION',
|
|
169
|
+
`Initialization path is not a regular file: ${relativePath}`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { absolutePath, exists: true };
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const validateNestedIgnore = function validateNestedIgnore(root: string) {
|
|
177
|
+
const relativePath = '.hivex/.gitignore';
|
|
178
|
+
const target = destination(root, relativePath);
|
|
179
|
+
if (!target.exists) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
let text: string;
|
|
183
|
+
try {
|
|
184
|
+
text = readFileSync(target.absolutePath, 'utf-8');
|
|
185
|
+
} catch (error) {
|
|
186
|
+
throw new HivexError({
|
|
187
|
+
code: 'INIT_READ_FAILED',
|
|
188
|
+
message: `Unable to read ${relativePath}: ${errorMessage(error)}`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (text.split(/\r?\n/u).some((line) => line.trim() !== '' && !line.startsWith('#'))) {
|
|
192
|
+
fail(
|
|
193
|
+
'INIT_IGNORE_CONFLICT',
|
|
194
|
+
'.hivex/.gitignore contains rules that can override snapshot visibility or local state privacy.'
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const hasFinalIgnoreRules = function hasFinalIgnoreRules(text: string) {
|
|
200
|
+
const lines = text.split(/\r?\n/u);
|
|
201
|
+
while (lines.at(-1) === '') {
|
|
202
|
+
lines.pop();
|
|
203
|
+
}
|
|
204
|
+
const start = lines.length - ignoreRules.length;
|
|
205
|
+
return start >= 0 && ignoreRules.every((rule, index) => lines[start + index] === rule);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const ignoreUpdate = function ignoreUpdate(existing: Buffer | null) {
|
|
209
|
+
const block = `${ignoreRules.join('\n')}\n`;
|
|
210
|
+
if (existing === null) {
|
|
211
|
+
return Buffer.from(block);
|
|
212
|
+
}
|
|
213
|
+
const text = existing.toString('utf-8');
|
|
214
|
+
if (hasFinalIgnoreRules(text)) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
const separator = text.length === 0 || text.endsWith('\n') ? '' : '\n';
|
|
218
|
+
return Buffer.concat([existing, Buffer.from(`${separator}${block}`)]);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const templateOperations = function templateOperations(root: string, templates: TemplateFile[]) {
|
|
222
|
+
return templates.map(({ bytes, path: relativePath }) => {
|
|
223
|
+
const target = destination(root, relativePath);
|
|
224
|
+
return {
|
|
225
|
+
absolutePath: target.absolutePath,
|
|
226
|
+
bytes,
|
|
227
|
+
path: relativePath,
|
|
228
|
+
state: target.exists ? 'preserved' : 'created',
|
|
229
|
+
} satisfies FileOperation;
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const ignoreOperation = function ignoreOperation(root: string) {
|
|
234
|
+
const relativePath = '.gitignore';
|
|
235
|
+
const target = destination(root, relativePath);
|
|
236
|
+
let existing: Buffer | null = null;
|
|
237
|
+
if (target.exists) {
|
|
238
|
+
try {
|
|
239
|
+
existing = readFileSync(target.absolutePath);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
return fail('INIT_READ_FAILED', `Unable to read ${relativePath}: ${errorMessage(error)}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const bytes = ignoreUpdate(existing);
|
|
245
|
+
let state: FileOperation['state'] = 'created';
|
|
246
|
+
if (target.exists) {
|
|
247
|
+
state = bytes === null ? 'preserved' : 'updated';
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
absolutePath: target.absolutePath,
|
|
251
|
+
bytes: bytes ?? existing ?? Buffer.alloc(0),
|
|
252
|
+
path: relativePath,
|
|
253
|
+
state,
|
|
254
|
+
} satisfies FileOperation;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const writeOperations = function writeOperations(operations: FileOperation[]) {
|
|
258
|
+
for (const operation of operations) {
|
|
259
|
+
if (operation.state === 'preserved') {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
mkdirSync(path.dirname(operation.absolutePath), { recursive: true });
|
|
263
|
+
if (operation.state === 'created') {
|
|
264
|
+
writeFileSync(operation.absolutePath, operation.bytes, { flag: 'wx', mode: 0o644 });
|
|
265
|
+
} else {
|
|
266
|
+
writeFileSync(operation.absolutePath, operation.bytes);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const report = function report(operations: FileOperation[]): InitReport {
|
|
272
|
+
const paths = function paths(state: FileOperation['state']) {
|
|
273
|
+
return operations
|
|
274
|
+
.filter((operation) => operation.state === state)
|
|
275
|
+
.map((operation) => operation.path)
|
|
276
|
+
.toSorted((left, right) => left.localeCompare(right));
|
|
277
|
+
};
|
|
278
|
+
return {
|
|
279
|
+
command: 'init',
|
|
280
|
+
created: paths('created'),
|
|
281
|
+
modelCalls: 0,
|
|
282
|
+
preserved: paths('preserved'),
|
|
283
|
+
updated: paths('updated'),
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const parseInitArguments = function parseInitArguments(argumentsList: string[]) {
|
|
288
|
+
try {
|
|
289
|
+
return parseArgs({
|
|
290
|
+
allowPositionals: true,
|
|
291
|
+
args: argumentsList,
|
|
292
|
+
options: { root: { type: 'string' } },
|
|
293
|
+
strict: true,
|
|
294
|
+
});
|
|
295
|
+
} catch (error) {
|
|
296
|
+
return fail(
|
|
297
|
+
'INVALID_ARGUMENT',
|
|
298
|
+
Error.isError(error) ? error.message : 'Invalid init arguments'
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
export const projectInitializationCommand = function projectInitializationCommand(
|
|
304
|
+
argumentsList: string[]
|
|
305
|
+
) {
|
|
306
|
+
const parsed = parseInitArguments(argumentsList);
|
|
307
|
+
if (parsed.positionals.length !== 1 || parsed.positionals[0] !== 'init') {
|
|
308
|
+
return fail('INVALID_ARGUMENT', 'Use init [--root <project>]');
|
|
309
|
+
}
|
|
310
|
+
const root = projectRoot(parsed.values.root ?? process.cwd());
|
|
311
|
+
validateNestedIgnore(root);
|
|
312
|
+
const templates = readTemplateFiles(assetsPath);
|
|
313
|
+
if (!templates.length) {
|
|
314
|
+
return fail('INIT_ASSETS_UNAVAILABLE', 'No project templates are available');
|
|
315
|
+
}
|
|
316
|
+
const operations = [...templateOperations(root, templates), ignoreOperation(root)];
|
|
317
|
+
writeOperations(operations);
|
|
318
|
+
return report(operations);
|
|
319
|
+
};
|