idea-manager 0.8.4 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idea-manager",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
4
4
  "description": "AI 기반 브레인스토밍 → 구조화 → 프롬프트 생성 도구. MCP Server 내장.",
5
5
  "keywords": [
6
6
  "brainstorm",
package/src/cli.ts CHANGED
@@ -96,4 +96,38 @@ program
96
96
  process.on('SIGTERM', () => { child.kill(); process.exit(0); });
97
97
  });
98
98
 
99
+ const syncCmd = program
100
+ .command('sync')
101
+ .description('Sync data via GitHub repository')
102
+ .action(async () => {
103
+ const { syncStatus } = await import('./lib/sync');
104
+ await syncStatus();
105
+ });
106
+
107
+ syncCmd
108
+ .command('init')
109
+ .description('Initialize sync with a GitHub repository')
110
+ .action(async () => {
111
+ const { syncInit } = await import('./lib/sync');
112
+ await syncInit();
113
+ });
114
+
115
+ syncCmd
116
+ .command('push')
117
+ .description('Export data and push to GitHub')
118
+ .option('-m, --message <msg>', 'Custom commit message')
119
+ .action(async (opts) => {
120
+ const { syncPush } = await import('./lib/sync');
121
+ await syncPush(opts.message);
122
+ });
123
+
124
+ syncCmd
125
+ .command('pull')
126
+ .description('Pull from GitHub and import data')
127
+ .option('--no-backup', 'Skip database backup before import')
128
+ .action(async (opts) => {
129
+ const { syncPull } = await import('./lib/sync');
130
+ await syncPull({ backup: opts.backup });
131
+ });
132
+
99
133
  program.parse();
