@kkdev92/vscode-ext-kit - v4.1.1
    Preparing search index...

    Migrating from 2.x

    2.x is a utility library: you call its helpers from your own activate(). 3.x and 4.x are a framework: it owns activation and deactivation, and you hand it modules. Everything 2.x could do, 4.x can do — the shapes changed, because there is now one way in per ability rather than a standalone function and a declaration. This page is the order to do the work in. The old-to-new table for every removed helper is in the changelog, under 3.0.0-alpha.1, and is not repeated here.

    Migrate straight to 4.x. The API is the one 3.0.0 exposed; 4.0.0 raised the VS Code floor and changed nothing else. Before you start, your extension needs what the platform requirements list: engines.vscode of ^1.134.0 or later, ESM, and ESNext.Disposable plus an AbortSignal lib in tsconfig. Typed storage keeps the 2.x envelope, so there is no data migration; values a 2.x build wrote are read as they are.

    Every step below works through an inventory like this one: an activate that builds state, registers things, pushes each disposable onto context.subscriptions, and reads configuration where it happens to need it. Nothing about it is wrong. The point of the migration is that after it, none of this file is yours to maintain.

    import * as vscode from 'vscode';

    // The shape most extensions start from, and the one 2.x helpers were called
    // from: an `activate` that registers everything, pushes each disposable onto
    // `context.subscriptions`, and reads configuration wherever it happens to be
    // needed. Nothing here is wrong. It is the inventory the steps work through.

    let index: Map<string, number> | undefined;
    let timer: ReturnType<typeof setInterval> | undefined;

    export async function activate(context: vscode.ExtensionContext): Promise<void> {
    // Async initialisation, awaited before anything is registered.
    index = await buildIndex();

    // A command that reads a setting at call time.
    context.subscriptions.push(
    vscode.commands.registerCommand('sample.countProjects', () => {
    const limit = vscode.workspace.getConfiguration('sample').get<number>('limit', 10);
    return Math.min(index?.size ?? 0, limit);
    })
    );

    // A watcher that keeps the index fresh, three callbacks at a time.
    const watcher = vscode.workspace.createFileSystemWatcher('**/*.project.json');
    watcher.onDidCreate((uri) => index?.set(uri.fsPath, Date.now()));
    watcher.onDidChange((uri) => index?.set(uri.fsPath, Date.now()));
    watcher.onDidDelete((uri) => index?.delete(uri.fsPath));
    context.subscriptions.push(watcher);

    // A periodic job on a timer, disposed by hand.
    timer = setInterval(() => {
    void buildIndex().then((fresh) => (index = fresh));
    }, 30_000);
    context.subscriptions.push({ dispose: () => clearInterval(timer) });

    // A provider this package has no model for.
    context.subscriptions.push(
    vscode.languages.registerHoverProvider(
    { scheme: 'file' },
    { provideHover: () => new vscode.Hover(`${String(index?.size ?? 0)} projects`) }
    )
    );
    }

    export function deactivate(): void {
    index = undefined;
    }

    async function buildIndex(): Promise<Map<string, number>> {
    const files = await vscode.workspace.findFiles('**/*.project.json', '**/node_modules/**');
    return new Map(files.map((file) => [file.fsPath, Date.now()]));
    }

    List what the extension does at activation, one line per item. The grep that finds most of it:

    grep -rn "subscriptions.push\|getConfiguration\|globalState\|workspaceState\|secrets\.\|setInterval\|setTimeout\|onDid" src
    

    Add the module-level lets, everything activate awaits, and every vscode.* call that runs outside a handler. If 2.x helpers are in use, each is one line too — createExtensionKit, safeExecute, withProgress, createTypedStorage, createFileWatcher, the picker functions. The list is the migration's scope, and the unit of every later commit.

    Each line is one of these, and the classification decides which declaration it becomes:

    It is… It becomes…
    a command defineCommandContract + module.commands.handle
    state built at activation a service: serviceToken + module.services.singleton
    something awaited before the extension is usable a hosted service start
    a loop or timer module.hostedServices.background, with context.delay
    a file system watcher module.fileWatchers.add
    a configuration read defineSettings + module.settings.add, injected under Settings.token
    a memento or secret defineStorage / defineSecret, injected under their tokens
    a notification, picker, progress or status message the Notifications, QuickInput, StatusBar tokens, or context.notify / .ask / .progress
    a tree view, webview view or panel restorer module.treeViews.add, module.webviews.addView, module.webviews.restorePanel
    a status bar or language status item defineStatusBarItem / defineLanguageStatusItem
    an editor operation the Editors token (editors.active)
    anything else the platform registers — a provider, a URI handler module.raw.register, with the disposable under registrations.own

    Two things stop being items: context.subscriptions.push (the host owns what it registered) and whatever deactivate cleaned up by hand (the host unwinds it, in reverse order, inside a shutdown budget).

    The framework owns activate, so there is no half-way state where some registrations are declared and the rest still live in your activate. The fastest safe path is to move everything first and improve it afterwards:

    1. Create one module. Wrap each existing registration in module.raw.register, with the disposable it produced under registrations.own(...) and the state it needs behind a service. The escape hatch shows the shape.
    2. Replace activate and deactivate with the framework's: export const app = defineExtension({ name, modules: [...] }), then export const activate = app.activate and export const deactivate = app.deactivate.
    3. Run the extension. Activation and cleanup are now the host's, every registration rolls back if a later one fails, and deactivate unwinds all of it — before a single declaration has been written.

    From here each step is a small commit: take one raw registration, turn it into the declaration step 2 named, run the tests, move on. The extension works at every commit.

    Anything activate awaited before registering — building an index, opening a connection — goes into a hosted service's start. Activation awaits it, and a throw rolls the whole activation back rather than leaving a half-activated extension behind. Service factories stay synchronous, so resolution cannot deadlock; the async part is the hosted service's.

    A timer or loop becomes module.hostedServices.background: the host tracks it, context.signal aborts it on shutdown, and context.delay returns early so a sleeping loop cannot hold the shutdown budget. The setInterval handle and the disposable that cleared it both disappear.

    A createFileSystemWatcher with three callbacks becomes one module.fileWatchers.add, delivering debounced, deduplicated batches that each run as an operation — with a logger, a cancellation signal and a resource scope of their own. A configuration read becomes a declaration whose accessor is injected, so the key, type, default and contribution scope are stated once and package.json is checked against them. A memento becomes defineStorage, and its existing values are read as they are.

    Everything that still needs the raw API subscribes inside raw.register, under registrations.own(...). Nothing is pushed onto context.subscriptions any more, by you or by anything you declared.

    This is the inventory above after steps 3 to 5:

    import {
    defineCommandContract,
    defineExtension,
    defineModule,
    defineSettings,
    serviceToken,
    setting,
    type OperationContext,
    } from '@kkdev92/vscode-ext-kit';

    // The inventory, declared. Each registration says what it is and what it needs;
    // when it is disposed is no longer its concern. Nothing in this file imports
    // `vscode`, which is what lets a test run this exact plan on fakes.

    interface ProjectIndex {
    count(): number;
    touch(path: string): void;
    forget(path: string): void;
    prune(olderThanMs: number): void;
    }
    const ProjectIndex = serviceToken<ProjectIndex>('sample.projectIndex');

    // The setting the command read ad hoc, declared once: key, type, default and
    // contribution scope, with package.json checked against it.
    export const Settings = defineSettings({
    section: 'sample',
    values: { limit: setting.number({ default: 10, minimum: 1 }) },
    });

    // The command's id, arguments and result, fixed for every caller.
    export const CountProjects = defineCommandContract<readonly [], number>({
    id: 'sample.countProjects',
    });

    export const projectsModule = defineModule('projects', (module): undefined => {
    module.settings.add(Settings);

    // The module-level `let index` becomes a service: built by the container,
    // owned by it, and replaceable in a test.
    module.services.singleton(ProjectIndex, () => {
    const seen = new Map<string, number>();
    return {
    count: () => seen.size,
    touch: (path) => void seen.set(path, Date.now()),
    forget: (path) => void seen.delete(path),
    prune: (olderThanMs) => {
    const cutoff = Date.now() - olderThanMs;
    for (const [path, at] of seen) {
    if (at < cutoff) seen.delete(path);
    }
    },
    };
    });

    module.commands.handle(CountProjects, {
    inject: { index: ProjectIndex, settings: Settings.token },
    execute: (_context: OperationContext, _args, { index, settings }) =>
    Math.min(index.count(), settings.read().values.limit),
    });

    // The watcher, its three callbacks collapsed into one debounced batch that
    // runs as an operation.
    module.fileWatchers.add({
    id: 'projects.files',
    patterns: ['**/*.project.json'],
    ignorePatterns: ['**/node_modules/**'],
    inject: { index: ProjectIndex },
    handle: (_context: OperationContext, events, { index }) => {
    for (const event of events) {
    if (event.type === 'delete') index.forget(event.uri.fsPath);
    else index.touch(event.uri.fsPath);
    }
    },
    });

    // The `setInterval` becomes a background hosted service. The host tracks it,
    // `context.delay` returns early on shutdown, and there is no handle to clear.
    module.hostedServices.background({
    id: 'projects.prune',
    inject: { index: ProjectIndex },
    run: async (context, { index }) => {
    while (!context.signal.aborted) {
    await context.delay(30_000);
    if (context.signal.aborted) return;
    index.prune(24 * 60 * 60 * 1000);
    }
    },
    });

    return undefined;
    });

    // Preflight runs here, at import time. `activate` and `deactivate` are the
    // framework's: `deactivate` is the one cleanup path, and `activate` puts a
    // single synchronous failsafe on `context.subscriptions`. Nothing else is
    // pushed there, by you or by anything you declared.
    export const app = defineExtension({ name: 'Sample', modules: [projectsModule] });
    export const activate = app.activate;
    export const deactivate = app.deactivate;

    The hover provider is missing from it on purpose. It is the one thing the framework has no model for, so it stays a raw registration — and the next step is about where that lives.

    Whatever is left in raw.register belongs in its own module file. The reason is the import * as vscode at the top of that file: a module that carries one cannot be imported where no VS Code exists, and a unit test is exactly that place. Keep the modules that need the platform apart from the ones that do not, and the plan a test loads is the plan that ships, minus the file it cannot load.

    A raw registration still shows up in the compiled plan, rolls back with the module, and unwinds through deactivate. What it does not get is a fake: the escape hatch section says what that costs.

    createTestHost({ plan: app.plan }) starts the plan you export — not a rebuild of it for testing — against one fake per capability port. Execute a command through host.application.commands.execute, override one singleton to isolate a feature, and assert host.leaks() after host.stop(): a registration or resource left behind is the bug that used to surface as a stale listener two releases later. Testing walks through it.

    If a module you load imports vscode (step 6 is why you might not), point Vitest's resolve.alias at @kkdev92/vscode-ext-kit/testing/vitest, or merge vscodeExtKitVitestConfig from @kkdev92/vscode-ext-kit/testing/vitest-config.

    package.json cannot be generated from the source — VS Code reads it before any code runs, and it carries titles and descriptions only a person can write — but the overlap is mechanical: command ids, setting keys, types, defaults, enum values, scopes, view ids. assertManifestMatches, from @kkdev92/vscode-ext-kit/testing, compares the two and names every disagreement at once, with the JSON to paste. Keeping package.json honest has the test. Run it now: a migration that renamed a section or fixed a default is exactly when the two drift.

    The same comparison is available without writing the test: npx vscode-ext-kit manifest ./dist/extension.js reports every disagreement between the built entry's plan and package.json, and --apply adds the commands and settings the manifest is missing, with placeholder titles and descriptions for a person to replace. A section the extension only reads — editor, say — is declared with contributed: false, so neither check asks the manifest for it. npx vscode-ext-kit plan ./dist/extension.js --check is the same idea one level up: preflight, from the command line, against the built entry.

    Unit tests on fakes answer most questions. The ones they cannot — does the extension activate in a real host, does deactivate run inside VS Code's deadline, does a text editor command behave as the platform actually calls it — need the real thing. This repository's fixtures show the two lanes, desktop and web, and how a case asserts an observable fact rather than what the API is believed to promise. A single case that activates the migrated extension and runs one command is enough to start with; the real extensions built on this package each found something there that no unit test saw.

    • Every context.subscriptions.push, and the deactivate body that mirrored it.
    • Module-level lets that held state between activate and the handlers. They are services now.
    • Error presentation around command handlers. A result and a rejection both reach the caller; the Command Palette already shows the dialog.
    • CancellationToken plumbing. context.signal combines the operation's own cancellation with the user's, and everything that took a token takes a signal.