@lancc/cmark 1.1.0 → 1.2.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,392 @@
1
+ /**
2
+ * remote-setup.js —— 一键授权建仓 / 绑定远程笔记仓库
3
+ *
4
+ * 为什么要这个模块:
5
+ * 原来绑定远程要用户自己开网页建仓、复制仓库地址、再配 SSH 或凭据管理器,
6
+ * 对不熟 git 的人门槛太高。这里把「授权 → 建仓(或选已有仓库)→ 绑定 remote → 首次推送」串起来。
7
+ *
8
+ * 两个平台的能力差异(查过官方文档):
9
+ * GitHub:OAuth Device Flow,只需 client_id,不需要 client_secret,
10
+ * 用户在 github.com/login/device 输入 8 位码即可,桌面端的安全做法。
11
+ * Gitee :OAuth2 强制 client_secret(嵌进安装包会被提取),所以改用「私人令牌」,
12
+ * 用户在 gitee.com/profile/personal_access_tokens 生成后粘进来。
13
+ *
14
+ * token 存哪:
15
+ * ~/.cmark-config.json 的 remoteToken 字段(明文,与 git 自身的凭据存储同级别)。
16
+ * git 运行时通过内联 credential helper + 环境变量传入,不写进 .git/config。
17
+ */
18
+ const fs = require('fs')
19
+ const os = require('os')
20
+ const path = require('path')
21
+ const https = require('https')
22
+ const { execFile } = require('child_process')
23
+ const wsPrefs = require('./ws-prefs.js')
24
+
25
+ const CFG_PATH = path.join(os.homedir(), '.cmark-config.json')
26
+ const GITHUB_CLIENT_ID = 'Ov23liDQDiefOfwzHHLA'
27
+ const GITHUB_SCOPE = 'repo'
28
+ const UA = 'CMark-ZTools-Plugin'
29
+
30
+ /* ---------------- 配置读写 ---------------- */
31
+ function readJSON (p) {
32
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch (e) { return null }
33
+ }
34
+ function readCfg () {
35
+ return readJSON(CFG_PATH) || {}
36
+ }
37
+ function patchCfg (patch) {
38
+ const next = Object.assign({}, readCfg(), patch || {})
39
+ try {
40
+ fs.mkdirSync(path.dirname(CFG_PATH), { recursive: true })
41
+ fs.writeFileSync(CFG_PATH, JSON.stringify(next, null, 2), 'utf8')
42
+ return true
43
+ } catch (e) { return false }
44
+ }
45
+
46
+ /* ---------------- HTTP ---------------- */
47
+ function request (opts) {
48
+ const o = opts || {}
49
+ return new Promise(function (resolve) {
50
+ let body = o.body || ''
51
+ const headers = Object.assign({
52
+ 'User-Agent': UA,
53
+ Accept: 'application/json'
54
+ }, o.headers || {})
55
+ if (body && !headers['Content-Type']) headers['Content-Type'] = 'application/x-www-form-urlencoded'
56
+ if (body) headers['Content-Length'] = Buffer.byteLength(body)
57
+ const req = https.request({
58
+ hostname: o.hostname,
59
+ path: o.path,
60
+ method: o.method || 'GET',
61
+ headers: headers,
62
+ timeout: o.timeout || 20000
63
+ }, function (res) {
64
+ let buf = ''
65
+ res.setEncoding('utf8')
66
+ res.on('data', function (c) { buf += c })
67
+ res.on('end', function () {
68
+ let data = null
69
+ try { data = JSON.parse(buf) } catch (e) { data = { raw: buf.slice(0, 500) } }
70
+ resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, data: data })
71
+ })
72
+ })
73
+ req.on('timeout', function () { req.destroy(new Error('请求超时')) })
74
+ req.on('error', function (e) { resolve({ ok: false, status: 0, data: { message: (e && e.message) || String(e) } }) })
75
+ if (body) req.write(body)
76
+ req.end()
77
+ })
78
+ }
79
+ function form (obj) {
80
+ return Object.keys(obj).map(function (k) {
81
+ return encodeURIComponent(k) + '=' + encodeURIComponent(obj[k] == null ? '' : obj[k])
82
+ }).join('&')
83
+ }
84
+
85
+ /* ---------------- GitHub Device Flow ---------------- */
86
+ function ghStart () {
87
+ return request({
88
+ hostname: 'github.com',
89
+ path: '/login/device/code',
90
+ method: 'POST',
91
+ body: form({ client_id: GITHUB_CLIENT_ID, scope: GITHUB_SCOPE })
92
+ }).then(function (r) {
93
+ const d = r.data || {}
94
+ if (!r.ok || !d.device_code) {
95
+ return { ok: false, message: d.error_description || d.message || '申请设备码失败(检查 OAuth App 是否开启 Device Flow)' }
96
+ }
97
+ return {
98
+ ok: true,
99
+ deviceCode: d.device_code,
100
+ userCode: d.user_code,
101
+ verificationUri: d.verification_uri || 'https://github.com/login/device',
102
+ expiresIn: d.expires_in || 900,
103
+ interval: d.interval || 5
104
+ }
105
+ })
106
+ }
107
+
108
+ // 单次轮询:pending = 用户还没在浏览器里确认
109
+ function ghPoll (deviceCode) {
110
+ return request({
111
+ hostname: 'github.com',
112
+ path: '/login/oauth/access_token',
113
+ method: 'POST',
114
+ body: form({
115
+ client_id: GITHUB_CLIENT_ID,
116
+ device_code: deviceCode,
117
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
118
+ })
119
+ }).then(function (r) {
120
+ const d = r.data || {}
121
+ if (d.access_token) return { ok: true, token: d.access_token }
122
+ if (d.error === 'authorization_pending') return { ok: false, pending: true }
123
+ if (d.error === 'slow_down') return { ok: false, pending: true, slowDown: true }
124
+ if (d.error === 'expired_token') return { ok: false, message: '设备码已过期,请重新发起授权' }
125
+ if (d.error === 'access_denied') return { ok: false, message: '你取消了授权' }
126
+ return { ok: false, message: d.error_description || d.error || ('轮询失败 HTTP ' + r.status) }
127
+ })
128
+ }
129
+
130
+ function ghApi (token, method, apiPath, bodyObj) {
131
+ const body = bodyObj ? JSON.stringify(bodyObj) : ''
132
+ return request({
133
+ hostname: 'api.github.com',
134
+ path: apiPath,
135
+ method: method,
136
+ headers: Object.assign({
137
+ Authorization: 'Bearer ' + token,
138
+ Accept: 'application/vnd.github+json'
139
+ }, body ? { 'Content-Type': 'application/json' } : {}),
140
+ body: body
141
+ }).then(function (r) { return { ok: r.ok, status: r.status, data: r.data || {} } })
142
+ }
143
+
144
+ function ghUser (token) {
145
+ return ghApi(token, 'GET', '/user').then(function (r) {
146
+ if (!r.ok) return { ok: false, message: (r.data && r.data.message) || '无法读取 GitHub 账号' }
147
+ return { ok: true, login: r.data.login, name: r.data.name || r.data.login }
148
+ })
149
+ }
150
+
151
+ function ghListRepos (token) {
152
+ return ghApi(token, 'GET', '/user/repos?per_page=100&sort=updated&affiliation=owner').then(function (r) {
153
+ if (!r.ok) return { ok: false, message: (r.data && r.data.message) || '读取仓库列表失败' }
154
+ const list = (Array.isArray(r.data) ? r.data : []).map(function (x) {
155
+ return {
156
+ name: x.name,
157
+ fullName: x.full_name,
158
+ private: !!x.private,
159
+ url: x.clone_url,
160
+ updatedAt: x.updated_at,
161
+ empty: x.size === 0
162
+ }
163
+ })
164
+ return { ok: true, repos: list }
165
+ })
166
+ }
167
+
168
+ function ghCreateRepo (token, name, isPrivate) {
169
+ return ghApi(token, 'POST', '/user/repos', {
170
+ name: name,
171
+ private: isPrivate !== false,
172
+ auto_init: false,
173
+ description: 'CMark 笔记'
174
+ }).then(function (r) {
175
+ if (!r.ok) {
176
+ const msg = (r.data && r.data.message) || '建仓失败'
177
+ const detail = (r.data && r.data.errors && r.data.errors[0] && r.data.errors[0].message) || ''
178
+ return { ok: false, message: msg + (detail ? ':' + detail : '') }
179
+ }
180
+ return { ok: true, url: r.data.clone_url, fullName: r.data.full_name }
181
+ })
182
+ }
183
+
184
+ /* ---------------- Gitee(私人令牌) ---------------- */
185
+ function giteeApi (method, apiPath, params, token) {
186
+ const query = Object.assign({}, params || {}, { access_token: token })
187
+ const qs = form(query)
188
+ const isGet = method === 'GET'
189
+ return request({
190
+ hostname: 'gitee.com',
191
+ path: '/api/v5' + apiPath + (isGet ? '?' + qs : ''),
192
+ method: method,
193
+ body: isGet ? '' : qs
194
+ }).then(function (r) { return { ok: r.ok, status: r.status, data: r.data || {} } })
195
+ }
196
+
197
+ function giteeVerify (token) {
198
+ return giteeApi('GET', '/user', {}, token).then(function (r) {
199
+ if (!r.ok) return { ok: false, message: (r.data && (r.data.message || r.data.error)) || '令牌无效或已过期' }
200
+ return { ok: true, login: r.data.login || r.data.name, name: r.data.name || r.data.login }
201
+ })
202
+ }
203
+
204
+ function giteeListRepos (token) {
205
+ return giteeApi('GET', '/user/repos', { per_page: 100, sort: 'updated' }, token).then(function (r) {
206
+ if (!r.ok) return { ok: false, message: (r.data && (r.data.message || r.data.error)) || '读取仓库列表失败' }
207
+ const list = (Array.isArray(r.data) ? r.data : []).map(function (x) {
208
+ return {
209
+ name: x.name,
210
+ fullName: x.full_name || (x.namespace && x.namespace.path ? x.namespace.path + '/' + x.name : x.name),
211
+ private: !!x.private,
212
+ url: x.html_url ? x.html_url.replace(/\.git$/, '') + '.git' : '',
213
+ updatedAt: x.updated_at
214
+ }
215
+ })
216
+ return { ok: true, repos: list }
217
+ })
218
+ }
219
+
220
+ function giteeCreateRepo (token, name, isPrivate) {
221
+ return giteeApi('POST', '/user/repos', {
222
+ name: name,
223
+ private: isPrivate !== false,
224
+ auto_init: false,
225
+ description: 'CMark 笔记'
226
+ }, token).then(function (r) {
227
+ if (!r.ok) return { ok: false, message: (r.data && (r.data.message || r.data.error)) || '建仓失败' }
228
+ const full = r.data.full_name || r.data.path
229
+ return { ok: true, url: 'https://gitee.com/' + full + '.git', fullName: full }
230
+ })
231
+ }
232
+
233
+ /* ---------------- git 绑定 ---------------- */
234
+ const TOKEN_HELPER = '!f() { echo username=x-access-token; echo password=$CMARK_GIT_TOKEN; }; f'
235
+
236
+ function git (args, cwd, token, timeout) {
237
+ return new Promise(function (resolve) {
238
+ const pre = token
239
+ ? ['-c', 'core.quotepath=false', '-c', 'credential.helper=', '-c', 'credential.helper=' + TOKEN_HELPER]
240
+ : ['-c', 'core.quotepath=false', '-c', 'credential.helper=', '-c', 'credential.helper=manager']
241
+ execFile('git', pre.concat(args), {
242
+ cwd: cwd,
243
+ encoding: 'utf8',
244
+ maxBuffer: 16 * 1024 * 1024,
245
+ windowsHide: true,
246
+ timeout: timeout || 120000,
247
+ env: Object.assign({}, process.env, {
248
+ GCM_INTERACTIVE: 'never',
249
+ GIT_TERMINAL_PROMPT: '0',
250
+ LC_ALL: 'C',
251
+ GIT_LITERAL_PATHSPECS: '1',
252
+ CMARK_GIT_TOKEN: token || ''
253
+ })
254
+ }, function (err, stdout, stderr) {
255
+ if (err) {
256
+ resolve({ ok: false, message: String(stderr || stdout || err.message || '').trim().slice(0, 600) })
257
+ } else {
258
+ resolve({ ok: true, data: String(stdout || '').trim() })
259
+ }
260
+ })
261
+ })
262
+ }
263
+
264
+ function isRepo (dir) {
265
+ try { return fs.existsSync(path.join(dir, '.git')) } catch (e) { return false }
266
+ }
267
+
268
+ /**
269
+ * 绑定远程笔记仓库
270
+ * @param {object} o
271
+ * provider: 'github' | 'gitee'
272
+ * url: https 仓库地址(不带 token)
273
+ * token: 访问令牌
274
+ * dir: 本地笔记目录
275
+ * existing: true 表示远程已有内容,需要先拉下来(否则会把本地空历史推上去)
276
+ */
277
+ async function bind (o) {
278
+ const opt = o || {}
279
+ const dir = opt.dir
280
+ const url = opt.url
281
+ const token = opt.token
282
+ if (!dir || !url || !token) return { ok: false, message: '参数不完整' }
283
+ if (!fs.existsSync(dir)) {
284
+ try { fs.mkdirSync(dir, { recursive: true }) } catch (e) {
285
+ return { ok: false, message: '无法创建目录:' + dir }
286
+ }
287
+ }
288
+
289
+ const steps = []
290
+ if (!isRepo(dir)) {
291
+ const init = await git(['init', '-b', 'main'], dir)
292
+ if (!init.ok) return { ok: false, message: 'git init 失败:' + init.message }
293
+ steps.push('init')
294
+ }
295
+
296
+ const getRemote = await git(['remote', 'get-url', 'origin'], dir)
297
+ if (getRemote.ok) {
298
+ const setUrl = await git(['remote', 'set-url', 'origin', url], dir)
299
+ if (!setUrl.ok) return { ok: false, message: '更新远端地址失败:' + setUrl.message }
300
+ steps.push('remote-set')
301
+ } else {
302
+ const add = await git(['remote', 'add', 'origin', url], dir)
303
+ if (!add.ok) return { ok: false, message: '添加 remote 失败:' + add.message }
304
+ steps.push('remote-add')
305
+ }
306
+
307
+ if (opt.existing) {
308
+ // 远程已有仓库:把内容拉下来(只能 fast-forward,避免覆盖本地笔记)
309
+ const fetch = await git(['fetch', 'origin'], dir, token, 180000)
310
+ if (!fetch.ok) return { ok: false, message: '拉取失败:' + fetch.message, steps: steps }
311
+ steps.push('fetch')
312
+
313
+ // 远端默认分支不一定是 main(Gitee 新建仓库默认 master),按远端 HEAD 指向来合并
314
+ let remoteBranch = 'main'
315
+ try {
316
+ const sym = await git(['ls-remote', '--symref', 'origin', 'HEAD'], dir, token, 60000)
317
+ const m = sym.ok ? /ref:\s+refs\/heads\/(\S+)\s+HEAD/.exec(sym.data || '') : null
318
+ if (m && m[1]) remoteBranch = m[1]
319
+ } catch (e) {}
320
+
321
+ if (remoteBranch !== 'main') {
322
+ // 本地是 main、远程是别的名字:把本地主干指到远程分支,避免两个平行根
323
+ await git(['branch', '-M', remoteBranch], dir)
324
+ }
325
+ const merge = await git(['merge', '--ff-only', 'origin/' + remoteBranch], dir, token, 60000)
326
+ if (!merge.ok) {
327
+ return { ok: false, message: '合并远端分支 ' + remoteBranch + ' 失败:' + merge.message, steps: steps }
328
+ }
329
+ steps.push('merge')
330
+ } else {
331
+ // 全新仓库:提交本地内容后首次推送
332
+ const hasCommit = await git(['rev-parse', '--verify', 'HEAD'], dir)
333
+ if (!hasCommit.ok) {
334
+ const addAll = await git(['add', '-A', '--', '.'], dir)
335
+ if (!addAll.ok) return { ok: false, message: '暂存失败:' + addAll.message }
336
+ const commit = await git(['commit', '-m', '初始化笔记仓库'], dir)
337
+ if (!commit.ok && !/nothing to commit/i.test(commit.message)) {
338
+ return { ok: false, message: '提交失败:' + commit.message }
339
+ }
340
+ steps.push('commit')
341
+ }
342
+ const push = await git(['push', '-u', 'origin', 'main'], dir, token, 180000)
343
+ if (!push.ok) return { ok: false, message: '首次推送失败:' + push.message, steps: steps }
344
+ steps.push('push')
345
+ }
346
+
347
+ // dir 是工作区(仓库根)。笔记目录若已经在它之内就保持不动,
348
+ // 这样「工作区 D:\Proj + 笔记 D:\Proj\Notes」这种布局不会被建仓流程压平
349
+ const cur = readCfg() || {}
350
+ const curWs = cur.workspace || ''
351
+ let keepWs = false
352
+ try {
353
+ const rel = path.relative(dir, curWs)
354
+ keepWs = !!curWs && !rel.startsWith('..') && !path.isAbsolute(rel)
355
+ } catch (e) { keepWs = false }
356
+ patchCfg(Object.assign({ repoDir: dir, initialized: true }, keepWs ? {} : { workspace: dir }))
357
+ const savedRes = wsPrefs.patch(dir, { remoteToken: { provider: opt.provider || '', token: token, url: url } })
358
+ const saved = !!(savedRes && savedRes.ok)
359
+ return { ok: saved, steps: steps, message: saved ? '' : ((savedRes && savedRes.message) || '写入配置失败') }
360
+ }
361
+
362
+ function attach (win) {
363
+ win.__CMARK_REMOTE = {
364
+ githubClientId: GITHUB_CLIENT_ID,
365
+ ghStart: ghStart,
366
+ ghPoll: ghPoll,
367
+ ghUser: ghUser,
368
+ ghListRepos: ghListRepos,
369
+ ghCreateRepo: ghCreateRepo,
370
+ giteeVerify: giteeVerify,
371
+ giteeListRepos: giteeListRepos,
372
+ giteeCreateRepo: giteeCreateRepo,
373
+ bind: bind,
374
+ // 当前是否已存过凭据
375
+ current: function () {
376
+ const c = readCfg() || {}
377
+ const p = c.workspace ? wsPrefs.read(c.workspace) : {}
378
+ const t = p.remoteToken || c.remoteToken
379
+ return t ? { provider: t.provider, url: t.url } : null
380
+ },
381
+ clear: function () {
382
+ const c = readCfg() || {}
383
+ if (c.workspace) wsPrefs.patch(c.workspace, { remoteToken: null })
384
+ patchCfg({ remoteToken: null })
385
+ return { ok: true }
386
+ },
387
+ openUrl: function (u) { try { win.ztools && win.ztools.shellOpenExternal && win.ztools.shellOpenExternal(u) } catch (e) {} }
388
+ }
389
+ return win.__CMARK_REMOTE
390
+ }
391
+
392
+ module.exports = attach