Skip to content

Commit 3b9724b

Browse files
mschmickingclaude
andcommitted
feat(types): add an iob-sync types command and fix phantom collisions
Reported from a cold run: after pull, neovim flags every script with 'Cannot find name log / schedule'. The capability existed but was reachable only as `init --types`, and re-running init on a working project needs --force, so adding types afterwards was awkward. It is now its own command, with --offline and --force. The generated tsconfig also changes in a way that matters more. It used to put every script in one global scope, so two scripts each declaring `const helper` collided with TS2451 — an error about code that runs fine, since the adapter gives each script its own sandbox. AGENTS.md recorded this as an unavoidable limitation. It is not: `moduleDetection: force` gives every file its own scope while globals declared in javascript.d.ts stay visible. That forces module semantics, so `module`/`target` move to es2022 — commonjs rejects the top-level await the adapter allows (TS1378). Verified end to end against a real download and a real tsc run: globals resolve, duplicate top-level names across files pass, top-level await passes, require() resolves, and .js scripts are checked. Removing the typings reproduces exactly the reported errors, which confirms they are what fixes it. Keeping an existing tsconfig is now info rather than a warning; it is expected behaviour, not a problem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 18bfd9f commit 3b9724b

7 files changed

Lines changed: 325 additions & 105 deletions

File tree

