copyhub-cli 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/.env.example +24 -0
- package/README.md +122 -0
- package/package.json +39 -0
- package/src/cli.js +337 -0
- package/src/clipboard-watcher.js +45 -0
- package/src/config.js +180 -0
- package/src/daemon-state.js +51 -0
- package/src/electron-launcher.js +47 -0
- package/src/oauth.js +579 -0
- package/src/paths.js +9 -0
- package/src/platform.js +17 -0
- package/src/sheet-api-errors.js +50 -0
- package/src/sheet-daily.js +11 -0
- package/src/sheets.js +106 -0
- package/src/start-daemon-logic.js +114 -0
- package/src/stop-process.js +29 -0
- package/src/storage.js +59 -0
- package/src/tokens.js +24 -0
- package/ui/main.mjs +331 -0
- package/ui/preload.cjs +12 -0
- package/ui/renderer/index.html +224 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { CONFIG_PATH } from './paths.js';
|
|
4
|
+
import { ensureDir } from './storage.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {{ clientId: string, clientSecret: string, redirectPort: number }} CopyHubConfig
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Default localhost port for OAuth browser callback. */
|
|
11
|
+
export const DEFAULT_OAUTH_REDIRECT_PORT = 19999;
|
|
12
|
+
|
|
13
|
+
export const ENV_GOOGLE_CLIENT_ID = 'COPYHUB_GOOGLE_CLIENT_ID';
|
|
14
|
+
export const ENV_GOOGLE_CLIENT_SECRET = 'COPYHUB_GOOGLE_CLIENT_SECRET';
|
|
15
|
+
export const ENV_OAUTH_REDIRECT_PORT = 'COPYHUB_OAUTH_REDIRECT_PORT';
|
|
16
|
+
|
|
17
|
+
function parseRedirectPortFromEnv() {
|
|
18
|
+
const raw = process.env[ENV_OAUTH_REDIRECT_PORT]?.trim();
|
|
19
|
+
if (!raw) return null;
|
|
20
|
+
const n = parseInt(raw, 10);
|
|
21
|
+
if (!Number.isFinite(n) || n < 1 || n > 65535) return null;
|
|
22
|
+
return n;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Both Client ID and Secret come from environment (or .env). */
|
|
26
|
+
export function hasOAuthCredentialsInEnv() {
|
|
27
|
+
const id = process.env[ENV_GOOGLE_CLIENT_ID]?.trim();
|
|
28
|
+
const sec = process.env[ENV_GOOGLE_CLIENT_SECRET]?.trim();
|
|
29
|
+
return Boolean(id && sec);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @returns {'env' | 'file' | 'mixed'} */
|
|
33
|
+
export function describeOAuthCredentialSource() {
|
|
34
|
+
const idEnv = Boolean(process.env[ENV_GOOGLE_CLIENT_ID]?.trim());
|
|
35
|
+
const secEnv = Boolean(process.env[ENV_GOOGLE_CLIENT_SECRET]?.trim());
|
|
36
|
+
if (idEnv && secEnv) return 'env';
|
|
37
|
+
if (idEnv || secEnv) return 'mixed';
|
|
38
|
+
return 'file';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @returns {Promise<CopyHubConfig | null>} */
|
|
42
|
+
export async function loadConfig() {
|
|
43
|
+
/** @type {{ clientId?: string, clientSecret?: string, redirectPort?: number }} */
|
|
44
|
+
const fromFile = {};
|
|
45
|
+
|
|
46
|
+
if (existsSync(CONFIG_PATH)) {
|
|
47
|
+
const raw = await readFile(CONFIG_PATH, 'utf8');
|
|
48
|
+
const j = JSON.parse(raw);
|
|
49
|
+
if (typeof j.clientId === 'string' && j.clientId) fromFile.clientId = j.clientId;
|
|
50
|
+
if (typeof j.clientSecret === 'string' && j.clientSecret) {
|
|
51
|
+
fromFile.clientSecret = j.clientSecret;
|
|
52
|
+
}
|
|
53
|
+
if (typeof j.redirectPort === 'number' && Number.isFinite(j.redirectPort)) {
|
|
54
|
+
fromFile.redirectPort = j.redirectPort;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const envId = process.env[ENV_GOOGLE_CLIENT_ID]?.trim();
|
|
59
|
+
const envSecret = process.env[ENV_GOOGLE_CLIENT_SECRET]?.trim();
|
|
60
|
+
const envPort = parseRedirectPortFromEnv();
|
|
61
|
+
|
|
62
|
+
const clientId = envId || fromFile.clientId;
|
|
63
|
+
const clientSecret = envSecret || fromFile.clientSecret;
|
|
64
|
+
|
|
65
|
+
const filePort =
|
|
66
|
+
typeof fromFile.redirectPort === 'number'
|
|
67
|
+
? fromFile.redirectPort
|
|
68
|
+
: DEFAULT_OAUTH_REDIRECT_PORT;
|
|
69
|
+
const redirectPort = envPort ?? filePort;
|
|
70
|
+
|
|
71
|
+
if (!clientId || !clientSecret) return null;
|
|
72
|
+
|
|
73
|
+
return { clientId, clientSecret, redirectPort };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @typedef {{ spreadsheetId: string }} SheetSyncTarget
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
/** @returns {Promise<SheetSyncTarget | null>} */
|
|
81
|
+
export async function loadSheetSyncTarget() {
|
|
82
|
+
/** @type {{ googleSheetId?: string }} */
|
|
83
|
+
let fromFile = {};
|
|
84
|
+
|
|
85
|
+
if (existsSync(CONFIG_PATH)) {
|
|
86
|
+
try {
|
|
87
|
+
const raw = await readFile(CONFIG_PATH, 'utf8');
|
|
88
|
+
const j = JSON.parse(raw);
|
|
89
|
+
if (typeof j.googleSheetId === 'string' && j.googleSheetId.trim()) {
|
|
90
|
+
fromFile.googleSheetId = j.googleSheetId.trim();
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
/* ignore */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const spreadsheetId = fromFile.googleSheetId;
|
|
98
|
+
|
|
99
|
+
if (!spreadsheetId) return null;
|
|
100
|
+
return { spreadsheetId };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Overlay shortcut (Electron Accelerator). Empty = app default.
|
|
105
|
+
* Env COPYHUB_OVERLAY_ACCELERATOR wins when set, then config.
|
|
106
|
+
*/
|
|
107
|
+
export function loadOverlayAcceleratorFromConfigSync() {
|
|
108
|
+
if (!existsSync(CONFIG_PATH)) return '';
|
|
109
|
+
try {
|
|
110
|
+
const j = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
111
|
+
if (typeof j.overlayAccelerator === 'string') {
|
|
112
|
+
return j.overlayAccelerator.trim();
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
/* ignore */
|
|
116
|
+
}
|
|
117
|
+
return '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @returns {'win' | 'mac' | 'linux' | ''} */
|
|
121
|
+
export function loadOverlayPlatformFromConfigSync() {
|
|
122
|
+
if (!existsSync(CONFIG_PATH)) return '';
|
|
123
|
+
try {
|
|
124
|
+
const j = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
125
|
+
const p = typeof j.overlayPlatform === 'string' ? j.overlayPlatform.trim().toLowerCase() : '';
|
|
126
|
+
if (p === 'win' || p === 'windows') return 'win';
|
|
127
|
+
if (p === 'mac' || p === 'macos' || p === 'darwin') return 'mac';
|
|
128
|
+
if (p === 'linux') return 'linux';
|
|
129
|
+
} catch {
|
|
130
|
+
/* ignore */
|
|
131
|
+
}
|
|
132
|
+
return '';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Merge fields into ~/.copyhub/config.json (keeps existing clientId/secret).
|
|
137
|
+
* @param {Record<string, unknown>} partial
|
|
138
|
+
*/
|
|
139
|
+
export async function mergeConfigPartial(partial) {
|
|
140
|
+
await ensureDir();
|
|
141
|
+
/** @type {Record<string, unknown>} */
|
|
142
|
+
let existing = {};
|
|
143
|
+
if (existsSync(CONFIG_PATH)) {
|
|
144
|
+
try {
|
|
145
|
+
existing = JSON.parse(await readFile(CONFIG_PATH, 'utf8'));
|
|
146
|
+
} catch {
|
|
147
|
+
/* ignore */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const out = { ...existing, ...partial };
|
|
151
|
+
delete out.sheetTab;
|
|
152
|
+
delete out.sheetDailyPrefix;
|
|
153
|
+
await writeFile(CONFIG_PATH, JSON.stringify(out, null, 2), 'utf8');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param {CopyHubConfig & { googleSheetId?: string }} cfg
|
|
158
|
+
*/
|
|
159
|
+
export async function saveConfig(cfg) {
|
|
160
|
+
await ensureDir();
|
|
161
|
+
/** @type {Record<string, unknown>} */
|
|
162
|
+
let existing = {};
|
|
163
|
+
if (existsSync(CONFIG_PATH)) {
|
|
164
|
+
try {
|
|
165
|
+
existing = JSON.parse(await readFile(CONFIG_PATH, 'utf8'));
|
|
166
|
+
} catch {
|
|
167
|
+
/* ignore */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const out = {
|
|
171
|
+
...existing,
|
|
172
|
+
clientId: cfg.clientId,
|
|
173
|
+
clientSecret: cfg.clientSecret,
|
|
174
|
+
redirectPort: cfg.redirectPort ?? DEFAULT_OAUTH_REDIRECT_PORT,
|
|
175
|
+
};
|
|
176
|
+
if (cfg.googleSheetId !== undefined) out.googleSheetId = cfg.googleSheetId;
|
|
177
|
+
delete out.sheetTab;
|
|
178
|
+
delete out.sheetDailyPrefix;
|
|
179
|
+
await writeFile(CONFIG_PATH, JSON.stringify(out, null, 2), 'utf8');
|
|
180
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
|
|
2
|
+
import { RUN_STATE_PATH } from './paths.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {{ pid: number, startedAt: string, foreground?: boolean }} RunState
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** @returns {RunState | null} */
|
|
9
|
+
export function readRunState() {
|
|
10
|
+
if (!existsSync(RUN_STATE_PATH)) return null;
|
|
11
|
+
try {
|
|
12
|
+
const j = JSON.parse(readFileSync(RUN_STATE_PATH, 'utf8'));
|
|
13
|
+
if (typeof j.pid !== 'number' || !Number.isFinite(j.pid)) return null;
|
|
14
|
+
return j;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @param {RunState} state */
|
|
21
|
+
export function writeRunState(state) {
|
|
22
|
+
writeFileSync(RUN_STATE_PATH, JSON.stringify(state, null, 2), 'utf8');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function clearRunState() {
|
|
26
|
+
if (!existsSync(RUN_STATE_PATH)) return;
|
|
27
|
+
try {
|
|
28
|
+
unlinkSync(RUN_STATE_PATH);
|
|
29
|
+
} catch {
|
|
30
|
+
/* ignore */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @param {number} pid */
|
|
35
|
+
export function isPidAlive(pid) {
|
|
36
|
+
try {
|
|
37
|
+
process.kill(pid, 0);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Remove run.json when the stored PID is no longer alive (cleanup). */
|
|
45
|
+
export function pruneStaleRunState() {
|
|
46
|
+
const s = readRunState();
|
|
47
|
+
if (!s) return;
|
|
48
|
+
if (!isPidAlive(s.pid)) {
|
|
49
|
+
clearRunState();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export function getProjectRoot() {
|
|
7
|
+
return join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Path to electron.exe (Windows) / Electron (macOS) after npm postinstall download. */
|
|
11
|
+
export function resolveElectronBinary() {
|
|
12
|
+
const root = getProjectRoot();
|
|
13
|
+
const pathTxt = join(root, 'node_modules', 'electron', 'path.txt');
|
|
14
|
+
if (!fs.existsSync(pathTxt)) return null;
|
|
15
|
+
const rel = fs.readFileSync(pathTxt, 'utf8').trim();
|
|
16
|
+
const abs = join(root, 'node_modules', 'electron', rel);
|
|
17
|
+
if (!fs.existsSync(abs)) return null;
|
|
18
|
+
return abs;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Spawn the floating history overlay (Electron).
|
|
23
|
+
* @param {{ stdio?: 'inherit' | 'ignore' | 'pipe', envExtra?: Record<string, string> }} [opts]
|
|
24
|
+
*/
|
|
25
|
+
export function spawnCopyhubOverlay(opts = {}) {
|
|
26
|
+
const stdio = opts.stdio ?? 'inherit';
|
|
27
|
+
const root = getProjectRoot();
|
|
28
|
+
const uiMain = join(root, 'ui', 'main.mjs');
|
|
29
|
+
const env = { ...process.env, ...opts.envExtra };
|
|
30
|
+
const direct = resolveElectronBinary();
|
|
31
|
+
if (direct) {
|
|
32
|
+
return spawn(direct, [uiMain], {
|
|
33
|
+
stdio,
|
|
34
|
+
cwd: root,
|
|
35
|
+
env,
|
|
36
|
+
detached: false,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
40
|
+
return spawn(npx, ['--yes', 'electron', uiMain], {
|
|
41
|
+
stdio,
|
|
42
|
+
cwd: root,
|
|
43
|
+
shell: true,
|
|
44
|
+
env,
|
|
45
|
+
detached: false,
|
|
46
|
+
});
|
|
47
|
+
}
|