@@ -0,0 +1,37 @@
1
+ import fs from 'fs';
2
+ import { getDb } from '../db';
3
+
4
+ // v2 active tables
5
+ const V2_TABLES = ['projects', 'brainstorms', 'sub_projects', 'tasks', 'task_prompts', 'task_conversations'];
6
+
7
+ function getExistingTables(db: ReturnType<typeof getDb>): string[] {
8
+ const rows = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all() as { name: string }[];
9
+ return rows.map(r => r.name);
10
+ }
11
+
12
+ export function exportToFile(filePath: string): { tables: Record<string, number> } {
13
+ const db = getDb();
14
+ const existingTables = getExistingTables(db);
15
+
16
+ const tables: Record<string, unknown[]> = {};
17
+ const counts: Record<string, number> = {};
18
+
19
+ // Export all known tables using raw SELECT for perfect round-trip
20
+ for (const table of V2_TABLES) {
21
+ if (existingTables.includes(table)) {
22
+ const rows = db.prepare(`SELECT * FROM ${table}`).all();
23
+ tables[table] = rows;
24
+ counts[table] = rows.length;
25
+ }
26
+ }
27
+
28
+ const data = {
29
+ version: 2,
30
+ exportedAt: new Date().toISOString(),
31
+ tables,
32
+ };
33
+
34
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
35
+
36
+ return { tables: counts };
37
+ }
@@ -0,0 +1,113 @@
1
+ import { execFile } from 'child_process';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ function quoteArg(arg: string): string {
6
+ // Quote args with spaces for shell mode
7
+ if (arg.includes(' ') || arg.includes('"')) {
8
+ return `"${arg.replace(/"/g, '\\"')}"`;
9
+ }
10
+ return arg;
11
+ }
12
+
13
+ function exec(cmd: string, args: string[], cwd?: string): Promise<string> {
14
+ return new Promise((resolve, reject) => {
15
+ const quotedArgs = args.map(quoteArg);
16
+ execFile(cmd, quotedArgs, { cwd, shell: true, timeout: 60000 }, (err, stdout, stderr) => {
17
+ if (err) {
18
+ reject(new Error(stderr?.trim() || err.message));
19
+ } else {
20
+ resolve(stdout.trim());
21
+ }
22
+ });
23
+ });
24
+ }
25
+
26
+ export async function isGitInstalled(): Promise<boolean> {
27
+ try { await exec('git', ['--version']); return true; } catch { return false; }
28
+ }
29
+
30
+ export async function isGhInstalled(): Promise<boolean> {
31
+ try { await exec('gh', ['--version']); return true; } catch { return false; }
32
+ }
33
+
34
+ export async function isGhAuthenticated(): Promise<boolean> {
35
+ try { await exec('gh', ['auth', 'status']); return true; } catch { return false; }
36
+ }
37
+
38
+ export function isGitRepo(dir: string): boolean {
39
+ return fs.existsSync(path.join(dir, '.git'));
40
+ }
41
+
42
+ export async function ghCreateRepo(name: string): Promise<string> {
43
+ // Create private repo and get URL
44
+ const result = await exec('gh', ['repo', 'create', name, '--private', '--confirm']);
45
+ // Extract repo URL from output
46
+ const urlMatch = result.match(/https:\/\/github\.com\/\S+/);
47
+ if (urlMatch) return urlMatch[0];
48
+ // Fallback: query the repo URL
49
+ const url = await exec('gh', ['repo', 'view', name, '--json', 'url', '-q', '.url']);
50
+ return url;
51
+ }
52
+
53
+ export async function gitClone(url: string, targetDir: string): Promise<void> {
54
+ await exec('git', ['clone', url, targetDir]);
55
+ }
56
+
57
+ export async function gitInit(cwd: string): Promise<void> {
58
+ await exec('git', ['init'], cwd);
59
+ // Set default branch to main
60
+ await exec('git', ['branch', '-M', 'main'], cwd);
61
+ }
62
+
63
+ export async function gitAddRemote(cwd: string, url: string): Promise<void> {
64
+ await exec('git', ['remote', 'add', 'origin', url], cwd);
65
+ }
66
+
67
+ export async function gitAdd(cwd: string, files: string[]): Promise<void> {
68
+ await exec('git', ['add', ...files], cwd);
69
+ }
70
+
71
+ export async function gitCommit(cwd: string, message: string): Promise<void> {
72
+ await exec('git', ['commit', '-m', message], cwd);
73
+ }
74
+
75
+ export async function gitPush(cwd: string, setUpstream = false): Promise<void> {
76
+ if (setUpstream) {
77
+ // Ensure branch is named main, then push
78
+ try { await exec('git', ['branch', '-M', 'main'], cwd); } catch { /* already main */ }
79
+ await exec('git', ['push', '-u', 'origin', 'main'], cwd);
80
+ } else {
81
+ await exec('git', ['push'], cwd);
82
+ }
83
+ }
84
+
85
+ export async function gitPull(cwd: string): Promise<void> {
86
+ await exec('git', ['pull'], cwd);
87
+ }
88
+
89
+ export async function hasRemote(cwd: string): Promise<boolean> {
90
+ try {
91
+ const result = await exec('git', ['remote'], cwd);
92
+ return result.includes('origin');
93
+ } catch { return false; }
94
+ }
95
+
96
+ export async function hasCommits(cwd: string): Promise<boolean> {
97
+ try {
98
+ await exec('git', ['rev-parse', 'HEAD'], cwd);
99
+ return true;
100
+ } catch { return false; }
101
+ }
102
+
103
+ export async function getRemoteUrl(cwd: string): Promise<string | null> {
104
+ try {
105
+ return await exec('git', ['remote', 'get-url', 'origin'], cwd);
106
+ } catch { return null; }
107
+ }
108
+
109
+ export async function getLastCommitInfo(cwd: string): Promise<string | null> {
110
+ try {
111
+ return await exec('git', ['log', '-1', '--format=%ci %s'], cwd);
112
+ } catch { return null; }
113
+ }
@@ -0,0 +1,69 @@
1
+ import fs from 'fs';
2
+ import { getDb } from '../db';
3
+ import { getDbPath } from '../utils/paths';
4
+
5
+ // Import order: parents first, then children
6
+ const IMPORT_ORDER = ['projects', 'brainstorms', 'sub_projects', 'tasks', 'task_prompts', 'task_conversations'];
7
+ // Delete order: children first, then parents
8
+ const DELETE_ORDER = [...IMPORT_ORDER].reverse();
9
+
10
+ export function backupDb(): string {
11
+ const dbPath = getDbPath();
12
+ const db = getDb();
13
+ // Flush WAL before backup
14
+ db.pragma('wal_checkpoint(TRUNCATE)');
15
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
16
+ const backupPath = `${dbPath}.backup-${timestamp}`;
17
+ fs.copyFileSync(dbPath, backupPath);
18
+ return backupPath;
19
+ }
20
+
21
+ export function importFromFile(filePath: string): { tables: Record<string, number> } {
22
+ const raw = fs.readFileSync(filePath, 'utf-8');
23
+ const data = JSON.parse(raw);
24
+
25
+ if (!data.version || !data.tables) {
26
+ throw new Error('Invalid sync data format');
27
+ }
28
+
29
+ const db = getDb();
30
+ const counts: Record<string, number> = {};
31
+
32
+ const doImport = db.transaction(() => {
33
+ db.pragma('foreign_keys = OFF');
34
+
35
+ // Delete all data in reverse dependency order
36
+ for (const table of DELETE_ORDER) {
37
+ try {
38
+ db.prepare(`DELETE FROM ${table}`).run();
39
+ } catch {
40
+ // Table might not exist, skip
41
+ }
42
+ }
43
+
44
+ // Insert data in dependency order
45
+ for (const table of IMPORT_ORDER) {
46
+ const rows = data.tables[table];
47
+ if (!rows || !Array.isArray(rows) || rows.length === 0) {
48
+ counts[table] = 0;
49
+ continue;
50
+ }
51
+
52
+ const columns = Object.keys(rows[0]);
53
+ const placeholders = columns.map(() => '?').join(', ');
54
+ const stmt = db.prepare(`INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`);
55
+
56
+ for (const row of rows) {
57
+ stmt.run(...columns.map(col => row[col] ?? null));
58
+ }
59
+
60
+ counts[table] = rows.length;
61
+ }
62
+
63
+ db.pragma('foreign_keys = ON');
64
+ });
65
+
66
+ doImport();
67
+
68
+ return { tables: counts };
69
+ }
@@ -0,0 +1,211 @@
1
+ import readline from 'readline';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { getSyncDir } from '../utils/paths';
5
+ import * as git from './git';
6
+ import { exportToFile } from './exporter';
7
+ import { importFromFile, backupDb } from './importer';
8
+
9
+ const SYNC_FILE = 'im-data.json';
10
+
11
+ function ask(question: string, defaultValue?: string): Promise<string> {
12
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
13
+ return new Promise(resolve => {
14
+ const prompt = defaultValue ? `${question} [${defaultValue}]: ` : `${question}: `;
15
+ rl.question(prompt, answer => {
16
+ rl.close();
17
+ resolve(answer.trim() || defaultValue || '');
18
+ });
19
+ });
20
+ }
21
+
22
+ function printCounts(label: string, counts: Record<string, number>) {
23
+ const parts = Object.entries(counts)
24
+ .filter(([, v]) => v > 0)
25
+ .map(([k, v]) => `${k}: ${v}`);
26
+ console.log(` ${label}: ${parts.join(', ') || 'empty'}`);
27
+ }
28
+
29
+ export async function syncInit() {
30
+ const syncDir = getSyncDir();
31
+
32
+ if (git.isGitRepo(syncDir)) {
33
+ const remoteUrl = await git.getRemoteUrl(syncDir);
34
+ console.log(`\n Sync already initialized.`);
35
+ if (remoteUrl) console.log(` Remote: ${remoteUrl}`);
36
+ console.log(` Use "im sync push" or "im sync pull"\n`);
37
+ return;
38
+ }
39
+
40
+ // Check git
41
+ if (!await git.isGitInstalled()) {
42
+ console.error('\n Error: git is not installed. Please install git first.\n');
43
+ process.exit(1);
44
+ }
45
+
46
+ let repoUrl = '';
47
+
48
+ // Try gh CLI
49
+ const ghAvailable = await git.isGhInstalled() && await git.isGhAuthenticated();
50
+
51
+ if (ghAvailable) {
52
+ const useGh = await ask('Create a new private GitHub repo? (Y/n)', 'Y');
53
+
54
+ if (useGh.toLowerCase() !== 'n') {
55
+ const repoName = await ask('Repository name', 'idea-manager-sync');
56
+
57
+ console.log(`\n Creating GitHub repo "${repoName}"...`);
58
+ try {
59
+ repoUrl = await git.ghCreateRepo(repoName);
60
+ console.log(` Created: ${repoUrl}`);
61
+ } catch (err) {
62
+ console.error(` Failed to create repo: ${(err as Error).message}`);
63
+ console.log(' Falling back to manual URL input.\n');
64
+ }
65
+ }
66
+ }
67
+
68
+ if (!repoUrl) {
69
+ repoUrl = await ask('Enter git repository URL');
70
+ if (!repoUrl) {
71
+ console.error('\n Error: Repository URL is required.\n');
72
+ process.exit(1);
73
+ }
74
+ }
75
+
76
+ // Clean sync dir and clone
77
+ console.log(`\n Cloning to ${syncDir}...`);
78
+ try {
79
+ // Remove sync dir contents (keep the dir itself)
80
+ const entries = fs.readdirSync(syncDir);
81
+ for (const e of entries) {
82
+ fs.rmSync(path.join(syncDir, e), { recursive: true, force: true });
83
+ }
84
+
85
+ await git.gitClone(repoUrl, syncDir);
86
+ } catch {
87
+ // Clone into existing empty dir — init + add remote instead
88
+ try {
89
+ await git.gitInit(syncDir);
90
+ await git.gitAddRemote(syncDir, repoUrl);
91
+ } catch (err2) {
92
+ console.error(` Failed: ${(err2 as Error).message}\n`);
93
+ process.exit(1);
94
+ }
95
+ }
96
+
97
+ // Create .gitignore
98
+ fs.writeFileSync(path.join(syncDir, '.gitignore'), '.DS_Store\n', 'utf-8');
99
+
100
+ // Initial export + push if repo is empty
101
+ if (!fs.existsSync(path.join(syncDir, SYNC_FILE))) {
102
+ console.log(' Performing initial export...');
103
+ const { tables } = exportToFile(path.join(syncDir, SYNC_FILE));
104
+ printCounts('Exported', tables);
105
+
106
+ try {
107
+ await git.gitAdd(syncDir, ['.gitignore', SYNC_FILE]);
108
+ await git.gitCommit(syncDir, 'sync: initial export');
109
+ await git.gitPush(syncDir, true);
110
+ console.log(' Pushed initial data.\n');
111
+ } catch (err) {
112
+ console.log(` Committed locally. Push manually if needed: ${(err as Error).message}\n`);
113
+ }
114
+ }
115
+
116
+ console.log(` Sync initialized successfully!`);
117
+ console.log(` Remote: ${repoUrl}\n`);
118
+ }
119
+
120
+ export async function syncPush(message?: string) {
121
+ const syncDir = getSyncDir();
122
+
123
+ if (!git.isGitRepo(syncDir)) {
124
+ console.error('\n Sync not initialized. Run "im sync init" first.\n');
125
+ process.exit(1);
126
+ }
127
+
128
+ // Export
129
+ const filePath = path.join(syncDir, SYNC_FILE);
130
+ const { tables } = exportToFile(filePath);
131
+ printCounts('Exported', tables);
132
+
133
+ // Commit + push
134
+ try {
135
+ await git.gitAdd(syncDir, [SYNC_FILE]);
136
+ const commitMsg = message || `sync: ${new Date().toISOString().slice(0, 19).replace('T', ' ')}`;
137
+ await git.gitCommit(syncDir, commitMsg);
138
+ } catch (err) {
139
+ const msg = (err as Error).message;
140
+ if (msg.includes('nothing to commit') || msg.includes('no changes')) {
141
+ console.log(' Already up to date.\n');
142
+ return;
143
+ }
144
+ throw err;
145
+ }
146
+
147
+ try {
148
+ const firstPush = !await git.hasCommits(syncDir).catch(() => true);
149
+ await git.gitPush(syncDir, firstPush);
150
+ console.log(' Pushed successfully.\n');
151
+ } catch (err) {
152
+ console.error(` Push failed: ${(err as Error).message}`);
153
+ console.log(' Data is committed locally. Try pushing manually.\n');
154
+ }
155
+ }
156
+
157
+ export async function syncPull(opts: { backup?: boolean } = {}) {
158
+ const syncDir = getSyncDir();
159
+
160
+ if (!git.isGitRepo(syncDir)) {
161
+ console.error('\n Sync not initialized. Run "im sync init" first.\n');
162
+ process.exit(1);
163
+ }
164
+
165
+ // Pull
166
+ try {
167
+ await git.gitPull(syncDir);
168
+ console.log(' Pulled latest data.');
169
+ } catch (err) {
170
+ console.error(` Pull failed: ${(err as Error).message}\n`);
171
+ process.exit(1);
172
+ }
173
+
174
+ const filePath = path.join(syncDir, SYNC_FILE);
175
+ if (!fs.existsSync(filePath)) {
176
+ console.log(' No sync data found. Run "im sync push" on another machine first.\n');
177
+ return;
178
+ }
179
+
180
+ // Backup
181
+ if (opts.backup !== false) {
182
+ const backupPath = backupDb();
183
+ console.log(` Backup: ${backupPath}`);
184
+ }
185
+
186
+ // Import
187
+ const { tables } = importFromFile(filePath);
188
+ printCounts('Imported', tables);
189
+ console.log(' Done. Refresh the browser to see updated data.\n');
190
+ }
191
+
192
+ export async function syncStatus() {
193
+ const syncDir = getSyncDir();
194
+
195
+ if (!git.isGitRepo(syncDir)) {
196
+ console.log('\n Sync not initialized.');
197
+ console.log(' Run "im sync init" to set up.\n');
198
+ return;
199
+ }
200
+
201
+ const remoteUrl = await git.getRemoteUrl(syncDir);
202
+ const lastCommit = await git.getLastCommitInfo(syncDir);
203
+ const filePath = path.join(syncDir, SYNC_FILE);
204
+ const hasData = fs.existsSync(filePath);
205
+
206
+ console.log('\n IM Sync Status');
207
+ console.log(` Remote: ${remoteUrl || 'none'}`);
208
+ console.log(` Last sync: ${lastCommit || 'never'}`);
209
+ console.log(` Data file: ${hasData ? 'exists' : 'not found'}`);
210
+ console.log(`\n Commands: im sync push | im sync pull\n`);
211
+ }
@@ -14,3 +14,12 @@ export function getDataDir(): string {
14
14
  export function getDbPath(): string {
15
15
  return path.join(getDataDir(), 'im.db');
16
16
  }
17
+
18
+ const SYNC_DIR = path.join(os.homedir(), '.idea-manager', 'sync');
19
+
20
+ export function getSyncDir(): string {
21
+ if (!fs.existsSync(SYNC_DIR)) {
22
+ fs.mkdirSync(SYNC_DIR, { recursive: true });
23
+ }
24
+ return SYNC_DIR;
25
+ }