dsh-ccswitch-sync 0.1.0 → 0.1.1

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 CHANGED
@@ -131,7 +131,7 @@ on the next run.
131
131
  ## Development
132
132
 
133
133
  ```sh
134
- node --test test/sync.test.mjs # splicing: idempotency, markers, stray rows
134
+ npm test # splicing (idempotency, markers, BOM) + file-level sync
135
135
  node scripts/check-manifest.mjs # dependency-free manifest shape check
136
136
  ```
137
137
 
package/README.zh.md CHANGED
@@ -125,7 +125,7 @@ dsh plugin --profile dsh-tui remove dsh-ccswitch-sync
125
125
  ## 开发
126
126
 
127
127
  ```sh
128
- node --test test/sync.test.mjs # 拼接逻辑:幂等、标记、残留行
128
+ npm test # 拼接逻辑(幂等、标记、BOM)+ 文件级同步
129
129
  node scripts/check-manifest.mjs # 无依赖的清单形状检查
130
130
  ```
131
131
 
package/dsh-plugin.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://unpkg.com/@dsh-std/manifest@0.1.1-rc.2/schema/dsh-plugin-0.15.schema.json",
3
3
  "id": "msyzzm.dsh-ccswitch-sync",
4
4
  "name": "CC Switch → dsh-TUI sync",
5
- "version": "0.1.0",
5
+ "version": "0.1.1",
6
6
  "manifestVersion": "0.15",
