acker-dacker 0.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/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # Acker Dacker
2
+
3
+ A Bun CLI for making deterministic repository ZIPs for AI handoffs and code review.
4
+
5
+ ## Requirements
6
+
7
+ - [Bun](https://bun.sh/) 1.0 or newer
8
+ - Git only for `--git-handoff`
9
+
10
+ ## Use it
11
+
12
+ Run the published CLI without a global install:
13
+
14
+ ```bash
15
+ bunx acker-dacker <repository-path> [options]
16
+ ```
17
+
18
+ Examples:
19
+
20
+ ```bash
21
+ # Privacy-oriented default pack
22
+ bunx acker-dacker ../my-repository
23
+
24
+ # Include only Markdown and TypeScript files
25
+ bunx acker-dacker ../my-repository --filter "*.md" --filter "*.ts"
26
+
27
+ # Include an additional local directory
28
+ bunx acker-dacker ../my-repository --include ../shared-fixtures
29
+
30
+ # Capture the Git worktree, including eligible untracked files
31
+ bunx acker-dacker ../my-repository --git-handoff --output ./handoff.zip
32
+
33
+ # Create the minimal Rust Stage 3 handoff and a content-free report
34
+ bunx acker-dacker ../codex --git-handoff --profile rust-stage3 \
35
+ --output ./rust-stage3.zip --report ./rust-stage3.json
36
+ ```
37
+
38
+ If `<repository-path>` is omitted, the current directory is used. Default-mode output is `./<repository-name>-pack.zip` unless `--output` is supplied.
39
+
40
+ Run `bunx acker-dacker --help` for the current usage text. The options are:
41
+
42
+ | Option | Description |
43
+ | --- | --- |
44
+ | `--git-handoff` | Use Git's tracked plus eligible-untracked file set and preserve modes and safe symlinks. |
45
+ | `--profile <name>` | Select a Git handoff profile. Currently `rust-stage3`; requires `--git-handoff`. |
46
+ | `--filter, -f <pattern>` | Include only matching paths or file names. Repeatable. |
47
+ | `--include, -i <path>` | Add a local directory or repository dependency in default mode. Repeatable. |
48
+ | `--exclude <path\|glob>` | Exclude a root-relative path or glob in Git handoff mode. Repeatable. |
49
+ | `--output, -o <path>` | Set the ZIP output path. |
50
+ | `--report <path>` | Write a content-free Git handoff JSON report. Requires `--git-handoff`. |
51
+ | `--help, -h` | Show help. |
52
+
53
+ ## Modes and safety
54
+
55
+ Default mode recursively discovers local `file:` and `link:` package dependencies, follows root and nested `.gitignore` files, and omits common generated output, Xcode build artifacts, and sensitive file names. It also supports explicit `--include` paths.
56
+
57
+ Git handoff mode asks Git for tracked files and standard-ignore-aware untracked files. Tracked files remain eligible by design; sensitive and generated files are safety-filtered only when untracked. Absolute, escaping, dangling, and cyclic symlinks are rejected. `--exclude`, `--filter`, `--profile`, and `--report` are applied as documented above.
58
+
59
+ Archives are deterministic: file ordering, ZIP timestamps, and compression choices do not depend on the current clock.
60
+
61
+ ## Development
62
+
63
+ ```bash
64
+ bun install
65
+ bun run test
66
+ bun run typecheck
67
+ bun run check
68
+ bun run build
69
+ ```
70
+
71
+ The CLI entrypoint is `index.ts`. Workflow orchestration lives in `src/cli.ts`; argument parsing is in `src/cli-options.ts`; default discovery and policy are in `src/repository-pack.ts`, `src/gitignore.ts`, and `src/file-policy.ts`; Git handoff selection and staging are in `src/git-handoff-selection.ts` and `src/git-handoff.ts`; deterministic ZIP writing is in `src/zip.ts`.
72
+
73
+ To inspect the npm package before publishing:
74
+
75
+ ```bash
76
+ npm pack --dry-run
77
+ ```
78
+
79
+ The package exposes the `acker-dacker` bin and intentionally ships only `index.ts` and `src/` as application files, plus npm's standard metadata files.
package/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bun
2
+ import { main } from './src/cli';
3
+
4
+ try {
5
+ await main();
6
+ } catch (error) {
7
+ const message = error instanceof Error ? error.message : String(error);
8
+ console.error(`acker-dacker: ${message}`);
9
+ process.exitCode = 1;
10
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "acker-dacker",
3
+ "author": {
4
+ "name": "Ragaeeb Haq",
5
+ "url": "https://github.com/ragaeeb"
6
+ },
7
+ "bugs": {
8
+ "url": "https://github.com/ragaeeb/acker-dacker/issues"
9
+ },
10
+ "version": "0.1.0",
11
+ "description": "Create deterministic, privacy-aware repository ZIPs for AI handoffs and code review.",
12
+ "type": "module",
13
+ "bin": {
14
+ "acker-dacker": "index.ts"
15
+ },
16
+ "files": [
17
+ "index.ts",
18
+ "src"
19
+ ],
20
+ "scripts": {
21
+ "build": "bun build ./index.ts --target=bun --outfile ./dist/acker-dacker.js",
22
+ "check": "bun run typecheck && bun test && bun build ./index.ts --target=bun --outfile /tmp/acker-dacker-check.js",
23
+ "prepublishOnly": "bun run check",
24
+ "test": "bun test",
25
+ "typecheck": "tsc --noEmit"
26
+ },
27
+ "engines": {
28
+ "bun": ">=1.4.0",
29
+ "node": ">=24.x"
30
+ },
31
+ "devDependencies": {
32
+ "@types/bun": "^1.4.0",
33
+ "typescript": "^7.0.2"
34
+ }
35
+ }
@@ -0,0 +1,93 @@
1
+ export type CliOptions = {
2
+ readonly excludes: readonly string[];
3
+ readonly filters: readonly string[];
4
+ readonly gitHandoff: boolean;
5
+ readonly includes: readonly string[];
6
+ readonly outputFile: string | null;
7
+ readonly profile: string | null;
8
+ readonly reportFile: string | null;
9
+ readonly showHelp: boolean;
10
+ readonly targetDir: string;
11
+ };
12
+
13
+ export const parseCliArgs = (args: readonly string[]): CliOptions => {
14
+ const excludes: string[] = [];
15
+ const filters: string[] = [];
16
+ const includes: string[] = [];
17
+ let gitHandoff = false;
18
+ let outputFile: string | null = null;
19
+ let profile: string | null = null;
20
+ let reportFile: string | null = null;
21
+ let showHelp = false;
22
+ let targetDir = process.cwd();
23
+
24
+ for (let index = 0; index < args.length; index += 1) {
25
+ const argument = args[index]!;
26
+ if (argument === '--help' || argument === '-h') {
27
+ showHelp = true;
28
+ } else if (argument === '--git-handoff') {
29
+ gitHandoff = true;
30
+ } else if (argument.startsWith('--profile=')) {
31
+ profile = argument.slice(10) || null;
32
+ } else if (argument === '--profile') {
33
+ index += 1;
34
+ profile = args[index] ?? null;
35
+ } else if (argument.startsWith('--exclude=')) {
36
+ excludes.push(argument.slice(10));
37
+ } else if (argument === '--exclude') {
38
+ index += 1;
39
+ if (args[index]) {
40
+ excludes.push(args[index]!);
41
+ }
42
+ } else if (argument.startsWith('--output=')) {
43
+ outputFile = argument.slice(9);
44
+ } else if (argument === '--output' || argument === '-o') {
45
+ index += 1;
46
+ outputFile = args[index] ?? null;
47
+ } else if (argument.startsWith('--include=')) {
48
+ includes.push(argument.slice(10));
49
+ } else if (argument === '--include' || argument === '-i') {
50
+ index += 1;
51
+ if (args[index]) {
52
+ includes.push(args[index]!);
53
+ }
54
+ } else if (argument.startsWith('--filter=')) {
55
+ filters.push(argument.slice(9));
56
+ } else if (argument === '--filter' || argument === '-f') {
57
+ index += 1;
58
+ if (args[index]) {
59
+ filters.push(args[index]!);
60
+ }
61
+ } else if (argument.startsWith('--report=')) {
62
+ reportFile = argument.slice(9);
63
+ } else if (argument === '--report') {
64
+ index += 1;
65
+ reportFile = args[index] ?? null;
66
+ } else if (!argument.startsWith('-')) {
67
+ targetDir = argument;
68
+ }
69
+ }
70
+
71
+ return { excludes, filters, gitHandoff, includes, outputFile, profile, reportFile, showHelp, targetDir };
72
+ };
73
+
74
+ export const printUsage = (): void => {
75
+ console.log(`
76
+ Usage: acker-dacker [targetRepoPath] [options]
77
+
78
+ Options:
79
+ --git-handoff Use Git's tracked + eligible-untracked set and preserve modes/symlinks
80
+ --profile <name> Select a named Git handoff profile (currently rust-stage3; requires --git-handoff)
81
+ --filter, -f <pattern> Include ONLY files matching pattern (e.g. *.md, *.ts; can be specified multiple times)
82
+ --include, -i <path> Additional folder path or repo name to include (can be specified multiple times)
83
+ --exclude <path|glob> Repeatable root-relative exclusion (only with --git-handoff)
84
+ --output, -o <path> Output zip file path (default: ./<targetRepoName>-pack.zip)
85
+ --report <path> Write a content-free Git handoff JSON report (only with --git-handoff)
86
+ --help, -h Show this help message
87
+
88
+ Examples:
89
+ acker-dacker ../ushman --filter "*.md"
90
+ acker-dacker ../ushman --include ../ushman-spector -f "*.md" -f "*.ts"
91
+ acker-dacker ../kodeguard --output=./kodeguard.zip
92
+ `);
93
+ };
package/src/cli.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { mkdtemp, rm, stat } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { parseCliArgs, printUsage } from './cli-options';
5
+ import { runGitHandoff } from './git-handoff';
6
+ import { globToRegex } from './path-patterns';
7
+ import { copyRepositoryFiles, discoverRecursiveRepositories } from './repository-pack';
8
+ import { createZipFile } from './zip';
9
+
10
+ const resolveTargetDirectory = (targetDir: string): string => {
11
+ return path.resolve(targetDir.replace(/^~(?=$|\/)/, process.env.HOME ?? ''));
12
+ };
13
+
14
+ const runDefaultPack = async ({
15
+ filters,
16
+ includes,
17
+ outputFile,
18
+ targetDir,
19
+ }: {
20
+ readonly filters: readonly string[];
21
+ readonly includes: readonly string[];
22
+ readonly outputFile: string | null;
23
+ readonly targetDir: string;
24
+ }): Promise<void> => {
25
+ console.log(`Resolving repositories for target: ${targetDir}`);
26
+ if (includes.length > 0) {
27
+ console.log(`Explicit includes specified: ${includes.join(', ')}`);
28
+ }
29
+ if (filters.length > 0) {
30
+ console.log(`File filters applied: ${filters.join(', ')}`);
31
+ }
32
+
33
+ const filterMatchers = filters.map(globToRegex);
34
+ const repositories = await discoverRecursiveRepositories(targetDir, includes);
35
+ console.log(`Discovered ${repositories.length} repository dependency closure:`);
36
+ for (const repository of repositories) {
37
+ const projectKind = repository.isXcodeProject ? ' [Xcode project]' : '';
38
+ console.log(
39
+ ` - ${repository.name}${projectKind} (${repository.path}) -> archive: ${repository.archivePrefix}/`,
40
+ );
41
+ }
42
+
43
+ const mainRepositoryName = path.basename(targetDir);
44
+ const resolvedOutputFile = path.resolve(
45
+ outputFile ?? path.join(process.cwd(), `${mainRepositoryName}-pack.zip`),
46
+ );
47
+ const stagingRoot = await mkdtemp(path.join(tmpdir(), 'pack-repo-staging-'));
48
+
49
+ try {
50
+ let totalFiles = 0;
51
+ let totalUncompressedBytes = 0;
52
+
53
+ for (const repository of repositories) {
54
+ const stats = await copyRepositoryFiles(repository, stagingRoot, filterMatchers);
55
+ totalFiles += stats.fileCount;
56
+ totalUncompressedBytes += stats.totalBytes;
57
+ console.log(
58
+ `Staged ${repository.name}: ${stats.fileCount} files (${(stats.totalBytes / 1024 / 1024).toFixed(2)} MB)`,
59
+ );
60
+ }
61
+
62
+ if (totalFiles === 0) {
63
+ console.warn('\nWarning: No files matched the criteria. Zip file will not be created.');
64
+ return;
65
+ }
66
+
67
+ console.log(`Creating zip archive at: ${resolvedOutputFile}`);
68
+ await createZipFile(stagingRoot, resolvedOutputFile);
69
+
70
+ const zipStats = await stat(resolvedOutputFile);
71
+ console.log('\nPack complete!');
72
+ console.log(`Repositories packaged: ${repositories.length}`);
73
+ console.log(`Total files: ${totalFiles}`);
74
+ console.log(`Uncompressed size: ${(totalUncompressedBytes / 1024 / 1024).toFixed(2)} MB`);
75
+ console.log(`Zip archive size: ${(zipStats.size / 1024 / 1024).toFixed(2)} MB`);
76
+ console.log(`Output archive: ${resolvedOutputFile}`);
77
+ } finally {
78
+ await rm(stagingRoot, { force: true, recursive: true });
79
+ }
80
+ };
81
+
82
+ export const main = async (args: readonly string[] = process.argv.slice(2)): Promise<void> => {
83
+ const options = parseCliArgs(args);
84
+ if (options.showHelp) {
85
+ printUsage();
86
+ return;
87
+ }
88
+
89
+ const targetDir = resolveTargetDirectory(options.targetDir);
90
+ if (options.gitHandoff) {
91
+ if (options.includes.length > 0) {
92
+ throw new Error('--include is not supported with --git-handoff; Git defines the handoff boundary.');
93
+ }
94
+ await runGitHandoff({
95
+ excludes: options.excludes,
96
+ filters: options.filters.map(globToRegex),
97
+ outputFile: options.outputFile,
98
+ profile: options.profile,
99
+ reportFile: options.reportFile,
100
+ targetDir,
101
+ });
102
+ return;
103
+ }
104
+
105
+ if (options.excludes.length > 0 || options.profile || options.reportFile) {
106
+ throw new Error('--exclude, --profile, and --report require --git-handoff.');
107
+ }
108
+
109
+ await runDefaultPack({
110
+ filters: options.filters,
111
+ includes: options.includes,
112
+ outputFile: options.outputFile,
113
+ targetDir,
114
+ });
115
+ };
@@ -0,0 +1,121 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const EXCLUDED_DIRECTORY_NAMES = new Set([
5
+ '.cache',
6
+ '.claude',
7
+ '.codex',
8
+ '.git',
9
+ '.github',
10
+ '.idea',
11
+ '.lab',
12
+ '.minimax',
13
+ '.next',
14
+ '.pnpm-store',
15
+ '.build',
16
+ '.swiftpm',
17
+ '.turbo',
18
+ '.vite',
19
+ '.vscode',
20
+ 'bin',
21
+ 'build',
22
+ 'coverage',
23
+ 'dist',
24
+ 'node_modules',
25
+ 'playwright-report',
26
+ 'test-results',
27
+ 'tmp',
28
+ ]);
29
+
30
+ const XCODE_BUILD_DIRECTORY_SUFFIXES = ['.app', '.appex', '.dsym', '.xcarchive', '.xcresult', '.xctest'];
31
+ const XCODE_BUILD_DIRECTORY_NAMES = new Set([
32
+ 'deriveddata',
33
+ 'xcuserdata',
34
+ 'sourcepackages',
35
+ 'index.noindex',
36
+ 'modulecache.noindex',
37
+ ]);
38
+ const XCODE_BUILD_FILE_SUFFIXES = ['.ipa', '.xcarchive', '.xcresult', '.xcuserstate'];
39
+
40
+ const ALLOWED_ENV_FILES = new Set(['.env.example', '.env.sample', '.env.template']);
41
+
42
+ const EXCLUDED_FILE_NAMES = new Set([
43
+ '.DS_Store',
44
+ '.gitignore',
45
+ '.npmrc',
46
+ '.pypirc',
47
+ 'LICENSE',
48
+ 'LICENSE.md',
49
+ 'credentials.json',
50
+ 'id_ed25519',
51
+ 'id_rsa',
52
+ 'pnpm-lock.yaml',
53
+ 'release.config.cjs',
54
+ 'release.config.mjs',
55
+ 'service-account.json',
56
+ ]);
57
+
58
+ const EXCLUDED_FILE_SUFFIXES = ['.key', '.keystore', '.log', '.p12', '.pem', '.pfx'];
59
+ const GENERATED_ARCHIVE_SUFFIXES = ['.br', '.gz', '.har', '.tar', '.tgz', '.zip'];
60
+
61
+ export const detectXcodeProject = async (directory: string): Promise<boolean> => {
62
+ const entries = await readdir(directory, { withFileTypes: true });
63
+ return entries.some((entry) => {
64
+ if (entry.isSymbolicLink()) {
65
+ return false;
66
+ }
67
+ const lowerName = entry.name.toLowerCase();
68
+ return lowerName.endsWith('.xcodeproj') || lowerName.endsWith('.xcworkspace');
69
+ });
70
+ };
71
+
72
+ export const isExcludedDirectory = (directoryName: string, isXcodeProject: boolean): boolean => {
73
+ const lowerName = directoryName.toLowerCase();
74
+ if (EXCLUDED_DIRECTORY_NAMES.has(directoryName) || EXCLUDED_DIRECTORY_NAMES.has(lowerName)) {
75
+ return true;
76
+ }
77
+
78
+ return (
79
+ isXcodeProject &&
80
+ (XCODE_BUILD_DIRECTORY_NAMES.has(lowerName) ||
81
+ XCODE_BUILD_DIRECTORY_SUFFIXES.some((suffix) => lowerName.endsWith(suffix)))
82
+ );
83
+ };
84
+
85
+ export const isExcludedFile = (fileName: string, isXcodeProject: boolean): boolean => {
86
+ const lowerName = fileName.toLowerCase();
87
+ if (EXCLUDED_FILE_NAMES.has(fileName) || EXCLUDED_FILE_NAMES.has(lowerName)) {
88
+ return true;
89
+ }
90
+ if ((lowerName === '.env' || lowerName.startsWith('.env.')) && !ALLOWED_ENV_FILES.has(lowerName)) {
91
+ return true;
92
+ }
93
+ if (EXCLUDED_FILE_SUFFIXES.some((suffix) => lowerName.endsWith(suffix))) {
94
+ return true;
95
+ }
96
+ if (GENERATED_ARCHIVE_SUFFIXES.some((suffix) => lowerName.endsWith(suffix))) {
97
+ return true;
98
+ }
99
+ return isXcodeProject && XCODE_BUILD_FILE_SUFFIXES.some((suffix) => lowerName.endsWith(suffix));
100
+ };
101
+
102
+ export const isSensitiveUntrackedPath = (relativePath: string): boolean => {
103
+ const lowerName = path.posix.basename(relativePath).toLowerCase();
104
+ const sensitiveNames = new Set(['credentials.json', 'id_ed25519', 'id_rsa', 'service-account.json']);
105
+ if (sensitiveNames.has(lowerName)) {
106
+ return true;
107
+ }
108
+ if (lowerName === '.env' || lowerName.startsWith('.env.')) {
109
+ return !ALLOWED_ENV_FILES.has(lowerName);
110
+ }
111
+ return EXCLUDED_FILE_SUFFIXES.some((suffix) => lowerName.endsWith(suffix));
112
+ };
113
+
114
+ export const isUntrackedSafetyExcludedPath = (relativePath: string, isXcodeProject: boolean): boolean => {
115
+ const segments = relativePath.split('/');
116
+ const directorySegments = segments.slice(0, -1);
117
+ if (directorySegments.some((segment) => isExcludedDirectory(segment, isXcodeProject))) {
118
+ return true;
119
+ }
120
+ return isExcludedFile(path.posix.basename(relativePath), isXcodeProject);
121
+ };