@gitpm/cli 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.
@@ -0,0 +1,238 @@
1
+ import {
2
+ loadConfig as loadGitHubConfig,
3
+ syncWithGitHub,
4
+ } from '@gitpm/sync-github';
5
+ import type { ConflictStrategy } from '@gitpm/sync-github';
6
+ import {
7
+ loadConfig as loadGitLabConfig,
8
+ syncWithGitLab,
9
+ } from '@gitpm/sync-gitlab';
10
+ import { confirm } from '@inquirer/prompts';
11
+ import chalk from 'chalk';
12
+ import { Command } from 'commander';
13
+ import ora from 'ora';
14
+ import { resolveToken } from '../utils/auth.js';
15
+ import { resolveMetaDir } from '../utils/config.js';
16
+ import { promptConflictResolution } from '../utils/conflict-ui.js';
17
+ import { printError, printSuccess } from '../utils/output.js';
18
+
19
+ export const syncCommand = new Command('sync')
20
+ .description('Bidirectional sync between local .meta/ and GitHub/GitLab')
21
+ .option('--token <token>', 'Personal access token')
22
+ .option(
23
+ '--strategy <strategy>',
24
+ 'Conflict resolution strategy (local-wins, remote-wins, ask)',
25
+ 'ask',
26
+ )
27
+ .option('--dry-run', 'Preview changes without syncing')
28
+ .option('--yes', 'Skip confirmation prompt')
29
+ .action(async (opts, cmd) => {
30
+ const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
31
+ const strategy = opts.strategy as ConflictStrategy;
32
+
33
+ // Detect platform
34
+ const ghConfig = await loadGitHubConfig(metaDir);
35
+ const glConfig = await loadGitLabConfig(metaDir);
36
+
37
+ if (glConfig.ok) {
38
+ await syncGitLab(opts, metaDir, glConfig.value, strategy);
39
+ } else if (ghConfig.ok) {
40
+ await syncGitHub(opts, metaDir, ghConfig.value.repo, strategy);
41
+ } else {
42
+ printError(
43
+ 'No sync config found. Run `gitpm import` first to set up sync.',
44
+ );
45
+ process.exit(1);
46
+ }
47
+ });
48
+
49
+ async function syncGitHub(
50
+ opts: Record<string, string | boolean | undefined>,
51
+ metaDir: string,
52
+ repo: string,
53
+ strategy: ConflictStrategy,
54
+ ): Promise<void> {
55
+ let token: string;
56
+ try {
57
+ token = await resolveToken(opts.token as string | undefined);
58
+ } catch (err) {
59
+ printError(err instanceof Error ? err.message : 'Failed to resolve token.');
60
+ process.exit(1);
61
+ }
62
+
63
+ if (opts.dryRun) {
64
+ const spinner = ora('Calculating sync changes...').start();
65
+ const result = await syncWithGitHub({
66
+ token,
67
+ repo,
68
+ metaDir,
69
+ strategy,
70
+ dryRun: true,
71
+ });
72
+
73
+ if (!result.ok) {
74
+ spinner.fail('Dry run failed.');
75
+ printError(result.error.message);
76
+ process.exit(1);
77
+ }
78
+
79
+ spinner.succeed('Dry run complete.');
80
+ printSyncSummary(result.value, 'GitHub');
81
+ return;
82
+ }
83
+
84
+ if (!opts.yes) {
85
+ const confirmed = await confirm({
86
+ message: 'Run bidirectional sync with GitHub?',
87
+ default: true,
88
+ });
89
+ if (!confirmed) {
90
+ console.log(chalk.dim('Sync cancelled.'));
91
+ return;
92
+ }
93
+ }
94
+
95
+ const spinner = ora('Syncing with GitHub...').start();
96
+ const result = await syncWithGitHub({
97
+ token,
98
+ repo,
99
+ metaDir,
100
+ strategy: strategy === 'ask' ? 'ask' : strategy,
101
+ });
102
+
103
+ if (!result.ok) {
104
+ spinner.fail('Sync failed.');
105
+ printError(result.error.message);
106
+ process.exit(1);
107
+ }
108
+
109
+ spinner.succeed('Sync complete.');
110
+ await handleConflicts(result.value, strategy);
111
+ printSyncSummary(result.value, 'GitHub');
112
+ }
113
+
114
+ async function syncGitLab(
115
+ opts: Record<string, string | boolean | undefined>,
116
+ metaDir: string,
117
+ config: { project: string; project_id: number; base_url: string },
118
+ strategy: ConflictStrategy,
119
+ ): Promise<void> {
120
+ const token = (opts.token as string | undefined) ?? process.env.GITLAB_TOKEN;
121
+ if (!token) {
122
+ printError(
123
+ 'No GitLab token found. Provide via --token flag or GITLAB_TOKEN env var.',
124
+ );
125
+ process.exit(1);
126
+ }
127
+
128
+ if (opts.dryRun) {
129
+ const spinner = ora('Calculating sync changes...').start();
130
+ const result = await syncWithGitLab({
131
+ token,
132
+ project: config.project,
133
+ projectId: config.project_id,
134
+ baseUrl: config.base_url,
135
+ metaDir,
136
+ strategy,
137
+ dryRun: true,
138
+ });
139
+
140
+ if (!result.ok) {
141
+ spinner.fail('Dry run failed.');
142
+ printError(result.error.message);
143
+ process.exit(1);
144
+ }
145
+
146
+ spinner.succeed('Dry run complete.');
147
+ printSyncSummary(result.value, 'GitLab');
148
+ return;
149
+ }
150
+
151
+ if (!opts.yes) {
152
+ const confirmed = await confirm({
153
+ message: 'Run bidirectional sync with GitLab?',
154
+ default: true,
155
+ });
156
+ if (!confirmed) {
157
+ console.log(chalk.dim('Sync cancelled.'));
158
+ return;
159
+ }
160
+ }
161
+
162
+ const spinner = ora('Syncing with GitLab...').start();
163
+ const result = await syncWithGitLab({
164
+ token,
165
+ project: config.project,
166
+ projectId: config.project_id,
167
+ baseUrl: config.base_url,
168
+ metaDir,
169
+ strategy: strategy === 'ask' ? 'ask' : strategy,
170
+ });
171
+
172
+ if (!result.ok) {
173
+ spinner.fail('Sync failed.');
174
+ printError(result.error.message);
175
+ process.exit(1);
176
+ }
177
+
178
+ spinner.succeed('Sync complete.');
179
+ await handleConflicts(result.value, strategy);
180
+ printSyncSummary(result.value, 'GitLab');
181
+ }
182
+
183
+ async function handleConflicts(
184
+ result: {
185
+ conflicts: unknown[];
186
+ },
187
+ strategy: ConflictStrategy,
188
+ ): Promise<void> {
189
+ const { conflicts } = result;
190
+ if (strategy === 'ask' && conflicts.length > 0) {
191
+ console.log();
192
+ console.log(
193
+ chalk.yellow(`${conflicts.length} conflict(s) require resolution:`),
194
+ );
195
+ const resolutions = await promptConflictResolution(
196
+ conflicts as Parameters<typeof promptConflictResolution>[0],
197
+ );
198
+ console.log(
199
+ chalk.dim(
200
+ `Resolved ${resolutions.length}, skipped ${conflicts.length - resolutions.length}`,
201
+ ),
202
+ );
203
+ }
204
+ }
205
+
206
+ function printSyncSummary(
207
+ result: {
208
+ pushed: { milestones: number; issues: number };
209
+ pulled: { milestones: number; issues: number };
210
+ conflicts: unknown[];
211
+ resolved: number;
212
+ skipped: number;
213
+ },
214
+ platform: string,
215
+ ): void {
216
+ const pushedTotal = result.pushed.milestones + result.pushed.issues;
217
+ const pulledTotal = result.pulled.milestones + result.pulled.issues;
218
+
219
+ console.log();
220
+ console.log(chalk.bold('┌──────────────────────────────────────┐'));
221
+ console.log(chalk.bold('│ Sync Complete │'));
222
+ console.log(chalk.bold('├──────────────────┬───────────────────┤'));
223
+ console.log(
224
+ `${chalk.bold('│')} Pushed to ${platform.padEnd(6)} │ ${String(pushedTotal).padEnd(17)} ${chalk.bold('│')}`,
225
+ );
226
+ console.log(
227
+ `${chalk.bold('│')} Pulled to local │ ${String(pulledTotal).padEnd(17)} ${chalk.bold('│')}`,
228
+ );
229
+ console.log(
230
+ `${chalk.bold('│')} Conflicts │ ${String(`${result.resolved} resolved`).padEnd(17)} ${chalk.bold('│')}`,
231
+ );
232
+ console.log(
233
+ `${chalk.bold('│')} Errors │ ${String(result.skipped).padEnd(17)} ${chalk.bold('│')}`,
234
+ );
235
+ console.log(chalk.bold('└──────────────────┴───────────────────┘'));
236
+ console.log();
237
+ printSuccess('Sync complete.');
238
+ }
@@ -0,0 +1,79 @@
1
+ import { relative } from 'node:path';
2
+ import { parseTree, resolveRefs, validateTree } from '@gitpm/core';
3
+ import chalk from 'chalk';
4
+ import { Command } from 'commander';
5
+ import { resolveMetaDir } from '../utils/config.js';
6
+ import { printError, printSuccess, printWarning } from '../utils/output.js';
7
+
8
+ export const validateCommand = new Command('validate')
9
+ .description('Validate the .meta/ project tree')
10
+ .action(async (_opts, cmd) => {
11
+ const metaDir = resolveMetaDir(cmd.optsWithGlobals().metaDir);
12
+
13
+ // Parse tree
14
+ const parseResult = await parseTree(metaDir);
15
+ if (!parseResult.ok) {
16
+ printError(parseResult.error.message);
17
+ process.exit(1);
18
+ }
19
+
20
+ const tree = parseResult.value;
21
+
22
+ // Check for parse errors
23
+ if (tree.errors.length > 0) {
24
+ for (const err of tree.errors) {
25
+ const rel = relative(process.cwd(), err.filePath);
26
+ console.log(chalk.red(` ✗ ${rel}: ${err.message}`));
27
+ }
28
+ }
29
+
30
+ // Resolve refs
31
+ const resolveResult = resolveRefs(tree);
32
+ if (!resolveResult.ok) {
33
+ printError(resolveResult.error.message);
34
+ process.exit(1);
35
+ }
36
+
37
+ // Validate
38
+ const result = validateTree(resolveResult.value);
39
+
40
+ // Print validation errors
41
+ for (const err of result.errors) {
42
+ const rel = err.filePath ? relative(process.cwd(), err.filePath) : '';
43
+ const location = rel ? `${rel}: ` : '';
44
+ const id = err.entityId ? `[${err.entityId}] ` : '';
45
+ console.log(chalk.red(` ✗ ${location}${id}${err.code}: ${err.message}`));
46
+ }
47
+
48
+ // Print validation warnings
49
+ for (const warn of result.warnings) {
50
+ const rel = warn.filePath ? relative(process.cwd(), warn.filePath) : '';
51
+ const location = rel ? `${rel}: ` : '';
52
+ const id = warn.entityId ? `[${warn.entityId}] ` : '';
53
+ console.log(
54
+ chalk.yellow(` ⚠ ${location}${id}${warn.code}: ${warn.message}`),
55
+ );
56
+ }
57
+
58
+ if (!result.valid) {
59
+ console.log();
60
+ printError(
61
+ `Validation failed with ${result.errors.length} error(s) and ${result.warnings.length} warning(s)`,
62
+ );
63
+ process.exit(1);
64
+ }
65
+
66
+ const entityCount =
67
+ tree.stories.length +
68
+ tree.epics.length +
69
+ tree.milestones.length +
70
+ tree.roadmaps.length +
71
+ tree.prds.length;
72
+
73
+ if (result.warnings.length > 0) {
74
+ console.log();
75
+ printWarning(`${result.warnings.length} warning(s)`);
76
+ }
77
+
78
+ printSuccess(`.meta/ tree is valid (${entityCount} entities)`);
79
+ });
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { Command } from 'commander';
5
+ import { importCommand } from './commands/import.js';
6
+ import { initCommand } from './commands/init.js';
7
+ import { pullCommand } from './commands/pull.js';
8
+ import { pushCommand } from './commands/push.js';
9
+ import { syncCommand } from './commands/sync.js';
10
+ import { validateCommand } from './commands/validate.js';
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const pkg = JSON.parse(
14
+ readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'),
15
+ );
16
+
17
+ const program = new Command();
18
+
19
+ program
20
+ .name('gitpm')
21
+ .description('Git-native project management')
22
+ .version(pkg.version)
23
+ .option('--meta-dir <path>', 'Path to .meta directory', '.meta')
24
+ .option('--token <token>', 'Personal access token (GitHub or GitLab)');
25
+
26
+ program.addCommand(initCommand);
27
+ program.addCommand(validateCommand);
28
+ program.addCommand(importCommand);
29
+ program.addCommand(pushCommand);
30
+ program.addCommand(pullCommand);
31
+ program.addCommand(syncCommand);
32
+
33
+ program.parse();
@@ -0,0 +1,28 @@
1
+ import { execSync } from 'node:child_process';
2
+
3
+ /**
4
+ * Resolve a GitHub token from multiple sources in priority order:
5
+ * 1. Explicit CLI --token flag
6
+ * 2. GITHUB_TOKEN environment variable
7
+ * 3. GitHub CLI (`gh auth token`)
8
+ */
9
+ export async function resolveToken(cliToken?: string): Promise<string> {
10
+ if (cliToken) return cliToken;
11
+
12
+ const envToken = process.env.GITHUB_TOKEN;
13
+ if (envToken) return envToken;
14
+
15
+ try {
16
+ const ghToken = execSync('gh auth token', {
17
+ encoding: 'utf-8',
18
+ stdio: ['pipe', 'pipe', 'pipe'],
19
+ }).trim();
20
+ if (ghToken) return ghToken;
21
+ } catch {
22
+ // gh CLI not installed or not authenticated — fall through
23
+ }
24
+
25
+ throw new Error(
26
+ "No GitHub token found. Provide via --token flag, GITHUB_TOKEN env var, or install GitHub CLI (gh) and run 'gh auth login'.",
27
+ );
28
+ }
@@ -0,0 +1,5 @@
1
+ import { resolve } from 'node:path';
2
+
3
+ export function resolveMetaDir(metaDirOption: string): string {
4
+ return resolve(process.cwd(), metaDirOption);
5
+ }
@@ -0,0 +1,45 @@
1
+ import type { FieldConflict, Resolution } from '@gitpm/sync-github';
2
+ import { select } from '@inquirer/prompts';
3
+ import chalk from 'chalk';
4
+
5
+ /**
6
+ * Interactively prompt the user to resolve each conflict.
7
+ */
8
+ export async function promptConflictResolution(
9
+ conflicts: FieldConflict[],
10
+ ): Promise<Resolution[]> {
11
+ const resolutions: Resolution[] = [];
12
+
13
+ for (const conflict of conflicts) {
14
+ console.log();
15
+ console.log(chalk.yellow('━━━ CONFLICT ━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
16
+ console.log(
17
+ `Entity: ${chalk.bold(`"${conflict.entityTitle}"`)} (${conflict.entityId})`,
18
+ );
19
+ console.log(`Type: ${conflict.entityType}`);
20
+ console.log(`Field: ${conflict.field}`);
21
+ console.log();
22
+ console.log(` Local: ${chalk.green(String(conflict.localValue))}`);
23
+ console.log(` Remote: ${chalk.blue(String(conflict.remoteValue))}`);
24
+ console.log(chalk.yellow('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
25
+
26
+ const pick = await select({
27
+ message: 'How do you want to resolve this conflict?',
28
+ choices: [
29
+ { name: '[l] Keep local', value: 'local' as const },
30
+ { name: '[r] Keep remote', value: 'remote' as const },
31
+ { name: '[s] Skip (leave unresolved)', value: 'skip' as const },
32
+ ],
33
+ });
34
+
35
+ if (pick !== 'skip') {
36
+ resolutions.push({
37
+ entityId: conflict.entityId,
38
+ field: conflict.field,
39
+ pick,
40
+ });
41
+ }
42
+ }
43
+
44
+ return resolutions;
45
+ }
@@ -0,0 +1,23 @@
1
+ import chalk from 'chalk';
2
+
3
+ export function printSuccess(msg: string): void {
4
+ console.log(chalk.green(`✓ ${msg}`));
5
+ }
6
+
7
+ export function printError(msg: string): void {
8
+ console.error(chalk.red(`✗ ${msg}`));
9
+ }
10
+
11
+ export function printWarning(msg: string): void {
12
+ console.warn(chalk.yellow(`⚠ ${msg}`));
13
+ }
14
+
15
+ export function printTree(files: string[]): void {
16
+ for (const file of files) {
17
+ const parts = file.split('/');
18
+ const indent = ' '.repeat(parts.length - 1);
19
+ const name = parts[parts.length - 1];
20
+ const isDir = !name.includes('.');
21
+ console.log(`${indent}${isDir ? chalk.blue(`${name}/`) : name}`);
22
+ }
23
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src"]
8
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['esm'],
6
+ dts: false,
7
+ clean: true,
8
+ banner: {
9
+ js: '#!/usr/bin/env node',
10
+ },
11
+ });