AGENTS.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,13 +223,15 @@ used to do, and in a repo that already has a build config it injects `scripts/**
223223
a config owning `rootDir`/`outDir` and breaks the build with TS6059. The scripts config
224224
sets `noEmit`, which keeps `rootDir` out of the picture entirely.
225225

226-
Pulled scripts **cannot be typechecked as a single program**. Each ioBroker script runs
226+
Pulled scripts could not originally be typechecked as a single program. Each ioBroker script runs
227227
in its own sandbox scope, so top-level names are private to it — but one `tsc` program
228228
puts them all in one global scope, where they collide (`TS2451` on a shared `axios`,
229229
`TS2393` on a shared `sendMessage`). These are artifacts of joint checking, not runtime
230-
bugs; scripts must be checked one program per file. The generated
231-
`scripts/tsconfig.json` is for editor intellisense and **will** show those false
232-
collisions.
230+
bugs. The generated `scripts/tsconfig.json` therefore sets `moduleDetection: force`, which
231+
gives every file its own scope and makes the whole folder checkable in one pass —
232+
verified against duplicate top-level names, top-level await, `require()` and .js scripts.
233+
`module`/`target` are es2022 rather than commonjs because the adapter permits top-level
234+
await, which commonjs rejects with TS1378.
233235

234236
### This repo holds no scripts
235237

README.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,36 @@ Script folders are ioBroker `channel` objects; nested folders map to nested dire
154154
`Blockly` and `Rules` scripts are pulled as `.block` / `.rules` for completeness, but their
155155
sources are generated XML/JSON and are not meant to be hand-edited.
156156

157-
`iob-sync init --types` additionally downloads the ioBroker type definitions and writes a
158-
`tsconfig.json` into the script folder, so your editor knows what `on()`, `getState()` and
159-
friends are.
157+
### Editor support
158+
159+
Straight after a `pull`, an editor will not know what `log`, `schedule`, `on` or
160+
`getState` are — they exist only inside the javascript adapter's sandbox, so every script
161+
shows "Cannot find name 'log'". Fix it once:
162+
163+
```bash
164+
iob-sync types
165+
```
166+
167+
That downloads the adapter's own typings to `.iobroker/types/` and writes
168+
`<scriptRoot>/tsconfig.json` so any LSP client — neovim, VS Code, Helix, Zed — picks them
169+
up. **Restart your language server afterwards.** `init --types` does the same thing during
170+
setup; `iob-sync types` exists so you can add or refresh them later without touching a
171+
working config.
172+
173+
Re-run it whenever the adapter gains functions you want typed. `--force` replaces an
174+
existing `tsconfig.json`, `--offline` skips the download.
175+
176+
If you see `Cannot find namespace 'NodeJS'`, the typings reference Node's own types:
177+
178+
```bash
179+
npm install --save-dev @types/node
180+
```
181+
182+
The generated config sets `moduleDetection: force` deliberately. Each ioBroker script
183+
runs in its own sandbox scope, but to TypeScript a folder of plain scripts shares one
184+
global scope — so two scripts each declaring `const helper` would collide with TS2451,
185+
an error about code that is perfectly fine at runtime. Module semantics give every file
186+
its own scope, matching how the adapter actually runs them.
160187

161188
## The edit loop
162189

@@ -183,6 +210,7 @@ iob-sync logs --level error # only failures
183210
| Command | Description |
184211
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
185212
| `init` | Write `.iobroker-sync.json`, verify the connection, create the script folder. Asks interactively when run without flags. `--types` also sets up TypeScript definitions. |
213+
| `types` | Set up editor intellisense (`log`, `schedule`, ...). `--force`, `--offline`. |
186214
| `login` / `logout` | Save or remove the password for this instance. Never stored in the project. |
187215
| `pull [pattern]` | Download scripts to disk. Never deletes local files. |
188216
| `push [pattern]` | Upload locally modified scripts. Never deletes remote objects. |

src/cli.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { rename } from './commands/rename';
3131
import { move } from './commands/move';
3232
import { remove } from './commands/remove';
3333
import { login, logout } from './commands/login';
34+
import { setupTypes } from './commands/types';
3435

3536
const program = new Command();
3637

@@ -236,6 +237,24 @@ program
236237
})();
237238
});
238239

240+
program
241+
.command('types')
242+
.description('set up editor intellisense for the pulled scripts (log, schedule, on, ...)')
243+
.option('-f, --force', 'replace an existing tsconfig.json in the script root')
244+
.option('--offline', 'skip downloading javascript.d.ts')
245+
.action(function (this: Command) {
246+
const opts = this.opts();
247+
return action(async () => {
248+
const { root, config } = await loadConfig(resolveCwd());
249+
await setupTypes(
250+
root,
251+
config.scriptRoot,
252+
{ force: opts.force, offline: opts.offline },
253+
logger,
254+
);
255+
})();
256+
});
257+
239258
program
240259
.command('logout')
241260
.description('remove the stored password for this instance')

src/commands/init.ts

Lines changed: 2 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,7 @@ import { AdminSocketClient } from '../client/socket';
1515
import { defaultConfig, writeConfig } from '../config';
1616
import { CONFIG_FILENAME, Logger, STATE_DIR, UserError } from '../types';
1717
import { isInteractive, promptText, promptYesNo } from '../prompt';
18-
19-
const JAVASCRIPT_DTS_URL =
20-
'https://raw.githubusercontent.com/ioBroker/ioBroker.javascript/refs/heads/master/src/lib/javascript.d.ts';
21-
22-
const GLOBAL_DTS_CONTENT = `export {};
23-
declare global {
24-
function require(library: string): any;
25-
}
26-
`;
18+
import { setupTypes } from './types';
2719

2820
export interface InitOptions {
2921
/** Optional: when absent and a TTY is attached, the user is asked for it. */
@@ -46,92 +38,6 @@ async function pathExists(p: string): Promise<boolean> {
4638
}
4739
}
4840

49-
/**
50-
* Builds the tsconfig that gives the *scripts* intellisense.
51-
*
52-
* This deliberately lives inside the script root rather than merging into a
53-
* `tsconfig.json` at the project root: the project root may already hold a build
54-
* config that owns `rootDir`/`outDir`, and injecting `scripts/**` into it produces
55-
* TS6059 ("not under rootDir") and breaks that build. Scripts are only ever
56-
* type-*checked*, never emitted, hence `noEmit`.
57-
*
58-
* `typesPrefix` is the relative hop from the script root back to the project root,
59-
* so the downloaded `.iobroker/types` are picked up from wherever the scripts live.
60-
*
61-
* `types` is intentionally left unset: naming `["node"]` hard-fails when `@types/node`
62-
* is absent, whereas the default (auto-include every visible `@types`) degrades to
63-
* merely missing the `NodeJS.*` names that `javascript.d.ts` refers to.
64-
*/
65-
function scriptsTsconfig(typesPrefix: string): Record<string, unknown> {
66-
return {
67-
compilerOptions: {
68-
allowJs: true,
69-
checkJs: true,
70-
target: 'es2018',
71-
lib: ['ES2022'],
72-
module: 'commonjs',
73-
moduleResolution: 'node',
74-
noEmit: true,
75-
skipLibCheck: true,
76-
},
77-
include: ['**/*.ts', '**/*.js', `${typesPrefix}.iobroker/types/**/*.d.ts`],
78-
};
79-
}
80-
81-
async function writeTypesScaffolding(
82-
root: string,
83-
scriptRoot: string,
84-
force: boolean,
85-
log: Logger,
86-
): Promise<void> {
87-
const scriptRootDir = path.join(root, scriptRoot);
88-
const tsconfigPath = path.join(scriptRootDir, 'tsconfig.json');
89-
90-
// path.relative gives '..' / '../..'; normalise to a POSIX prefix for tsconfig globs.
91-
const rel = path.relative(scriptRootDir, root).split(path.sep).join('/');
92-
const typesPrefix = rel === '' ? '' : `${rel}/`;
93-
94-
if ((await pathExists(tsconfigPath)) && !force) {
95-
log.warn(
96-
`${tsconfigPath} already exists; leaving it alone. Re-run with --force to replace it.`,
97-
);
98-
} else {
99-
await fs.mkdir(scriptRootDir, { recursive: true });
100-
await fs.writeFile(
101-
tsconfigPath,
102-
JSON.stringify(scriptsTsconfig(typesPrefix), null, 2) + '\n',
103-
'utf8',
104-
);
105-
log.info(`Wrote ${tsconfigPath}`);
106-
}
107-
108-
const typesDir = path.join(root, '.iobroker', 'types');
109-
await fs.mkdir(typesDir, { recursive: true });
110-
111-
try {
112-
const res = await fetch(JAVASCRIPT_DTS_URL);
113-
if (!res.ok) {
114-
throw new Error(`HTTP ${res.status}`);
115-
}
116-
const text = await res.text();
117-
await fs.writeFile(path.join(typesDir, 'javascript.d.ts'), text, 'utf8');
118-
log.info('Downloaded javascript.d.ts');
119-
} catch (err) {
120-
log.warn(`Could not download javascript.d.ts (${(err as Error).message}); skipping.`);
121-
}
122-
123-
await fs.writeFile(path.join(typesDir, 'global.d.ts'), GLOBAL_DTS_CONTENT, 'utf8');
124-
log.info(`Wrote ${path.join(typesDir, 'global.d.ts')}`);
125-
126-
// javascript.d.ts refers to NodeJS.Timeout, NodeJS.ErrnoException and friends
127-
// throughout, so without @types/node the scripts light up with "Cannot find
128-
// namespace 'NodeJS'". Nothing here can install it, so say so plainly.
129-
if (!(await pathExists(path.join(root, 'node_modules', '@types', 'node')))) {
130-
log.warn('@types/node is not installed; javascript.d.ts needs it for NodeJS.* types.');
131-
log.warn('Run: npm install --save-dev @types/node');
132-
}
133-
}
134-
13541
/** Read-only probe: confirms the config actually works and reports what it finds. */
13642
async function probeConnection(
13743
url: string,
@@ -284,6 +190,6 @@ export async function runInit(cwd: string, rawOpts: InitOptions, log: Logger): P
284190
await probeConnection(config.url, config.username, config.allowSelfSigned, log, opts.interactive);
285191

286192
if (opts.types) {
287-
await writeTypesScaffolding(root, config.scriptRoot, opts.force ?? false, log);
193+
await setupTypes(root, config.scriptRoot, { force: opts.force }, log);
288194
}
289195
}

