Skip to content

Commit e8ccf4a

Browse files
mschmickingclaude
andcommitted
fix(sync): stop pull silently overwriting files the user already had
Found while trying the tool the way a new user would. Pointing scriptRoot at a folder that already contains files meant pull wrote straight over them, without a word. The safety model said 'pull never deletes local files', which was true and beside the point: clobbering loses the work just the same. compare.ts reported an untracked local file colliding with a never-synced remote script as remote-only, so pull treated it as a plain download. A collision is now a conflict, so pull refuses without --force. Identical content is adopted silently rather than reported, since that is already what pull wants the file to say. The docs failed here too. The README explained neither where the config lives, nor that commands search upward for it, nor what scriptRoot is relative to — the only way to find out was to read config.ts. A 'Where things live' section now covers the layout, the upward search, and why scriptRoot cannot escape the project root. The error message itself said what was forbidden and not what to do. The natural reaction to 'must be relative' is to try '../elsewhere', which then hits the second rule with equally little help. Both now point at the actual answer: run init in the folder you want, or use -C. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9ee40bf commit e8ccf4a

4 files changed

Lines changed: 179 additions & 6 deletions

File tree

README.md

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ The five commands worth knowing on day one:
5454

5555
- [Why this exists](#why-this-exists)
5656
- [iobroker-sync or the VS Code extension?](#iobroker-sync-or-the-vs-code-extension)
57+
- [Where things live](#where-things-live)
5758
- [How scripts map to files](#how-scripts-map-to-files)
5859
- [The edit loop](#the-edit-loop)
5960
- [Commands](#commands)
@@ -98,6 +99,50 @@ Rough rule: **if you live in VS Code, use the extension.** If you want your own
9899
git-first workflow, or anything automated, use this. They are not exclusive — both talk
99100
to the same Admin API, and this tool never writes fields it does not own.
100101

102+
## Where things live
103+
104+
`iob-sync` is a global command that works on a **project folder** — the folder holding
105+
`.iobroker-sync.json`. Where the tool itself is installed is irrelevant; nothing is ever
106+
read from or written to `node_modules`.
107+
108+
```
109+
~/iobroker-scripts/ <- project root: run iob-sync anywhere inside it
110+
├── .iobroker-sync.json <- config. Commit it; it holds no password.
111+
├── .iobroker-sync/ <- state, backups, trash. Gitignored; may contain secrets.
112+
└── scripts/ <- scriptRoot: your scripts land here
113+
├── common/garage.ts
114+
└── Switch-Musiccast.js
115+
```
116+
117+
Commands search upward from the current directory for `.iobroker-sync.json`, the way
118+
`git` finds `.git` — so you can run them from any subfolder. `-C <dir>` runs as if
119+
started somewhere else.
120+
121+
**`scriptRoot` is relative to the project root and cannot escape it.** Absolute paths and
122+
`../` are rejected: it is the directory the tool writes into, so a config that pointed
123+
outside could drop files anywhere on your disk.
124+
125+
That means you do not point an existing project at a scripts folder elsewhere — you run
126+
`init` **in** the folder you want to keep scripts in:
127+
128+
```bash
129+
cd ~/iobroker-scripts # your git repo
130+
iob-sync init # config lands here, scriptRoot defaults to "scripts"
131+
```
132+
133+
Your password is **not** stored here. It lives in
134+
`~/.config/iobroker-sync/credentials.json` — see [Authentication](#authentication).
135+
136+
### Pointing scriptRoot at a folder that already has files
137+
138+
Safe, but worth knowing: if a script would land on top of a file you already have, and
139+
the two differ, `pull` reports a **conflict** and leaves your file alone. Use `--force`
140+
to take the server's copy. Identical files are adopted silently.
141+
142+
Setting `scriptRoot` to `.` (the project root itself) works, but then scripts land beside
143+
your `README.md` and `package.json`. A subfolder is tidier and keeps `status` output
144+
meaningful.
145+
101146
## How scripts map to files
102147

103148
| ioBroker object | local file |
@@ -170,8 +215,10 @@ containing `*` is an anchored glob (`iob-sync status 'common/*.ts'`).
170215
This tool talks to a live home-automation system, and is deliberately conservative about
171216
destroying work:
172217

173-
- **`pull` never deletes local files.** A script removed on the server shows up in `status`
174-
as `remote-missing`; what to do about it is your call.
218+
- **`pull` never deletes local files, and never silently overwrites one.** A script
219+
removed on the server shows up in `status` as `remote-missing`; what to do is your
220+
call. A script that would land on top of an existing untracked file is reported as a
221+
conflict rather than written over it.
175222
- **`push` never deletes remote objects**, and writes only `common.source` and
176223
`common.engineType`. It _cannot_ disable a running script or move it to a different
177224
javascript instance, because it never sends those fields — a sync bug structurally

src/config.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,32 @@ function validateUrl(url: string): void {
3737
}
3838
}
3939

40+
/**
41+
* `scriptRoot` names the directory this tool writes into, resolved against the folder
42+
* holding the config. It therefore may not be absolute or contain `..` — either would
43+
* let a config file place files anywhere on the filesystem.
44+
*
45+
* The hints matter: the natural reaction to "must be relative" is to try `../my-scripts`,
46+
* which then hits the second rule, and neither message says what to do instead. The
47+
* answer is always the same — run `init` inside the folder you want the scripts in.
48+
*/
49+
const SCRIPT_ROOT_HINT =
50+
'scriptRoot is a folder *inside* the project (the directory holding ' +
51+
`${CONFIG_FILENAME}), e.g. "scripts". To keep scripts somewhere else, run ` +
52+
'`iob-sync init` in that folder instead, or use `iob-sync -C <dir>` to work there.';
53+
4054
function validateScriptRoot(scriptRoot: string): void {
4155
if (path.isAbsolute(scriptRoot) || /^[a-zA-Z]:[\\/]/.test(scriptRoot)) {
4256
throw new UserError(
43-
`Config "scriptRoot" must be relative to the project root, got "${scriptRoot}".`,
44-
'Use a relative path such as "scripts".',
57+
`Config "scriptRoot" must be relative, got the absolute path "${scriptRoot}".`,
58+
SCRIPT_ROOT_HINT,
4559
);
4660
}
4761
const segments = scriptRoot.split(/[\\/]+/).filter((s) => s.length > 0);
4862
if (segments.some((s) => s === '..')) {
4963
throw new UserError(
5064
`Config "scriptRoot" must not escape the project root: "${scriptRoot}".`,
51-
'Remove ".." segments from scriptRoot.',
65+
SCRIPT_ROOT_HINT,
5266
);
5367
}
5468
}

src/sync/compare.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,32 @@ export function computeStatus(input: ComputeStatusInput): SyncStatus[] {
126126
if (!entry && remoteInfo) {
127127
// Never synced, exists on the server only. There's no manifest path to trust
128128
// yet, so derive the path the same way `pull` would place it.
129+
const derivedPath = idToRelPath(id, remoteInfo.engineType);
130+
const collidingLocal = local.get(derivedPath);
131+
132+
if (collidingLocal) {
133+
// A file the user already had is sitting exactly where this script would
134+
// land, and nothing records that the two are related. Reporting `remote-only`
135+
// here made `pull` overwrite it without a word — "pull never deletes local
136+
// files" was true and beside the point, because clobbering loses the work
137+
// just the same. Treat it as a conflict so it needs --force.
138+
consumedLocalPaths.add(derivedPath);
139+
results.push({
140+
id,
141+
path: derivedPath,
142+
state: collidingLocal.hash === remoteInfo.sourceHash ? 'in-sync' : 'conflict',
143+
engineType: remoteInfo.engineType,
144+
engine: remoteInfo.engine,
145+
enabled: remoteInfo.enabled,
146+
remoteHash: remoteInfo.sourceHash,
147+
localHash: collidingLocal.hash,
148+
});
149+
continue;
150+
}
151+
129152
results.push({
130153
id,
131-
path: idToRelPath(id, remoteInfo.engineType),
154+
path: derivedPath,
132155
state: 'remote-only',
133156
engineType: remoteInfo.engineType,
134157
engine: remoteInfo.engine,

test/commands-sync.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,3 +365,92 @@ describe('sync commands', () => {
365365
assert.match(t.captured.all.join('\n'), /REMOTE-MISSING/i);
366366
});
367367
});
368+
369+
describe('pull vs files the user already had', () => {
370+
let server: FakeAdminServer;
371+
let port: number;
372+
let project: TempProject;
373+
374+
before(async () => {
375+
server = new FakeAdminServer();
376+
port = await server.start();
377+
});
378+
379+
after(async () => {
380+
await server.stop();
381+
});
382+
383+
beforeEach(async () => {
384+
server.reset();
385+
if (project) await project.cleanup();
386+
project = await makeTempProject();
387+
});
388+
389+
/** A never-synced script whose derived path collides with an existing local file. */
390+
function collidingScript(source: string): ScriptObject {
391+
return {
392+
_id: 'script.js.notes',
393+
type: 'script',
394+
common: {
395+
name: 'notes',
396+
source,
397+
engineType: 'TypeScript/ts',
398+
engine: 'system.adapter.javascript.0',
399+
enabled: true,
400+
expert: true,
401+
},
402+
native: {},
403+
};
404+
}
405+
406+
it('refuses to clobber an untracked local file a script would land on', async () => {
407+
// The scenario: someone points scriptRoot at a folder that already has files.
408+
// "pull never deletes local files" was true and beside the point — overwriting
409+
// loses the work just the same.
410+
server.seed([collidingScript('REMOTE CONTENT\n')]);
411+
await writeLocal(project, 'notes.ts', 'MY OWN FILE\n');
412+
413+
const t = await makeContext(port, project);
414+
try {
415+
await pull(t.ctx, {});
416+
417+
assert.equal(await readLocal(project, 'notes.ts'), 'MY OWN FILE\n');
418+
assert.ok(
419+
t.captured.all.some((l) => /conflict/i.test(l)),
420+
`expected a conflict report, got ${JSON.stringify(t.captured.all)}`,
421+
);
422+
} finally {
423+
await t.close();
424+
}
425+
});
426+
427+
it('--force still takes the remote copy', async () => {
428+
server.seed([collidingScript('REMOTE CONTENT\n')]);
429+
await writeLocal(project, 'notes.ts', 'MY OWN FILE\n');
430+
431+
const t = await makeContext(port, project);
432+
try {
433+
await pull(t.ctx, { force: true });
434+
435+
assert.equal(await readLocal(project, 'notes.ts'), 'REMOTE CONTENT\n');
436+
} finally {
437+
await t.close();
438+
}
439+
});
440+
441+
it('adopts an identical local file without complaining', async () => {
442+
// Same bytes on both sides is not a conflict — it is already what pull wants.
443+
server.seed([collidingScript('SAME\n')]);
444+
await writeLocal(project, 'notes.ts', 'SAME\n');
445+
446+
const t = await makeContext(port, project);
447+
try {
448+
await pull(t.ctx, {});
449+
450+
assert.equal(await readLocal(project, 'notes.ts'), 'SAME\n');
451+
assert.ok(!t.captured.all.some((l) => /conflict/i.test(l)));
452+
} finally {
453+
await t.close();
454+
}
455+
});
456+
});

0 commit comments

Comments
 (0)