@aldegad/safedeps 2.1.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/ARCHITECTURE.md +595 -0
- package/LICENSE +190 -0
- package/README.md +311 -0
- package/ROADMAP.md +131 -0
- package/SKILL.md +200 -0
- package/agents/openai.yaml +4 -0
- package/bin/safedeps +842 -0
- package/lib/ledger/ledger.sh +346 -0
- package/lib/providers/providers.sh +479 -0
- package/package.json +41 -0
- package/scripts/install/install-safedeps-hooks.mjs +209 -0
- package/scripts/install/install-safedeps-recheck-agent.mjs +203 -0
- package/scripts/install/migrate-safedeps-state.mjs +91 -0
- package/scripts/safedeps-post-verify.sh +584 -0
- package/scripts/safedeps-pre-guard.sh +427 -0
- package/scripts/safedeps-recheck-alert.sh +115 -0
- package/scripts/test/e2e.sh +107 -0
- package/scripts/test/fixture-provider.mjs +104 -0
- package/scripts/test/smoke.sh +89 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// safedeps cross-engine installer.
|
|
3
|
+
// Registers the safedeps skill + PreToolUse/PostToolUse hooks for both
|
|
4
|
+
// Claude Code (~/.claude) and Codex CLI (~/.codex).
|
|
5
|
+
//
|
|
6
|
+
// Idempotent: running twice leaves state unchanged.
|
|
7
|
+
// Backup-before-write: every JSON config file is copied to .bak before edit.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// node scripts/install/install-safedeps-hooks.mjs
|
|
11
|
+
// node scripts/install/install-safedeps-hooks.mjs --uninstall
|
|
12
|
+
// node scripts/install/install-safedeps-hooks.mjs --link-bin (optional ~/.local/bin/safedeps)
|
|
13
|
+
|
|
14
|
+
import { existsSync, lstatSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, symlinkSync, unlinkSync, readlinkSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const REPO_ROOT = resolve(HERE, "..", "..");
|
|
21
|
+
const HOME = homedir();
|
|
22
|
+
|
|
23
|
+
const SKILL_ID = "safedeps";
|
|
24
|
+
const PRE_HOOK = join(REPO_ROOT, "scripts", "safedeps-pre-guard.sh");
|
|
25
|
+
const POST_HOOK = join(REPO_ROOT, "scripts", "safedeps-post-verify.sh");
|
|
26
|
+
const CLI_BIN = join(REPO_ROOT, "bin", "safedeps");
|
|
27
|
+
|
|
28
|
+
const args = new Set(process.argv.slice(2));
|
|
29
|
+
const UNINSTALL = args.has("--uninstall");
|
|
30
|
+
const LINK_BIN = args.has("--link-bin");
|
|
31
|
+
|
|
32
|
+
function log(...parts) { console.log(`[safedeps-install]`, ...parts); }
|
|
33
|
+
function warn(...parts) { console.warn(`[safedeps-install]`, ...parts); }
|
|
34
|
+
|
|
35
|
+
function isSymlink(p) {
|
|
36
|
+
try { return lstatSync(p).isSymbolicLink(); } catch { return false; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ensureSymlink(target, linkPath) {
|
|
40
|
+
if (isSymlink(linkPath)) {
|
|
41
|
+
const current = readlinkSync(linkPath);
|
|
42
|
+
if (current === target) { log(`symlink ok ${linkPath} -> ${target}`); return; }
|
|
43
|
+
unlinkSync(linkPath);
|
|
44
|
+
} else if (existsSync(linkPath)) {
|
|
45
|
+
throw new Error(`refusing to overwrite non-symlink at ${linkPath}`);
|
|
46
|
+
}
|
|
47
|
+
mkdirSync(dirname(linkPath), { recursive: true });
|
|
48
|
+
symlinkSync(target, linkPath);
|
|
49
|
+
log(`symlink wrote ${linkPath} -> ${target}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function removeSymlink(linkPath) {
|
|
53
|
+
if (isSymlink(linkPath)) {
|
|
54
|
+
unlinkSync(linkPath);
|
|
55
|
+
log(`symlink removed ${linkPath}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readJson(path) {
|
|
60
|
+
if (!existsSync(path)) return {};
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
63
|
+
} catch (err) {
|
|
64
|
+
throw new Error(`invalid JSON at ${path}: ${err.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeJsonWithBackup(path, value) {
|
|
69
|
+
if (existsSync(path)) {
|
|
70
|
+
copyFileSync(path, `${path}.bak`);
|
|
71
|
+
} else {
|
|
72
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
73
|
+
}
|
|
74
|
+
writeFileSync(path, JSON.stringify(value, null, 2) + "\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ensureHook(config, eventName, command) {
|
|
78
|
+
config.hooks = config.hooks ?? {};
|
|
79
|
+
config.hooks[eventName] = config.hooks[eventName] ?? [];
|
|
80
|
+
const buckets = config.hooks[eventName];
|
|
81
|
+
|
|
82
|
+
let bashBucket = buckets.find((b) => b && b.matcher === "Bash");
|
|
83
|
+
if (!bashBucket) {
|
|
84
|
+
bashBucket = { matcher: "Bash", hooks: [] };
|
|
85
|
+
buckets.push(bashBucket);
|
|
86
|
+
}
|
|
87
|
+
bashBucket.hooks = bashBucket.hooks ?? [];
|
|
88
|
+
|
|
89
|
+
const already = bashBucket.hooks.some((h) => h && h.type === "command" && h.command === command);
|
|
90
|
+
if (already) return false;
|
|
91
|
+
|
|
92
|
+
bashBucket.hooks.push({ type: "command", command });
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function removeHook(config, eventName, command) {
|
|
97
|
+
const buckets = config?.hooks?.[eventName];
|
|
98
|
+
if (!Array.isArray(buckets)) return false;
|
|
99
|
+
let changed = false;
|
|
100
|
+
for (const bucket of buckets) {
|
|
101
|
+
if (!bucket || bucket.matcher !== "Bash" || !Array.isArray(bucket.hooks)) continue;
|
|
102
|
+
const before = bucket.hooks.length;
|
|
103
|
+
bucket.hooks = bucket.hooks.filter((h) => !(h && h.type === "command" && h.command === command));
|
|
104
|
+
if (bucket.hooks.length !== before) changed = true;
|
|
105
|
+
}
|
|
106
|
+
return changed;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function pruneLegacySafedepsHooks(config, eventName) {
|
|
110
|
+
const buckets = config?.hooks?.[eventName];
|
|
111
|
+
if (!Array.isArray(buckets)) return false;
|
|
112
|
+
let changed = false;
|
|
113
|
+
for (const bucket of buckets) {
|
|
114
|
+
if (!bucket || bucket.matcher !== "Bash" || !Array.isArray(bucket.hooks)) continue;
|
|
115
|
+
const before = bucket.hooks.length;
|
|
116
|
+
bucket.hooks = bucket.hooks.filter((h) => {
|
|
117
|
+
const command = h?.command;
|
|
118
|
+
if (typeof command !== "string") return true;
|
|
119
|
+
const legacySafedeps = command.includes("npm-reorg-guard") || command.includes("/safedeps/");
|
|
120
|
+
const legacyHookName = command.endsWith("/scripts/guard.sh") || command.endsWith("/scripts/verify.sh");
|
|
121
|
+
return !(legacySafedeps && legacyHookName);
|
|
122
|
+
});
|
|
123
|
+
if (bucket.hooks.length !== before) changed = true;
|
|
124
|
+
}
|
|
125
|
+
return changed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function installInEngine({ engineRoot, configPath, label }) {
|
|
129
|
+
if (!existsSync(engineRoot)) {
|
|
130
|
+
warn(`skip ${label} (${engineRoot} not present)`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const skillsRoot = join(engineRoot, "skills");
|
|
134
|
+
const skillLink = join(skillsRoot, SKILL_ID);
|
|
135
|
+
|
|
136
|
+
if (UNINSTALL) {
|
|
137
|
+
removeSymlink(skillLink);
|
|
138
|
+
if (existsSync(configPath)) {
|
|
139
|
+
const cfg = readJson(configPath);
|
|
140
|
+
const pre = removeHook(cfg, "PreToolUse", PRE_HOOK);
|
|
141
|
+
const post = removeHook(cfg, "PostToolUse", POST_HOOK);
|
|
142
|
+
if (pre || post) {
|
|
143
|
+
writeJsonWithBackup(configPath, cfg);
|
|
144
|
+
log(`patched ${configPath} (removed safedeps hooks)`);
|
|
145
|
+
} else {
|
|
146
|
+
log(`config clean ${configPath}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
ensureSymlink(REPO_ROOT, skillLink);
|
|
153
|
+
|
|
154
|
+
const cfg = readJson(configPath);
|
|
155
|
+
const legacyPreRemoved = pruneLegacySafedepsHooks(cfg, "PreToolUse");
|
|
156
|
+
const legacyPostRemoved = pruneLegacySafedepsHooks(cfg, "PostToolUse");
|
|
157
|
+
const preAdded = ensureHook(cfg, "PreToolUse", PRE_HOOK);
|
|
158
|
+
const postAdded = ensureHook(cfg, "PostToolUse", POST_HOOK);
|
|
159
|
+
if (legacyPreRemoved || legacyPostRemoved || preAdded || postAdded) {
|
|
160
|
+
writeJsonWithBackup(configPath, cfg);
|
|
161
|
+
log(`patched ${configPath} (pre=${preAdded ? "added" : "ok"}, post=${postAdded ? "added" : "ok"}, legacy=${legacyPreRemoved || legacyPostRemoved ? "removed" : "ok"})`);
|
|
162
|
+
} else {
|
|
163
|
+
log(`config ok ${configPath} (hooks already registered)`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function maybeLinkBin() {
|
|
168
|
+
if (!LINK_BIN || UNINSTALL) return;
|
|
169
|
+
const target = CLI_BIN;
|
|
170
|
+
const linkPath = join(HOME, ".local", "bin", "safedeps");
|
|
171
|
+
try {
|
|
172
|
+
ensureSymlink(target, linkPath);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
warn(`bin symlink skipped: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function unlinkBin() {
|
|
179
|
+
if (!UNINSTALL) return;
|
|
180
|
+
removeSymlink(join(HOME, ".local", "bin", "safedeps"));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function main() {
|
|
184
|
+
if (!existsSync(PRE_HOOK) || !existsSync(POST_HOOK)) {
|
|
185
|
+
throw new Error(`hook scripts not found at ${PRE_HOOK} / ${POST_HOOK}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
installInEngine({
|
|
189
|
+
engineRoot: join(HOME, ".claude"),
|
|
190
|
+
configPath: join(HOME, ".claude", "settings.json"),
|
|
191
|
+
label: "Claude Code",
|
|
192
|
+
});
|
|
193
|
+
installInEngine({
|
|
194
|
+
engineRoot: join(HOME, ".codex"),
|
|
195
|
+
configPath: join(HOME, ".codex", "hooks.json"),
|
|
196
|
+
label: "Codex CLI",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
maybeLinkBin();
|
|
200
|
+
unlinkBin();
|
|
201
|
+
|
|
202
|
+
if (UNINSTALL) {
|
|
203
|
+
log("uninstall done.");
|
|
204
|
+
} else {
|
|
205
|
+
log("install done. New hook events fire on the next session start.");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
main();
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* Install a per-user macOS launchd agent for daily safedeps re-check. */
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const LABEL = 'com.aldegad.safedeps.recheck';
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
const repoRoot = path.resolve(__dirname, '..', '..');
|
|
14
|
+
const wrapperPath = path.join(repoRoot, 'scripts', 'safedeps-recheck-alert.sh');
|
|
15
|
+
const safedepsHome = path.join(os.homedir(), '.safedeps');
|
|
16
|
+
const agentRoot = path.join(safedepsHome, 'agent');
|
|
17
|
+
const installedWrapperPath = path.join(agentRoot, 'scripts', 'safedeps-recheck-alert.sh');
|
|
18
|
+
const launchAgentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
19
|
+
const plistPath = path.join(launchAgentsDir, `${LABEL}.plist`);
|
|
20
|
+
const launchdStdoutPath = path.join(safedepsHome, 'launchd-recheck.out.log');
|
|
21
|
+
const launchdStderrPath = path.join(safedepsHome, 'launchd-recheck.err.log');
|
|
22
|
+
|
|
23
|
+
function usage() {
|
|
24
|
+
console.log(`usage: install-safedeps-recheck-agent.mjs <install|uninstall|status> [--hour HH] [--minute MM]
|
|
25
|
+
|
|
26
|
+
Default schedule: 09:00 local time.
|
|
27
|
+
`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseArgs(argv) {
|
|
31
|
+
const args = { command: argv[0] || 'install', hour: 9, minute: 0 };
|
|
32
|
+
if (args.command === '-h' || args.command === '--help') {
|
|
33
|
+
args.command = 'help';
|
|
34
|
+
}
|
|
35
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
36
|
+
const arg = argv[i];
|
|
37
|
+
if (arg === '--hour') {
|
|
38
|
+
args.hour = Number(argv[++i]);
|
|
39
|
+
} else if (arg === '--minute') {
|
|
40
|
+
args.minute = Number(argv[++i]);
|
|
41
|
+
} else if (arg === '-h' || arg === '--help') {
|
|
42
|
+
args.command = 'help';
|
|
43
|
+
} else {
|
|
44
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!Number.isInteger(args.hour) || args.hour < 0 || args.hour > 23) {
|
|
48
|
+
throw new Error('--hour must be an integer from 0 to 23');
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isInteger(args.minute) || args.minute < 0 || args.minute > 59) {
|
|
51
|
+
throw new Error('--minute must be an integer from 0 to 59');
|
|
52
|
+
}
|
|
53
|
+
return args;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function xmlEscape(value) {
|
|
57
|
+
return String(value)
|
|
58
|
+
.replaceAll('&', '&')
|
|
59
|
+
.replaceAll('<', '<')
|
|
60
|
+
.replaceAll('>', '>')
|
|
61
|
+
.replaceAll('"', '"')
|
|
62
|
+
.replaceAll("'", ''');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function plistXml({ hour, minute }) {
|
|
66
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
67
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
68
|
+
<plist version="1.0">
|
|
69
|
+
<dict>
|
|
70
|
+
<key>Label</key>
|
|
71
|
+
<string>${LABEL}</string>
|
|
72
|
+
<key>ProgramArguments</key>
|
|
73
|
+
<array>
|
|
74
|
+
<string>${xmlEscape(installedWrapperPath)}</string>
|
|
75
|
+
</array>
|
|
76
|
+
<key>StartCalendarInterval</key>
|
|
77
|
+
<dict>
|
|
78
|
+
<key>Hour</key>
|
|
79
|
+
<integer>${hour}</integer>
|
|
80
|
+
<key>Minute</key>
|
|
81
|
+
<integer>${minute}</integer>
|
|
82
|
+
</dict>
|
|
83
|
+
<key>EnvironmentVariables</key>
|
|
84
|
+
<dict>
|
|
85
|
+
<key>SAFEDEPS_HOME</key>
|
|
86
|
+
<string>${xmlEscape(safedepsHome)}</string>
|
|
87
|
+
</dict>
|
|
88
|
+
<key>StandardOutPath</key>
|
|
89
|
+
<string>${xmlEscape(launchdStdoutPath)}</string>
|
|
90
|
+
<key>StandardErrorPath</key>
|
|
91
|
+
<string>${xmlEscape(launchdStderrPath)}</string>
|
|
92
|
+
</dict>
|
|
93
|
+
</plist>
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function copyRuntimeFile(relativePath, mode) {
|
|
98
|
+
const source = path.join(repoRoot, relativePath);
|
|
99
|
+
const target = path.join(agentRoot, relativePath);
|
|
100
|
+
|
|
101
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
102
|
+
fs.copyFileSync(source, target);
|
|
103
|
+
fs.chmodSync(target, mode);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function installRuntime() {
|
|
107
|
+
copyRuntimeFile('bin/safedeps', 0o755);
|
|
108
|
+
copyRuntimeFile('lib/providers/providers.sh', 0o755);
|
|
109
|
+
copyRuntimeFile('lib/ledger/ledger.sh', 0o755);
|
|
110
|
+
copyRuntimeFile('scripts/safedeps-recheck-alert.sh', 0o755);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function run(command, args, { allowFailure = false } = {}) {
|
|
114
|
+
const result = spawnSync(command, args, { encoding: 'utf8' });
|
|
115
|
+
if (result.status !== 0 && !allowFailure) {
|
|
116
|
+
const detail = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
117
|
+
throw new Error(`${command} ${args.join(' ')} failed${detail ? `\n${detail}` : ''}`);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function serviceTarget() {
|
|
123
|
+
return `gui/${process.getuid()}/${LABEL}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function domainTarget() {
|
|
127
|
+
return `gui/${process.getuid()}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function install(args) {
|
|
131
|
+
if (process.platform !== 'darwin') {
|
|
132
|
+
throw new Error('launchd re-check agent install is supported on macOS only');
|
|
133
|
+
}
|
|
134
|
+
if (!fs.existsSync(wrapperPath)) {
|
|
135
|
+
throw new Error(`wrapper not found: ${wrapperPath}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
fs.mkdirSync(launchAgentsDir, { recursive: true, mode: 0o700 });
|
|
139
|
+
fs.mkdirSync(safedepsHome, { recursive: true, mode: 0o700 });
|
|
140
|
+
fs.chmodSync(wrapperPath, 0o755);
|
|
141
|
+
installRuntime();
|
|
142
|
+
fs.writeFileSync(launchdStdoutPath, '');
|
|
143
|
+
fs.writeFileSync(launchdStderrPath, '');
|
|
144
|
+
|
|
145
|
+
const tempPath = `${plistPath}.${process.pid}.tmp`;
|
|
146
|
+
fs.writeFileSync(tempPath, plistXml(args), { mode: 0o644 });
|
|
147
|
+
fs.renameSync(tempPath, plistPath);
|
|
148
|
+
|
|
149
|
+
run('launchctl', ['bootout', domainTarget(), plistPath], { allowFailure: true });
|
|
150
|
+
run('launchctl', ['bootstrap', domainTarget(), plistPath]);
|
|
151
|
+
run('launchctl', ['kickstart', '-k', serviceTarget()]);
|
|
152
|
+
|
|
153
|
+
console.log(JSON.stringify({
|
|
154
|
+
installed: true,
|
|
155
|
+
label: LABEL,
|
|
156
|
+
plistPath,
|
|
157
|
+
agentRoot,
|
|
158
|
+
program: installedWrapperPath,
|
|
159
|
+
schedule: { hour: args.hour, minute: args.minute },
|
|
160
|
+
logs: {
|
|
161
|
+
recheck: path.join(os.homedir(), '.safedeps', 'recheck.log'),
|
|
162
|
+
alerts: path.join(os.homedir(), '.safedeps', 'recheck-alerts.jsonl'),
|
|
163
|
+
launchdStdout: launchdStdoutPath,
|
|
164
|
+
launchdStderr: launchdStderrPath
|
|
165
|
+
}
|
|
166
|
+
}, null, 2));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function uninstall() {
|
|
170
|
+
if (process.platform !== 'darwin') {
|
|
171
|
+
throw new Error('launchd re-check agent uninstall is supported on macOS only');
|
|
172
|
+
}
|
|
173
|
+
run('launchctl', ['bootout', domainTarget(), plistPath], { allowFailure: true });
|
|
174
|
+
if (fs.existsSync(plistPath)) {
|
|
175
|
+
fs.rmSync(plistPath);
|
|
176
|
+
}
|
|
177
|
+
console.log(JSON.stringify({ installed: false, label: LABEL, plistPath, agentRoot }, null, 2));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function status() {
|
|
181
|
+
const result = run('launchctl', ['print', serviceTarget()], { allowFailure: true });
|
|
182
|
+
process.stdout.write(result.stdout || '');
|
|
183
|
+
process.stderr.write(result.stderr || '');
|
|
184
|
+
process.exitCode = result.status === 0 ? 0 : 1;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const args = parseArgs(process.argv.slice(2));
|
|
189
|
+
if (args.command === 'help') {
|
|
190
|
+
usage();
|
|
191
|
+
} else if (args.command === 'install') {
|
|
192
|
+
install(args);
|
|
193
|
+
} else if (args.command === 'uninstall') {
|
|
194
|
+
uninstall();
|
|
195
|
+
} else if (args.command === 'status') {
|
|
196
|
+
status();
|
|
197
|
+
} else {
|
|
198
|
+
throw new Error(`unknown command: ${args.command}`);
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.error(`safedeps re-check agent: ${error.message}`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* Migrate legacy npm-reorg-guard state into the safedeps namespace. */
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const legacyRoot = process.env.SAFEDEPS_LEGACY_HOME || path.join(os.homedir(), '.npm-reorg-guard');
|
|
9
|
+
const targetRoot = process.env.SAFEDEPS_HOME || path.join(os.homedir(), '.safedeps');
|
|
10
|
+
const keepLegacy = process.argv.includes('--keep-legacy');
|
|
11
|
+
|
|
12
|
+
function stamp() {
|
|
13
|
+
return new Date().toISOString().replaceAll(':', '').replaceAll('.', '').replace('Z', 'Z');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function uniqueArchivePath(base) {
|
|
17
|
+
let candidate = `${base}.migrated-${stamp()}`;
|
|
18
|
+
let i = 1;
|
|
19
|
+
while (fs.existsSync(candidate)) {
|
|
20
|
+
candidate = `${base}.migrated-${stamp()}-${i}`;
|
|
21
|
+
i += 1;
|
|
22
|
+
}
|
|
23
|
+
return candidate;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function walk(root) {
|
|
27
|
+
const out = [];
|
|
28
|
+
if (!fs.existsSync(root)) return out;
|
|
29
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
30
|
+
const abs = path.join(root, entry.name);
|
|
31
|
+
if (entry.isDirectory()) {
|
|
32
|
+
out.push(...walk(abs));
|
|
33
|
+
} else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
34
|
+
out.push(abs);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function migrate() {
|
|
41
|
+
if (!fs.existsSync(legacyRoot)) {
|
|
42
|
+
return { migrated: false, reason: 'legacy_missing', legacyRoot, targetRoot, copied: 0, skipped: 0 };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fs.mkdirSync(targetRoot, { recursive: true, mode: 0o700 });
|
|
46
|
+
|
|
47
|
+
let copied = 0;
|
|
48
|
+
let skipped = 0;
|
|
49
|
+
const skippedPaths = [];
|
|
50
|
+
for (const source of walk(legacyRoot)) {
|
|
51
|
+
const rel = path.relative(legacyRoot, source);
|
|
52
|
+
const target = path.join(targetRoot, rel);
|
|
53
|
+
if (fs.existsSync(target)) {
|
|
54
|
+
skipped += 1;
|
|
55
|
+
skippedPaths.push(rel);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
59
|
+
fs.copyFileSync(source, target);
|
|
60
|
+
try {
|
|
61
|
+
fs.chmodSync(target, fs.statSync(source).mode & 0o777);
|
|
62
|
+
} catch {
|
|
63
|
+
fs.chmodSync(target, 0o600);
|
|
64
|
+
}
|
|
65
|
+
copied += 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let archivedAs = null;
|
|
69
|
+
if (!keepLegacy) {
|
|
70
|
+
archivedAs = uniqueArchivePath(legacyRoot);
|
|
71
|
+
fs.renameSync(legacyRoot, archivedAs);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
migrated: true,
|
|
76
|
+
legacyRoot,
|
|
77
|
+
targetRoot,
|
|
78
|
+
copied,
|
|
79
|
+
skipped,
|
|
80
|
+
skippedPaths,
|
|
81
|
+
archivedAs,
|
|
82
|
+
keptLegacy: keepLegacy
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
console.log(JSON.stringify(migrate(), null, 2));
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error(`safedeps migrate: ${error.message}`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|