src/commands/types.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* `iob-sync types` — sets up editor intellisense for pulled scripts.
3+
*
4+
* ioBroker scripts call globals that exist only inside the javascript adapter's
5+
* sandbox — `log`, `schedule`, `on`, `getState`. Nothing in a plain checkout tells an
6+
* editor those exist, so every script lights up red in neovim, VS Code, or any other
7+
* LSP client. This writes the two pieces that fix it:
8+
*
9+
* .iobroker/types/javascript.d.ts the adapter's own typings, downloaded
10+
* <scriptRoot>/tsconfig.json a config that picks them up
11+
*
12+
* Also reachable as `init --types`, but it exists separately because wanting types
13+
* later — or refreshing them after the adapter adds a function — is the common case,
14+
* and re-running `init` to get them would mean `--force`-overwriting a working config.
15+
*/
16+
17+
import * as fs from 'node:fs/promises';
18+
import * as path from 'node:path';
19+
20+
import { Logger } from '../types';
21+
22+
const JAVASCRIPT_DTS_URL =
23+
'https://raw.githubusercontent.com/ioBroker/ioBroker.javascript/refs/heads/master/src/lib/javascript.d.ts';
24+
25+
const GLOBAL_DTS_CONTENT = `export {};
26+
declare global {
27+
function require(library: string): any;
28+
}
29+
`;
30+
31+
export interface TypesOptions {
32+
/** Replace an existing `<scriptRoot>/tsconfig.json` instead of leaving it alone. */
33+
force?: boolean;
34+
/** Skip the download and only write the local files. */
35+
offline?: boolean;
36+
}
37+
38+
async function pathExists(p: string): Promise<boolean> {
39+
try {
40+
await fs.access(p);
41+
return true;
42+
} catch {
43+
return false;
44+
}
45+
}
46+
47+
/**
48+
* The tsconfig that gives the *scripts* intellisense.
49+
*
50+
* It lives inside the script root rather than merging into a `tsconfig.json` at the
51+
* project root: the project root may already hold a build config that owns
52+
* `rootDir`/`outDir`, and injecting `scripts/**` into it produces TS6059 and breaks
53+
* that build. Scripts are only ever type-*checked*, never emitted, hence `noEmit`.
54+
*
55+
* `typesPrefix` is the relative hop from the script root back to the project root, so
56+
* the downloaded `.iobroker/types` are found from wherever the scripts live.
57+
*
58+
* `types` is intentionally left unset: naming `["node"]` hard-fails when `@types/node`
59+
* is absent, whereas the default (auto-include every visible `@types`) degrades to
60+
* merely missing the `NodeJS.*` names that `javascript.d.ts` refers to.
61+
*/
62+
function scriptsTsconfig(typesPrefix: string): Record<string, unknown> {
63+
return {
64+
compilerOptions: {
65+
allowJs: true,
66+
checkJs: true,
67+
// `moduleDetection: force` is what makes a folder of scripts checkable at all.
68+
// Each ioBroker script runs in its own sandbox scope, but to TypeScript they
69+
// are plain scripts sharing one global scope, so two files each declaring
70+
// `const helper` collide with TS2451 — a phantom error about code that is
71+
// fine at runtime. Forcing module semantics gives every file its own scope,
72+
// matching how the adapter actually runs them. Globals from javascript.d.ts
73+
// are declared with `declare global` and stay visible.
74+
moduleDetection: 'force',
75+
// es2022 (not commonjs) because scripts may use top-level `await`, which the
76+
// adapter supports and which TS1378 rejects under commonjs.
77+
module: 'es2022',
78+
target: 'es2022',
79+
lib: ['ES2022'],
80+
moduleResolution: 'node',
81+
noEmit: true,
82+
skipLibCheck: true,
83+
},
84+
include: ['**/*.ts', '**/*.js', `${typesPrefix}.iobroker/types/**/*.d.ts`],
85+
};
86+
}
87+
88+
export async function setupTypes(
89+
root: string,
90+
scriptRoot: string,
91+
opts: TypesOptions,
92+
log: Logger,
93+
): Promise<void> {
94+
const scriptRootDir = path.join(root, scriptRoot);
95+
const tsconfigPath = path.join(scriptRootDir, 'tsconfig.json');
96+
97+
// path.relative gives '..' / '../..'; normalise to a POSIX prefix for tsconfig globs.
98+
const rel = path.relative(scriptRootDir, root).split(path.sep).join('/');
99+
const typesPrefix = rel === '' ? '' : `${rel}/`;
100+
101+
if ((await pathExists(tsconfigPath)) && !opts.force) {
102+
log.info(`${tsconfigPath} already exists; keeping it. Use --force to replace it.`);
103+
} else {
104+
await fs.mkdir(scriptRootDir, { recursive: true });
105+
await fs.writeFile(
106+
tsconfigPath,
107+
JSON.stringify(scriptsTsconfig(typesPrefix), null, 2) + '\n',
108+
'utf8',
109+
);
110+
log.info(`Wrote ${tsconfigPath}`);
111+
}
112+
113+
const typesDir = path.join(root, '.iobroker', 'types');
114+
await fs.mkdir(typesDir, { recursive: true });
115+
116+
const dtsPath = path.join(typesDir, 'javascript.d.ts');
117+
if (opts.offline) {
118+
log.info('Skipping the javascript.d.ts download (--offline).');
119+
} else {
120+
try {
121+
const res = await fetch(JAVASCRIPT_DTS_URL);
122+
if (!res.ok) {
123+
throw new Error(`HTTP ${res.status}`);
124+
}
125+
const text = await res.text();
126+
await fs.writeFile(dtsPath, text, 'utf8');
127+
log.info(`Downloaded javascript.d.ts (${Math.round(text.length / 1024)} KB)`);
128+
} catch (err) {
129+
// Not fatal: the tsconfig and global.d.ts are still worth writing, and a
130+
// previously downloaded copy may already be sitting there.
131+
log.warn(`Could not download javascript.d.ts (${(err as Error).message}).`);
132+
log.warn(
133+
(await pathExists(dtsPath))
134+
? 'Keeping the copy already in .iobroker/types/.'
135+
: `Scripts will still show "Cannot find name 'log'" until it is fetched. Re-run \`iob-sync types\` when online.`,
136+
);
137+
}
138+
}
139+
140+
await fs.writeFile(path.join(typesDir, 'global.d.ts'), GLOBAL_DTS_CONTENT, 'utf8');
141+
log.info(`Wrote ${path.join(typesDir, 'global.d.ts')}`);
142+
143+
// javascript.d.ts refers to NodeJS.Timeout, NodeJS.ErrnoException and friends
144+
// throughout, so without @types/node the scripts light up with "Cannot find
145+
// namespace 'NodeJS'". Nothing here can install it, so say so plainly.
146+
if (!(await pathExists(path.join(root, 'node_modules', '@types', 'node')))) {
147+
log.warn('@types/node is not installed; javascript.d.ts needs it for NodeJS.* types.');
148+
log.warn(`Run this in ${root}: npm install --save-dev @types/node`);
149+
}
150+
151+
log.result(`Types ready. Restart your editor's language server to pick them up.`);
152+
}

test/commands-init.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ describe('init --types', () => {
106106

107107
assert.equal(await fs.readFile(scriptsTsconfig, 'utf8'), mine);
108108
assert.ok(
109-
captured.warn.some((m) => m.includes('already exists')),
110-
'expected a warning that the existing config was kept',
109+
captured.all.some((m) => m.includes('already exists')),
110+
'the user should be told the existing config was kept',
111111
);
112112
} finally {
113113
await project.cleanup();

0 commit comments

Comments
 (0)