security-migrate 1.0.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/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import { resolve, join } from "node:path";
4
+ import { execSync } from "node:child_process";
5
+ import { discoverSecurityFiles } from "./discover.js";
6
+ import { migrateFiles } from "./migrate.js";
7
+ import { banner, bold, dim, error, yellow, green } from "./log.js";
8
+ const VERSION = "1.0.0";
9
+ const HELP = `
10
+ ${bold("security-migrate")} — Migrate gitignored security files to git worktrees
11
+
12
+ ${bold("Usage:")}
13
+ security-migrate <target-worktree> [options]
14
+
15
+ ${bold("Options:")}
16
+ --copy Copy files instead of symlinking (default: symlink)
17
+ --dry-run Show what would be done without doing it
18
+ --force Overwrite existing files in target
19
+ --source <dir> Source worktree (default: current git root)
20
+ --help Show this help
21
+ --version Show version
22
+
23
+ ${bold("Examples:")}
24
+ security-migrate ../worktrees/feature-x
25
+ security-migrate ../worktrees/feature-x --dry-run
26
+ security-migrate ../worktrees/feature-x --copy --force
27
+ security-migrate ../worktrees/feature-x --source /path/to/main
28
+
29
+ ${bold("Manifest:")}
30
+ Add a ${dim(".security-migrate")} file to your project root with extra
31
+ glob patterns (one per line, # for comments).
32
+ `.trim();
33
+ function parseArgs(argv) {
34
+ const args = argv.slice(2);
35
+ let target = "";
36
+ let source = null;
37
+ let copy = false;
38
+ let dryRun = false;
39
+ let force = false;
40
+ for (let i = 0; i < args.length; i++) {
41
+ const arg = args[i];
42
+ if (arg === "--help" || arg === "-h") {
43
+ console.log(HELP);
44
+ process.exit(0);
45
+ }
46
+ if (arg === "--version" || arg === "-v") {
47
+ console.log(VERSION);
48
+ process.exit(0);
49
+ }
50
+ if (arg === "--copy") {
51
+ copy = true;
52
+ continue;
53
+ }
54
+ if (arg === "--dry-run") {
55
+ dryRun = true;
56
+ continue;
57
+ }
58
+ if (arg === "--force") {
59
+ force = true;
60
+ continue;
61
+ }
62
+ if (arg === "--source") {
63
+ source = args[++i];
64
+ if (!source) {
65
+ error("--source requires a path argument");
66
+ process.exit(1);
67
+ }
68
+ continue;
69
+ }
70
+ if (arg.startsWith("-")) {
71
+ error(`Unknown option: ${arg}`);
72
+ console.log(`Run ${dim("security-migrate --help")} for usage.`);
73
+ process.exit(1);
74
+ }
75
+ if (!target) {
76
+ target = arg;
77
+ }
78
+ else {
79
+ error(`Unexpected argument: ${arg}`);
80
+ process.exit(1);
81
+ }
82
+ }
83
+ if (!target) {
84
+ return null;
85
+ }
86
+ return { target, source, copy, dryRun, force };
87
+ }
88
+ function getGitRoot() {
89
+ try {
90
+ return execSync("git rev-parse --show-toplevel", { encoding: "utf-8" }).trim();
91
+ }
92
+ catch {
93
+ error("Not inside a git repository.");
94
+ process.exit(1);
95
+ }
96
+ }
97
+ function isGitWorktreeOrRepo(path) {
98
+ // .git can be a file (worktree) or directory (main repo)
99
+ return existsSync(join(path, ".git"));
100
+ }
101
+ function main() {
102
+ const parsed = parseArgs(process.argv);
103
+ if (!parsed) {
104
+ console.log(HELP);
105
+ process.exit(1);
106
+ }
107
+ banner(VERSION);
108
+ const sourceRoot = resolve(parsed.source ?? getGitRoot());
109
+ const targetRoot = resolve(parsed.target);
110
+ // Validate source
111
+ if (!existsSync(sourceRoot)) {
112
+ error(`Source directory does not exist: ${sourceRoot}`);
113
+ process.exit(1);
114
+ }
115
+ if (!isGitWorktreeOrRepo(sourceRoot)) {
116
+ error(`Source is not a git repository or worktree: ${sourceRoot}`);
117
+ process.exit(1);
118
+ }
119
+ // Validate target
120
+ if (!existsSync(targetRoot)) {
121
+ error(`Target directory does not exist: ${targetRoot}`);
122
+ process.exit(1);
123
+ }
124
+ if (!isGitWorktreeOrRepo(targetRoot)) {
125
+ error(`Target is not a git repository or worktree: ${targetRoot}`);
126
+ process.exit(1);
127
+ }
128
+ console.log(`Source: ${dim(sourceRoot)}`);
129
+ console.log(`Target: ${dim(targetRoot)}`);
130
+ console.log();
131
+ if (parsed.dryRun) {
132
+ console.log(yellow("(dry run — no changes will be made)"));
133
+ console.log();
134
+ }
135
+ // Discover
136
+ console.log("Discovering security files...");
137
+ const files = discoverSecurityFiles({ sourceRoot });
138
+ if (files.length === 0) {
139
+ console.log("No gitignored security files found.");
140
+ process.exit(0);
141
+ }
142
+ console.log(`Found ${bold(String(files.length))} gitignored security file${files.length === 1 ? "" : "s"}`);
143
+ console.log();
144
+ // Migrate
145
+ const result = migrateFiles({
146
+ sourceRoot,
147
+ targetRoot,
148
+ files,
149
+ copy: parsed.copy,
150
+ force: parsed.force,
151
+ dryRun: parsed.dryRun,
152
+ });
153
+ // Summary
154
+ console.log();
155
+ const parts = [];
156
+ if (result.linked > 0)
157
+ parts.push(green(`${result.linked} symlinked`));
158
+ if (result.copied > 0)
159
+ parts.push(green(`${result.copied} copied`));
160
+ if (result.skipped > 0)
161
+ parts.push(yellow(`${result.skipped} skipped`));
162
+ if (parsed.dryRun) {
163
+ const total = result.linked + result.copied;
164
+ console.log(`Dry run complete. ${bold(String(total))} file${total === 1 ? "" : "s"} would be ${parsed.copy ? "copied" : "symlinked"}, ${result.skipped} skipped.`);
165
+ }
166
+ else {
167
+ console.log(`Done! ${parts.join(", ")}`);
168
+ }
169
+ }
170
+ main();
@@ -0,0 +1,8 @@
1
+ export interface DiscoverOptions {
2
+ sourceRoot: string;
3
+ }
4
+ /**
5
+ * Discover gitignored security files in the source tree.
6
+ * Returns relative paths from sourceRoot.
7
+ */
8
+ export declare function discoverSecurityFiles(opts: DiscoverOptions): string[];
@@ -0,0 +1,115 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { join, relative, basename } from "node:path";
3
+ import { execSync } from "node:child_process";
4
+ import { DEFAULT_PATTERNS, loadManifestPatterns, getSkipDirs } from "./patterns.js";
5
+ /**
6
+ * Minimal glob matcher supporting:
7
+ * * — matches anything except /
8
+ * ** — matches any number of path segments (including zero)
9
+ * ? — matches a single char except /
10
+ */
11
+ function matchGlob(pattern, filePath) {
12
+ // Normalize separators
13
+ const p = pattern.replace(/\\/g, "/");
14
+ const f = filePath.replace(/\\/g, "/");
15
+ // Convert glob to regex
16
+ let regex = "";
17
+ let i = 0;
18
+ while (i < p.length) {
19
+ if (p[i] === "*" && p[i + 1] === "*") {
20
+ // ** matches any number of path segments
21
+ if (p[i + 2] === "/") {
22
+ regex += "(?:.+/)?";
23
+ i += 3;
24
+ }
25
+ else {
26
+ regex += ".*";
27
+ i += 2;
28
+ }
29
+ }
30
+ else if (p[i] === "*") {
31
+ regex += "[^/]*";
32
+ i++;
33
+ }
34
+ else if (p[i] === "?") {
35
+ regex += "[^/]";
36
+ i++;
37
+ }
38
+ else if (p[i] === ".") {
39
+ regex += "\\.";
40
+ i++;
41
+ }
42
+ else {
43
+ regex += p[i];
44
+ i++;
45
+ }
46
+ }
47
+ return new RegExp(`^${regex}$`).test(f);
48
+ }
49
+ /**
50
+ * Walk the source tree recursively, collecting all file paths
51
+ * relative to sourceRoot. Skips known non-content directories.
52
+ */
53
+ function walkDir(dir, sourceRoot, skipDirs) {
54
+ const results = [];
55
+ let entries;
56
+ try {
57
+ entries = readdirSync(dir, { withFileTypes: true });
58
+ }
59
+ catch {
60
+ return results;
61
+ }
62
+ for (const entry of entries) {
63
+ if (skipDirs.has(entry.name))
64
+ continue;
65
+ const fullPath = join(dir, entry.name);
66
+ if (entry.isDirectory()) {
67
+ results.push(...walkDir(fullPath, sourceRoot, skipDirs));
68
+ }
69
+ else if (entry.isFile() || entry.isSymbolicLink()) {
70
+ results.push(relative(sourceRoot, fullPath));
71
+ }
72
+ }
73
+ return results;
74
+ }
75
+ /**
76
+ * Check if a file is gitignored by running `git check-ignore`.
77
+ */
78
+ function isGitIgnored(filePath, cwd) {
79
+ try {
80
+ execSync(`git check-ignore -q ${JSON.stringify(filePath)}`, {
81
+ cwd,
82
+ stdio: "ignore",
83
+ });
84
+ return true; // exit code 0 = ignored
85
+ }
86
+ catch {
87
+ return false; // exit code 1 = not ignored
88
+ }
89
+ }
90
+ /**
91
+ * Discover gitignored security files in the source tree.
92
+ * Returns relative paths from sourceRoot.
93
+ */
94
+ export function discoverSecurityFiles(opts) {
95
+ const { sourceRoot } = opts;
96
+ const skipDirs = getSkipDirs();
97
+ // Combine default + manifest patterns
98
+ const patterns = [...DEFAULT_PATTERNS, ...loadManifestPatterns(sourceRoot)];
99
+ // Walk the tree
100
+ const allFiles = walkDir(sourceRoot, sourceRoot, skipDirs);
101
+ // Filter by pattern match
102
+ const matched = allFiles.filter((filePath) => patterns.some((pattern) => {
103
+ // Match against the full relative path
104
+ if (matchGlob(pattern, filePath))
105
+ return true;
106
+ // Also match against just the filename for non-** patterns
107
+ if (!pattern.includes("/") && !pattern.startsWith("**/")) {
108
+ return matchGlob(pattern, basename(filePath));
109
+ }
110
+ return false;
111
+ }));
112
+ // Filter to only gitignored files
113
+ const gitignored = matched.filter((filePath) => isGitIgnored(filePath, sourceRoot));
114
+ return gitignored.sort();
115
+ }
package/dist/log.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export declare const green: (s: string) => string;
2
+ export declare const yellow: (s: string) => string;
3
+ export declare const red: (s: string) => string;
4
+ export declare const dim: (s: string) => string;
5
+ export declare const bold: (s: string) => string;
6
+ export declare const cyan: (s: string) => string;
7
+ export declare function banner(version: string): void;
8
+ export declare function info(label: string, msg: string): void;
9
+ export declare function skip(msg: string): void;
10
+ export declare function warn(msg: string): void;
11
+ export declare function error(msg: string): void;
package/dist/log.js ADDED
@@ -0,0 +1,24 @@
1
+ const isColorSupported = process.stdout.isTTY && !process.env.NO_COLOR;
2
+ const wrap = (code) => isColorSupported ? (s) => `\x1b[${code}m${s}\x1b[0m` : (s) => s;
3
+ export const green = wrap("32");
4
+ export const yellow = wrap("33");
5
+ export const red = wrap("31");
6
+ export const dim = wrap("2");
7
+ export const bold = wrap("1");
8
+ export const cyan = wrap("36");
9
+ export function banner(version) {
10
+ console.log(bold(`security-migrate v${version}`));
11
+ console.log();
12
+ }
13
+ export function info(label, msg) {
14
+ console.log(` ${green(label.padEnd(6))} ${msg}`);
15
+ }
16
+ export function skip(msg) {
17
+ console.log(` ${yellow("SKIP".padEnd(6))} ${msg}`);
18
+ }
19
+ export function warn(msg) {
20
+ console.log(` ${yellow("WARN".padEnd(6))} ${msg}`);
21
+ }
22
+ export function error(msg) {
23
+ console.error(`${red("Error:")} ${msg}`);
24
+ }
@@ -0,0 +1,17 @@
1
+ export interface MigrateOptions {
2
+ sourceRoot: string;
3
+ targetRoot: string;
4
+ files: string[];
5
+ copy: boolean;
6
+ force: boolean;
7
+ dryRun: boolean;
8
+ }
9
+ export interface MigrateResult {
10
+ linked: number;
11
+ copied: number;
12
+ skipped: number;
13
+ }
14
+ /**
15
+ * Migrate discovered security files from source to target worktree.
16
+ */
17
+ export declare function migrateFiles(opts: MigrateOptions): MigrateResult;
@@ -0,0 +1,62 @@
1
+ import { existsSync, mkdirSync, symlinkSync, copyFileSync, lstatSync, unlinkSync } from "node:fs";
2
+ import { join, dirname, resolve } from "node:path";
3
+ import { info, skip } from "./log.js";
4
+ /**
5
+ * Migrate discovered security files from source to target worktree.
6
+ */
7
+ export function migrateFiles(opts) {
8
+ const { sourceRoot, targetRoot, files, copy, force, dryRun } = opts;
9
+ const result = { linked: 0, copied: 0, skipped: 0 };
10
+ const mode = copy ? "COPY" : "LINK";
11
+ for (const relPath of files) {
12
+ const sourcePath = resolve(sourceRoot, relPath);
13
+ const targetPath = join(targetRoot, relPath);
14
+ const targetDir = dirname(targetPath);
15
+ // Check if target already exists
16
+ if (existsSync(targetPath) || isSymlink(targetPath)) {
17
+ if (!force) {
18
+ if (!dryRun) {
19
+ skip(`${relPath} (already exists, use --force)`);
20
+ }
21
+ result.skipped++;
22
+ continue;
23
+ }
24
+ // Force mode: remove existing
25
+ if (!dryRun) {
26
+ unlinkSync(targetPath);
27
+ }
28
+ }
29
+ if (dryRun) {
30
+ const arrow = copy ? "←" : "→";
31
+ info(mode, `${relPath} ${arrow} ${sourcePath}`);
32
+ }
33
+ else {
34
+ // Ensure parent directory exists
35
+ mkdirSync(targetDir, { recursive: true });
36
+ if (copy) {
37
+ copyFileSync(sourcePath, targetPath);
38
+ info("COPY", relPath);
39
+ }
40
+ else {
41
+ symlinkSync(sourcePath, targetPath);
42
+ info("LINK", `${relPath} → ${sourcePath}`);
43
+ }
44
+ }
45
+ if (copy) {
46
+ result.copied++;
47
+ }
48
+ else {
49
+ result.linked++;
50
+ }
51
+ }
52
+ return result;
53
+ }
54
+ function isSymlink(path) {
55
+ try {
56
+ lstatSync(path);
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
@@ -0,0 +1,7 @@
1
+ export declare const DEFAULT_PATTERNS: string[];
2
+ export declare function getSkipDirs(): Set<string>;
3
+ /**
4
+ * Load additional patterns from a `.security-migrate` manifest file
5
+ * in the source root, if it exists. One glob per line, # for comments.
6
+ */
7
+ export declare function loadManifestPatterns(sourceRoot: string): string[];
@@ -0,0 +1,51 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export const DEFAULT_PATTERNS = [
4
+ // Environment files
5
+ ".env",
6
+ ".env.*",
7
+ "**/.env",
8
+ "**/.env.*",
9
+ // Firebase
10
+ "**/serviceAccountKey*.json",
11
+ "**/service-account*.json",
12
+ "**/GoogleService-Info.plist",
13
+ "**/google-services.json",
14
+ "**/.firebaserc",
15
+ // Certificates & keys
16
+ "**/*.p8",
17
+ "**/*.p12",
18
+ "**/*.pem",
19
+ "**/*.key",
20
+ // Common secrets
21
+ "**/*.secret",
22
+ "**/secrets.json",
23
+ "**/secrets.yaml",
24
+ ];
25
+ const SKIP_DIRS = new Set([
26
+ "node_modules",
27
+ ".git",
28
+ "dist",
29
+ "build",
30
+ ".build",
31
+ ".next",
32
+ "Pods",
33
+ "DerivedData",
34
+ ]);
35
+ export function getSkipDirs() {
36
+ return SKIP_DIRS;
37
+ }
38
+ /**
39
+ * Load additional patterns from a `.security-migrate` manifest file
40
+ * in the source root, if it exists. One glob per line, # for comments.
41
+ */
42
+ export function loadManifestPatterns(sourceRoot) {
43
+ const manifestPath = join(sourceRoot, ".security-migrate");
44
+ if (!existsSync(manifestPath))
45
+ return [];
46
+ const content = readFileSync(manifestPath, "utf-8");
47
+ return content
48
+ .split("\n")
49
+ .map((line) => line.trim())
50
+ .filter((line) => line.length > 0 && !line.startsWith("#"));
51
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "security-migrate",
3
+ "version": "1.0.0",
4
+ "description": "Migrate gitignored security files (env, credentials, keys) to new git worktrees",
5
+ "type": "module",
6
+ "bin": {
7
+ "security-migrate": "./dist/cli.js"
8
+ },
9
+ "files": ["dist"],
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "dev": "bun run src/cli.ts"
13
+ },
14
+ "devDependencies": {
15
+ "typescript": "^5.0.0",
16
+ "@types/node": "^20.0.0"
17
+ }
18
+ }