@rainy-updates/cli 0.5.1 → 0.5.2-rc.2
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/CHANGELOG.md +93 -1
- package/README.md +88 -25
- package/dist/bin/cli.js +50 -1
- package/dist/commands/audit/fetcher.d.ts +2 -6
- package/dist/commands/audit/fetcher.js +2 -79
- package/dist/commands/audit/mapper.d.ts +8 -1
- package/dist/commands/audit/mapper.js +106 -10
- package/dist/commands/audit/parser.js +36 -2
- package/dist/commands/audit/runner.js +179 -15
- package/dist/commands/audit/sources/github.d.ts +2 -0
- package/dist/commands/audit/sources/github.js +125 -0
- package/dist/commands/audit/sources/index.d.ts +6 -0
- package/dist/commands/audit/sources/index.js +92 -0
- package/dist/commands/audit/sources/osv.d.ts +2 -0
- package/dist/commands/audit/sources/osv.js +131 -0
- package/dist/commands/audit/sources/types.d.ts +21 -0
- package/dist/commands/audit/sources/types.js +1 -0
- package/dist/commands/audit/targets.d.ts +20 -0
- package/dist/commands/audit/targets.js +314 -0
- package/dist/commands/changelog/fetcher.d.ts +9 -0
- package/dist/commands/changelog/fetcher.js +130 -0
- package/dist/commands/licenses/parser.d.ts +2 -0
- package/dist/commands/licenses/parser.js +116 -0
- package/dist/commands/licenses/runner.d.ts +9 -0
- package/dist/commands/licenses/runner.js +163 -0
- package/dist/commands/licenses/sbom.d.ts +10 -0
- package/dist/commands/licenses/sbom.js +70 -0
- package/dist/commands/resolve/graph/builder.d.ts +20 -0
- package/dist/commands/resolve/graph/builder.js +183 -0
- package/dist/commands/resolve/graph/conflict.d.ts +20 -0
- package/dist/commands/resolve/graph/conflict.js +52 -0
- package/dist/commands/resolve/graph/resolver.d.ts +17 -0
- package/dist/commands/resolve/graph/resolver.js +71 -0
- package/dist/commands/resolve/parser.d.ts +2 -0
- package/dist/commands/resolve/parser.js +89 -0
- package/dist/commands/resolve/runner.d.ts +13 -0
- package/dist/commands/resolve/runner.js +136 -0
- package/dist/commands/snapshot/parser.d.ts +2 -0
- package/dist/commands/snapshot/parser.js +80 -0
- package/dist/commands/snapshot/runner.d.ts +11 -0
- package/dist/commands/snapshot/runner.js +115 -0
- package/dist/commands/snapshot/store.d.ts +35 -0
- package/dist/commands/snapshot/store.js +158 -0
- package/dist/commands/unused/matcher.d.ts +22 -0
- package/dist/commands/unused/matcher.js +95 -0
- package/dist/commands/unused/parser.d.ts +2 -0
- package/dist/commands/unused/parser.js +95 -0
- package/dist/commands/unused/runner.d.ts +11 -0
- package/dist/commands/unused/runner.js +113 -0
- package/dist/commands/unused/scanner.d.ts +18 -0
- package/dist/commands/unused/scanner.js +129 -0
- package/dist/core/impact.d.ts +36 -0
- package/dist/core/impact.js +82 -0
- package/dist/core/options.d.ts +13 -1
- package/dist/core/options.js +35 -13
- package/dist/types/index.d.ts +187 -1
- package/dist/ui/tui.d.ts +6 -0
- package/dist/ui/tui.js +50 -0
- package/dist/utils/semver.d.ts +18 -0
- package/dist/utils/semver.js +88 -3
- package/package.json +8 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export function parseResolveArgs(args) {
|
|
2
|
+
const options = {
|
|
3
|
+
cwd: process.cwd(),
|
|
4
|
+
workspace: false,
|
|
5
|
+
afterUpdate: false,
|
|
6
|
+
safe: false,
|
|
7
|
+
jsonFile: undefined,
|
|
8
|
+
concurrency: 12,
|
|
9
|
+
registryTimeoutMs: 10_000,
|
|
10
|
+
cacheTtlSeconds: 3600,
|
|
11
|
+
};
|
|
12
|
+
for (let i = 0; i < args.length; i++) {
|
|
13
|
+
const current = args[i];
|
|
14
|
+
const next = args[i + 1];
|
|
15
|
+
if (current === "--cwd" && next) {
|
|
16
|
+
options.cwd = next;
|
|
17
|
+
i++;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (current === "--cwd")
|
|
21
|
+
throw new Error("Missing value for --cwd");
|
|
22
|
+
if (current === "--workspace") {
|
|
23
|
+
options.workspace = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (current === "--after-update") {
|
|
27
|
+
options.afterUpdate = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (current === "--safe") {
|
|
31
|
+
options.safe = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (current === "--json-file" && next) {
|
|
35
|
+
options.jsonFile = next;
|
|
36
|
+
i++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (current === "--json-file")
|
|
40
|
+
throw new Error("Missing value for --json-file");
|
|
41
|
+
if (current === "--concurrency" && next) {
|
|
42
|
+
const n = Number(next);
|
|
43
|
+
if (!Number.isInteger(n) || n <= 0)
|
|
44
|
+
throw new Error("--concurrency must be a positive integer");
|
|
45
|
+
options.concurrency = n;
|
|
46
|
+
i++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (current === "--concurrency")
|
|
50
|
+
throw new Error("Missing value for --concurrency");
|
|
51
|
+
if (current === "--timeout" && next) {
|
|
52
|
+
const ms = Number(next);
|
|
53
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
54
|
+
throw new Error("--timeout must be a positive number");
|
|
55
|
+
options.registryTimeoutMs = ms;
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (current === "--timeout")
|
|
60
|
+
throw new Error("Missing value for --timeout");
|
|
61
|
+
if (current === "--help" || current === "-h") {
|
|
62
|
+
process.stdout.write(RESOLVE_HELP);
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
if (current.startsWith("-"))
|
|
66
|
+
throw new Error(`Unknown option: ${current}`);
|
|
67
|
+
}
|
|
68
|
+
return options;
|
|
69
|
+
}
|
|
70
|
+
const RESOLVE_HELP = `
|
|
71
|
+
rup resolve — Detect peer dependency conflicts (pure-TS, no subprocess spawn)
|
|
72
|
+
|
|
73
|
+
Usage:
|
|
74
|
+
rup resolve [options]
|
|
75
|
+
|
|
76
|
+
Options:
|
|
77
|
+
--after-update Simulate conflicts after applying pending \`rup check\` updates
|
|
78
|
+
--safe Exit non-zero if any error-level conflicts exist
|
|
79
|
+
--workspace Scan all workspace packages
|
|
80
|
+
--json-file <path> Write JSON conflict report to file
|
|
81
|
+
--timeout <ms> Registry request timeout in ms (default: 10000)
|
|
82
|
+
--concurrency <n> Parallel registry requests (default: 12)
|
|
83
|
+
--cwd <path> Working directory (default: cwd)
|
|
84
|
+
--help Show this help
|
|
85
|
+
|
|
86
|
+
Exit codes:
|
|
87
|
+
0 No conflicts
|
|
88
|
+
1 One or more peer conflicts detected
|
|
89
|
+
`.trimStart();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ResolveOptions, ResolveResult } from "../../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Entry point for `rup resolve`. Lazy-loaded by cli.ts.
|
|
4
|
+
*
|
|
5
|
+
* Modes:
|
|
6
|
+
* default — check current peer-dep state for conflicts
|
|
7
|
+
* --after-update — re-check after applying pending `rup check` updates
|
|
8
|
+
* in-memory (reads proposed versions from check runner)
|
|
9
|
+
*
|
|
10
|
+
* The pure-TS peer graph is assembled entirely from registry data; no subprocess
|
|
11
|
+
* is spawned. When the cache is warm this completes in < 1 s for typical projects.
|
|
12
|
+
*/
|
|
13
|
+
export declare function runResolve(options: ResolveOptions): Promise<ResolveResult>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { buildPeerGraph } from "./graph/builder.js";
|
|
3
|
+
import { resolvePeerConflicts } from "./graph/resolver.js";
|
|
4
|
+
import { stableStringify } from "../../utils/stable-json.js";
|
|
5
|
+
import { writeFileAtomic } from "../../utils/io.js";
|
|
6
|
+
/**
|
|
7
|
+
* Entry point for `rup resolve`. Lazy-loaded by cli.ts.
|
|
8
|
+
*
|
|
9
|
+
* Modes:
|
|
10
|
+
* default — check current peer-dep state for conflicts
|
|
11
|
+
* --after-update — re-check after applying pending `rup check` updates
|
|
12
|
+
* in-memory (reads proposed versions from check runner)
|
|
13
|
+
*
|
|
14
|
+
* The pure-TS peer graph is assembled entirely from registry data; no subprocess
|
|
15
|
+
* is spawned. When the cache is warm this completes in < 1 s for typical projects.
|
|
16
|
+
*/
|
|
17
|
+
export async function runResolve(options) {
|
|
18
|
+
const result = {
|
|
19
|
+
conflicts: [],
|
|
20
|
+
errorConflicts: 0,
|
|
21
|
+
warningConflicts: 0,
|
|
22
|
+
errors: [],
|
|
23
|
+
warnings: [],
|
|
24
|
+
};
|
|
25
|
+
let versionOverrides;
|
|
26
|
+
if (options.afterUpdate) {
|
|
27
|
+
versionOverrides = await fetchProposedVersions(options);
|
|
28
|
+
if (versionOverrides.size === 0) {
|
|
29
|
+
process.stderr.write("[resolve] No pending updates found — checking current state.\n");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
let graph;
|
|
33
|
+
try {
|
|
34
|
+
graph = await buildPeerGraph(options, versionOverrides);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
result.errors.push(`Failed to build peer graph: ${String(err)}`);
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
const conflicts = resolvePeerConflicts(graph);
|
|
41
|
+
result.conflicts = conflicts;
|
|
42
|
+
result.errorConflicts = conflicts.filter((c) => c.severity === "error").length;
|
|
43
|
+
result.warningConflicts = conflicts.filter((c) => c.severity === "warning").length;
|
|
44
|
+
process.stdout.write(renderConflictsTable(result, options) + "\n");
|
|
45
|
+
if (options.jsonFile) {
|
|
46
|
+
await writeFileAtomic(options.jsonFile, stableStringify(result, 2) + "\n");
|
|
47
|
+
process.stderr.write(`[resolve] JSON report written to ${options.jsonFile}\n`);
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* In --after-update mode, runs `rup check` logic in read-only mode to get
|
|
53
|
+
* the proposed new versions, returning them as a version override map.
|
|
54
|
+
*/
|
|
55
|
+
async function fetchProposedVersions(options) {
|
|
56
|
+
const overrides = new Map();
|
|
57
|
+
try {
|
|
58
|
+
const { check } = await import("../../core/check.js");
|
|
59
|
+
const checkResult = await check({
|
|
60
|
+
cwd: options.cwd,
|
|
61
|
+
workspace: options.workspace,
|
|
62
|
+
concurrency: options.concurrency,
|
|
63
|
+
target: "latest",
|
|
64
|
+
filter: undefined,
|
|
65
|
+
reject: undefined,
|
|
66
|
+
includeKinds: ["dependencies", "devDependencies", "optionalDependencies"],
|
|
67
|
+
ci: false,
|
|
68
|
+
format: "table",
|
|
69
|
+
jsonFile: undefined,
|
|
70
|
+
githubOutputFile: undefined,
|
|
71
|
+
sarifFile: undefined,
|
|
72
|
+
cacheTtlSeconds: options.cacheTtlSeconds,
|
|
73
|
+
registryTimeoutMs: options.registryTimeoutMs,
|
|
74
|
+
registryRetries: 2,
|
|
75
|
+
offline: false,
|
|
76
|
+
stream: false,
|
|
77
|
+
policyFile: undefined,
|
|
78
|
+
prReportFile: undefined,
|
|
79
|
+
failOn: "none",
|
|
80
|
+
maxUpdates: undefined,
|
|
81
|
+
fixPr: false,
|
|
82
|
+
fixBranch: "chore/rainy-updates",
|
|
83
|
+
fixCommitMessage: undefined,
|
|
84
|
+
fixDryRun: false,
|
|
85
|
+
fixPrNoCheckout: false,
|
|
86
|
+
fixPrBatchSize: undefined,
|
|
87
|
+
noPrReport: false,
|
|
88
|
+
logLevel: "info",
|
|
89
|
+
groupBy: "none",
|
|
90
|
+
groupMax: undefined,
|
|
91
|
+
cooldownDays: undefined,
|
|
92
|
+
prLimit: undefined,
|
|
93
|
+
onlyChanged: false,
|
|
94
|
+
ciProfile: "minimal",
|
|
95
|
+
lockfileMode: "preserve",
|
|
96
|
+
});
|
|
97
|
+
for (const update of checkResult.updates ?? []) {
|
|
98
|
+
overrides.set(update.name, update.toVersionResolved);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// If check fails, fall back to current state (no overrides)
|
|
103
|
+
}
|
|
104
|
+
return overrides;
|
|
105
|
+
}
|
|
106
|
+
function renderConflictsTable(result, options) {
|
|
107
|
+
const { conflicts } = result;
|
|
108
|
+
if (conflicts.length === 0) {
|
|
109
|
+
return options.afterUpdate
|
|
110
|
+
? "✔ No peer conflicts detected after proposed updates are applied."
|
|
111
|
+
: "✔ No peer conflicts detected in current dependency tree.";
|
|
112
|
+
}
|
|
113
|
+
const lines = [];
|
|
114
|
+
const header = options.afterUpdate
|
|
115
|
+
? `\nPeer conflicts after proposed updates (${conflicts.length} found):\n`
|
|
116
|
+
: `\nPeer conflicts in current dependency tree (${conflicts.length} found):\n`;
|
|
117
|
+
lines.push(header);
|
|
118
|
+
const errors = conflicts.filter((c) => c.severity === "error");
|
|
119
|
+
const warnings = conflicts.filter((c) => c.severity === "warning");
|
|
120
|
+
if (errors.length > 0) {
|
|
121
|
+
lines.push(` ✖ Errors (${errors.length}) — would cause ERESOLVE on install:\n`);
|
|
122
|
+
for (const c of errors) {
|
|
123
|
+
lines.push(` \x1b[31m✖\x1b[0m ${c.requester} requires ${c.peer}@${c.requiredRange} got ${c.resolvedVersion}`);
|
|
124
|
+
lines.push(` → ${c.suggestion}`);
|
|
125
|
+
}
|
|
126
|
+
lines.push("");
|
|
127
|
+
}
|
|
128
|
+
if (warnings.length > 0) {
|
|
129
|
+
lines.push(` ⚠ Warnings (${warnings.length}) — soft peer incompatibilities:\n`);
|
|
130
|
+
for (const c of warnings) {
|
|
131
|
+
lines.push(` \x1b[33m⚠\x1b[0m ${c.requester} requires ${c.peer}@${c.requiredRange} got ${c.resolvedVersion}`);
|
|
132
|
+
lines.push(` → ${c.suggestion}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return lines.join("\n");
|
|
136
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const VALID_ACTIONS = ["save", "list", "restore", "diff"];
|
|
2
|
+
export function parseSnapshotArgs(args) {
|
|
3
|
+
const options = {
|
|
4
|
+
cwd: process.cwd(),
|
|
5
|
+
workspace: false,
|
|
6
|
+
action: "list",
|
|
7
|
+
label: undefined,
|
|
8
|
+
snapshotId: undefined,
|
|
9
|
+
storeFile: undefined,
|
|
10
|
+
};
|
|
11
|
+
// First positional arg is the action
|
|
12
|
+
let positionalIndex = 0;
|
|
13
|
+
const positionals = [];
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
const current = args[i];
|
|
16
|
+
const next = args[i + 1];
|
|
17
|
+
if (current === "--cwd" && next) {
|
|
18
|
+
options.cwd = next;
|
|
19
|
+
i++;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (current === "--cwd")
|
|
23
|
+
throw new Error("Missing value for --cwd");
|
|
24
|
+
if (current === "--workspace") {
|
|
25
|
+
options.workspace = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (current === "--label" && next) {
|
|
29
|
+
options.label = next;
|
|
30
|
+
i++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (current === "--label")
|
|
34
|
+
throw new Error("Missing value for --label");
|
|
35
|
+
if (current === "--store" && next) {
|
|
36
|
+
options.storeFile = next;
|
|
37
|
+
i++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (current === "--store")
|
|
41
|
+
throw new Error("Missing value for --store");
|
|
42
|
+
if (current === "--help" || current === "-h") {
|
|
43
|
+
process.stdout.write(SNAPSHOT_HELP);
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
if (current.startsWith("-"))
|
|
47
|
+
throw new Error(`Unknown option: ${current}`);
|
|
48
|
+
// Positional arguments
|
|
49
|
+
positionals.push(current);
|
|
50
|
+
}
|
|
51
|
+
// positionals[0] = action, positionals[1] = id/label for restore/diff
|
|
52
|
+
if (positionals.length >= 1) {
|
|
53
|
+
const action = positionals[0];
|
|
54
|
+
if (!VALID_ACTIONS.includes(action)) {
|
|
55
|
+
throw new Error(`Unknown snapshot action: "${positionals[0]}". Valid: ${VALID_ACTIONS.join(", ")}`);
|
|
56
|
+
}
|
|
57
|
+
options.action = action;
|
|
58
|
+
}
|
|
59
|
+
if (positionals.length >= 2) {
|
|
60
|
+
// For restore/diff the second positional is the id/label
|
|
61
|
+
options.snapshotId = positionals[1];
|
|
62
|
+
}
|
|
63
|
+
return options;
|
|
64
|
+
}
|
|
65
|
+
const SNAPSHOT_HELP = `
|
|
66
|
+
rup snapshot — Save, list, restore, and diff dependency state snapshots
|
|
67
|
+
|
|
68
|
+
Usage:
|
|
69
|
+
rup snapshot save [--label <name>] Save current dependency state
|
|
70
|
+
rup snapshot list List all saved snapshots
|
|
71
|
+
rup snapshot restore <id|label> Restore package.json files from a snapshot
|
|
72
|
+
rup snapshot diff <id|label> Show changes since a snapshot
|
|
73
|
+
|
|
74
|
+
Options:
|
|
75
|
+
--label <name> Human-readable label for the snapshot
|
|
76
|
+
--store <path> Custom snapshot store file (default: .rup-snapshots.json)
|
|
77
|
+
--workspace Include all workspace packages
|
|
78
|
+
--cwd <path> Working directory (default: cwd)
|
|
79
|
+
--help Show this help
|
|
80
|
+
`.trimStart();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SnapshotOptions, SnapshotResult } from "../../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Entry point for `rup snapshot`. Lazy-loaded by cli.ts.
|
|
4
|
+
*
|
|
5
|
+
* Actions:
|
|
6
|
+
* save — Capture package.json + lockfile state → store
|
|
7
|
+
* list — Print all saved snapshots
|
|
8
|
+
* restore — Restore a snapshot by id or label
|
|
9
|
+
* diff — Show dependency changes since a snapshot
|
|
10
|
+
*/
|
|
11
|
+
export declare function runSnapshot(options: SnapshotOptions): Promise<SnapshotResult>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { discoverPackageDirs } from "../../workspace/discover.js";
|
|
3
|
+
import { SnapshotStore, captureState, restoreState, diffManifests, } from "./store.js";
|
|
4
|
+
/**
|
|
5
|
+
* Entry point for `rup snapshot`. Lazy-loaded by cli.ts.
|
|
6
|
+
*
|
|
7
|
+
* Actions:
|
|
8
|
+
* save — Capture package.json + lockfile state → store
|
|
9
|
+
* list — Print all saved snapshots
|
|
10
|
+
* restore — Restore a snapshot by id or label
|
|
11
|
+
* diff — Show dependency changes since a snapshot
|
|
12
|
+
*/
|
|
13
|
+
export async function runSnapshot(options) {
|
|
14
|
+
const result = {
|
|
15
|
+
action: options.action,
|
|
16
|
+
errors: [],
|
|
17
|
+
warnings: [],
|
|
18
|
+
};
|
|
19
|
+
const packageDirs = await discoverPackageDirs(options.cwd, options.workspace);
|
|
20
|
+
const store = new SnapshotStore(options.cwd, options.storeFile);
|
|
21
|
+
switch (options.action) {
|
|
22
|
+
// ─ save ──────────────────────────────────────────────────────────────────
|
|
23
|
+
case "save": {
|
|
24
|
+
const label = options.label ??
|
|
25
|
+
`snap-${new Date().toISOString().replace(/[:T]/g, "-").slice(0, 19)}`;
|
|
26
|
+
const { manifests, lockfileHashes } = await captureState(packageDirs);
|
|
27
|
+
const entry = await store.saveSnapshot(manifests, lockfileHashes, label);
|
|
28
|
+
result.snapshotId = entry.id;
|
|
29
|
+
result.label = entry.label;
|
|
30
|
+
process.stdout.write(`✔ Snapshot saved: ${entry.label} (${entry.id})\n`);
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
// ─ list ──────────────────────────────────────────────────────────────────
|
|
34
|
+
case "list": {
|
|
35
|
+
const entries = await store.listSnapshots();
|
|
36
|
+
result.entries = entries.map((e) => ({
|
|
37
|
+
id: e.id,
|
|
38
|
+
label: e.label,
|
|
39
|
+
createdAt: new Date(e.createdAt).toISOString(),
|
|
40
|
+
}));
|
|
41
|
+
if (entries.length === 0) {
|
|
42
|
+
process.stdout.write("No snapshots saved yet. Use `rup snapshot save` to create one.\n");
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
process.stdout.write(`\n${entries.length} snapshot(s):\n\n`);
|
|
46
|
+
process.stdout.write(" " + "ID".padEnd(30) + "Label".padEnd(30) + "Created\n");
|
|
47
|
+
process.stdout.write(" " + "─".repeat(75) + "\n");
|
|
48
|
+
for (const e of entries) {
|
|
49
|
+
process.stdout.write(" " +
|
|
50
|
+
e.id.padEnd(30) +
|
|
51
|
+
e.label.padEnd(30) +
|
|
52
|
+
new Date(e.createdAt).toLocaleString() +
|
|
53
|
+
"\n");
|
|
54
|
+
}
|
|
55
|
+
process.stdout.write("\n");
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
// ─ restore ───────────────────────────────────────────────────────────────
|
|
60
|
+
case "restore": {
|
|
61
|
+
const idOrLabel = options.snapshotId ?? options.label;
|
|
62
|
+
if (!idOrLabel) {
|
|
63
|
+
result.errors.push("Restore requires a snapshot ID or label. Usage: rup snapshot restore <id|label>");
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
const entry = await store.findSnapshot(idOrLabel);
|
|
67
|
+
if (!entry) {
|
|
68
|
+
result.errors.push(`Snapshot not found: ${idOrLabel}. Use \`rup snapshot list\` to view saved snapshots.`);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
await restoreState(entry);
|
|
72
|
+
result.snapshotId = entry.id;
|
|
73
|
+
result.label = entry.label;
|
|
74
|
+
const count = Object.keys(entry.manifests).length;
|
|
75
|
+
process.stdout.write(`✔ Restored ${count} package.json file(s) from snapshot "${entry.label}" (${entry.id})\n`);
|
|
76
|
+
process.stdout.write(" Re-run your package manager install to apply.\n");
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
// ─ diff ──────────────────────────────────────────────────────────────────
|
|
80
|
+
case "diff": {
|
|
81
|
+
const idOrLabel = options.snapshotId ?? options.label;
|
|
82
|
+
if (!idOrLabel) {
|
|
83
|
+
result.errors.push("Diff requires a snapshot ID or label. Usage: rup snapshot diff <id|label>");
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
const entry = await store.findSnapshot(idOrLabel);
|
|
87
|
+
if (!entry) {
|
|
88
|
+
result.errors.push(`Snapshot not found: ${idOrLabel}`);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
const { manifests: currentManifests } = await captureState(packageDirs);
|
|
92
|
+
const changes = diffManifests(entry.manifests, currentManifests);
|
|
93
|
+
result.diff = changes;
|
|
94
|
+
if (changes.length === 0) {
|
|
95
|
+
process.stdout.write(`✔ No dependency changes since snapshot "${entry.label}"\n`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
process.stdout.write(`\nDependency changes since snapshot "${entry.label}":\n\n`);
|
|
99
|
+
process.stdout.write(" " + "Package".padEnd(35) + "Before".padEnd(20) + "After\n");
|
|
100
|
+
process.stdout.write(" " + "─".repeat(65) + "\n");
|
|
101
|
+
for (const c of changes) {
|
|
102
|
+
process.stdout.write(" " + c.name.padEnd(35) + c.from.padEnd(20) + c.to + "\n");
|
|
103
|
+
}
|
|
104
|
+
process.stdout.write("\n");
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (result.errors.length > 0) {
|
|
110
|
+
for (const err of result.errors) {
|
|
111
|
+
process.stderr.write(`[snapshot] ✖ ${err}\n`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { SnapshotEntry } from "../../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Lightweight SQLite-free snapshot store (uses a JSON file in the project root).
|
|
4
|
+
*
|
|
5
|
+
* Design goals:
|
|
6
|
+
* - No extra runtime dependencies (SQLite bindings vary by runtime)
|
|
7
|
+
* - Human-readable store file (git-committable if desired)
|
|
8
|
+
* - Atomic writes via tmp-rename to prevent corruption
|
|
9
|
+
* - Fast: entire store fits in memory for typical use (< 50 snapshots)
|
|
10
|
+
*/
|
|
11
|
+
export declare class SnapshotStore {
|
|
12
|
+
private readonly storePath;
|
|
13
|
+
private entries;
|
|
14
|
+
private loaded;
|
|
15
|
+
constructor(cwd: string, storeFile?: string);
|
|
16
|
+
load(): Promise<void>;
|
|
17
|
+
save(): Promise<void>;
|
|
18
|
+
saveSnapshot(manifests: Record<string, string>, lockfileHashes: Record<string, string>, label: string): Promise<SnapshotEntry>;
|
|
19
|
+
listSnapshots(): Promise<SnapshotEntry[]>;
|
|
20
|
+
findSnapshot(idOrLabel: string): Promise<SnapshotEntry | null>;
|
|
21
|
+
deleteSnapshot(idOrLabel: string): Promise<boolean>;
|
|
22
|
+
}
|
|
23
|
+
/** Captures current package.json and lockfile state for a set of directories. */
|
|
24
|
+
export declare function captureState(packageDirs: string[]): Promise<{
|
|
25
|
+
manifests: Record<string, string>;
|
|
26
|
+
lockfileHashes: Record<string, string>;
|
|
27
|
+
}>;
|
|
28
|
+
/** Restores package.json files from a snapshot's manifest map. */
|
|
29
|
+
export declare function restoreState(entry: SnapshotEntry): Promise<void>;
|
|
30
|
+
/** Computes a diff of dependency versions between two manifest snapshots. */
|
|
31
|
+
export declare function diffManifests(before: Record<string, string>, after: Record<string, string>): Array<{
|
|
32
|
+
name: string;
|
|
33
|
+
from: string;
|
|
34
|
+
to: string;
|
|
35
|
+
}>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const DEFAULT_STORE_NAME = ".rup-snapshots.json";
|
|
5
|
+
/**
|
|
6
|
+
* Lightweight SQLite-free snapshot store (uses a JSON file in the project root).
|
|
7
|
+
*
|
|
8
|
+
* Design goals:
|
|
9
|
+
* - No extra runtime dependencies (SQLite bindings vary by runtime)
|
|
10
|
+
* - Human-readable store file (git-committable if desired)
|
|
11
|
+
* - Atomic writes via tmp-rename to prevent corruption
|
|
12
|
+
* - Fast: entire store fits in memory for typical use (< 50 snapshots)
|
|
13
|
+
*/
|
|
14
|
+
export class SnapshotStore {
|
|
15
|
+
storePath;
|
|
16
|
+
entries = [];
|
|
17
|
+
loaded = false;
|
|
18
|
+
constructor(cwd, storeFile) {
|
|
19
|
+
this.storePath = storeFile
|
|
20
|
+
? path.isAbsolute(storeFile)
|
|
21
|
+
? storeFile
|
|
22
|
+
: path.join(cwd, storeFile)
|
|
23
|
+
: path.join(cwd, DEFAULT_STORE_NAME);
|
|
24
|
+
}
|
|
25
|
+
async load() {
|
|
26
|
+
if (this.loaded)
|
|
27
|
+
return;
|
|
28
|
+
try {
|
|
29
|
+
const raw = await fs.readFile(this.storePath, "utf8");
|
|
30
|
+
const parsed = JSON.parse(raw);
|
|
31
|
+
if (Array.isArray(parsed)) {
|
|
32
|
+
this.entries = parsed;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
this.entries = [];
|
|
37
|
+
}
|
|
38
|
+
this.loaded = true;
|
|
39
|
+
}
|
|
40
|
+
async save() {
|
|
41
|
+
const tmp = this.storePath + ".tmp";
|
|
42
|
+
await fs.writeFile(tmp, JSON.stringify(this.entries, null, 2) + "\n", "utf8");
|
|
43
|
+
await fs.rename(tmp, this.storePath);
|
|
44
|
+
}
|
|
45
|
+
async saveSnapshot(manifests, lockfileHashes, label) {
|
|
46
|
+
await this.load();
|
|
47
|
+
const id = `snap-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
48
|
+
const entry = {
|
|
49
|
+
id,
|
|
50
|
+
label,
|
|
51
|
+
createdAt: Date.now(),
|
|
52
|
+
manifests,
|
|
53
|
+
lockfileHashes,
|
|
54
|
+
};
|
|
55
|
+
this.entries.push(entry);
|
|
56
|
+
await this.save();
|
|
57
|
+
return entry;
|
|
58
|
+
}
|
|
59
|
+
async listSnapshots() {
|
|
60
|
+
await this.load();
|
|
61
|
+
return [...this.entries].sort((a, b) => b.createdAt - a.createdAt);
|
|
62
|
+
}
|
|
63
|
+
async findSnapshot(idOrLabel) {
|
|
64
|
+
await this.load();
|
|
65
|
+
return (this.entries.find((e) => e.id === idOrLabel || e.label === idOrLabel) ??
|
|
66
|
+
null);
|
|
67
|
+
}
|
|
68
|
+
async deleteSnapshot(idOrLabel) {
|
|
69
|
+
await this.load();
|
|
70
|
+
const before = this.entries.length;
|
|
71
|
+
this.entries = this.entries.filter((e) => e.id !== idOrLabel && e.label !== idOrLabel);
|
|
72
|
+
if (this.entries.length < before) {
|
|
73
|
+
await this.save();
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Captures current package.json and lockfile state for a set of directories. */
|
|
80
|
+
export async function captureState(packageDirs) {
|
|
81
|
+
const manifests = {};
|
|
82
|
+
const lockfileHashes = {};
|
|
83
|
+
const LOCKFILES = [
|
|
84
|
+
"package-lock.json",
|
|
85
|
+
"pnpm-lock.yaml",
|
|
86
|
+
"yarn.lock",
|
|
87
|
+
"bun.lockb",
|
|
88
|
+
];
|
|
89
|
+
await Promise.all(packageDirs.map(async (dir) => {
|
|
90
|
+
// Read package.json
|
|
91
|
+
try {
|
|
92
|
+
const content = await fs.readFile(path.join(dir, "package.json"), "utf8");
|
|
93
|
+
manifests[dir] = content;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// No package.json — skip
|
|
97
|
+
}
|
|
98
|
+
// Hash the first lockfile found
|
|
99
|
+
for (const lf of LOCKFILES) {
|
|
100
|
+
try {
|
|
101
|
+
const content = await fs.readFile(path.join(dir, lf));
|
|
102
|
+
lockfileHashes[dir] = createHash("sha256")
|
|
103
|
+
.update(content)
|
|
104
|
+
.digest("hex");
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Try next
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}));
|
|
112
|
+
return { manifests, lockfileHashes };
|
|
113
|
+
}
|
|
114
|
+
/** Restores package.json files from a snapshot's manifest map. */
|
|
115
|
+
export async function restoreState(entry) {
|
|
116
|
+
await Promise.all(Object.entries(entry.manifests).map(async ([dir, content]) => {
|
|
117
|
+
const manifestPath = path.join(dir, "package.json");
|
|
118
|
+
const tmp = manifestPath + ".tmp";
|
|
119
|
+
await fs.writeFile(tmp, content, "utf8");
|
|
120
|
+
await fs.rename(tmp, manifestPath);
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
/** Computes a diff of dependency versions between two manifest snapshots. */
|
|
124
|
+
export function diffManifests(before, after) {
|
|
125
|
+
const changes = [];
|
|
126
|
+
for (const [dir, afterJson] of Object.entries(after)) {
|
|
127
|
+
const beforeJson = before[dir];
|
|
128
|
+
if (!beforeJson)
|
|
129
|
+
continue;
|
|
130
|
+
let beforeManifest;
|
|
131
|
+
let afterManifest;
|
|
132
|
+
try {
|
|
133
|
+
beforeManifest = JSON.parse(beforeJson);
|
|
134
|
+
afterManifest = JSON.parse(afterJson);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const fields = [
|
|
140
|
+
"dependencies",
|
|
141
|
+
"devDependencies",
|
|
142
|
+
"optionalDependencies",
|
|
143
|
+
];
|
|
144
|
+
for (const field of fields) {
|
|
145
|
+
const before = beforeManifest[field] ?? {};
|
|
146
|
+
const after = afterManifest[field] ?? {};
|
|
147
|
+
const allNames = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
148
|
+
for (const name of allNames) {
|
|
149
|
+
const fromVer = before[name] ?? "(removed)";
|
|
150
|
+
const toVer = after[name] ?? "(removed)";
|
|
151
|
+
if (fromVer !== toVer) {
|
|
152
|
+
changes.push({ name, from: fromVer, to: toVer });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return changes;
|
|
158
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PackageManifest } from "../../types/index.js";
|
|
2
|
+
import type { UnusedDependency } from "../../types/index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Cross-references declared package.json dependencies against imported package names
|
|
5
|
+
* to surface two classes of problems:
|
|
6
|
+
*
|
|
7
|
+
* - "declared-not-imported": in package.json but never imported in source
|
|
8
|
+
* - "imported-not-declared": imported in source but absent from package.json
|
|
9
|
+
*/
|
|
10
|
+
export interface MatchOptions {
|
|
11
|
+
includeDevDependencies: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function matchDependencies(manifest: PackageManifest, importedPackages: Set<string>, packageDir: string, options: MatchOptions): {
|
|
14
|
+
unused: UnusedDependency[];
|
|
15
|
+
missing: UnusedDependency[];
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Applies the unused/missing result of `matchDependencies` to a package.json
|
|
19
|
+
* manifest in-memory, removing `unused` entries. Returns the modified manifest
|
|
20
|
+
* as a formatted JSON string ready to write back to disk.
|
|
21
|
+
*/
|
|
22
|
+
export declare function removeUnusedFromManifest(manifestJson: string, unused: UnusedDependency[]): string;
|