@soleri/cli 9.0.2 → 9.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/dist/commands/agent.js +116 -3
- package/dist/commands/agent.js.map +1 -1
- package/dist/commands/create.js +6 -2
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/hooks.js +36 -13
- package/dist/commands/hooks.js.map +1 -1
- package/dist/commands/install.d.ts +1 -0
- package/dist/commands/install.js +61 -12
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/pack.js +0 -1
- package/dist/commands/pack.js.map +1 -1
- package/dist/commands/staging.d.ts +2 -0
- package/dist/commands/staging.js +175 -0
- package/dist/commands/staging.js.map +1 -0
- package/dist/hook-packs/full/manifest.json +2 -2
- package/dist/hook-packs/installer.d.ts +4 -11
- package/dist/hook-packs/installer.js +197 -23
- package/dist/hook-packs/installer.js.map +1 -1
- package/dist/hook-packs/installer.ts +223 -38
- package/dist/hook-packs/registry.d.ts +16 -13
- package/dist/hook-packs/registry.js +11 -18
- package/dist/hook-packs/registry.js.map +1 -1
- package/dist/hook-packs/registry.ts +31 -30
- package/dist/hook-packs/yolo-safety/manifest.json +23 -0
- package/dist/hook-packs/yolo-safety/scripts/anti-deletion.sh +214 -0
- package/dist/hooks/templates.js +1 -1
- package/dist/hooks/templates.js.map +1 -1
- package/dist/main.js +2 -0
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/create.test.ts +6 -2
- package/src/__tests__/hook-packs.test.ts +67 -25
- package/src/__tests__/wizard-e2e.mjs +153 -58
- package/src/commands/agent.ts +146 -3
- package/src/commands/create.ts +8 -2
- package/src/commands/hooks.ts +36 -31
- package/src/commands/install.ts +65 -22
- package/src/commands/pack.ts +0 -1
- package/src/commands/staging.ts +208 -0
- package/src/hook-packs/full/manifest.json +2 -2
- package/src/hook-packs/installer.ts +223 -38
- package/src/hook-packs/registry.ts +31 -30
- package/src/hook-packs/yolo-safety/manifest.json +23 -0
- package/src/hook-packs/yolo-safety/scripts/anti-deletion.sh +214 -0
- package/src/hooks/templates.ts +1 -1
- package/src/main.ts +2 -0
- package/dist/commands/cognee.d.ts +0 -10
- package/dist/commands/cognee.js +0 -364
- package/dist/commands/cognee.js.map +0 -1
package/src/commands/hooks.ts
CHANGED
|
@@ -18,13 +18,11 @@ export function registerHooks(program: Command): void {
|
|
|
18
18
|
log.fail(`Unknown editor "${editor}". Supported: ${SUPPORTED_EDITORS.join(', ')}`);
|
|
19
19
|
process.exit(1);
|
|
20
20
|
}
|
|
21
|
-
|
|
22
21
|
const ctx = detectAgent();
|
|
23
22
|
if (!ctx) {
|
|
24
23
|
log.fail('No agent project detected in current directory.');
|
|
25
24
|
process.exit(1);
|
|
26
25
|
}
|
|
27
|
-
|
|
28
26
|
const files = installHooks(editor, ctx.agentPath);
|
|
29
27
|
for (const f of files) {
|
|
30
28
|
log.pass(`Created ${f}`);
|
|
@@ -41,13 +39,11 @@ export function registerHooks(program: Command): void {
|
|
|
41
39
|
log.fail(`Unknown editor "${editor}". Supported: ${SUPPORTED_EDITORS.join(', ')}`);
|
|
42
40
|
process.exit(1);
|
|
43
41
|
}
|
|
44
|
-
|
|
45
42
|
const ctx = detectAgent();
|
|
46
43
|
if (!ctx) {
|
|
47
44
|
log.fail('No agent project detected in current directory.');
|
|
48
45
|
process.exit(1);
|
|
49
46
|
}
|
|
50
|
-
|
|
51
47
|
const removed = removeHooks(editor, ctx.agentPath);
|
|
52
48
|
if (removed.length === 0) {
|
|
53
49
|
log.info(`No ${editor} hooks found to remove.`);
|
|
@@ -68,11 +64,8 @@ export function registerHooks(program: Command): void {
|
|
|
68
64
|
log.fail('No agent project detected in current directory.');
|
|
69
65
|
process.exit(1);
|
|
70
66
|
}
|
|
71
|
-
|
|
72
67
|
const installed = detectInstalledHooks(ctx.agentPath);
|
|
73
|
-
|
|
74
68
|
log.heading(`Editor hooks for ${ctx.agentId}`);
|
|
75
|
-
|
|
76
69
|
for (const editor of SUPPORTED_EDITORS) {
|
|
77
70
|
if (installed.includes(editor)) {
|
|
78
71
|
log.pass(editor, 'installed');
|
|
@@ -82,8 +75,6 @@ export function registerHooks(program: Command): void {
|
|
|
82
75
|
}
|
|
83
76
|
});
|
|
84
77
|
|
|
85
|
-
// ── Hook Pack subcommands ──
|
|
86
|
-
|
|
87
78
|
hooks
|
|
88
79
|
.command('add-pack')
|
|
89
80
|
.argument('<pack>', 'Hook pack name')
|
|
@@ -96,18 +87,24 @@ export function registerHooks(program: Command): void {
|
|
|
96
87
|
log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`);
|
|
97
88
|
process.exit(1);
|
|
98
89
|
}
|
|
99
|
-
|
|
100
90
|
const projectDir = opts.project ? process.cwd() : undefined;
|
|
101
91
|
const target = opts.project ? '.claude/' : '~/.claude/';
|
|
102
|
-
const { installed, skipped } = installPack(packName, { projectDir });
|
|
92
|
+
const { installed, skipped, scripts, lifecycleHooks } = installPack(packName, { projectDir });
|
|
103
93
|
for (const hook of installed) {
|
|
104
94
|
log.pass(`Installed hookify.${hook}.local.md → ${target}`);
|
|
105
95
|
}
|
|
106
96
|
for (const hook of skipped) {
|
|
107
97
|
log.dim(` hookify.${hook}.local.md — already exists, skipped`);
|
|
108
98
|
}
|
|
109
|
-
|
|
110
|
-
log.
|
|
99
|
+
for (const script of scripts) {
|
|
100
|
+
log.pass(`Installed ${script} → ${target}`);
|
|
101
|
+
}
|
|
102
|
+
for (const lc of lifecycleHooks) {
|
|
103
|
+
log.pass(`Registered lifecycle hook: ${lc}`);
|
|
104
|
+
}
|
|
105
|
+
const totalInstalled = installed.length + scripts.length + lifecycleHooks.length;
|
|
106
|
+
if (totalInstalled > 0) {
|
|
107
|
+
log.info(`Pack "${packName}" installed (${totalInstalled} items) → ${target}`);
|
|
111
108
|
} else {
|
|
112
109
|
log.info(`Pack "${packName}" — all hooks already installed`);
|
|
113
110
|
}
|
|
@@ -125,16 +122,22 @@ export function registerHooks(program: Command): void {
|
|
|
125
122
|
log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`);
|
|
126
123
|
process.exit(1);
|
|
127
124
|
}
|
|
128
|
-
|
|
129
125
|
const projectDir = opts.project ? process.cwd() : undefined;
|
|
130
|
-
const { removed } = removePack(packName, { projectDir });
|
|
131
|
-
|
|
126
|
+
const { removed, scripts, lifecycleHooks } = removePack(packName, { projectDir });
|
|
127
|
+
const totalRemoved = removed.length + scripts.length + lifecycleHooks.length;
|
|
128
|
+
if (totalRemoved === 0) {
|
|
132
129
|
log.info(`No hooks from pack "${packName}" found to remove.`);
|
|
133
130
|
} else {
|
|
134
131
|
for (const hook of removed) {
|
|
135
132
|
log.warn(`Removed hookify.${hook}.local.md`);
|
|
136
133
|
}
|
|
137
|
-
|
|
134
|
+
for (const script of scripts) {
|
|
135
|
+
log.warn(`Removed ${script}`);
|
|
136
|
+
}
|
|
137
|
+
for (const lc of lifecycleHooks) {
|
|
138
|
+
log.warn(`Removed lifecycle hook: ${lc}`);
|
|
139
|
+
}
|
|
140
|
+
log.info(`Pack "${packName}" removed (${totalRemoved} items)`);
|
|
138
141
|
}
|
|
139
142
|
});
|
|
140
143
|
|
|
@@ -143,28 +146,28 @@ export function registerHooks(program: Command): void {
|
|
|
143
146
|
.description('Show available hook packs and their status')
|
|
144
147
|
.action(() => {
|
|
145
148
|
const packs = listPacks();
|
|
146
|
-
|
|
147
149
|
log.heading('Hook Packs');
|
|
148
|
-
|
|
149
150
|
for (const pack of packs) {
|
|
150
151
|
const status = isPackInstalled(pack.name);
|
|
151
|
-
|
|
152
152
|
const versionLabel = pack.version ? ` v${pack.version}` : '';
|
|
153
153
|
const sourceLabel = pack.source === 'local' ? ' [local]' : '';
|
|
154
|
-
|
|
154
|
+
const hookCount = pack.hooks.length;
|
|
155
|
+
const scriptCount = pack.scripts?.length ?? 0;
|
|
156
|
+
const itemCount = hookCount + scriptCount;
|
|
157
|
+
const itemLabel = itemCount === 1 ? '1 item' : `${itemCount} items`;
|
|
155
158
|
if (status === true) {
|
|
156
159
|
log.pass(
|
|
157
160
|
`${pack.name}${versionLabel}${sourceLabel}`,
|
|
158
|
-
`${pack.description} (${
|
|
161
|
+
`${pack.description} (${itemLabel})`,
|
|
159
162
|
);
|
|
160
163
|
} else if (status === 'partial') {
|
|
161
164
|
log.warn(
|
|
162
165
|
`${pack.name}${versionLabel}${sourceLabel}`,
|
|
163
|
-
`${pack.description} (${
|
|
166
|
+
`${pack.description} (${itemLabel}) — partial`,
|
|
164
167
|
);
|
|
165
168
|
} else {
|
|
166
169
|
log.dim(
|
|
167
|
-
` ${pack.name}${versionLabel}${sourceLabel} — ${pack.description} (${
|
|
170
|
+
` ${pack.name}${versionLabel}${sourceLabel} — ${pack.description} (${itemLabel})`,
|
|
168
171
|
);
|
|
169
172
|
}
|
|
170
173
|
}
|
|
@@ -182,19 +185,21 @@ export function registerHooks(program: Command): void {
|
|
|
182
185
|
log.fail(`Unknown pack "${packName}". Available: ${available.join(', ')}`);
|
|
183
186
|
process.exit(1);
|
|
184
187
|
}
|
|
185
|
-
|
|
186
188
|
const projectDir = opts.project ? process.cwd() : undefined;
|
|
187
189
|
const packVersion = pack.manifest.version ?? 'unknown';
|
|
188
|
-
|
|
189
|
-
// Remove then reinstall to force overwrite
|
|
190
190
|
removePack(packName, { projectDir });
|
|
191
|
-
const { installed } = installPack(packName, { projectDir });
|
|
192
|
-
|
|
191
|
+
const { installed, scripts, lifecycleHooks } = installPack(packName, { projectDir });
|
|
193
192
|
for (const hook of installed) {
|
|
194
193
|
log.pass(`hookify.${hook}.local.md → v${packVersion}`);
|
|
195
194
|
}
|
|
196
|
-
|
|
197
|
-
|
|
195
|
+
for (const script of scripts) {
|
|
196
|
+
log.pass(`${script} → v${packVersion}`);
|
|
197
|
+
}
|
|
198
|
+
for (const lc of lifecycleHooks) {
|
|
199
|
+
log.pass(`lifecycle hook ${lc} → v${packVersion}`);
|
|
200
|
+
}
|
|
201
|
+
const total = installed.length + scripts.length + lifecycleHooks.length;
|
|
202
|
+
log.info(`Pack "${packName}" upgraded to v${packVersion} (${total} items)`);
|
|
198
203
|
});
|
|
199
204
|
}
|
|
200
205
|
|
package/src/commands/install.ts
CHANGED
|
@@ -1,14 +1,40 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
2
3
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
3
4
|
import { join, resolve } from 'node:path';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
5
6
|
import * as p from '@clack/prompts';
|
|
6
7
|
import { detectAgent } from '../utils/agent-context.js';
|
|
7
8
|
|
|
9
|
+
/** Default parent directory for agents: ~/.soleri/ */
|
|
10
|
+
const SOLERI_HOME = process.env.SOLERI_HOME ?? join(homedir(), '.soleri');
|
|
11
|
+
|
|
8
12
|
type Target = 'claude' | 'codex' | 'opencode' | 'both' | 'all';
|
|
9
13
|
|
|
10
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the absolute path to the soleri-engine binary.
|
|
16
|
+
* Falls back to `npx @soleri/engine` if resolution fails (e.g. not installed globally).
|
|
17
|
+
*/
|
|
18
|
+
function resolveEngineBin(): { command: string; bin: string } {
|
|
19
|
+
try {
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const bin = require.resolve('@soleri/core/dist/engine/bin/soleri-engine.js');
|
|
22
|
+
return { command: 'node', bin };
|
|
23
|
+
} catch {
|
|
24
|
+
return { command: 'npx', bin: '@soleri/engine' };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** MCP server entry for file-tree agents (resolved engine path, no npx) */
|
|
11
29
|
function fileTreeMcpEntry(agentDir: string): Record<string, unknown> {
|
|
30
|
+
const engine = resolveEngineBin();
|
|
31
|
+
if (engine.command === 'node') {
|
|
32
|
+
return {
|
|
33
|
+
type: 'stdio',
|
|
34
|
+
command: 'node',
|
|
35
|
+
args: [engine.bin, '--agent', join(agentDir, 'agent.yaml')],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
12
38
|
return {
|
|
13
39
|
type: 'stdio',
|
|
14
40
|
command: 'npx',
|
|
@@ -26,7 +52,7 @@ function legacyMcpEntry(agentDir: string): Record<string, unknown> {
|
|
|
26
52
|
};
|
|
27
53
|
}
|
|
28
54
|
|
|
29
|
-
function installClaude(agentId: string, agentDir: string, isFileTree: boolean): void {
|
|
55
|
+
export function installClaude(agentId: string, agentDir: string, isFileTree: boolean): void {
|
|
30
56
|
const configPath = join(homedir(), '.claude.json');
|
|
31
57
|
let config: Record<string, unknown> = {};
|
|
32
58
|
|
|
@@ -72,7 +98,12 @@ function installCodex(agentId: string, agentDir: string, isFileTree: boolean): v
|
|
|
72
98
|
let section: string;
|
|
73
99
|
if (isFileTree) {
|
|
74
100
|
const agentYamlPath = join(agentDir, 'agent.yaml');
|
|
75
|
-
|
|
101
|
+
const engine = resolveEngineBin();
|
|
102
|
+
if (engine.command === 'node') {
|
|
103
|
+
section = `\n\n${sectionHeader}\ncommand = "node"\nargs = ["${engine.bin}", "--agent", "${agentYamlPath}"]\n`;
|
|
104
|
+
} else {
|
|
105
|
+
section = `\n\n${sectionHeader}\ncommand = "npx"\nargs = ["@soleri/engine", "--agent", "${agentYamlPath}"]\n`;
|
|
106
|
+
}
|
|
76
107
|
} else {
|
|
77
108
|
const entryPoint = join(agentDir, 'dist', 'index.js');
|
|
78
109
|
section = `\n\n${sectionHeader}\ncommand = "node"\nargs = ["${entryPoint}"]\n`;
|
|
@@ -111,24 +142,13 @@ function installOpencode(agentId: string, agentDir: string, isFileTree: boolean)
|
|
|
111
142
|
|
|
112
143
|
const servers = config.mcp as Record<string, unknown>;
|
|
113
144
|
if (isFileTree) {
|
|
145
|
+
const engine = resolveEngineBin();
|
|
114
146
|
servers[agentId] = {
|
|
115
147
|
type: 'local',
|
|
116
|
-
command:
|
|
117
|
-
'node'
|
|
118
|
-
|
|
119
|
-
agentDir,
|
|
120
|
-
'..',
|
|
121
|
-
'soleri',
|
|
122
|
-
'packages',
|
|
123
|
-
'core',
|
|
124
|
-
'dist',
|
|
125
|
-
'engine',
|
|
126
|
-
'bin',
|
|
127
|
-
'soleri-engine.js',
|
|
128
|
-
),
|
|
129
|
-
'--agent',
|
|
130
|
-
join(agentDir, 'agent.yaml'),
|
|
131
|
-
],
|
|
148
|
+
command:
|
|
149
|
+
engine.command === 'node'
|
|
150
|
+
? ['node', engine.bin, '--agent', join(agentDir, 'agent.yaml')]
|
|
151
|
+
: ['npx', '-y', '@soleri/engine', '--agent', join(agentDir, 'agent.yaml')],
|
|
132
152
|
};
|
|
133
153
|
} else {
|
|
134
154
|
servers[agentId] = {
|
|
@@ -174,15 +194,30 @@ function installLauncher(agentId: string, agentDir: string): void {
|
|
|
174
194
|
export function registerInstall(program: Command): void {
|
|
175
195
|
program
|
|
176
196
|
.command('install')
|
|
177
|
-
.argument('[dir]', 'Agent directory (
|
|
197
|
+
.argument('[dir]', 'Agent directory or agent name (checks ~/.soleri/<name> first, then cwd)')
|
|
178
198
|
.option('--target <target>', 'Registration target: claude, opencode, codex, or all', 'claude')
|
|
179
199
|
.description('Register agent as MCP server in editor config')
|
|
180
200
|
.action(async (dir?: string, opts?: { target?: string }) => {
|
|
181
|
-
|
|
201
|
+
let resolvedDir: string | undefined;
|
|
202
|
+
|
|
203
|
+
if (dir) {
|
|
204
|
+
// If dir looks like a bare agent name (no slashes), check ~/.soleri/{name} first
|
|
205
|
+
if (!dir.includes('/') && !dir.includes('\\')) {
|
|
206
|
+
const soleriPath = join(SOLERI_HOME, dir);
|
|
207
|
+
if (existsSync(join(soleriPath, 'agent.yaml'))) {
|
|
208
|
+
resolvedDir = soleriPath;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (!resolvedDir) {
|
|
212
|
+
resolvedDir = resolve(dir);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
182
216
|
const ctx = detectAgent(resolvedDir);
|
|
183
217
|
|
|
184
218
|
if (!ctx) {
|
|
185
219
|
p.log.error('Not in an agent project. Run from an agent directory or pass its path.');
|
|
220
|
+
p.log.info(`Tip: agents created with "soleri create" live in ${SOLERI_HOME}/`);
|
|
186
221
|
process.exit(1);
|
|
187
222
|
}
|
|
188
223
|
|
|
@@ -196,7 +231,15 @@ export function registerInstall(program: Command): void {
|
|
|
196
231
|
}
|
|
197
232
|
|
|
198
233
|
if (isFileTree) {
|
|
199
|
-
|
|
234
|
+
const engine = resolveEngineBin();
|
|
235
|
+
if (engine.command === 'node') {
|
|
236
|
+
p.log.info(`Detected file-tree agent (v7) — using resolved engine at ${engine.bin}`);
|
|
237
|
+
} else {
|
|
238
|
+
p.log.warn(
|
|
239
|
+
`Could not resolve @soleri/core locally — falling back to npx (slower startup)`,
|
|
240
|
+
);
|
|
241
|
+
p.log.info(`For instant startup: npm install -g @soleri/cli`);
|
|
242
|
+
}
|
|
200
243
|
}
|
|
201
244
|
|
|
202
245
|
if (target === 'claude' || target === 'both' || target === 'all') {
|
package/src/commands/pack.ts
CHANGED
|
@@ -636,7 +636,6 @@ export function registerPack(program: Command): void {
|
|
|
636
636
|
|
|
637
637
|
function listMdFiles(dir: string): string[] {
|
|
638
638
|
try {
|
|
639
|
-
const { readdirSync } = require('node:fs');
|
|
640
639
|
const { basename } = require('node:path');
|
|
641
640
|
return readdirSync(dir)
|
|
642
641
|
.filter((f: string) => f.endsWith('.md'))
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { existsSync, readdirSync, statSync, rmSync, cpSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import * as log from '../utils/logger.js';
|
|
6
|
+
|
|
7
|
+
const STAGING_ROOT = join(homedir(), '.soleri', 'staging');
|
|
8
|
+
|
|
9
|
+
interface StagedEntry {
|
|
10
|
+
id: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
path: string;
|
|
13
|
+
size: number;
|
|
14
|
+
isDirectory: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Walk a directory tree and collect all items with their relative paths.
|
|
19
|
+
*/
|
|
20
|
+
function walkDir(dir: string, base: string): { relPath: string; size: number }[] {
|
|
21
|
+
const results: { relPath: string; size: number }[] = [];
|
|
22
|
+
if (!existsSync(dir)) return results;
|
|
23
|
+
|
|
24
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
const fullPath = join(dir, entry.name);
|
|
27
|
+
const relPath = relative(base, fullPath);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
results.push(...walkDir(fullPath, base));
|
|
30
|
+
} else {
|
|
31
|
+
const stat = statSync(fullPath);
|
|
32
|
+
results.push({ relPath, size: stat.size });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return results;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* List all staged entries.
|
|
40
|
+
*/
|
|
41
|
+
function listStaged(): StagedEntry[] {
|
|
42
|
+
if (!existsSync(STAGING_ROOT)) return [];
|
|
43
|
+
|
|
44
|
+
const entries: StagedEntry[] = [];
|
|
45
|
+
const dirs = readdirSync(STAGING_ROOT, { withFileTypes: true });
|
|
46
|
+
|
|
47
|
+
for (const dir of dirs) {
|
|
48
|
+
if (!dir.isDirectory()) continue;
|
|
49
|
+
const stagePath = join(STAGING_ROOT, dir.name);
|
|
50
|
+
const _stat = statSync(stagePath);
|
|
51
|
+
const files = walkDir(stagePath, stagePath);
|
|
52
|
+
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
|
53
|
+
|
|
54
|
+
entries.push({
|
|
55
|
+
id: dir.name,
|
|
56
|
+
timestamp: dir.name,
|
|
57
|
+
path: stagePath,
|
|
58
|
+
size: totalSize,
|
|
59
|
+
isDirectory: true,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a duration string like "7d", "24h", "30m" into milliseconds.
|
|
68
|
+
*/
|
|
69
|
+
function parseDuration(duration: string): number | null {
|
|
70
|
+
const match = duration.match(/^(\d+)(d|h|m)$/);
|
|
71
|
+
if (!match) return null;
|
|
72
|
+
|
|
73
|
+
const value = parseInt(match[1], 10);
|
|
74
|
+
const unit = match[2];
|
|
75
|
+
|
|
76
|
+
switch (unit) {
|
|
77
|
+
case 'd':
|
|
78
|
+
return value * 24 * 60 * 60 * 1000;
|
|
79
|
+
case 'h':
|
|
80
|
+
return value * 60 * 60 * 1000;
|
|
81
|
+
case 'm':
|
|
82
|
+
return value * 60 * 1000;
|
|
83
|
+
default:
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatSize(bytes: number): string {
|
|
89
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
90
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
91
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function registerStaging(program: Command): void {
|
|
95
|
+
const staging = program.command('staging').description('Manage anti-deletion staging folder');
|
|
96
|
+
|
|
97
|
+
staging
|
|
98
|
+
.command('list')
|
|
99
|
+
.description('Show staged files with timestamps')
|
|
100
|
+
.action(() => {
|
|
101
|
+
const entries = listStaged();
|
|
102
|
+
|
|
103
|
+
if (entries.length === 0) {
|
|
104
|
+
log.info('No staged files found.');
|
|
105
|
+
log.dim(` Staging directory: ${STAGING_ROOT}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
log.heading('Staged Files');
|
|
110
|
+
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
const files = walkDir(entry.path, entry.path);
|
|
113
|
+
log.pass(entry.id, `${files.length} file(s), ${formatSize(entry.size)}`);
|
|
114
|
+
for (const file of files.slice(0, 10)) {
|
|
115
|
+
log.dim(` ${file.relPath}`);
|
|
116
|
+
}
|
|
117
|
+
if (files.length > 10) {
|
|
118
|
+
log.dim(` ... and ${files.length - 10} more`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
log.info(`${entries.length} staging snapshot(s) in ${STAGING_ROOT}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
staging
|
|
126
|
+
.command('restore')
|
|
127
|
+
.argument('<id>', 'Staging snapshot ID (timestamp)')
|
|
128
|
+
.description('Restore files from staging to their original locations')
|
|
129
|
+
.action((id: string) => {
|
|
130
|
+
const stagePath = join(STAGING_ROOT, id);
|
|
131
|
+
|
|
132
|
+
if (!existsSync(stagePath)) {
|
|
133
|
+
log.fail(`Staging snapshot "${id}" not found.`);
|
|
134
|
+
const entries = listStaged();
|
|
135
|
+
if (entries.length > 0) {
|
|
136
|
+
log.info(`Available: ${entries.map((e) => e.id).join(', ')}`);
|
|
137
|
+
}
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const files = walkDir(stagePath, stagePath);
|
|
142
|
+
let restored = 0;
|
|
143
|
+
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
// The staging preserves absolute paths, so the relPath starts from root
|
|
146
|
+
const destPath = join('/', file.relPath);
|
|
147
|
+
const srcPath = join(stagePath, file.relPath);
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const destDir = join(destPath, '..');
|
|
151
|
+
mkdirSync(destDir, { recursive: true });
|
|
152
|
+
cpSync(srcPath, destPath, { force: true });
|
|
153
|
+
restored++;
|
|
154
|
+
log.pass(`Restored ${destPath}`);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
log.fail(`Failed to restore ${destPath}: ${err}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
log.info(`Restored ${restored}/${files.length} file(s) from snapshot "${id}"`);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
staging
|
|
164
|
+
.command('purge')
|
|
165
|
+
.option('--older-than <duration>', 'Only purge snapshots older than duration (e.g. 7d, 24h)')
|
|
166
|
+
.description('Permanently delete staged files')
|
|
167
|
+
.action((opts: { olderThan?: string }) => {
|
|
168
|
+
if (!existsSync(STAGING_ROOT)) {
|
|
169
|
+
log.info('No staging directory found. Nothing to purge.');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const entries = listStaged();
|
|
174
|
+
|
|
175
|
+
if (entries.length === 0) {
|
|
176
|
+
log.info('No staged files to purge.');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let toPurge = entries;
|
|
181
|
+
|
|
182
|
+
if (opts.olderThan) {
|
|
183
|
+
const maxAge = parseDuration(opts.olderThan);
|
|
184
|
+
if (!maxAge) {
|
|
185
|
+
log.fail(`Invalid duration: "${opts.olderThan}". Use format like 7d, 24h, 30m`);
|
|
186
|
+
process.exit(1);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const cutoff = Date.now() - maxAge;
|
|
190
|
+
toPurge = entries.filter((entry) => {
|
|
191
|
+
const stat = statSync(entry.path);
|
|
192
|
+
return stat.mtimeMs < cutoff;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (toPurge.length === 0) {
|
|
197
|
+
log.info('No snapshots match the purge criteria.');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const entry of toPurge) {
|
|
202
|
+
rmSync(entry.path, { recursive: true, force: true });
|
|
203
|
+
log.warn(`Purged ${entry.id}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
log.info(`Purged ${toPurge.length} staging snapshot(s)`);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "full",
|
|
3
3
|
"version": "1.0.0",
|
|
4
|
-
"description": "Complete quality suite — all
|
|
4
|
+
"description": "Complete quality suite — all hooks",
|
|
5
5
|
"hooks": [
|
|
6
6
|
"no-any-types",
|
|
7
7
|
"no-console-log",
|
|
@@ -12,5 +12,5 @@
|
|
|
12
12
|
"ux-touch-targets",
|
|
13
13
|
"no-ai-attribution"
|
|
14
14
|
],
|
|
15
|
-
"composedFrom": ["typescript-safety", "a11y", "css-discipline", "clean-commits"]
|
|
15
|
+
"composedFrom": ["typescript-safety", "a11y", "css-discipline", "clean-commits", "yolo-safety"]
|
|
16
16
|
}
|