7
7
  "facets": {
8
8
  "host": {
package/lib/index.js CHANGED
@@ -15,14 +15,11 @@
15
15
  */
16
16
  import { appendFile, mkdir, readFile, stat } from 'node:fs/promises'
17
17
  import { appendFileSync } from 'node:fs'
18
- import { homedir } from 'node:os'
19
18
  import { join } from 'node:path'
20
- import { defaultPaths, syncOnce } from './sync.mjs'
19
+ import { STATE_DIR, defaultPaths, syncOnce } from './sync.mjs'
21
20
 
22
21
  export const name = 'ccswitch-sync'
23
22
 
24
- const HOME = homedir()
25
- const STATE_DIR = join(HOME, '.dsh-tui', 'ccswitch-sync')
26
23
  const LOG_PATH = join(STATE_DIR, 'plugin.log')
27
24
 
28
25
  // Import-time probe: distinguishes "module never imported" from "apply never
@@ -36,9 +33,8 @@ if (DEBUG) {
36
33
  // probe only
37
34
  }
38
35
  }
39
- const DSH_HOME = process.env.DSH_HOME || join(HOME, '.dsh')
40
- const PROFILE_DIR = process.env.DSH_PROFILE_DIR || join(DSH_HOME, 'profiles', 'dsh-tui')
41
- const PATCH_PATH = join(PROFILE_DIR, 'cordis.patch.yml')
36
+ /** Same file the sync writes, so the status always describes what was synced. */
37
+ const PATCH_PATH = defaultPaths().target
42
38
 
43
39
  const FIELD_KEY = 'ccswitch'
44
40
  const REFRESH_MS = 60_000
package/lib/sync.mjs CHANGED
@@ -17,21 +17,29 @@ export const END_MARK = '# <<< cc-switch sync (managed) <<<'
17
17
 
18
18
  const EMPTY_PROVIDERS_BLOCK = '- id: llm-pi-ai\n config:\n providers: {}\n'
19
19
  const KEEP_BACKUPS = 20
20
- /** Overridable so tests never touch the real state directory. */
21
- const STATE_DIR = process.env.CCSWITCH_SYNC_STATE || join(homedir(), '.dsh-tui')
20
+ const STABLE_READ_DELAY_MS = 150
21
+ const STABLE_READ_ATTEMPTS = 5
22
+ /** Plugin state (backups, log). The env override keeps tests off the real directory. */
23
+ export const STATE_DIR = join(process.env.CCSWITCH_SYNC_STATE || join(homedir(), '.dsh-tui'), 'ccswitch-sync')
22
24
 
23
25
  export function defaultPaths() {
24
26
  const home = process.env.DSH_HOME || join(homedir(), '.dsh')
25
27
  const profiles = join(home, 'profiles')
28
+ const targetDir = process.env.DSH_PROFILE_DIR || join(profiles, 'dsh-tui')
26
29
  return {
27
30
  sources: [
28
31
  join(profiles, 'web', 'cordis.patch.yml'),
29
32
  join(profiles, 'desktop', 'cordis.patch.yml'),
30
33
  ],
31
- target: join(profiles, 'dsh-tui', 'cordis.patch.yml'),
34
+ target: join(targetDir, 'cordis.patch.yml'),
32
35
  }
33
36
  }
34
37
 
38
+ /** A leading BOM would hide the first "- " entry from blockSpans. */
39
+ function stripBom(text) {
40
+ return text.startsWith('\uFEFF') ? text.slice(1) : text
41
+ }
42
+
35
43
  function linesWithEnds(text) {
36
44
  const out = []
37
45
  let index = 0
@@ -77,7 +85,8 @@ export function isManagedInsert(block) {
77
85
  }
78
86
 
79
87
  /** The blocks to mirror, always with an `llm-pi-ai` entry. */
80
- export function extractManaged(sourceText) {
88
+ export function extractManaged(rawSourceText) {
89
+ const sourceText = stripBom(rawSourceText)
81
90
  const blocks = blockSpans(sourceText).map(({ start, end }) => sourceText.slice(start, end))
82
91
  const wanted = blocks.filter((block) => isLlmPiAiBlock(block) || isManagedInsert(block))
83
92
  if (!wanted.some(isLlmPiAiBlock)) wanted.unshift(EMPTY_PROVIDERS_BLOCK)
@@ -87,7 +96,6 @@ export function extractManaged(sourceText) {
87
96
  function dropBlocks(text, predicate) {
88
97
  const spans = blockSpans(text)
89
98
  .filter(({ start, end }) => predicate(text.slice(start, end)))
90
- .map(({ start, end }) => ({ start, end }))
91
99
  let result = text
92
100
  for (let i = spans.length - 1; i >= 0; i -= 1) {
93
101
  result = result.slice(0, spans[i].start) + result.slice(spans[i].end)
@@ -112,7 +120,8 @@ function dropEmptyFlowSequence(text) {
112
120
  }
113
121
 
114
122
  /** Splice the managed block into the target patch text. */
115
- export function composePatch(targetText, managedBlocks) {
123
+ export function composePatch(rawTargetText, managedBlocks) {
124
+ const targetText = stripBom(rawTargetText)
116
125
  const managed = `${BEGIN_MARK}\n${managedBlocks.join('')}${END_MARK}\n`
117
126
  let head = targetText
118
127
  let tail = ''
@@ -131,12 +140,21 @@ export function composePatch(targetText, managedBlocks) {
131
140
  return out
132
141
  }
133
142
 
143
+ /**
144
+ * Read until two consecutive reads agree, so a file another process is still
145
+ * writing is not mirrored half-written. Gives up after a few attempts and
146
+ * returns the latest read.
147
+ */
134
148
  async function readStable(path) {
135
149
  try {
136
- const first = await readFile(path, 'utf8')
137
- await new Promise((resolve) => setTimeout(resolve, 150))
138
- const second = await readFile(path, 'utf8')
139
- return second === first ? first : second
150
+ let previous = await readFile(path, 'utf8')
151
+ for (let attempt = 1; attempt < STABLE_READ_ATTEMPTS; attempt += 1) {
152
+ await new Promise((resolve) => setTimeout(resolve, STABLE_READ_DELAY_MS))
153
+ const current = await readFile(path, 'utf8')
154
+ if (current === previous) return current
155
+ previous = current
156
+ }
157
+ return previous
140
158
  } catch (error) {
141
159
  if (error && error.code === 'ENOENT') return undefined
142
160
  throw error
@@ -150,7 +168,7 @@ async function backupTarget(target) {
150
168
  .replace(/[-:]/g, '')
151
169
  .replace('T', '-')
152
170
  .slice(0, 15)
153
- const dir = join(STATE_DIR, 'ccswitch-sync', 'backups')
171
+ const dir = join(STATE_DIR, 'backups')
154
172
  await mkdir(dir, { recursive: true })
155
173
  const dest = join(dir, `cordis.patch.${stamp}.yml`)
156
174
  await copyFile(target, dest)
@@ -164,7 +182,7 @@ async function backupTarget(target) {
164
182
  }
165
183
 
166
184
  /**
167
- * Mirror the newest readable source into the target.
185
+ * Mirror the first existing source (web, then desktop) into the target.
168
186
  * @returns `{ changed, note }` — `changed` is true only when bytes were written.
169
187
  */
170
188
  export async function syncOnce(paths = defaultPaths()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ccswitch-sync",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Mirror CC Switch's DSH providers and MCP servers into the dsh-tui profile, and show how fresh the mirror is.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -42,7 +42,7 @@
42
42
  "scripts": {
43
43
  "check": "node --check lib/index.js && node --check lib/sync.mjs",
44
44
  "check:manifest": "node scripts/check-manifest.mjs",
45
- "test": "node --test test/sync.test.mjs"
45
+ "test": "node --test \"test/*.test.mjs\""
46
46
  },
47
47
  "dsh": {
48
48
  "bundle": {